Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions gate-engine/co-occurrence/__tests__/clone-detector.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,55 @@ describe.skipIf(!HAS_JSCPD)('clone-detector --gate exit-code contract', () => {
rmSync(mjs, { recursive: true, force: true });
});

it('exit 0 — an identical import preamble alone is not a clone (module-system forced)', () => {
// Big enough to clear jscpd's 50-token floor on its own — the exact shape that false-blocked
// sibling CLI commands sharing the same libs. Imports are ignore-pattern'd out at the
// tokenizer, so no fragment can even contain them.
const imports = Array.from(
{ length: 14 },
(_, i) => `import { helperNumber${i}, otherThing${i} } from './lib/module-${i}.ts';`,
).join('\n');
const dir = mkdtempSync(join(tmpdir(), 'clone-imports-'));
writeFileSync(join(dir, 'a.ts'), `${imports}\nconst runA = () => helperNumber0(1);\n`);
writeFileSync(
join(dir, 'b.ts'),
`${imports}\nfunction makeB() {\n return otherThing3(2, 3);\n}\n`,
);
expect(run(['scan', '--gate', '--paths', dir], {}).status).toBe(0);
rmSync(dir, { recursive: true, force: true });
});

it('exit 1 — a real duplicated body behind an identical import preamble still blocks', () => {
// The whole reason the ignore-pattern approach won over a post-hoc fragment classifier: six
// reviewer-verified classifier holes each let some duplicated statement hide behind imports.
// With imports gone from the token stream, the shared body must trip the floor on its own.
const imports = Array.from(
{ length: 6 },
(_, i) => `import { helperNumber${i} } from './lib/module-${i}.ts';`,
).join('\n');
const dir = mkdtempSync(join(tmpdir(), 'clone-imports-body-'));
writeFileSync(join(dir, 'a.ts'), `${imports}\n${SHARED}\nexport const A_ONLY = 1;\n`);
writeFileSync(join(dir, 'b.ts'), `${imports}\n${SHARED}\nexport const B_ONLY = 2;\n`);
expect(run(['scan', '--gate', '--paths', dir], {}).status).toBe(1);
rmSync(dir, { recursive: true, force: true });
});

it('exit 1 — a duplicated dynamic-import block is runtime code and still blocks', () => {
// Dynamic import(...) deliberately matches none of the ignore patterns.
const block = `export function loadWidgetPlugin(registry) {
return import('./widget-plugin').then((mod) => {
registry.register(mod.widgetName, mod.createWidget);
registry.enable(mod.widgetName, { eager: true, retries: 3 });
return mod.initialize(registry, { verbose: false, timeoutMs: 5000 });
});
}`;
const dir = mkdtempSync(join(tmpdir(), 'clone-dynimport-'));
writeFileSync(join(dir, 'a.ts'), `${block}\nexport const A_ONLY = 1;\n`);
writeFileSync(join(dir, 'b.ts'), `${block}\nexport const B_ONLY = 2;\n`);
expect(run(['scan', '--gate', '--paths', dir], {}).status).toBe(1);
rmSync(dir, { recursive: true, force: true });
});

