From 02106e5c9d6ca13da2c497757244c9c711e0f943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 26 Oct 2026 21:19:57 +0100 Subject: [PATCH 1/4] chore(gates): detect new native clones against a reviewed baseline --- .github/workflows/ci.yml | 16 ++ .jscpd-native.json | 12 ++ CONTRIBUTING.md | 9 ++ docs/experiments/native-duplication.md | 93 +++++++++++ native-dupes-baseline.json | 87 ++++++++++ package.json | 12 +- pnpm-lock.yaml | 87 ++++++++++ scripts/check-affected/checks.ts | 6 + scripts/check-affected/model.test.ts | 27 ++++ scripts/check-affected/model.ts | 17 ++ scripts/native-dupes/check.test.ts | 213 +++++++++++++++++++++++++ scripts/native-dupes/prototype.ts | 103 ++++++++++++ 12 files changed, 679 insertions(+), 3 deletions(-) create mode 100644 .jscpd-native.json create mode 100644 docs/experiments/native-duplication.md create mode 100644 native-dupes-baseline.json create mode 100644 scripts/native-dupes/check.test.ts create mode 100644 scripts/native-dupes/prototype.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53bdd1ae10..deb041a1db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,6 +164,22 @@ jobs: --base ${{ env.FALLOW_BASE }} + - name: Test native clone detection and baseline policy + uses: ./.github/actions/run-gate + with: { gate: native-dupes-model } + + - name: Check new native code clones + uses: ./.github/actions/run-gate + with: { gate: native-dupes } + + - name: Upload native duplication report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: native-dupes-report + path: .tmp/native-dupes/jscpd-report.json + if-no-files-found: ignore + - name: Check import-direction DAG # Structured import-direction lint over the resolved graph. See # scripts/layering/check.ts and CONTEXT.md (Architecture: folder DAG + diff --git a/.jscpd-native.json b/.jscpd-native.json new file mode 100644 index 0000000000..9ec85281d5 --- /dev/null +++ b/.jscpd-native.json @@ -0,0 +1,12 @@ +{ + "path": ["apple", "android", "linux"], + "format": ["swift", "objectivec", "kotlin", "java", "python"], + "formatsExts": { "objectivec": ["m", "h"] }, + "mode": "weak", + "minTokens": 50, + "minLines": 5, + "reporters": ["console", "json"], + "output": ".tmp/native-dupes", + "noColors": true, + "noTips": true +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f71c193c5..7fb87d5e05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -241,6 +241,15 @@ Useful direct entry points: fails when `coverage/` was not written by that command on this commit. `--all` reports every function and never fails: that is `main`'s backlog. Both list the per-function CRAP ceilings from `.fallowrc.json` they honoured. `check:fallow` leaves CRAP off without coverage. +- `pnpm check:native-dupes` — reject new native code clones against the reviewed baseline; + scans Swift, Objective-C (including headers), Kotlin, Java, and Python in `apple/`, `android/`, + and `linux/`, including tests. No Xcode or JVM is needed. +- `pnpm native-dupes:report` — inspect all native clones without failing on new findings; + writes `.tmp/native-dupes/jscpd-report.json` with both locations and `isNew` markers. +- `pnpm native-dupes:prototype` — compare detection settings and the Duo fixture pair; + see [measurements and baseline review](docs/experiments/native-duplication.md). +- `pnpm native-dupes:baseline` — update accepted clones only after reviewing their source; + do not use a baseline refresh merely to clear a failure. - `pnpm fallow:all` — full-tree audit, including grandfathered baseline findings - `pnpm fallow:baseline` — intentionally regenerate the reviewed Fallow health baseline diff --git a/docs/experiments/native-duplication.md b/docs/experiments/native-duplication.md new file mode 100644 index 0000000000..49e8993870 --- /dev/null +++ b/docs/experiments/native-duplication.md @@ -0,0 +1,93 @@ +# Native duplication defaults + +Measured at `a16d4372376bf18eea3dff4cbe85e15fc68e7fc0` with jscpd 5.4.2 on macOS arm64. +The scans include native tests and Objective-C headers. They respect `.gitignore`, which excludes +Swift build caches, Android build output, and packaged helpers. No source exclusions were added. + +## Reproduce the comparison + +```sh +pnpm install --frozen-lockfile +pnpm native-dupes:prototype +``` + +Each variant scans `apple/`, `android/`, and `linux/`, then scans the two TypeScript tests named in +the original report. JSON reports and `summary.json` go to `.tmp/native-dupes-prototypes/`. +The prototype fixes the worker count at two. The table records this source revision, not a target +for later revisions. Percentages are jscpd's duplicated-line metric, not unique lines to remove. +Files below the token minimum do not contribute to its analyzed-file count. + +| Mode | Tokens / lines | Native clones | Duplicated lines | Duplication | Duo pair clones | Duo panel fragments | +| --- | --- | ---: | ---: | ---: | ---: | ---: | +| mild | 50 / 5 | 85 | 873 | 1.80% | 4 | 0 | +| weak | 50 / 5 | 82 | 857 | 1.77% | 4 | 0 | +| weak | 70 / 5 | 18 | 228 | 0.47% | 2 | 0 | +| weak | 100 / 8 | 0 | 0 | 0.00% | 0 | 0 | +| weak | 30 / 3 | 401 | 2971 | 6.12% | 16 | 0 | +| weak | 20 / 3 | 860 | 5435 | 11.20% | 31 | 2 | +| weak + consistent identifier normalization | 50 / 5 | 196 | 2150 | 4.44% | 5 | 0 | +| weak + gaps up to 8 lines | 30 / 3 | 384 | 2960 | 6.10% | 13 | 0 | + +The measured native runs took about 170–195 ms each, including Node startup, using two workers. +This is a single timing sample per variant, not a performance benchmark. Linux execution belongs +to the Repo Guards & Compatibility CI job. + +## Selected policy + +Use weak mode with a minimum of 50 tokens and 5 lines. Weak mode removes whitespace and comments +from matching. The default mild mode adds three findings without improving either motivating +example. At 70 tokens, all seven Java findings disappear, including repeated snapshot capture and +error response handling. At 100 tokens and 8 lines, the native report is empty. At 30 tokens and +3 lines, imports, small guards, and repeated test setup dominate a much larger review backlog. +Identifier normalization also expands that backlog and still misses the Duo panels. + +The selected scan analyzes 218 files and reports 82 clones: 68 Swift, 7 Java, 7 Objective-C, and +0 Python. There are no Kotlin source files in the measured trees; an executable fixture proves +Kotlin detection. The Objective-C extension mapping includes `.h`, because those headers contain +executable geometry code as well as declarations. Five header findings are declaration/implementation +pairs. Existing findings also include deliberately similar tests and Swift package manifests. +The baseline records these findings without declaring them defects or requiring extraction. + +jscpd 5.4.2 has a [built-in fingerprint baseline](https://jscpd.dev/guides/baseline), introduced +in 5.1.0. A custom comparison script or percentage ceiling is unnecessary. The baseline records +content fingerprints and clone multiplicities. It tolerates line shifts and fails on a third copy +of an accepted pair. A new clone fails even if another clone is removed and the total stays equal. +The executable policy tests cover each language, headers, line shifts, extra copies, replacement +clones, missing baselines, and empty scans. + +The rollout starts with a report-only measurement and saves that result in +`native-dupes-baseline.json`. `check:native-dupes` then rejects new fingerprints or increased +multiplicities. It scans all native files rather than filtering by changed files. This keeps +unchanged files available as comparison targets and needs no Git history in CI. `check:affected` +selects it for native source changes; configuration and baseline changes select the full check set. +`--changed` is unsuitable for PR enforcement because it filters by working-tree status, which is +empty after a commit. The normal check never updates the baseline. + +## Motivating cases and limits + +The closed-Duo fixtures differ at the measured revision. `display-inventory.test.ts` includes +bounds, orientation, and unique IDs absent from `simctl-facade.test.ts`. At 50 / 5, jscpd finds +shared imports and the separate single-panel fixture, but no shared closed-Duo panel fragment. +At 20 / 3, it finds two 26-token panel fragments, at `simctl-facade.test.ts:36–40` and `46–50` +against `display-inventory.test.ts:76–80` and `93–97`. It also reports 860 native clones. Enabling +gap merging cannot recover fragments below its seed token minimum. TypeScript remains owned by +Fallow; this experiment does not add a second TypeScript enforcement policy. + +No matrix variant finds a clone in the checked-in `Fold.m`. A separate 10-token / 2-line probe +finds two tiny repeated fragments (lines 165–167 against 230–232, and 214–216 against 218–220). +Those are not evidence that it would catch the historical seven-line case; that original duplicated +revision was not replayed. Keep small or reimplemented logic in code review. Renamed or partially +edited copies can evade the selected exact-token policy. Reported blocks need human assessment, +especially test setup and Objective-C declarations. + +## Inspect or change the baseline + +Run `pnpm native-dupes:report` to inspect findings without failing on new clones. Both report and +check write `.tmp/native-dupes/jscpd-report.json`; each clone has an `isNew` field. CI uploads the +check report as `native-dupes-report` even when the check fails. + +When a check reports a new clone, compare both locations and remove the repeated block when they +share an owner. If retaining duplication is intentional, review its source before running +`pnpm native-dupes:baseline`. Review the baseline diff together with the source change. Do not +regenerate it merely to clear a failing check. A detector upgrade or a change to matching options +also needs a fresh comparison and baseline review. Missing baselines and empty scans fail closed. diff --git a/native-dupes-baseline.json b/native-dupes-baseline.json new file mode 100644 index 0000000000..40c5d55bce --- /dev/null +++ b/native-dupes-baseline.json @@ -0,0 +1,87 @@ +{ + "version": 1, + "fingerprints": { + "08241ae1747c89ca": 1, + "0c4f1a12fc6266db": 1, + "13e6519917e53aab": 1, + "16f64c13af3b5133": 1, + "177dd03fcad181e3": 1, + "1820c3730c884e03": 1, + "1bcd955ed0310803": 1, + "1e78746beb116a42": 1, + "20171c882520cfa8": 1, + "227ed1e368c386a3": 1, + "27eb86edc47396c2": 1, + "2bb560fa44bc0244": 1, + "2ebd28fb2fcbff63": 1, + "320b08ffc8a201a2": 1, + "3a4e282cb1e5c9e5": 1, + "3cbf0776f9254f0d": 1, + "3d12b710a26fd356": 1, + "411c3f06f49cd0a2": 1, + "524d0e8ddb986bee": 1, + "52af4ac9900c2849": 1, + "54b3cba0fee01e85": 1, + "5642c43795f3f29c": 1, + "5fa328e6b670b2b2": 1, + "6102c64469fb953e": 1, + "616ccefa6658d7d2": 1, + "65cd2c6cb3d72ad2": 1, + "65cd54e3cc7ae70b": 1, + "65e1ce685b7d8fec": 1, + "680fc5b248058fe3": 1, + "681fecffbcf9ab84": 1, + "6bd1eae209b54d99": 1, + "6bf827b16ebbcdf9": 1, + "707b34f8e5cb3dec": 1, + "734566f43c6eeead": 1, + "73ae12f493ebe4ea": 1, + "764a7f81b4003392": 1, + "7aa11fc25854039d": 1, + "7ba42cc0416d2c0b": 1, + "7cb430dbae9d7106": 1, + "8054ef6981eda322": 1, + "851cc95e5042cc92": 1, + "8792a45dec33a4ff": 1, + "89044cc861f67793": 1, + "8f44d31bc1be550a": 1, + "93fdf4830756d189": 1, + "950d64ca7c88e560": 1, + "9cac643baca35232": 1, + "9db36d2dd0a613e1": 1, + "a1e36b17c095ff5d": 1, + "a579c1b5b09a0ee4": 1, + "ab2e4a3493b14737": 1, + "abb89aabf30035db": 1, + "af0b0f46b52c4b61": 1, + "b407499133c2b5a4": 1, + "b559829f47faaf3e": 1, + "b7db5591789370e5": 1, + "bbb5e2b410e79619": 1, + "bce350f674af29b1": 1, + "c1bd7ebca2d95036": 1, + "c64b76f41c6604bd": 1, + "cf6e4f135bedee4a": 1, + "d2fc7241663fd1da": 1, + "d52956d1f66b005e": 1, + "d65d2069d5c1284c": 1, + "d891ba5c8127fe48": 1, + "db5122c549f1ddec": 1, + "de2f09c7e4f92a19": 1, + "dff4eab801b90d8f": 1, + "e1956821626a2308": 1, + "e4ae6f992718e4bd": 1, + "e7f1b8db1990c7e0": 1, + "e7fc04b26c3b63d2": 1, + "e88f11085e4e61d5": 1, + "e8984499b65f90d8": 1, + "edaddb383eec4cc2": 1, + "f2cf05f6de02ad76": 1, + "f65edc1e8000a681": 1, + "f901d457d8368e97": 1, + "fc5de2790407f117": 1, + "fd3d1f4c504aa1b4": 1, + "fed244c725eb70f6": 1, + "ff2cfa62ae0daa36": 1 + } +} diff --git a/package.json b/package.json index 7ccada0504..da3d6bfe2d 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,11 @@ "fallow:all": "fallow --summary", "fallow:baseline": "fallow health --report-only --save-baseline fallow-baselines/health.json --summary", "check:fallow": "fallow audit", + "check:native-dupes": "jscpd --config .jscpd-native.json --baseline native-dupes-baseline.json --fail-on-new-clones --fail-on-empty", + "check:native-dupes:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/native-dupes/check.test.ts", + "native-dupes:report": "jscpd --config .jscpd-native.json --baseline native-dupes-baseline.json --fail-on-empty", + "native-dupes:baseline": "jscpd --config .jscpd-native.json --baseline native-dupes-baseline.json --update-baseline --fail-on-empty", + "native-dupes:prototype": "node --experimental-strip-types scripts/native-dupes/prototype.ts", "check:affected": "node --experimental-strip-types scripts/check-affected/run.ts", "check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/*.test.ts", "gate": "node --experimental-strip-types scripts/gate/run.ts", @@ -179,7 +184,7 @@ "check:quick": "pnpm lint && pnpm typecheck", "sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs", "check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check", - "check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:gate-manifest:test && pnpm check:gate-manifest && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:xctest-selection && pnpm check:packaged-runner-swift && pnpm check:mcp-metadata && pnpm check:doc-prose && pnpm check:doc-links && pnpm build && pnpm check:bundle-owner-files && pnpm check:package", + "check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:gate-manifest:test && pnpm check:gate-manifest && pnpm check:production-exports && pnpm check:native-dupes:test && pnpm check:native-dupes && pnpm check:tmpdir-leaks:test && pnpm check:xctest-selection && pnpm check:packaged-runner-swift && pnpm check:mcp-metadata && pnpm check:doc-prose && pnpm check:doc-links && pnpm build && pnpm check:bundle-owner-files && pnpm check:package", "check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke", "check": "node --experimental-strip-types scripts/check-affected/run.ts --run --full", "prepack": "npm_config_dry_run=false pnpm release:prepare", @@ -324,6 +329,7 @@ "@agent-device/selectors": "workspace:*", "@agent-device/session-journal": "workspace:*", "@agent-device/test-kit": "workspace:*", + "@agent-device/testmu": "workspace:*", "@agent-device/xml": "workspace:*", "@arethetypeswrong/cli": "^0.18.5", "@chenglou/freerange": "^0.0.4", @@ -348,6 +354,7 @@ "fast-check": "^4.9.0", "ipaddr.js": "^2.5.0", "jpeg-js": "^0.4.4", + "jscpd": "5.4.2", "oxc-parser": "^0.146.0", "oxfmt": "^0.67.0", "oxlint": "^1.79.0", @@ -360,7 +367,6 @@ "vite": "^8.3.5", "vitest": "5.0.4", "yaml": "^2.9.0", - "yauzl": "^3.4.0", - "@agent-device/testmu": "workspace:*" + "yauzl": "^3.4.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb7a9a0b7a..72ba668cf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: jpeg-js: specifier: ^0.4.4 version: 0.4.4 + jscpd: + specifier: 5.4.2 + version: 5.4.2 oxc-parser: specifier: ^0.146.0 version: 0.146.0 @@ -3390,6 +3393,55 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + jscpd-darwin-arm64@5.4.2: + resolution: {integrity: sha512-EGoPYQZqESW2kJfxKZrUYsYWhzq2Irk6sBQQWZ7VPAGj2wwk3n8HtDP2FnJOiHWZtSz55I8zQWldzIrD55B5Vw==} + cpu: [arm64] + os: [darwin] + + jscpd-darwin-x64@5.4.2: + resolution: {integrity: sha512-/bz/rgoahqeK0p5r+KjeTTS59d7bZpMxD7hQ4CaQ0qKZWBZIyu7hRdExzJ7/KrCEJ5cmRogvXX1jhFqldYLNXw==} + cpu: [x64] + os: [darwin] + + jscpd-linux-arm64-gnu@5.4.2: + resolution: {integrity: sha512-YW/eX+mOYZmT/A3QEARYmJQoVQNV/cfecKCobNXZcD3mE57drpKHmJnvuL9uS85z0cUKWGCM57JVkVz6Y9oxxw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + jscpd-linux-arm64-musl@5.4.2: + resolution: {integrity: sha512-jOLTO8YVRz65NaeYNeXoIf13yOPWhbM61xKXpv51DXQ2BXce6XfgPaJVeDCOM5s+8mt6aDtq0BZ8zptNeqGuIw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + jscpd-linux-x64-gnu@5.4.2: + resolution: {integrity: sha512-eAmKLxx+pIOEX4lZ342CCCjL5bvf+a3gdmaaVHXiGIx2Zr3zKkF32YkfuN2sCvCui5I/wnely80XsRvVVFO47Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + jscpd-linux-x64-musl@5.4.2: + resolution: {integrity: sha512-ers9YFscD6DrmhyjBDXo/L+yBKcclHdWTFLMulYVGLcoGRKfBFhmK08MI3ajN/GMmCpJu7rkDYKL3egdfE8OIw==} + cpu: [x64] + os: [linux] + libc: [musl] + + jscpd-windows-arm64-msvc@5.4.2: + resolution: {integrity: sha512-kOhVw4cVhN8T9jr3OVThB3pPpbff0ITHTwpJ/+4EGzMkbf0y6QtPpHDzRgiHJj7u1FSTBXTI6VhPhDUhT/os1g==} + cpu: [arm64] + os: [win32] + + jscpd-windows-x64-msvc@5.4.2: + resolution: {integrity: sha512-BuR5DCV7F/G5ukMEIzCrz43ea5C0t0ALcP7v+opOWefoi4aiQYCEmtoRwfXMM/zjOmNYmvJBDDfeH05U9uuzEg==} + cpu: [x64] + os: [win32] + + jscpd@5.4.2: + resolution: {integrity: sha512-bxZ1ObA4NyWBcM7Zh+QoGrdcMovbCxrDofFaLo2Lwgk/Axe/e45VYu8EM+mpPx2CpU/QGCoktONRnaRZl8Yq2w==} + engines: {node: '>=18'} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -6787,6 +6839,41 @@ snapshots: js-tokens@10.0.0: {} + jscpd-darwin-arm64@5.4.2: + optional: true + + jscpd-darwin-x64@5.4.2: + optional: true + + jscpd-linux-arm64-gnu@5.4.2: + optional: true + + jscpd-linux-arm64-musl@5.4.2: + optional: true + + jscpd-linux-x64-gnu@5.4.2: + optional: true + + jscpd-linux-x64-musl@5.4.2: + optional: true + + jscpd-windows-arm64-msvc@5.4.2: + optional: true + + jscpd-windows-x64-msvc@5.4.2: + optional: true + + jscpd@5.4.2: + optionalDependencies: + jscpd-darwin-arm64: 5.4.2 + jscpd-darwin-x64: 5.4.2 + jscpd-linux-arm64-gnu: 5.4.2 + jscpd-linux-arm64-musl: 5.4.2 + jscpd-linux-x64-gnu: 5.4.2 + jscpd-linux-x64-musl: 5.4.2 + jscpd-windows-arm64-msvc: 5.4.2 + jscpd-windows-x64-msvc: 5.4.2 + jsesc@3.1.0: {} json-buffer@3.0.1: {} diff --git a/scripts/check-affected/checks.ts b/scripts/check-affected/checks.ts index 4b77ca3b3d..77402f2ce7 100644 --- a/scripts/check-affected/checks.ts +++ b/scripts/check-affected/checks.ts @@ -53,6 +53,12 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ gate('layering', 'Import-direction layering guard', 'check:layering'), gate('di-seams', 'Test-only DI seam guard', 'check:di-seams'), gate('fallow', 'Fallow code-quality audit', 'check:fallow'), + gate('native-dupes', 'New native code clones (jscpd)', 'check:native-dupes'), + gate( + 'native-dupes-model', + 'Native clone detection and baseline policy', + 'check:native-dupes:test', + ), gate('mcp-metadata', 'MCP registry metadata sync', 'check:mcp-metadata'), gate('build', 'Build (tsdown + declarations)', 'build'), gate('package', 'Published package (publint, attw, clean-install resolution)', 'check:package'), diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index 267635e4f8..2f5dd7ea7f 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -87,6 +87,7 @@ test('Swift runner change selects both XCUITest platform builds', () => { // Each platform build is its own gate in its own lane, so a Swift change owns both. // (The Apple device lanes ride along: the runner is what those lanes boot — device-lanes.ts.) assert.deepEqual(ids(['apple/runner/Sources/Runner/Main.swift']), [ + 'native-dupes', 'swift-runner-ios', 'swift-runner-macos', 'packaged-runner-swift', @@ -104,6 +105,7 @@ test('a runner XCTest source also selects the test-list and package-source check assert.deepEqual( ids(['apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift']), [ + 'native-dupes', 'swift-runner-ios', 'swift-runner-macos', 'xctest-selection', @@ -149,6 +151,7 @@ test('the shared foreground geometry header selects the Apple runner builds', () test('Android helper change selects the android-helpers build', () => { assert.deepEqual(ids(['android/snapshot-helper/src/Main.kt']), [ + 'native-dupes', 'android-helpers', 'replay-android', ]); @@ -450,3 +453,27 @@ test('vitest-related delegates changed paths to Vitest instead of modeling proje }); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +test('native duplication covers language inputs without selecting metadata or TypeScript', () => { + for (const file of [ + 'apple/fold-helper/Fold.m', + 'apple/snapshot-bridge/Bridge.h', + 'android/ime-helper/Input.kt', + 'android/ime-helper/Build.kts', + 'android/snapshot-helper/Touch.java', + 'linux/atspi-dump.py', + ]) { + assert.ok(ids([file]).includes('native-dupes'), file); + } + for (const file of [ + 'apple/runner/README.md', + 'android/ime-helper/AndroidManifest.xml', + 'packages/platform-apple/src/core/app-resolution.ts', + ]) { + assert.ok(!ids([file]).includes('native-dupes'), file); + } + for (const file of ['.jscpd-native.json', 'native-dupes-baseline.json']) { + assert.equal(plan([file]).failOpen, true); + assert.ok(ids([file]).includes('native-dupes-model'), file); + } +}); diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index de27de35d3..ebd5bb3079 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -38,6 +38,8 @@ export const ALL_CHECKS = [ 'layering', 'di-seams', 'fallow', + 'native-dupes', + 'native-dupes-model', 'mcp-metadata', 'build', 'package', @@ -138,6 +140,8 @@ const ROOT_TOOLING = new Set([ 'tsdown.config.ts', 'vitest.config.ts', '.fallowrc.json', + '.jscpd-native.json', + 'native-dupes-baseline.json', 'oxlint.config.ts', '.oxfmtrc.json', '.npmrc', @@ -391,6 +395,18 @@ const ownsAppleRunnerBuildSource = (file: string): boolean => (file.startsWith('packages/platform-apple/src/runner/') && !file.includes('/__tests__/')) || file.endsWith('.swift'); +const nativeDupesOwnership: OwnershipRule = ({ file }) => + /^(?:apple|android|linux)\/.*\.(?:swift|m|h|kt|kts|java|py)$/.test(file) + ? [ + reason( + 'native-dupes', + file, + 'own:native-dupes', + 'jscpd compares native code against the clone baseline', + ), + ] + : []; + const BUILD_OWNERSHIP: ReadonlyArray<{ check: CheckId; rule: string; @@ -633,6 +649,7 @@ const OWNERSHIP_RULES: readonly OwnershipRule[] = [ replayCompatOwnership, daemonWireCompatOwnership, buildOwnership, + nativeDupesOwnership, deviceLaneRule, ]; diff --git a/scripts/native-dupes/check.test.ts b/scripts/native-dupes/check.test.ts new file mode 100644 index 0000000000..c30a8bec2d --- /dev/null +++ b/scripts/native-dupes/check.test.ts @@ -0,0 +1,213 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { runCmdSync } from '@agent-device/host-kit/command'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; + +const root = path.resolve(import.meta.dirname, '../..'); +const cli = path.join(root, 'node_modules/jscpd/run-jscpd.js'); +const { scripts } = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const gateArgs: string[] = scripts['check:native-dupes'].split(' ').slice(1); +const baselineArgs: string[] = scripts['native-dupes:baseline'].split(' ').slice(1); + +type Report = { + statistics: { total: { clones: number; newClones: number } }; + duplicates: Array<{ format: string; isNew: boolean }>; +}; + +function fixture() { + const cwd = mkdtempForTestSync('native-dupes-'); + runCmdSync('git', ['init', '-q'], { cwd }); + fs.copyFileSync(path.join(root, '.jscpd-native.json'), path.join(cwd, '.jscpd-native.json')); + for (const dir of ['apple', 'android', 'linux']) fs.mkdirSync(path.join(cwd, dir)); + const write = (file: string, source: string) => fs.writeFileSync(path.join(cwd, file), source); + const run = (args = gateArgs) => { + const result = runCmdSync( + process.execPath, + [cli, ...args, '--workers', '1', '--reporters', 'json'], + { + cwd, + allowFailure: true, + }, + ); + const reportPath = path.join(cwd, '.tmp/native-dupes/jscpd-report.json'); + const report: Report | undefined = fs.existsSync(reportPath) + ? JSON.parse(fs.readFileSync(reportPath, 'utf8')) + : undefined; + return { ...result, report }; + }; + return { cwd, write, run }; +} + +const sources = [ + { + file: 'apple/Capture.swift', + format: 'swift', + source: ` +func captureWindow(_ frames: [Int]) -> Int { + var total = 0 + for frame in frames { + let bounded = max(0, frame) + let scaled = bounded * 17 + total += scaled + if scaled > 100 { + total -= 3 + } + } + print("captured window", total) + return total +} +`, + }, + { + file: 'apple/Capture.m', + format: 'objectivec', + source: ` +int captureWindow(int count) { + int total = 0; + for (int frame = 0; frame < count; frame++) { + int bounded = MAX(0, frame); + int scaled = bounded * 19; + total += scaled; + if (scaled > 110) { + total -= 5; + } + } + NSLog(@"captured panel %d", total); + return total; +} +`, + }, + { + file: 'android/Capture.java', + format: 'java', + source: ` +class Capture { + static int captureWindow(int count) { + int total = 0; + for (int frame = 0; frame < count; frame++) { + int bounded = Math.max(0, frame); + int scaled = bounded * 23; + total += scaled; + if (scaled > 120) { + total -= 7; + } + } + System.out.println("captured view " + total); + return total; + } +} +`, + }, + { + file: 'android/Capture.kt', + format: 'kotlin', + source: ` +fun captureWindow(frames: List): Int { + var total = 0 + for (frame in frames) { + val bounded = maxOf(0, frame) + val scaled = bounded * 29 + total += scaled + if (scaled > 130) { + total -= 11 + } + } + println("captured activity: $total") + return total +} +`, + }, + { + file: 'linux/capture.py', + format: 'python', + source: ` +def capture_window(frames): + total = 0 + for frame in frames: + bounded = max(0, frame) + scaled = bounded * 31 + total += scaled + if scaled > 140: + total -= 13 + else: + total += 17 + print("captured surface", total) + return total +`, + }, +]; + +for (const { file, format, source } of sources) { + test(`the gate rejects a new ${format} clone of an unchanged file`, () => { + const { write, run } = fixture(); + write(file, source); + const baseline = run(baselineArgs); + assert.equal(baseline.exitCode, 0, baseline.stderr); + assert.equal(baseline.report?.statistics.total.clones, 0); + write(file.replace('.', '-copy.'), source); + const result = run(); + assert.equal(result.exitCode, 1, result.stdout + result.stderr); + assert.ok(result.report?.duplicates.some((clone) => clone.format === format && clone.isNew)); + }); +} + +test('a header clone is analyzed as Objective-C', () => { + const { write, run } = fixture(); + write('apple/Geometry.h', sources[1]!.source); + assert.equal(run(baselineArgs).exitCode, 0); + write('apple/GeometryCopy.h', sources[1]!.source); + const result = run(); + assert.equal(result.exitCode, 1); + assert.ok( + result.report?.duplicates.some((clone) => clone.format === 'objectivec' && clone.isNew), + ); +}); + +test('baseline accepts line shifts, rejects extra copies and replacement clones at the same count', () => { + const { cwd, write, run } = fixture(); + const source = sources[0]!.source; + write('apple/Capture.swift', source); + write('apple/Copy.swift', source); + assert.equal(run(baselineArgs).exitCode, 0); + assert.equal(run().exitCode, 0); + write('apple/Copy.swift', `// A shifted copy.\n\n${source}`); + assert.equal(run().exitCode, 0); + write('apple/Third.swift', source); + assert.equal(run().exitCode, 1, 'a third copy must exceed the fingerprint count'); + fs.unlinkSync(path.join(cwd, 'apple/Third.swift')); + const replacement = source.replace('17', '37'); + write('apple/Capture.swift', replacement); + write('apple/Copy.swift', replacement); + const changed = run(); + assert.equal(changed.report?.statistics.total.clones, 1); + assert.equal(changed.report?.statistics.total.newClones, 1); + assert.equal(changed.exitCode, 1, 'unchanged total count must not hide a different clone'); +}); + +test('the gate rejects a missing baseline and an empty scan', () => { + const { write, run } = fixture(); + write('apple/Capture.swift', sources[0]!.source); + assert.equal(run().exitCode, 1, 'a missing baseline must not become a report-only scan'); + assert.equal(run(baselineArgs).exitCode, 0); + write('apple/Capture.swift', '// No executable tokens.\n'); + assert.equal(run().exitCode, 1, 'a format or scope mistake must not silently pass'); +}); + +test('same-file clones fail while comment blocks and ignored build outputs do not', () => { + const { cwd, write, run } = fixture(); + const source = sources[0]!.source; + write('apple/Capture.swift', source); + assert.equal(run(baselineArgs).exitCode, 0); + write('apple/Capture.swift', `${source}\n${source.replace('captureWindow', 'captureDisplay')}`); + assert.equal(run().exitCode, 1, 'copies within one file must remain in scope'); + write('apple/Capture.swift', source); + const comment = `/*\n${'Repeated documentation without executable code.\n'.repeat(30)}*/\n`; + write('apple/Notes.swift', comment); + write('apple/OtherNotes.swift', comment); + write('.gitignore', 'apple/build/\n'); + fs.mkdirSync(path.join(cwd, 'apple/build')); + write('apple/build/Generated.swift', source); + assert.equal(run().exitCode, 0, 'comments and ignored generated copies must not add clones'); +}); diff --git a/scripts/native-dupes/prototype.ts b/scripts/native-dupes/prototype.ts new file mode 100644 index 0000000000..825836ce15 --- /dev/null +++ b/scripts/native-dupes/prototype.ts @@ -0,0 +1,103 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { runCmdSync } from '@agent-device/host-kit/command'; + +const root = path.resolve(import.meta.dirname, '../..'); +const cli = path.join(root, 'node_modules/jscpd/run-jscpd.js'); +const output = path.join(root, '.tmp/native-dupes-prototypes'); +const pair = [ + 'packages/platform-apple/src/core/__tests__/display-inventory.test.ts', + 'packages/platform-apple/src/__tests__/simctl-facade.test.ts', +]; +const variants = [ + { name: 'mild-50-5', tokens: 50, lines: 5, mode: 'mild', extra: [] }, + { name: 'weak-50-5', tokens: 50, lines: 5, mode: 'weak', extra: [] }, + { name: 'weak-70-5', tokens: 70, lines: 5, mode: 'weak', extra: [] }, + { name: 'weak-100-8', tokens: 100, lines: 8, mode: 'weak', extra: [] }, + { name: 'weak-30-3', tokens: 30, lines: 3, mode: 'weak', extra: [] }, + { name: 'weak-20-3', tokens: 20, lines: 3, mode: 'weak', extra: [] }, + { + name: 'renamed-50-5', + tokens: 50, + lines: 5, + mode: 'weak', + extra: ['--ignore-identifiers=consistent'], + }, + { name: 'gap-30-3', tokens: 30, lines: 3, mode: 'weak', extra: ['--max-gap-lines', '8'] }, +]; + +type Report = { + statistics: { + total: { clones: number; duplicatedLines: number; percentage: number; sources: number }; + }; + duplicates: Array<{ + firstFile: { name: string; start: number; end: number }; + secondFile: { name: string; start: number; end: number }; + }>; +}; + +const rows = []; +for (const variant of variants) { + for (const scope of ['native', 'duo'] as const) { + const destination = path.join(output, `${variant.name}-${scope}`); + const start = performance.now(); + runCmdSync( + process.execPath, + [ + cli, + '--config', + '.jscpd-native.json', + ...(scope === 'duo' ? pair : []), + ...(scope === 'duo' ? ['--format', 'typescript'] : []), + '--mode', + variant.mode, + '--min-tokens', + String(variant.tokens), + '--min-lines', + String(variant.lines), + ...variant.extra, + '--workers', + '2', + '--absolute', + '--reporters', + 'json', + '--output', + destination, + ], + { cwd: root }, + ); + const report: Report = JSON.parse( + fs.readFileSync(path.join(destination, 'jscpd-report.json'), 'utf8'), + ); + const { clones, duplicatedLines, percentage, sources } = report.statistics.total; + const fold = report.duplicates.filter((clone) => + [clone.firstFile, clone.secondFile].some((file) => file.name.endsWith('/fold-helper/Fold.m')), + ).length; + const duoPanels = report.duplicates.filter( + (clone) => + clone.firstFile.name !== clone.secondFile.name && + [clone.firstFile, clone.secondFile].some( + (file) => + file.name.endsWith('/simctl-facade.test.ts') && file.start <= 50 && file.end >= 34, + ), + ).length; + const row = { + variant: variant.name, + scope, + sources, + clones, + duplicatedLines, + percentage: Number(percentage.toFixed(2)), + fold, + duoPanels, + milliseconds: Math.round(performance.now() - start), + }; + rows.push(row); + console.log( + `${variant.name} ${scope}: ${clones} clones, ${row.percentage}%, Fold=${fold}, Duo panels=${duoPanels}, ${row.milliseconds}ms`, + ); + } +} +fs.writeFileSync(path.join(output, 'summary.json'), `${JSON.stringify(rows, null, 2)}\n`); +console.log(`Reports: ${output}`); From 6a2596706c4416a0a437688f8b42257a0aa4739e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 26 Oct 2026 21:26:43 +0100 Subject: [PATCH 2/4] chore(gates): preserve native duplication report artifacts --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deb041a1db..9ed8267b01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,6 +177,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: native-dupes-report + include-hidden-files: true path: .tmp/native-dupes/jscpd-report.json if-no-files-found: ignore From e091b4bb8444c4ecca97527d832581d77f2bf82a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 26 Oct 2026 22:31:53 +0100 Subject: [PATCH 3/4] chore(gates): guard native scan scope and portable prototype counts --- package.json | 2 +- scripts/check-affected/model.test.ts | 28 +++++++++ scripts/native-dupes/prototype-report.test.ts | 63 +++++++++++++++++++ scripts/native-dupes/prototype-report.ts | 46 ++++++++++++++ scripts/native-dupes/prototype.ts | 26 ++++---- 5 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 scripts/native-dupes/prototype-report.test.ts create mode 100644 scripts/native-dupes/prototype-report.ts diff --git a/package.json b/package.json index da3d6bfe2d..b627087449 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ "fallow:baseline": "fallow health --report-only --save-baseline fallow-baselines/health.json --summary", "check:fallow": "fallow audit", "check:native-dupes": "jscpd --config .jscpd-native.json --baseline native-dupes-baseline.json --fail-on-new-clones --fail-on-empty", - "check:native-dupes:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/native-dupes/check.test.ts", + "check:native-dupes:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/native-dupes/*.test.ts", "native-dupes:report": "jscpd --config .jscpd-native.json --baseline native-dupes-baseline.json --fail-on-empty", "native-dupes:baseline": "jscpd --config .jscpd-native.json --baseline native-dupes-baseline.json --update-baseline --fail-on-empty", "native-dupes:prototype": "node --experimental-strip-types scripts/native-dupes/prototype.ts", diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index 2f5dd7ea7f..797e4075b2 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -477,3 +477,31 @@ test('native duplication covers language inputs without selecting metadata or Ty assert.ok(ids([file]).includes('native-dupes-model'), file); } }); + +test('native duplication selection covers every configured root, format, and custom extension', () => { + const config: { path: string[]; format: string[]; formatsExts: Record } = + JSON.parse(fs.readFileSync(path.join(repoRoot, '.jscpd-native.json'), 'utf8')); + const defaultExtensions: Record = { + swift: ['swift'], + objectivec: ['m'], + kotlin: ['kt', 'kts'], + java: ['java'], + python: ['py'], + }; + for (const format of config.format) { + const defaults = defaultExtensions[format]; + assert.ok(defaults, `Declare the scanner extensions for the added ${format} format`); + const extensions = new Set([...defaults, ...(config.formatsExts[format] ?? [])]); + for (const root of config.path) { + for (const extension of extensions) { + const file = `${root}/duplication-probe/Source.${extension}`; + assert.ok( + plan([file]).reasons.some( + (entry) => entry.check === 'native-dupes' && entry.rule === 'own:native-dupes', + ), + file, + ); + } + } + } +}); diff --git a/scripts/native-dupes/prototype-report.test.ts b/scripts/native-dupes/prototype-report.test.ts new file mode 100644 index 0000000000..69c2294835 --- /dev/null +++ b/scripts/native-dupes/prototype-report.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { countMotivatingClones, namedConstantRange } from './prototype-report.ts'; + +const source = `// const IPHONE_DUO_CLOSED = "😀 comment, not the fixture"; +const other = { nativeSize: [1, 2] }; +const IPHONE_DUO_CLOSED = JSON.stringify({ + result: { + displays: [ + { active: true, nativeSize: [1398, 2034] }, + { active: false, nativeSize: [2007, 2853] }, + ], + }, +}); +const singlePanel = { active: true }; +`; + +test('Duo range follows its named constant after line shifts and ignores comment decoys', () => { + assert.deepEqual(namedConstantRange(source, 'IPHONE_DUO_CLOSED'), { start: 3, end: 10 }); + assert.deepEqual(namedConstantRange('\n'.repeat(40) + source, 'IPHONE_DUO_CLOSED'), { + start: 43, + end: 50, + }); + assert.throws(() => namedConstantRange(source, 'RENAMED_FIXTURE'), /RENAMED_FIXTURE/); +}); + +for (const separator of ['/', '\\']) { + test(`motivating clone counts accept ${JSON.stringify(separator)} paths and shifted fixture lines`, () => { + const target = ['checkout', 'simctl-facade.test.ts'].join(separator); + const other = ['checkout', 'display-inventory.test.ts'].join(separator); + const fold = ['checkout', 'apple', 'fold-helper', 'Fold.m'].join(separator); + const counts = countMotivatingClones( + [ + { + firstFile: { name: fold, start: 2, end: 8 }, + secondFile: { name: fold, start: 12, end: 18 }, + }, + { + firstFile: { name: target, start: 45, end: 47 }, + secondFile: { name: other, start: 70, end: 72 }, + }, + { + firstFile: { name: other, start: 73, end: 75 }, + secondFile: { name: target, start: 48, end: 50 }, + }, + { + firstFile: { name: target, start: 51, end: 60 }, + secondFile: { name: other, start: 80, end: 89 }, + }, + { + firstFile: { name: target, start: 41, end: 42 }, + secondFile: { name: other, start: 5, end: 6 }, + }, + { + firstFile: { name: target, start: 45, end: 47 }, + secondFile: { name: target, start: 48, end: 50 }, + }, + ], + namedConstantRange('\n'.repeat(40) + source, 'IPHONE_DUO_CLOSED'), + ); + assert.deepEqual(counts, { fold: 1, duoPanels: 2 }); + }); +} diff --git a/scripts/native-dupes/prototype-report.ts b/scripts/native-dupes/prototype-report.ts new file mode 100644 index 0000000000..d9e5143774 --- /dev/null +++ b/scripts/native-dupes/prototype-report.ts @@ -0,0 +1,46 @@ +import { parseSync } from 'oxc-parser'; +import { repoRelativePath } from '../lib/repo-path.ts'; + +type LineRange = { start: number; end: number }; +type CloneLocation = LineRange & { name: string }; +export type PrototypeClone = { firstFile: CloneLocation; secondFile: CloneLocation }; + +export function namedConstantRange(source: string, name: string): LineRange { + const parsed = parseSync('fixture.ts', source); + if (parsed.errors.length > 0) throw new Error('Cannot parse the prototype fixture'); + const declaration = parsed.program.body + .flatMap((statement) => + statement.type === 'VariableDeclaration' && statement.kind === 'const' + ? statement.declarations + : [], + ) + .find((item) => item.id.type === 'Identifier' && item.id.name === name); + if (!declaration?.init) throw new Error(`Cannot locate prototype fixture constant ${name}`); + return { + start: source.slice(0, declaration.init.start).split('\n').length, + end: source.slice(0, declaration.init.end).split('\n').length, + }; +} + +export function countMotivatingClones(clones: readonly PrototypeClone[], duoRange: LineRange) { + let fold = 0; + let duoPanels = 0; + for (const clone of clones) { + const files = [clone.firstFile, clone.secondFile].map((file) => ({ + ...file, + name: repoRelativePath(file.name), + })); + if (files.some((file) => file.name.endsWith('/fold-helper/Fold.m'))) fold++; + if ( + files[0]!.name !== files[1]!.name && + files.some( + (file) => + file.name.endsWith('/simctl-facade.test.ts') && + file.start <= duoRange.end && + file.end >= duoRange.start, + ) + ) + duoPanels++; + } + return { fold, duoPanels }; +} diff --git a/scripts/native-dupes/prototype.ts b/scripts/native-dupes/prototype.ts index 825836ce15..d7ad0ed78b 100644 --- a/scripts/native-dupes/prototype.ts +++ b/scripts/native-dupes/prototype.ts @@ -2,6 +2,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { runCmdSync } from '@agent-device/host-kit/command'; +import { + countMotivatingClones, + namedConstantRange, + type PrototypeClone, +} from './prototype-report.ts'; const root = path.resolve(import.meta.dirname, '../..'); const cli = path.join(root, 'node_modules/jscpd/run-jscpd.js'); @@ -10,6 +15,10 @@ const pair = [ 'packages/platform-apple/src/core/__tests__/display-inventory.test.ts', 'packages/platform-apple/src/__tests__/simctl-facade.test.ts', ]; +const duoRange = namedConstantRange( + fs.readFileSync(path.join(root, pair[1]!), 'utf8'), + 'IPHONE_DUO_CLOSED', +); const variants = [ { name: 'mild-50-5', tokens: 50, lines: 5, mode: 'mild', extra: [] }, { name: 'weak-50-5', tokens: 50, lines: 5, mode: 'weak', extra: [] }, @@ -31,10 +40,7 @@ type Report = { statistics: { total: { clones: number; duplicatedLines: number; percentage: number; sources: number }; }; - duplicates: Array<{ - firstFile: { name: string; start: number; end: number }; - secondFile: { name: string; start: number; end: number }; - }>; + duplicates: PrototypeClone[]; }; const rows = []; @@ -71,17 +77,7 @@ for (const variant of variants) { fs.readFileSync(path.join(destination, 'jscpd-report.json'), 'utf8'), ); const { clones, duplicatedLines, percentage, sources } = report.statistics.total; - const fold = report.duplicates.filter((clone) => - [clone.firstFile, clone.secondFile].some((file) => file.name.endsWith('/fold-helper/Fold.m')), - ).length; - const duoPanels = report.duplicates.filter( - (clone) => - clone.firstFile.name !== clone.secondFile.name && - [clone.firstFile, clone.secondFile].some( - (file) => - file.name.endsWith('/simctl-facade.test.ts') && file.start <= 50 && file.end >= 34, - ), - ).length; + const { fold, duoPanels } = countMotivatingClones(report.duplicates, duoRange); const row = { variant: variant.name, scope, From e14a19edeed7a14e3e72dcc9abb6603e607d44ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 27 Oct 2026 12:55:06 +0100 Subject: [PATCH 4/4] chore(gates): simplify native duplication scope and prune removed clones --- CONTRIBUTING.md | 5 +- docs/experiments/native-duplication.md | 80 +++++++++++++-- native-dupes-baseline.json | 6 +- package.json | 7 +- scripts/check-affected/model.test.ts | 63 ++++-------- scripts/check-affected/model.ts | 3 +- scripts/native-dupes/check.test.ts | 27 +++++ scripts/native-dupes/prototype-report.test.ts | 63 ------------ scripts/native-dupes/prototype-report.ts | 46 --------- scripts/native-dupes/prototype.ts | 99 ------------------- 10 files changed, 128 insertions(+), 271 deletions(-) delete mode 100644 scripts/native-dupes/prototype-report.test.ts delete mode 100644 scripts/native-dupes/prototype-report.ts delete mode 100644 scripts/native-dupes/prototype.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7fb87d5e05..1e2787b561 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -246,13 +246,14 @@ Useful direct entry points: and `linux/`, including tests. No Xcode or JVM is needed. - `pnpm native-dupes:report` — inspect all native clones without failing on new findings; writes `.tmp/native-dupes/jscpd-report.json` with both locations and `isNew` markers. -- `pnpm native-dupes:prototype` — compare detection settings and the Duo fixture pair; - see [measurements and baseline review](docs/experiments/native-duplication.md). - `pnpm native-dupes:baseline` — update accepted clones only after reviewing their source; do not use a baseline refresh merely to clear a failure. - `pnpm fallow:all` — full-tree audit, including grandfathered baseline findings - `pnpm fallow:baseline` — intentionally regenerate the reviewed Fallow health baseline +For native duplication settings, reproduction commands, and baseline pruning, see +[the experiment notes](docs/experiments/native-duplication.md). + See [`docs/agents/testing.md`](docs/agents/testing.md) for check selection and blocked validation; see [`test authoring`](docs/agents/test-authoring.md) for shared test utilities and test-speed rules. See [gate maintenance](docs/agents/gates.md) for mutation and fuzz harnesses. For real devices, follow [`docs/agents/device-verification.md`](docs/agents/device-verification.md); a fixture-backed test does diff --git a/docs/experiments/native-duplication.md b/docs/experiments/native-duplication.md index 49e8993870..83bfb3fcf7 100644 --- a/docs/experiments/native-duplication.md +++ b/docs/experiments/native-duplication.md @@ -6,16 +6,27 @@ Swift build caches, Android build output, and packaged helpers. No source exclus ## Reproduce the comparison +The table records source commit `a16d4372376bf18eea3dff4cbe85e15fc68e7fc0`. +It is an experiment record, not a target for later revisions. Files below the token minimum do +not contribute to jscpd's analyzed-file count. Percentages are its duplicated-line metric, +not unique lines to remove. + +Use jscpd 5.4.2. To replay the recorded inputs without changing this checkout, extract only +tracked source from that commit: + ```sh pnpm install --frozen-lockfile -pnpm native-dupes:prototype +pnpm exec jscpd --version +native_dupes_source="$(mktemp -d "${TMPDIR:-/tmp}/agent-device-native-dupes.XXXXXX")" +git archive a16d4372376bf18eea3dff4cbe85e15fc68e7fc0 -- apple android linux \ + packages/platform-apple/src/core/__tests__/display-inventory.test.ts \ + packages/platform-apple/src/__tests__/simctl-facade.test.ts | tar -x -C "$native_dupes_source" ``` -Each variant scans `apple/`, `android/`, and `linux/`, then scans the two TypeScript tests named in -the original report. JSON reports and `summary.json` go to `.tmp/native-dupes-prototypes/`. -The prototype fixes the worker count at two. The table records this source revision, not a target -for later revisions. Percentages are jscpd's duplicated-line metric, not unique lines to remove. -Files below the token minimum do not contribute to its analyzed-file count. +The version command must report `jscpd 5.4.2`. The commands below fix the worker count at two. +`--no-gitignore` lets the tracked-source archive be scanned independently of its temporary path; +the archive contains no ignored build output. Each row has a native scan and a separate scan of +the two TypeScript tests. Reports go to `.tmp/native-dupes-prototypes/`. | Mode | Tokens / lines | Native clones | Duplicated lines | Duplication | Duo pair clones | Duo panel fragments | | --- | --- | ---: | ---: | ---: | ---: | ---: | @@ -28,6 +39,44 @@ Files below the token minimum do not contribute to its analyzed-file count. | weak + consistent identifier normalization | 50 / 5 | 196 | 2150 | 4.44% | 5 | 0 | | weak + gaps up to 8 lines | 30 / 3 | 384 | 2960 | 6.10% | 13 | 0 | +Exact commands, in table order: + +```sh +# mild-50-5 +pnpm exec jscpd "$native_dupes_source/apple" "$native_dupes_source/android" "$native_dupes_source/linux" --format swift,objectivec,kotlin,java,python --formats-exts objectivec:m,h --mode mild --min-tokens 50 --min-lines 5 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/mild-50-5-native +pnpm exec jscpd "$native_dupes_source/packages/platform-apple/src/core/__tests__/display-inventory.test.ts" "$native_dupes_source/packages/platform-apple/src/__tests__/simctl-facade.test.ts" --format typescript --mode mild --min-tokens 50 --min-lines 5 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/mild-50-5-duo + +# weak-50-5 +pnpm exec jscpd "$native_dupes_source/apple" "$native_dupes_source/android" "$native_dupes_source/linux" --format swift,objectivec,kotlin,java,python --formats-exts objectivec:m,h --mode weak --min-tokens 50 --min-lines 5 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/weak-50-5-native +pnpm exec jscpd "$native_dupes_source/packages/platform-apple/src/core/__tests__/display-inventory.test.ts" "$native_dupes_source/packages/platform-apple/src/__tests__/simctl-facade.test.ts" --format typescript --mode weak --min-tokens 50 --min-lines 5 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/weak-50-5-duo + +# weak-70-5 +pnpm exec jscpd "$native_dupes_source/apple" "$native_dupes_source/android" "$native_dupes_source/linux" --format swift,objectivec,kotlin,java,python --formats-exts objectivec:m,h --mode weak --min-tokens 70 --min-lines 5 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/weak-70-5-native +pnpm exec jscpd "$native_dupes_source/packages/platform-apple/src/core/__tests__/display-inventory.test.ts" "$native_dupes_source/packages/platform-apple/src/__tests__/simctl-facade.test.ts" --format typescript --mode weak --min-tokens 70 --min-lines 5 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/weak-70-5-duo + +# weak-100-8 +pnpm exec jscpd "$native_dupes_source/apple" "$native_dupes_source/android" "$native_dupes_source/linux" --format swift,objectivec,kotlin,java,python --formats-exts objectivec:m,h --mode weak --min-tokens 100 --min-lines 8 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/weak-100-8-native +pnpm exec jscpd "$native_dupes_source/packages/platform-apple/src/core/__tests__/display-inventory.test.ts" "$native_dupes_source/packages/platform-apple/src/__tests__/simctl-facade.test.ts" --format typescript --mode weak --min-tokens 100 --min-lines 8 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/weak-100-8-duo + +# weak-30-3 +pnpm exec jscpd "$native_dupes_source/apple" "$native_dupes_source/android" "$native_dupes_source/linux" --format swift,objectivec,kotlin,java,python --formats-exts objectivec:m,h --mode weak --min-tokens 30 --min-lines 3 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/weak-30-3-native +pnpm exec jscpd "$native_dupes_source/packages/platform-apple/src/core/__tests__/display-inventory.test.ts" "$native_dupes_source/packages/platform-apple/src/__tests__/simctl-facade.test.ts" --format typescript --mode weak --min-tokens 30 --min-lines 3 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/weak-30-3-duo + +# weak-20-3 +pnpm exec jscpd "$native_dupes_source/apple" "$native_dupes_source/android" "$native_dupes_source/linux" --format swift,objectivec,kotlin,java,python --formats-exts objectivec:m,h --mode weak --min-tokens 20 --min-lines 3 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/weak-20-3-native +pnpm exec jscpd "$native_dupes_source/packages/platform-apple/src/core/__tests__/display-inventory.test.ts" "$native_dupes_source/packages/platform-apple/src/__tests__/simctl-facade.test.ts" --format typescript --mode weak --min-tokens 20 --min-lines 3 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/weak-20-3-duo + +# renamed-50-5 +pnpm exec jscpd "$native_dupes_source/apple" "$native_dupes_source/android" "$native_dupes_source/linux" --format swift,objectivec,kotlin,java,python --formats-exts objectivec:m,h --mode weak --min-tokens 50 --min-lines 5 --ignore-identifiers=consistent --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/renamed-50-5-native +pnpm exec jscpd "$native_dupes_source/packages/platform-apple/src/core/__tests__/display-inventory.test.ts" "$native_dupes_source/packages/platform-apple/src/__tests__/simctl-facade.test.ts" --format typescript --mode weak --min-tokens 50 --min-lines 5 --ignore-identifiers=consistent --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/renamed-50-5-duo + +# gap-30-3 +pnpm exec jscpd "$native_dupes_source/apple" "$native_dupes_source/android" "$native_dupes_source/linux" --format swift,objectivec,kotlin,java,python --formats-exts objectivec:m,h --mode weak --min-tokens 30 --min-lines 3 --max-gap-lines 8 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/gap-30-3-native +pnpm exec jscpd "$native_dupes_source/packages/platform-apple/src/core/__tests__/display-inventory.test.ts" "$native_dupes_source/packages/platform-apple/src/__tests__/simctl-facade.test.ts" --format typescript --mode weak --min-tokens 30 --min-lines 3 --max-gap-lines 8 --workers 2 --no-gitignore --absolute --reporters json --output .tmp/native-dupes-prototypes/gap-30-3-duo + +rm -rf "$native_dupes_source" +``` + The measured native runs took about 170–195 ms each, including Node startup, using two workers. This is a single timing sample per variant, not a performance benchmark. Linux execution belongs to the Repo Guards & Compatibility CI job. @@ -53,16 +102,23 @@ in 5.1.0. A custom comparison script or percentage ceiling is unnecessary. The b content fingerprints and clone multiplicities. It tolerates line shifts and fails on a third copy of an accepted pair. A new clone fails even if another clone is removed and the total stays equal. The executable policy tests cover each language, headers, line shifts, extra copies, replacement -clones, missing baselines, and empty scans. +clones, missing baselines, empty scans, and removed-clone pruning. The rollout starts with a report-only measurement and saves that result in `native-dupes-baseline.json`. `check:native-dupes` then rejects new fingerprints or increased multiplicities. It scans all native files rather than filtering by changed files. This keeps unchanged files available as comparison targets and needs no Git history in CI. `check:affected` -selects it for native source changes; configuration and baseline changes select the full check set. +selects it for non-documentation changes under the roots in `.jscpd-native.json`. It does not +filter by extension, so new scanner formats cannot escape selection. Some metadata changes also +select this inexpensive scan. Configuration and baseline changes select the full check set. `--changed` is unsuitable for PR enforcement because it filters by working-tree status, which is empty after a commit. The normal check never updates the baseline. +After rebasing onto `21f3f50a90f21bf8f994e742cbb7dd295f255d6c`, the initial baseline was +refreshed from main's native source: 82 clones, 854 duplicated lines (1.78%), and 218 analyzed +files. Three fingerprints were replaced by upstream native changes. The table above remains the +historical comparison at `a16d4372376bf18eea3dff4cbe85e15fc68e7fc0`. + ## Motivating cases and limits The closed-Duo fixtures differ at the measured revision. `display-inventory.test.ts` includes @@ -91,3 +147,11 @@ share an owner. If retaining duplication is intentional, review its source befor `pnpm native-dupes:baseline`. Review the baseline diff together with the source change. Do not regenerate it merely to clear a failing check. A detector upgrade or a change to matching options also needs a fresh comparison and baseline review. Missing baselines and empty scans fail closed. + +The normal check does not prune stale fingerprints. If a clone disappears while its fingerprint +remains in the baseline, the same block can be reintroduced up to its saved multiplicity without +failing. When removing a clone, run `pnpm native-dupes:baseline` in the same PR. jscpd's +`--update-baseline` replaces the saved fingerprints with those found by that scan, removing absent +fingerprints and reducing obsolete multiplicities. Review that the refresh removes or reduces +entries; investigate any additions. The executable pruning test proves that removal passes, +an unpruned allowance accepts reintroduction, and a refreshed baseline rejects reintroduction. diff --git a/native-dupes-baseline.json b/native-dupes-baseline.json index 40c5d55bce..08a2c031b3 100644 --- a/native-dupes-baseline.json +++ b/native-dupes-baseline.json @@ -7,7 +7,6 @@ "16f64c13af3b5133": 1, "177dd03fcad181e3": 1, "1820c3730c884e03": 1, - "1bcd955ed0310803": 1, "1e78746beb116a42": 1, "20171c882520cfa8": 1, "227ed1e368c386a3": 1, @@ -18,8 +17,8 @@ "3a4e282cb1e5c9e5": 1, "3cbf0776f9254f0d": 1, "3d12b710a26fd356": 1, + "3e739c27a227ed2c": 1, "411c3f06f49cd0a2": 1, - "524d0e8ddb986bee": 1, "52af4ac9900c2849": 1, "54b3cba0fee01e85": 1, "5642c43795f3f29c": 1, @@ -47,6 +46,7 @@ "8f44d31bc1be550a": 1, "93fdf4830756d189": 1, "950d64ca7c88e560": 1, + "96d008f544ddde7e": 1, "9cac643baca35232": 1, "9db36d2dd0a613e1": 1, "a1e36b17c095ff5d": 1, @@ -76,11 +76,11 @@ "e88f11085e4e61d5": 1, "e8984499b65f90d8": 1, "edaddb383eec4cc2": 1, + "edcf0986a0221682": 1, "f2cf05f6de02ad76": 1, "f65edc1e8000a681": 1, "f901d457d8368e97": 1, "fc5de2790407f117": 1, - "fd3d1f4c504aa1b4": 1, "fed244c725eb70f6": 1, "ff2cfa62ae0daa36": 1 } diff --git a/package.json b/package.json index b627087449..8798d6c977 100644 --- a/package.json +++ b/package.json @@ -147,10 +147,9 @@ "fallow:baseline": "fallow health --report-only --save-baseline fallow-baselines/health.json --summary", "check:fallow": "fallow audit", "check:native-dupes": "jscpd --config .jscpd-native.json --baseline native-dupes-baseline.json --fail-on-new-clones --fail-on-empty", - "check:native-dupes:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/native-dupes/*.test.ts", + "check:native-dupes:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/native-dupes/check.test.ts", "native-dupes:report": "jscpd --config .jscpd-native.json --baseline native-dupes-baseline.json --fail-on-empty", "native-dupes:baseline": "jscpd --config .jscpd-native.json --baseline native-dupes-baseline.json --update-baseline --fail-on-empty", - "native-dupes:prototype": "node --experimental-strip-types scripts/native-dupes/prototype.ts", "check:affected": "node --experimental-strip-types scripts/check-affected/run.ts", "check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/*.test.ts", "gate": "node --experimental-strip-types scripts/gate/run.ts", @@ -329,7 +328,6 @@ "@agent-device/selectors": "workspace:*", "@agent-device/session-journal": "workspace:*", "@agent-device/test-kit": "workspace:*", - "@agent-device/testmu": "workspace:*", "@agent-device/xml": "workspace:*", "@arethetypeswrong/cli": "^0.18.5", "@chenglou/freerange": "^0.0.4", @@ -367,6 +365,7 @@ "vite": "^8.3.5", "vitest": "5.0.4", "yaml": "^2.9.0", - "yauzl": "^3.4.0" + "yauzl": "^3.4.0", + "@agent-device/testmu": "workspace:*" } } diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index 797e4075b2..5a01805a95 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -156,6 +156,7 @@ test('Android helper change selects the android-helpers build', () => { 'replay-android', ]); assert.deepEqual(ids(['android/ime-helper/AndroidManifest.xml']), [ + 'native-dupes', 'android-helpers', 'replay-android', ]); @@ -454,54 +455,26 @@ test('vitest-related delegates changed paths to Vitest instead of modeling proje const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); -test('native duplication covers language inputs without selecting metadata or TypeScript', () => { - for (const file of [ - 'apple/fold-helper/Fold.m', - 'apple/snapshot-bridge/Bridge.h', - 'android/ime-helper/Input.kt', - 'android/ime-helper/Build.kts', - 'android/snapshot-helper/Touch.java', - 'linux/atspi-dump.py', - ]) { - assert.ok(ids([file]).includes('native-dupes'), file); - } - for (const file of [ - 'apple/runner/README.md', - 'android/ime-helper/AndroidManifest.xml', - 'packages/platform-apple/src/core/app-resolution.ts', - ]) { - assert.ok(!ids([file]).includes('native-dupes'), file); +test('every configured scan root selects native duplication regardless of extension', () => { + const config: { path: string[] } = JSON.parse( + fs.readFileSync(path.join(repoRoot, '.jscpd-native.json'), 'utf8'), + ); + for (const root of config.path) { + for (const extension of ['mm', 'future']) { + const file = path.posix.join(root, 'duplication-probe', `Source.${extension}`); + assert.ok( + plan([file]).reasons.some( + (entry) => entry.check === 'native-dupes' && entry.rule === 'own:native-dupes', + ), + file, + ); + } + const neighbor = path.posix.join(`${root}-neighbor`, 'Source.mm'); + assert.ok(!plan([neighbor]).reasons.some((entry) => entry.rule === 'own:native-dupes')); } + assert.ok(!ids(['packages/platform-apple/src/core/app-resolution.ts']).includes('native-dupes')); for (const file of ['.jscpd-native.json', 'native-dupes-baseline.json']) { assert.equal(plan([file]).failOpen, true); assert.ok(ids([file]).includes('native-dupes-model'), file); } }); - -test('native duplication selection covers every configured root, format, and custom extension', () => { - const config: { path: string[]; format: string[]; formatsExts: Record } = - JSON.parse(fs.readFileSync(path.join(repoRoot, '.jscpd-native.json'), 'utf8')); - const defaultExtensions: Record = { - swift: ['swift'], - objectivec: ['m'], - kotlin: ['kt', 'kts'], - java: ['java'], - python: ['py'], - }; - for (const format of config.format) { - const defaults = defaultExtensions[format]; - assert.ok(defaults, `Declare the scanner extensions for the added ${format} format`); - const extensions = new Set([...defaults, ...(config.formatsExts[format] ?? [])]); - for (const root of config.path) { - for (const extension of extensions) { - const file = `${root}/duplication-probe/Source.${extension}`; - assert.ok( - plan([file]).reasons.some( - (entry) => entry.check === 'native-dupes' && entry.rule === 'own:native-dupes', - ), - file, - ); - } - } - } -}); diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index ebd5bb3079..0cb44ec071 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -19,6 +19,7 @@ // non-.ts fixture whose owning suite cannot be derived). Existing GitHub CI // remains authoritative; this only optimizes local/agent feedback. +import nativeDupesConfig from '../../.jscpd-native.json' with { type: 'json' }; import { WIRE_SURFACE_FILES } from '../../test/wire-compat/surface.ts'; import { deviceLaneOwnership } from './device-lanes.ts'; @@ -396,7 +397,7 @@ const ownsAppleRunnerBuildSource = (file: string): boolean => file.endsWith('.swift'); const nativeDupesOwnership: OwnershipRule = ({ file }) => - /^(?:apple|android|linux)\/.*\.(?:swift|m|h|kt|kts|java|py)$/.test(file) + nativeDupesConfig.path.some((root) => file.startsWith(`${root}/`)) ? [ reason( 'native-dupes', diff --git a/scripts/native-dupes/check.test.ts b/scripts/native-dupes/check.test.ts index c30a8bec2d..528a677576 100644 --- a/scripts/native-dupes/check.test.ts +++ b/scripts/native-dupes/check.test.ts @@ -211,3 +211,30 @@ test('same-file clones fail while comment blocks and ignored build outputs do no write('apple/build/Generated.swift', source); assert.equal(run().exitCode, 0, 'comments and ignored generated copies must not add clones'); }); + +test('baseline refresh prunes removed clones so their later reintroduction fails', () => { + const { cwd, write, run } = fixture(); + const source = sources[0]!.source; + const baselinePath = path.join(cwd, 'native-dupes-baseline.json'); + write('apple/Capture.swift', source); + write('apple/Copy.swift', source); + assert.equal(run(baselineArgs).exitCode, 0); + const accepted = fs.readFileSync(baselinePath, 'utf8'); + fs.unlinkSync(path.join(cwd, 'apple/Copy.swift')); + assert.equal(run().exitCode, 0); + assert.equal( + fs.readFileSync(baselinePath, 'utf8'), + accepted, + 'checks must leave the baseline unchanged', + ); + write('apple/Copy.swift', source); + assert.equal(run().exitCode, 0, 'an unpruned allowance accepts the same clone again'); + fs.unlinkSync(path.join(cwd, 'apple/Copy.swift')); + assert.equal(run(baselineArgs).exitCode, 0); + const pruned = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + assert.deepEqual(pruned.fingerprints, {}); + write('apple/Copy.swift', source); + const reintroduced = run(); + assert.equal(reintroduced.exitCode, 1); + assert.equal(reintroduced.report?.statistics.total.newClones, 1); +}); diff --git a/scripts/native-dupes/prototype-report.test.ts b/scripts/native-dupes/prototype-report.test.ts deleted file mode 100644 index 69c2294835..0000000000 --- a/scripts/native-dupes/prototype-report.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { countMotivatingClones, namedConstantRange } from './prototype-report.ts'; - -const source = `// const IPHONE_DUO_CLOSED = "😀 comment, not the fixture"; -const other = { nativeSize: [1, 2] }; -const IPHONE_DUO_CLOSED = JSON.stringify({ - result: { - displays: [ - { active: true, nativeSize: [1398, 2034] }, - { active: false, nativeSize: [2007, 2853] }, - ], - }, -}); -const singlePanel = { active: true }; -`; - -test('Duo range follows its named constant after line shifts and ignores comment decoys', () => { - assert.deepEqual(namedConstantRange(source, 'IPHONE_DUO_CLOSED'), { start: 3, end: 10 }); - assert.deepEqual(namedConstantRange('\n'.repeat(40) + source, 'IPHONE_DUO_CLOSED'), { - start: 43, - end: 50, - }); - assert.throws(() => namedConstantRange(source, 'RENAMED_FIXTURE'), /RENAMED_FIXTURE/); -}); - -for (const separator of ['/', '\\']) { - test(`motivating clone counts accept ${JSON.stringify(separator)} paths and shifted fixture lines`, () => { - const target = ['checkout', 'simctl-facade.test.ts'].join(separator); - const other = ['checkout', 'display-inventory.test.ts'].join(separator); - const fold = ['checkout', 'apple', 'fold-helper', 'Fold.m'].join(separator); - const counts = countMotivatingClones( - [ - { - firstFile: { name: fold, start: 2, end: 8 }, - secondFile: { name: fold, start: 12, end: 18 }, - }, - { - firstFile: { name: target, start: 45, end: 47 }, - secondFile: { name: other, start: 70, end: 72 }, - }, - { - firstFile: { name: other, start: 73, end: 75 }, - secondFile: { name: target, start: 48, end: 50 }, - }, - { - firstFile: { name: target, start: 51, end: 60 }, - secondFile: { name: other, start: 80, end: 89 }, - }, - { - firstFile: { name: target, start: 41, end: 42 }, - secondFile: { name: other, start: 5, end: 6 }, - }, - { - firstFile: { name: target, start: 45, end: 47 }, - secondFile: { name: target, start: 48, end: 50 }, - }, - ], - namedConstantRange('\n'.repeat(40) + source, 'IPHONE_DUO_CLOSED'), - ); - assert.deepEqual(counts, { fold: 1, duoPanels: 2 }); - }); -} diff --git a/scripts/native-dupes/prototype-report.ts b/scripts/native-dupes/prototype-report.ts deleted file mode 100644 index d9e5143774..0000000000 --- a/scripts/native-dupes/prototype-report.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { parseSync } from 'oxc-parser'; -import { repoRelativePath } from '../lib/repo-path.ts'; - -type LineRange = { start: number; end: number }; -type CloneLocation = LineRange & { name: string }; -export type PrototypeClone = { firstFile: CloneLocation; secondFile: CloneLocation }; - -export function namedConstantRange(source: string, name: string): LineRange { - const parsed = parseSync('fixture.ts', source); - if (parsed.errors.length > 0) throw new Error('Cannot parse the prototype fixture'); - const declaration = parsed.program.body - .flatMap((statement) => - statement.type === 'VariableDeclaration' && statement.kind === 'const' - ? statement.declarations - : [], - ) - .find((item) => item.id.type === 'Identifier' && item.id.name === name); - if (!declaration?.init) throw new Error(`Cannot locate prototype fixture constant ${name}`); - return { - start: source.slice(0, declaration.init.start).split('\n').length, - end: source.slice(0, declaration.init.end).split('\n').length, - }; -} - -export function countMotivatingClones(clones: readonly PrototypeClone[], duoRange: LineRange) { - let fold = 0; - let duoPanels = 0; - for (const clone of clones) { - const files = [clone.firstFile, clone.secondFile].map((file) => ({ - ...file, - name: repoRelativePath(file.name), - })); - if (files.some((file) => file.name.endsWith('/fold-helper/Fold.m'))) fold++; - if ( - files[0]!.name !== files[1]!.name && - files.some( - (file) => - file.name.endsWith('/simctl-facade.test.ts') && - file.start <= duoRange.end && - file.end >= duoRange.start, - ) - ) - duoPanels++; - } - return { fold, duoPanels }; -} diff --git a/scripts/native-dupes/prototype.ts b/scripts/native-dupes/prototype.ts deleted file mode 100644 index d7ad0ed78b..0000000000 --- a/scripts/native-dupes/prototype.ts +++ /dev/null @@ -1,99 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { performance } from 'node:perf_hooks'; -import { runCmdSync } from '@agent-device/host-kit/command'; -import { - countMotivatingClones, - namedConstantRange, - type PrototypeClone, -} from './prototype-report.ts'; - -const root = path.resolve(import.meta.dirname, '../..'); -const cli = path.join(root, 'node_modules/jscpd/run-jscpd.js'); -const output = path.join(root, '.tmp/native-dupes-prototypes'); -const pair = [ - 'packages/platform-apple/src/core/__tests__/display-inventory.test.ts', - 'packages/platform-apple/src/__tests__/simctl-facade.test.ts', -]; -const duoRange = namedConstantRange( - fs.readFileSync(path.join(root, pair[1]!), 'utf8'), - 'IPHONE_DUO_CLOSED', -); -const variants = [ - { name: 'mild-50-5', tokens: 50, lines: 5, mode: 'mild', extra: [] }, - { name: 'weak-50-5', tokens: 50, lines: 5, mode: 'weak', extra: [] }, - { name: 'weak-70-5', tokens: 70, lines: 5, mode: 'weak', extra: [] }, - { name: 'weak-100-8', tokens: 100, lines: 8, mode: 'weak', extra: [] }, - { name: 'weak-30-3', tokens: 30, lines: 3, mode: 'weak', extra: [] }, - { name: 'weak-20-3', tokens: 20, lines: 3, mode: 'weak', extra: [] }, - { - name: 'renamed-50-5', - tokens: 50, - lines: 5, - mode: 'weak', - extra: ['--ignore-identifiers=consistent'], - }, - { name: 'gap-30-3', tokens: 30, lines: 3, mode: 'weak', extra: ['--max-gap-lines', '8'] }, -]; - -type Report = { - statistics: { - total: { clones: number; duplicatedLines: number; percentage: number; sources: number }; - }; - duplicates: PrototypeClone[]; -}; - -const rows = []; -for (const variant of variants) { - for (const scope of ['native', 'duo'] as const) { - const destination = path.join(output, `${variant.name}-${scope}`); - const start = performance.now(); - runCmdSync( - process.execPath, - [ - cli, - '--config', - '.jscpd-native.json', - ...(scope === 'duo' ? pair : []), - ...(scope === 'duo' ? ['--format', 'typescript'] : []), - '--mode', - variant.mode, - '--min-tokens', - String(variant.tokens), - '--min-lines', - String(variant.lines), - ...variant.extra, - '--workers', - '2', - '--absolute', - '--reporters', - 'json', - '--output', - destination, - ], - { cwd: root }, - ); - const report: Report = JSON.parse( - fs.readFileSync(path.join(destination, 'jscpd-report.json'), 'utf8'), - ); - const { clones, duplicatedLines, percentage, sources } = report.statistics.total; - const { fold, duoPanels } = countMotivatingClones(report.duplicates, duoRange); - const row = { - variant: variant.name, - scope, - sources, - clones, - duplicatedLines, - percentage: Number(percentage.toFixed(2)), - fold, - duoPanels, - milliseconds: Math.round(performance.now() - start), - }; - rows.push(row); - console.log( - `${variant.name} ${scope}: ${clones} clones, ${row.percentage}%, Fold=${fold}, Duo panels=${duoPanels}, ${row.milliseconds}ms`, - ); - } -} -fs.writeFileSync(path.join(output, 'summary.json'), `${JSON.stringify(rows, null, 2)}\n`); -console.log(`Reports: ${output}`);