it('exit 0 — no cross-file clone (clean)', () => {
const clean = mkdtempSync(join(tmpdir(), 'clone-clean-'));
writeFileSync(join(clean, 'solo.ts'), `${SHARED}\nexport const SOLO = 1;\n`);
Expand Down
32 changes: 32 additions & 0 deletions gate-engine/co-occurrence/clone-detector.mts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ export function detectClones({
out,
'--ignore',
DEFAULTS.ignore.join(','),
'--ignore-pattern',
IMPORT_IGNORE_PATTERNS.join(','),
'--silent',
],
{ cwd: repoRoot, stdio: ['ignore', 'ignore', 'pipe'] },
Expand Down Expand Up @@ -208,6 +210,36 @@ export function relPath(f: string): string {
return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
}

/**
* Import/re-export declarations are excluded from clone detection AT THE TOKENIZER, via jscpd's
* `--ignore-pattern` (each entry becomes an ignore token rule in the format grammar; matched
* text never enters any fragment). Two modules using the same shared libs are FORCED into
* identical, linter-sorted import preambles — the module system working, not copy-paste debt —
* and a big enough preamble clears the min-tokens floor on its own, false-blocking sibling
* files. Removing imports up front gives the clean semantic "clones are measured over
* non-import code": nothing can ride a preamble over the token floor, and nothing real can hide
* behind one. (A post-hoc fragment classifier was tried first; six reviewer-verified holes —
* ratio leaks, dynamic import(...), quote-terminated statements, import attributes — showed
* that design to be unfixable. See the gate-contract tests.)
*
* Each pattern is bounded so it can never span arbitrary code: single-line forms forbid `\n`,
* the multi-line named form crosses lines only inside `{...}` and requires the `} from '...'`
* closer. Dynamic `import(...)` is runtime code and deliberately matches none of them. jscpd's
* CLI comma-splits this option, so patterns must stay comma-free. Unmatched exotic shapes
* (import attributes, default+named multi-line) fall through to normal tokenization — the safe
* direction: worst case a clone REPORTS and the allowlist is the remedy, never the reverse.
*/
export const IMPORT_IGNORE_PATTERNS = [
// Named / namespace-in-braces, incl. multi-line specifier lists: import [type] { ... } from 'x';
String.raw`import\s*(type\s+)?\{[^}]*\}\s*from\s*['"][^'"]+['"];?`,
// Default / namespace, single-line only: import foo from 'x'; import * as ns from 'x';
String.raw`import\s+(type\s+)?[\w$*][^\n]*?from\s*['"][^'"]+['"];?`,
// Side-effect: import 'x';
String.raw`import\s*['"][^'"]+['"];?`,
// Re-export barrels: export { a } from 'x'; / export * from 'x';
String.raw`export\s*(\{[^}]*\}|\*)\s*from\s*['"][^'"]+['"];?`,
Comment on lines +239 to +240

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C2 --glob '*.{ts,tsx,mts}' \
  '^\s*export\s+(type\s+)?\*\s+as\s+|^\s*export\s+type\s+\{' .

Repository: norvalbv/devkit

Length of output: 2263


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== targeted source =="
sed -n '220,250p' gate-engine/co-occurrence/clone-detector.mts

echo
echo "== relevant static re-export declarations =="
rg -n --glob '*.{ts,tsx,mts}' '^\s*export\s+(\*|\{[^}]*\})\s+as\s+|\bexport\s+type\s+\{' . || true

echo
echo "== read-only regex behavior probe =="
python3 - <<'PY'
import re
pattern = re.compile(r"export\s*(\{[^}]*\}|\*)\s*from\s*['"][^'"]+['"];?", re.I)
cases = [
    "export { Widget } from './widgets';",
    "export * from './widgets';",
    "export {} from './widgets';",
    "export type { Widget } from './widgets';",
    "export * as widgets from './widgets';",
    "export * as $widgets from './widgets';",
]
for case in cases:
    print(case, "-->", "matches" if pattern.search(case) else "no match")
PY

Repository: norvalbv/devkit

Length of output: 2945


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pattern definitions =="
rg -n --context 8 "IMPORT_IGNORE_PATTERNS" gate-engine/co-occurrence/clone-detector.mts || true

echo
echo "== broad source form =="
rg -n "Re-export barrels|export\\s\\(\\{\\[^\\}\\]\\*\\}\\)|import_ignore_patterns|IMPORT_IGNORE_PATTERNS" gate-engine -S || true

echo
echo "== existing export declarations =="
rg -n --glob '*.{ts,tsx,mts}' '^\s*export\s+(type\s+)?\*(\s+as\s+[\w$]+)?\s+from\s+|^\s*export\s+type\s+\{' . || true

echo
echo "== read-only regex behavior probe =="
python3 - <<'PY'
import re

literal_source = r"""String.raw`export\s*(\{[^}]*\}|\*)\s*from\s*['"][^'"]+['"];?`,"""
current = re.compile(r"export\s*(\{[^}]*\}|\*)\s*from\s*['"][^'"]+['"];?")
proposed = re.compile(r"export\s*(type\s+)?(\{[^}]*\}|\*(\s+as\s+[\w$]+)?)\s*from\s*['"][^'"]+['"];?")

cases = [
    "export { Widget } from './widgets';",
    "export * from './widgets';",
    "export {} from './widgets';",
    "export type { Widget } from './widgets';",
    "export * as widgets from './widgets';",
    "export * as $widgets from './widgets';",
]

for case in cases:
    print(case)
    print(" current matches", current.search(case) is not None)
    print(" proposed matches", proposed.search(case) is not None)
PY

Repository: norvalbv/devkit

Length of output: 3483


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 <<'PY'
import re

current = re.compile(r"export\s*(\{[^}]*\}|\*)\s*from\s*['\"].*?['\"];\??")
namespace = re.compile(r"export\s*(type\s+)?(\{[^}]*\}|\*(\s+as\s+[\w$]+)?)\s*from\s*['\"].*?['\"];\??")

cases = [
    "export { Widget } from './widgets';",
    "export * from './widgets';",
    "export type { Widget } from './widgets';",
    "export * as widgets from './widgets';",
    "export * as $widgets from './widgets';",
]

for case in cases:
    print(case)
    print(" current matches", bool(current.search(case)))
    print(" proposed matches", bool(namespace.search(case)))
PY

Repository: norvalbv/devkit

Length of output: 562


Match type-only re-exports.

Type-only exports are valid module declarations, and export type { ... } from 'module'; is currently present in the repo. It falls outside the current IMPORT_IGNORE_PATTERNS re-export regex, so duplicated type-only re-export preambles can still block the gate. Extend the pattern for export type { ... } from ... and add matching contract cases.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gate-engine/co-occurrence/clone-detector.mts` around lines 239 - 240, Extend
the re-export pattern in IMPORT_IGNORE_PATTERNS to match `export type { ... }
from ...` alongside existing named and wildcard re-exports, while preserving
current matching behavior; add contract cases covering type-only re-exports so
duplicated type-only preambles are ignored.

];

/** Stable key: hash the fragment with whitespace collapsed so reformatting
* doesn't change the key, but real code changes do. */
export function hashFragment(fragment: string): string {
Expand Down
Loading