From bd9586e008c6a2dbcd2f15598a3a20c3f2f44f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 30 Oct 2026 21:06:08 +0000 Subject: [PATCH] fix: recover duplicate candidates and expose test inventory gaps (#3743) * fix: recover missed test scenarios Run the full core test inventory and retrieve identical callback bodies across setup contexts. Separate assertion evidence from context and retain conservative input, path and runtime provenance checks. Restore prepared test inputs and report missing runtime collection. Add the audited recall corpus and inventory regressions. * Address PR review feedback (#3743) Correct counted nouns in the inventory report, document fully skipped modules, and include unhandled runner errors in the incomplete-collection warning. * Address PR review feedback (#3743) Force-stage explicit temporary fixtures despite inherited Git ignores, and isolate recorded-file collection-error accounting in the inventory tests. * fix(review): simplify selection and guard mixed artifacts * feat(test): detect fixture consolidation opportunities Capture source inputs used by integration tests and compare fixture clones, executed blocks, and assertion subjects and values. Report partial overlaps with the unique checks and fixture spans that consolidation must preserve. Keep cross-language structural suggestions separate from observed equality. Use pinned jscpd with direct pair verification when longer representatives hide partial clones. Retain capture gaps and native/worker limitations. Validate with 1,245 detector regressions, 28 pipeline pair controls, and 12 manually labeled comparisons. Scan 30,698 core cases and find 3,111 opportunity groups; exclude the capture's 103 failed and 89 skipped cases. * Fix scenario-map review findings (#3743) Resolve assertion aliases by lexical binding and normalize duplicate static object keys. Capture fixture files through checked descriptors with bounded reads and guaranteed cleanup. * fix: address test duplicate audit false signals * refactor(test): reuse assertion binding analysis Reuse lexical bindings within each evidence extraction while retaining assertion-specific offsets and expansion limits. Deduplicate fixture coverage endpoints numerically without string conversion. Preserve complete assertion evidence across 29,286 static instances and weighted coverage across 10,000 generated interval cases. * Fix scenario-map review findings (#3743) Bind consolidation evidence to the assertion call accepted by the check extractor, so unsupported same-line calls cannot supply incorrect subjects or expectations. Keep source provenance private and serialized checks intact. Exercise omitted plugins alongside instrumented Vitest runs. * fix: retain input-shaped scenario controls (#3743) Compare assertion subject structure separately from exact input-bearing assertion keys, so changing literal inputs retains table-row and boundary classification. Preserve selectors, callee identity and regex values. Keep the control expectations unchanged. Add regressions for literal inputs, queried subjects and nested regex arguments; bump the fingerprint engine version so reclassified findings resurface. * test: give scenario cases unique titles Include the parameterized case index so repeated input expressions and truncated audit paths cannot collide in the CI execution inventory. * fix: scope assertion subjects to their bindings Prevent same-named declarations in different files or scopes from becoming exact assertion matches. Preserve same-binding consolidation and advance the fingerprint engine to version 5. * fix: raise full-suite planning heap budget The 30,953-case replay aborts at the default 4 GiB heap. Give only the Plan step 8 GiB and document the same local invocation. The identical four coverage slices complete in 4m34s at 8.2 GiB peak RSS, retaining all 116,409 candidate pairs. * fix: stream full-suite scenario reports The full inventory produces a 663 MiB report, exceeding V8's single-string limit. Write records incrementally and stream only required fields when reading baselines and publishing issue updates. Replay preserves all 116,398 pairs from 30,953 collected cases. The report remains partial when the trace run fails. * fix: preserve test lifecycles during capture Forced test repeats changed cache and file state. Capture ordinary runs, retain runnable siblings during tracing, and keep retry-bearing files inconclusive. Preserve failure details and mark failed captures partial. Use the original JSON serializer so test mocks cannot break fixture reads. * fix: limit trace capture to candidates Keep sibling tests in both passes for suite state, while recording only candidate task IDs. This prevents unrelated timing checks and large corpus tests from failing under tracing. * ci: budget full-suite trace collection * fix: address scenario-map feedback (#3743) --------- Co-authored-by: Gergo Magyar --- .github/workflows/test-scenario-map.yml | 19 +- gitnexus/package-lock.json | 147 ++++ gitnexus/package.json | 2 + .../mutation-scenario-map/expected.json | 4 +- .../order-dependent.case.ts | 4 +- .../fixtures/scenario-map-recall/audit.json | 566 +++++++++++++ .../scenario-map-consolidation-inputs.test.ts | 65 ++ .../scenario-map-runtime-inputs.test.ts | 49 ++ .../test/unit/scenario-map/candidates.test.ts | 18 +- gitnexus/test/unit/scenario-map/cli.test.ts | 121 ++- .../unit/scenario-map/consolidation.test.ts | 750 ++++++++++++++++++ .../scenario-map/coverage-inventory.test.ts | 76 ++ .../scenario-map/coverage-records.test.ts | 17 + .../unit/scenario-map/fingerprint.test.ts | 230 ++++++ .../scenario-map/fixture-similarity.test.ts | 365 +++++++++ .../test/unit/scenario-map/partitions.test.ts | 70 ++ .../unit/scenario-map/recall-audit.test.ts | 53 ++ .../unit/scenario-map/report-json.test.ts | 124 +++ .../test/unit/scenario-map/report.test.ts | 2 +- .../scenario-map/runtime-fixtures.test.ts | 430 ++++++++++ .../unit/scenario-map/trace-setup.test.ts | 41 + gitnexus/test/unit/scenario-map/trace.test.ts | 104 +++ .../test/unit/scenario-map/vitest-run.test.ts | 32 + gitnexus/tools/scenario-map/README.md | 226 ++++++ .../scenario-map/consolidation/assertions.ts | 419 ++++++++++ .../consolidation/fixture-clones.ts | 533 +++++++++++++ .../consolidation/fixture-sources.ts | 27 + .../scenario-map/consolidation/report.ts | 391 +++++++++ .../tools/scenario-map/consolidation/run.ts | 27 + .../consolidation/verify-controls.ts | 87 ++ gitnexus/tools/scenario-map/facet/body-key.ts | 8 + gitnexus/tools/scenario-map/facet/checks.ts | 28 +- .../scenario-map/facet/fixture-inputs.ts | 313 ++++++++ .../scenario-map/facet/fixture-profile.ts | 318 ++++++++ .../scenario-map/facet/runtime-fixtures.ts | 122 +++ .../inventory/coverage-inventory.ts | 60 ++ .../tools/scenario-map/mutation/vitest-run.ts | 7 +- .../scenario-map/path-coverage/candidates.ts | 100 ++- .../path-coverage/coverage.setup.ts | 39 +- .../path-coverage/fixture-capture.ts | 269 +++++++ .../path-coverage/fixture-plugin.ts | 53 ++ .../scenario-map/path-coverage/partitions.ts | 63 +- .../scenario-map/path-coverage/prefilter.ts | 39 +- .../scenario-map/path-coverage/records.ts | 11 +- .../path-coverage/run-coverage.ts | 62 +- .../scenario-map/path-coverage/slices.ts | 8 + gitnexus/tools/scenario-map/report/json.ts | 79 ++ .../tools/scenario-map/report/markdown.ts | 31 +- gitnexus/tools/scenario-map/report/report.ts | 10 +- gitnexus/tools/scenario-map/scenario-map.ts | 174 +++- .../scenario-map/value-flow/trace-fixtures.ts | 26 + .../scenario-map/value-flow/trace-records.ts | 33 +- .../scenario-map/value-flow/trace.setup.ts | 19 +- .../tools/scenario-map/value-flow/trace.ts | 40 +- .../tools/scenario-map/verdict/fingerprint.ts | 123 ++- .../tools/scenario-map/verdict/verdict.ts | 6 + 56 files changed, 6850 insertions(+), 190 deletions(-) create mode 100644 gitnexus/test/fixtures/scenario-map-recall/audit.json create mode 100644 gitnexus/test/integration/scenario-map-consolidation-inputs.test.ts create mode 100644 gitnexus/test/integration/scenario-map-runtime-inputs.test.ts create mode 100644 gitnexus/test/unit/scenario-map/consolidation.test.ts create mode 100644 gitnexus/test/unit/scenario-map/coverage-inventory.test.ts create mode 100644 gitnexus/test/unit/scenario-map/fixture-similarity.test.ts create mode 100644 gitnexus/test/unit/scenario-map/recall-audit.test.ts create mode 100644 gitnexus/test/unit/scenario-map/report-json.test.ts create mode 100644 gitnexus/test/unit/scenario-map/runtime-fixtures.test.ts create mode 100644 gitnexus/test/unit/scenario-map/trace-setup.test.ts create mode 100644 gitnexus/tools/scenario-map/README.md create mode 100644 gitnexus/tools/scenario-map/consolidation/assertions.ts create mode 100644 gitnexus/tools/scenario-map/consolidation/fixture-clones.ts create mode 100644 gitnexus/tools/scenario-map/consolidation/fixture-sources.ts create mode 100644 gitnexus/tools/scenario-map/consolidation/report.ts create mode 100644 gitnexus/tools/scenario-map/consolidation/run.ts create mode 100644 gitnexus/tools/scenario-map/consolidation/verify-controls.ts create mode 100644 gitnexus/tools/scenario-map/facet/body-key.ts create mode 100644 gitnexus/tools/scenario-map/facet/fixture-inputs.ts create mode 100644 gitnexus/tools/scenario-map/facet/fixture-profile.ts create mode 100644 gitnexus/tools/scenario-map/facet/runtime-fixtures.ts create mode 100644 gitnexus/tools/scenario-map/inventory/coverage-inventory.ts create mode 100644 gitnexus/tools/scenario-map/path-coverage/fixture-capture.ts create mode 100644 gitnexus/tools/scenario-map/path-coverage/fixture-plugin.ts create mode 100644 gitnexus/tools/scenario-map/report/json.ts create mode 100644 gitnexus/tools/scenario-map/value-flow/trace-fixtures.ts diff --git a/.github/workflows/test-scenario-map.yml b/.github/workflows/test-scenario-map.yml index d3fa58bc9..1e9445d92 100644 --- a/.github/workflows/test-scenario-map.yml +++ b/.github/workflows/test-scenario-map.yml @@ -226,6 +226,9 @@ jobs: ls "$dest" - name: Plan the run env: + # Full-suite Layer 2 exceeds Node's default ~4 GiB heap. Budget 8 GiB + # for this step, leaving room on the 16 GB runner for native memory. + NODE_OPTIONS: --max-old-space-size=8192 SEED: ${{ inputs.seed || github.run_id }} SHARDS: ${{ inputs.shards }} MUTATION: ${{ steps.scope.outputs.mutation }} @@ -246,6 +249,9 @@ jobs: ${{ runner.temp }}/scenario-map/scenario-run-manifest.json ${{ runner.temp }}/scenario-map/layer0.json ${{ runner.temp }}/scenario-map/layer2.json + ${{ runner.temp }}/scenario-map/consolidation.json + ${{ runner.temp }}/scenario-map/consolidation.html + ${{ runner.temp }}/scenario-map/fixtures.json ${{ runner.temp }}/scenario-map/coverage/coverage.json if-no-files-found: error retention-days: 14 @@ -261,9 +267,9 @@ jobs: needs: plan if: ${{ !cancelled() && needs.plan.outputs.artifact != '' }} runs-on: ubuntu-latest - # Locally: PDG index about 3 min (peak RSS about 8.4 GB of the runner's 16 GB), the whole - # stage about 5 min. - timeout-minutes: 60 + # Full-suite tracing runs candidate files twice, including their runnable siblings. + # The 31k-case inventory exceeded 50 minutes; leave room for both passes and uploads. + timeout-minutes: 100 continue-on-error: true permissions: contents: read @@ -283,7 +289,7 @@ jobs: name: ${{ needs.plan.outputs.artifact }} path: ${{ runner.temp }}/scenario-map - name: Run value flow - timeout-minutes: 50 + timeout-minutes: 90 env: GITNEXUS_WORKER_READY_TIMEOUT_MS: '120000' run: | @@ -660,13 +666,14 @@ jobs: import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { shouldUpdateIssue } from './tools/scenario-map/report/issue-body.ts'; + import { readReportFields } from './tools/scenario-map/report/json.ts'; import { completenessLine, degradedLine, runLink } from './tools/scenario-map/report/markdown.ts'; const { ISSUE_NUMBER, GITHUB_OUTPUT, RUNNER_TEMP } = process.env; const reportDir = path.join(RUNNER_TEMP, 'scenario-map-report'); const issueDir = path.join(RUNNER_TEMP, 'scenario-map-issue'); - const report = JSON.parse( - readFileSync(path.join(reportDir, 'scenario-map-report.json'), 'utf8'), + const report = await readReportFields( + path.join(reportDir, 'scenario-map-report.json'), ['run', 'completeness'], ); const existing = ISSUE_NUMBER ? readFileSync(path.join(issueDir, 'existing-body.md'), 'utf8') diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index bdbf1d277..37ee5799a 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -73,6 +73,7 @@ "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0", "@mermaid-js/tiny": "12.2.0", + "@streamparser/json": "0.0.26", "@stryker-mutator/api": "10.0.0", "@stryker-mutator/core": "10.0.0", "@types/busboy": "^1.5.4", @@ -83,6 +84,7 @@ "@types/proxy-addr": "^2.0.3", "@vitest/coverage-v8": "^5.0.0", "gitnexus-shared": "file:../gitnexus-shared", + "jscpd": "5.4.2", "jsdom": "30.1.3", "tsx": "^4.0.0", "typescript": "^7.0.2", @@ -2230,6 +2232,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@streamparser/json": { + "version": "0.0.26", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.26.tgz", + "integrity": "sha512-46597LNFI+MFdUnzX2QJWwmdTRdq0XVD+vVNJTtGVzIrnCuhG9pFo1OAzbNBqci8UJgk/X5KJZ6LcV+y7PTuDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@stryker-mutator/api": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-10.0.0.tgz", @@ -4792,6 +4801,144 @@ "js-yaml": "bin/js-yaml.mjs" } }, + "node_modules/jscpd": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-5.4.2.tgz", + "integrity": "sha512-bxZ1ObA4NyWBcM7Zh+QoGrdcMovbCxrDofFaLo2Lwgk/Axe/e45VYu8EM+mpPx2CpU/QGCoktONRnaRZl8Yq2w==", + "dev": true, + "license": "MIT", + "bin": { + "jscpd": "run-jscpd.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://opencollective.com/jscpd" + }, + "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" + } + }, + "node_modules/jscpd-darwin-arm64": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/jscpd-darwin-arm64/-/jscpd-darwin-arm64-5.4.2.tgz", + "integrity": "sha512-EGoPYQZqESW2kJfxKZrUYsYWhzq2Irk6sBQQWZ7VPAGj2wwk3n8HtDP2FnJOiHWZtSz55I8zQWldzIrD55B5Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/jscpd-darwin-x64": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/jscpd-darwin-x64/-/jscpd-darwin-x64-5.4.2.tgz", + "integrity": "sha512-/bz/rgoahqeK0p5r+KjeTTS59d7bZpMxD7hQ4CaQ0qKZWBZIyu7hRdExzJ7/KrCEJ5cmRogvXX1jhFqldYLNXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/jscpd-linux-arm64-gnu": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/jscpd-linux-arm64-gnu/-/jscpd-linux-arm64-gnu-5.4.2.tgz", + "integrity": "sha512-YW/eX+mOYZmT/A3QEARYmJQoVQNV/cfecKCobNXZcD3mE57drpKHmJnvuL9uS85z0cUKWGCM57JVkVz6Y9oxxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/jscpd-linux-arm64-musl": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/jscpd-linux-arm64-musl/-/jscpd-linux-arm64-musl-5.4.2.tgz", + "integrity": "sha512-jOLTO8YVRz65NaeYNeXoIf13yOPWhbM61xKXpv51DXQ2BXce6XfgPaJVeDCOM5s+8mt6aDtq0BZ8zptNeqGuIw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/jscpd-linux-x64-gnu": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/jscpd-linux-x64-gnu/-/jscpd-linux-x64-gnu-5.4.2.tgz", + "integrity": "sha512-eAmKLxx+pIOEX4lZ342CCCjL5bvf+a3gdmaaVHXiGIx2Zr3zKkF32YkfuN2sCvCui5I/wnely80XsRvVVFO47Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/jscpd-linux-x64-musl": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/jscpd-linux-x64-musl/-/jscpd-linux-x64-musl-5.4.2.tgz", + "integrity": "sha512-ers9YFscD6DrmhyjBDXo/L+yBKcclHdWTFLMulYVGLcoGRKfBFhmK08MI3ajN/GMmCpJu7rkDYKL3egdfE8OIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/jscpd-windows-arm64-msvc": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/jscpd-windows-arm64-msvc/-/jscpd-windows-arm64-msvc-5.4.2.tgz", + "integrity": "sha512-kOhVw4cVhN8T9jr3OVThB3pPpbff0ITHTwpJ/+4EGzMkbf0y6QtPpHDzRgiHJj7u1FSTBXTI6VhPhDUhT/os1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/jscpd-windows-x64-msvc": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/jscpd-windows-x64-msvc/-/jscpd-windows-x64-msvc-5.4.2.tgz", + "integrity": "sha512-BuR5DCV7F/G5ukMEIzCrz43ea5C0t0ALcP7v+opOWefoi4aiQYCEmtoRwfXMM/zjOmNYmvJBDDfeH05U9uuzEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/jsdom": { "version": "30.1.3", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index 2abadf380..1289c1c00 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -137,6 +137,7 @@ "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0", "@mermaid-js/tiny": "12.2.0", + "@streamparser/json": "0.0.26", "@stryker-mutator/api": "10.0.0", "@stryker-mutator/core": "10.0.0", "@types/busboy": "^1.5.4", @@ -147,6 +148,7 @@ "@types/proxy-addr": "^2.0.3", "@vitest/coverage-v8": "^5.0.0", "gitnexus-shared": "file:../gitnexus-shared", + "jscpd": "5.4.2", "jsdom": "30.1.3", "tsx": "^4.0.0", "typescript": "^7.0.2", diff --git a/gitnexus/test/fixtures/mutation-scenario-map/expected.json b/gitnexus/test/fixtures/mutation-scenario-map/expected.json index a0b535607..061631521 100644 --- a/gitnexus/test/fixtures/mutation-scenario-map/expected.json +++ b/gitnexus/test/fixtures/mutation-scenario-map/expected.json @@ -302,8 +302,8 @@ "verdict": "inconclusive", "rule": "isolation-disagrees", "fingerprint": { - "verdict": "inconclusive", - "rule": "not-passing" + "verdict": "table-row", + "rule": "input-values-differ" }, "stages": { "facet": "equal", diff --git a/gitnexus/test/fixtures/mutation-scenario-map/order-dependent.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/order-dependent.case.ts index 8a184da01..2a1b9559c 100644 --- a/gitnexus/test/fixtures/mutation-scenario-map/order-dependent.case.ts +++ b/gitnexus/test/fixtures/mutation-scenario-map/order-dependent.case.ts @@ -1,7 +1,7 @@ // Scenario-map controls fixture (Phase 1 U17), special pair `order-dependent` (design §5.4 full // isolation pass, KTD9): the second test relies on the counter the first test leaves behind. In -// file order both pass; run alone, the second gets `beta-1` and fails. Expected: Inconclusive -// (expected.json). +// file order both pass; run alone, the second gets `beta-1` and fails. The fingerprint sees +// different row values; mutation remains Inconclusive on isolation (expected.json). import { describe, expect, it } from 'vitest'; import { nextTicketLabel } from './src/order-dependent.js'; diff --git a/gitnexus/test/fixtures/scenario-map-recall/audit.json b/gitnexus/test/fixtures/scenario-map-recall/audit.json new file mode 100644 index 000000000..9a96d7f82 --- /dev/null +++ b/gitnexus/test/fixtures/scenario-map-recall/audit.json @@ -0,0 +1,566 @@ +{ + "revision": "f354e66ff4a9515c8e4b33c4d39a94fc56846444", + "method": "Independently generated callback candidates; source review of setup, helper targets, fixture paths, inputs and assertions. Hand-adjudicated subset, not an exhaustive semantic oracle.", + "positives": [ + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Java > detects User class with save and getName methods", + "b": "test/integration/resolvers/java.test.ts#Java cross-file binding propagation > detects User class with save and getName methods", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Kotlin > detects User class with save and getName methods", + "b": "test/integration/resolvers/kotlin.test.ts#Kotlin cross-file binding propagation > detects User class with save and getName methods", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: JavaScript > detects User class with save and getName methods", + "b": "test/integration/resolvers/javascript.test.ts#JavaScript cross-file binding propagation > detects User class with save and getName methods", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: PHP > detects User class with save and getName methods", + "b": "test/integration/resolvers/php.test.ts#PHP cross-file binding propagation > detects User class with save and getName methods", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Rust > resolves u.save() in process() to User#save via cross-file return type", + "b": "test/integration/resolvers/rust.test.ts#Rust cross-file binding propagation > resolves u.save() in process() to User#save via cross-file return type propagation", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Ruby > resolves user.save in process() to User#save via cross-file return type", + "b": "test/integration/resolvers/ruby.test.ts#Ruby cross-file binding propagation > resolves user.save in process to User#save via cross-file propagation", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/resolvers/rust-scope.test.ts#Rust scope: impl block method classification > classifies save as a function owned by User (HAS_METHOD edge)", + "b": "test/integration/resolvers/rust-scope.test.ts#Rust scope: impl block method classification > emits HAS_METHOD edge from User to save", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Kotlin > resolves u.save() in run() to User#save via cross-file return type", + "b": "test/integration/resolvers/kotlin.test.ts#Kotlin cross-file binding propagation > resolves u.save() in run() to User#save via cross-file return type propagation", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Python > resolves u.save() in run() to User#save via cross-file return type", + "b": "test/integration/resolvers/python.test.ts#Python cross-file binding propagation > resolves u.save() in run() to User#save via cross-file return type propagation", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: JavaScript > resolves u.save() in run() to User#save via cross-file return type", + "b": "test/integration/resolvers/javascript.test.ts#JavaScript cross-file binding propagation > resolves u.save() in run() to User#save via cross-file return type propagation", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: C++ > detects User class with save and get_name methods", + "b": "test/integration/resolvers/cpp.test.ts#C++ cross-file binding propagation > detects User class with save and get_name methods", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Ruby > detects User class with save and get_name methods", + "b": "test/integration/resolvers/ruby.test.ts#Ruby cross-file binding propagation > detects User class with save and get_name methods", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases \u2014 return undefined > bare [] prefix with spaces only \u2192 undefined", + "b": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases \u2014 return undefined > empty array suffix \u2192 undefined", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases \u2014 return undefined > bare [] prefix with spaces only \u2192 undefined", + "b": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases \u2014 return undefined > [] suffix with no base \u2192 undefined", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases \u2014 return undefined > bare [] prefix with spaces only \u2192 undefined", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined for just brackets: []", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases \u2014 return undefined > empty array suffix \u2192 undefined", + "b": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases \u2014 return undefined > [] suffix with no base \u2192 undefined", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases \u2014 return undefined > empty array suffix \u2192 undefined", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined for just brackets: []", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases \u2014 return undefined > [] suffix with no base \u2192 undefined", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined for just brackets: []", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Rust > detects get_user function and process function", + "b": "test/integration/resolvers/rust.test.ts#Rust cross-file binding propagation > detects get_user and process functions", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/resolvers/java.test.ts#Java same-arity overload cross-file and chain resolution > crossFileByName() emits exactly one CALLS edge to find(String) in DbLookup", + "b": "test/integration/resolvers/java.test.ts#Java same-arity overloads (worker path) > crossFileByName() \u2192 find(String) \u2014 sequential path", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/resolvers/java.test.ts#Java same-arity overload cross-file and chain resolution > emits METHOD_IMPLEMENTS from DbLookup.find(int) \u2192 ILookup.find(int)", + "b": "test/integration/resolvers/java.test.ts#Java same-arity overloads (worker path) > METHOD_IMPLEMENTS edges match interface methods \u2014 sequential path", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/unit/pdg-mode-flip.test.ts#pdgModeMismatch \u2014 M1\u2192M2 stamp upgrade (#2082 M2, pure) > an identical resolved M2 config compares equal (steady state keeps incremental)", + "b": "test/unit/pdg-mode-flip.test.ts#pdgModeMismatch \u2014 M2\u2192M3 stamp upgrade (#2083 M3 U5, pure) > an identical resolved M3 config compares equal (steady state keeps incremental)", + "category": "import-initialization", + "basis": "Identical scenario body in one file, using the same helpers, input data and assertions. Enclosing setup has no relevant difference." + }, + { + "a": "test/unit/pdg-mode-flip.test.ts#pdgModeMismatch \u2014 M1\u2192M2 stamp upgrade (#2082 M2, pure) > an identical resolved M2 config compares equal (steady state keeps incremental)", + "b": "test/unit/pdg-mode-flip.test.ts#pdgModeMismatch \u2014 pre-#2201\u2192SSA reaching-defs solver upgrade (#2201 review R3, pure) > an identical post-#2201 stamp compares equal (no spurious re-analysis churn)", + "category": "import-initialization", + "basis": "Identical scenario body in one file, using the same helpers, input data and assertions. Enclosing setup has no relevant difference." + }, + { + "a": "test/unit/pdg-mode-flip.test.ts#pdgModeMismatch \u2014 M2\u2192M3 stamp upgrade (#2083 M3 U5, pure) > an identical resolved M3 config compares equal (steady state keeps incremental)", + "b": "test/unit/pdg-mode-flip.test.ts#pdgModeMismatch \u2014 pre-#2201\u2192SSA reaching-defs solver upgrade (#2201 review R3, pure) > an identical post-#2201 stamp compares equal (no spurious re-analysis churn)", + "category": "import-initialization", + "basis": "Identical scenario body in one file, using the same helpers, input data and assertions. Enclosing setup has no relevant difference." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Python > detects User class with save and get_name methods", + "b": "test/integration/resolvers/python.test.ts#Python cross-file binding propagation > detects User class with save and get_name methods", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Python > detects get_user function and run function", + "b": "test/integration/resolvers/python.test.ts#Python cross-file binding propagation > detects get_user and run functions", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Python > resolves u.get_name() in run() to User#get_name via cross-file return type", + "b": "test/integration/resolvers/python.test.ts#Python cross-file binding propagation > resolves u.get_name() in run() to User#get_name via cross-file return type propagation", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Go > detects User struct with Save and GetName methods", + "b": "test/integration/resolvers/go.test.ts#Go cross-file binding propagation > detects User struct with Save and GetName methods", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Go > resolves user.Save() in main() to User#Save via cross-file return type", + "b": "test/integration/resolvers/go.test.ts#Go cross-file binding propagation > resolves user.Save() in main() to User#Save via cross-file propagation", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: Rust > detects User struct with save and get_name methods", + "b": "test/integration/resolvers/rust.test.ts#Rust cross-file binding propagation > detects User struct with save and get_name methods", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: JavaScript > detects getUser factory and run function", + "b": "test/integration/resolvers/javascript.test.ts#JavaScript cross-file binding propagation > detects getUser and run functions", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/cross-file-binding.test.ts#Phase 9 \u2014 Cross-File Call-Result Binding: C# > detects User class with Save and GetName methods", + "b": "test/integration/resolvers/csharp.test.ts#C# cross-file binding propagation > detects User class with Save and GetName methods", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/resolvers/java.test.ts#Java same-arity overload cross-file and chain resolution > crossFileById() emits exactly one CALLS edge to find(int) in DbLookup", + "b": "test/integration/resolvers/java.test.ts#Java same-arity overloads (worker path) > crossFileById() \u2192 find(int) \u2014 sequential path", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/resolvers/javascript-object-properties.test.ts#JavaScript plain-object property access (A1/A5) > emits NOTHING when two objects share the key name", + "b": "test/integration/resolvers/javascript-object-properties.test.ts#JavaScript plain-object property access (A1/A5) > scope narrowing for multi-candidate names (R2) > keeps refusing two same-named keys in the reading file", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/resolvers/rust.test.ts#Rust qualified paths resolve against the module tree (#2730) > ignores a function-local fn of the same name in the target module (H3)", + "b": "test/integration/resolvers/rust.test.ts#Rust qualified paths resolve against the module tree (#2730) > follows a `pub use` re-export through to the original definition", + "category": "integration-same-fixture", + "basis": "Same fixture directory or shared PipelineResult, identical runPipelineFromRepo setup and matching assertions. CROSS_FILE_FIXTURES resolves to the same directory through both definitions." + }, + { + "a": "test/integration/tree-sitter-languages.test.ts#Tree-sitter multi-language parsing > unhappy path > handles empty string file path", + "b": "test/unit/ingestion-utils.test.ts#getLanguageFromFilename > unsupported > returns null for empty string", + "category": "irrelevant-setup", + "basis": "Both import getLanguageFromFilename from gitnexus-shared, pass empty string and assert toBeNull. Loading a parser in an unrelated beforeAll does not change this pure function." + }, + { + "a": "test/unit/analyze-api.test.ts#analyze API logic > rejects when job already active for different repo", + "b": "test/unit/analyze-job.test.ts#JobManager > enforces single-slot concurrency", + "category": "cleanup-context", + "basis": "Both instantiate JobManager before each test, use identical URLs and job transitions, and assert the same result. Extra mock/timer restoration happens after the assertions." + }, + { + "a": "test/unit/analyze-api.test.ts#analyze API logic > returns existing job for same repo URL", + "b": "test/unit/analyze-job.test.ts#JobManager > returns existing job for same repoUrl when active", + "category": "cleanup-context", + "basis": "Both instantiate JobManager before each test, use identical URLs and job transitions, and assert the same result. Extra mock/timer restoration happens after the assertions." + }, + { + "a": "test/unit/cobol-preprocessor.test.ts#extractCobolSymbolsWithRegex > Linkage & Data Flow > PROCEDURE DIVISION USING RETURNING excludes return value from USING list", + "b": "test/unit/cobol-preprocessor.test.ts#extractCobolSymbolsWithRegex > PROCEDURE DIVISION USING edge cases > excludes RETURNING value from USING parameter list", + "category": "same-file", + "basis": "Identical scenario body in one file, using the same helpers, input data and assertions. Enclosing setup has no relevant difference." + }, + { + "a": "test/unit/embedder.test.ts#embedder > getEmbeddingDims > returns 384 (MiniLM default)", + "b": "test/unit/http-embedder.test.ts#HTTP embedding backend > MCP embedder > returns 384 dimensions by default", + "category": "cleanup-context", + "basis": "Same getEmbeddingDims import, default environment and 384 assertion; additional afterEach restoration adds no scenario input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > array suffix (TypeScript / Java / C#) > User[] \u2192 User", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > strips array suffix: User[] \u2192 User", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > array suffix (TypeScript / Java / C#) > string[] \u2192 string", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > strips array suffix for primitive: string[] \u2192 string", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > Go slice prefix > []User \u2192 User", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > strips Go slice prefix: []User \u2192 User", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > Go slice prefix > []string \u2192 string", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > strips Go slice prefix for primitive: []string \u2192 string", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > Swift array sugar > [User] \u2192 User", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > unwraps Swift array sugar: [User] \u2192 User", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > generic angle-bracket containers > Array \u2192 User", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > unwraps single-arg generic angle: Array \u2192 User", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > generic angle-bracket containers > Vec \u2192 User (Rust)", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > unwraps single-arg generic angle: Rust Vec \u2192 User", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > generic angle-bracket containers > vector \u2192 User (C++)", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > unwraps single-arg generic angle: C++ vector \u2192 User", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > Python subscript-style generics > List[User] \u2192 User", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > unwraps Python subscript: List[User] \u2192 User", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > multi-argument generics \u2014 default returns last (value) arg > Map \u2192 User (default: last/value arg)", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns last arg by default for Map: \u2192 User", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > multi-argument generics \u2014 default returns last (value) arg > Map \u2192 String (pos=first: key arg)", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns first arg with pos=first for Map: \u2192 String", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > multi-argument generics \u2014 default returns last (value) arg > Map> \u2192 undefined (last arg is nested generic)", + "b": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > cross-bracket nesting (bracket depth fix) > Map> \u2192 undefined (default: last arg is nested generic)", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > multi-argument generics \u2014 default returns last (value) arg > Map> \u2192 String (pos=first: key arg)", + "b": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > cross-bracket nesting (bracket depth fix) > Map> \u2192 String (pos=first: key arg)", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases \u2014 return undefined > empty string \u2192 undefined", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined for empty string", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases \u2014 return undefined > plain type name (no container) \u2192 undefined", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined when no brackets are present: User", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/group/storage.test.ts#Group storage > getGroupDir returns correct path for group name", + "b": "test/unit/group/storage.test.ts#Group storage > getGroupDir rejects invalid names > test_getGroupDir_valid_name_returns_path", + "category": "same-file", + "basis": "Identical scenario body in one file, using the same helpers, input data and assertions. Enclosing setup has no relevant difference." + }, + { + "a": "test/unit/has-method.test.ts#findEnclosingClassId > Rust > finds enclosing impl_item for a method", + "b": "test/unit/has-method.test.ts#findEnclosingClassId > Rust > still picks struct name for plain impl Struct (no trait)", + "category": "native-parser", + "basis": "Identical scenario body in one file, using the same helpers, input data and assertions. Enclosing setup has no relevant difference." + }, + { + "a": "test/unit/index-repo-command.test.ts#indexCommand > fails when meta.json is missing and --force is not set", + "b": "test/unit/index-repo-command.test.ts#indexCommand > fails without --force when LadybugDB exists but metadata is missing", + "category": "same-file", + "basis": "Identical scenario body in one file, using the same helpers, input data and assertions. Enclosing setup has no relevant difference." + }, + { + "a": "test/unit/model/registration-table.test.ts#behavior group isolation > Impl hooks write to types.registerImpl, never to types.registerClass", + "b": "test/unit/model/registration-table.test.ts#hook behavior (real registries, no mocks) > implHook writes to types.registerImpl, NOT types.registerClass", + "category": "same-file", + "basis": "Identical scenario body in one file, using the same helpers, input data and assertions. Enclosing setup has no relevant difference." + }, + { + "a": "test/unit/scope-resolution/function-local-import-chain.test.ts#Python: a function-local import reaches the IMPORTS reason > `def f(): from m import X` is deferred", + "b": "test/unit/scope-resolution/function-local-import-chain.test.ts#Rust: a function-local `use` is NOT deferred > Python still defers on the same run", + "category": "native-parser", + "basis": "Identical scenario body in one file, using the same helpers, input data and assertions. Enclosing setup has no relevant difference." + }, + { + "a": "test/unit/security.test.ts#path traversal (isTestFilePath as proxy for path handling) > isTestFilePath returns false for nodes without a filePath", + "b": "test/unit/test-file-path.test.ts#isTestFilePath \u2014 shared predicate > returns false for nullish input rather than throwing", + "category": "reexport-context", + "basis": "local-backend.ts imports and directly re-exports the same isTestFilePath function. Both tests pass undefined, null and empty string and assert false three times." + }, + { + "a": "test/unit/server.test.ts#server error handling > createMCPServer reads version from package.json", + "b": "test/unit/server.test.ts#prompt registration > server registers detect_impact and generate_map prompts", + "category": "same-file", + "basis": "Identical scenario body in one file, using the same helpers, input data and assertions. Enclosing setup has no relevant difference." + }, + { + "a": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined when base is not a simple word: user model[]", + "b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined for complex non-word base before []: user model[]", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/shared-type-extractors.test.ts#stripNullable > strips \"| null\": User | null \u2192 User", + "b": "test/unit/type-env.test.ts#buildTypeEnv > stripNullable > strips User | null \u2192 User", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/shared-type-extractors.test.ts#stripNullable > strips \"| undefined\": User | undefined \u2192 User", + "b": "test/unit/type-env.test.ts#buildTypeEnv > stripNullable > strips User | undefined \u2192 User", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/shared-type-extractors.test.ts#stripNullable > strips both \"| null | undefined\": User | null | undefined \u2192 User", + "b": "test/unit/type-env.test.ts#buildTypeEnv > stripNullable > strips User | null | undefined \u2192 User", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/shared-type-extractors.test.ts#stripNullable > strips nullable suffix: User? \u2192 User", + "b": "test/unit/type-env.test.ts#buildTypeEnv > stripNullable > strips User? \u2192 User", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for genuine union: User | Repo", + "b": "test/unit/type-env.test.ts#buildTypeEnv > stripNullable > refuses genuine union User | Repo \u2192 undefined", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for bare \"null\"", + "b": "test/unit/type-env.test.ts#buildTypeEnv > stripNullable > returns undefined for null alone", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for bare \"undefined\"", + "b": "test/unit/type-env.test.ts#buildTypeEnv > stripNullable > returns undefined for undefined alone", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for bare \"None\"", + "b": "test/unit/type-env.test.ts#buildTypeEnv > stripNullable > returns undefined for None alone", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for bare \"nil\"", + "b": "test/unit/type-env.test.ts#buildTypeEnv > stripNullable > returns undefined for nil alone", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for bare \"void\"", + "b": "test/unit/type-env.test.ts#buildTypeEnv > stripNullable > returns undefined for void alone", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for empty string", + "b": "test/unit/type-env.test.ts#buildTypeEnv > stripNullable > returns undefined for empty string", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/shared-type-extractors.test.ts#stripNullable > returns the type name unchanged when no nullable markers: User \u2192 User", + "b": "test/unit/type-env.test.ts#buildTypeEnv > stripNullable > passes through User unchanged", + "category": "pure-helper", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/stdout-silence.test.ts#stdout silencing > handles nested silence/restore (reference counting)", + "b": "test/unit/stdout-silence.test.ts#stdout silencing > simulates embedder + pool-adapter concurrent usage without conflict", + "category": "same-file", + "basis": "Identical scenario body in one file, using the same helpers, input data and assertions. Enclosing setup has no relevant difference." + }, + { + "a": "test/unit/type-env.test.ts#buildTypeEnv > constructor inference (Tier 1 fallback) > Rust if-let / while-let pattern bindings > extracts binding from if let Some(x) = opt via Phase 5.2 pattern binding", + "b": "test/unit/type-env.test.ts#buildTypeEnv > constructor inference (Tier 1 fallback) > Rust if-let / while-let pattern bindings > Phase 5.2: extracts binding from if let Some(x) = opt where opt: Option", + "category": "native-parser", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/type-env.test.ts#buildTypeEnv > previously-skipped limitations (now resolved) > TS instanceof narrowing: if (x instanceof User) \u2014 first-writer-wins, not block-scoped", + "b": "test/unit/type-env.test.ts#buildTypeEnv > null-check narrowing via patternOverrides (Phase C Task 7) > TS: instanceof still works alongside null-check narrowing", + "category": "native-parser", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/type-env.test.ts#buildTypeEnv > previously-skipped limitations (now resolved) > Rust for with .iter(): for user in users.iter() \u2014 call_expression iterable", + "b": "test/unit/type-env.test.ts#buildTypeEnv > container descriptor-aware type arg selection > Vec.iter() resolves to element type (User) \u2014 arity 1 always returns last", + "category": "native-parser", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/type-env.test.ts#buildTypeEnv > method-aware type arg selection (.keys() vs .values()) > Rust for key in map.keys() resolves to key type (String)", + "b": "test/unit/type-env.test.ts#buildTypeEnv > container descriptor-aware type arg selection > HashMap.keys() resolves to key type (String) via descriptor", + "category": "native-parser", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/type-env.test.ts#buildTypeEnv > method-aware type arg selection (.keys() vs .values()) > Rust for user in map.values() resolves to value type (User)", + "b": "test/unit/type-env.test.ts#buildTypeEnv > container descriptor-aware type arg selection > HashMap.values() resolves to value type (User) via descriptor", + "category": "native-parser", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + }, + { + "a": "test/unit/type-env.test.ts#buildTypeEnv > Kotlin when/is pattern binding (Phase 6) > when (x) { is User -> } binds x to User", + "b": "test/unit/type-env.test.ts#buildTypeEnv > null-check narrowing via patternOverrides (Phase C Task 7) > Kotlin: when/is still works alongside null-check narrowing", + "category": "native-parser", + "basis": "Same production function, literal inputs and expected values; source callbacks are identical. No enclosing hooks change the input." + } + ], + "negatives": [ + { + "a": "test/integration/resolvers/swift.test.ts#Swift return type inference > resolves user.save() to User#save via return type of getUser() -> User", + "b": "test/integration/resolvers/swift.test.ts#Swift return-type inference via function return type > resolves user.save() to User#save via return type of getUser()", + "audit": "distinct", + "reason": "Different fixtures: swift-return-type versus swift-return-type-inference; latter tests competing methods." + }, + { + "a": "test/unit/cursor-hook.test.ts#Cursor hook source regressions > uses npx.cmd for Windows", + "b": "test/unit/factory-plugin.test.ts#Factory hook source regressions > invokes npx.cmd directly on Windows instead of a shell", + "audit": "distinct", + "reason": "The captured source is a different integration hook implementation." + }, + { + "a": "test/unit/cursor-hook.test.ts#Cursor hook source regressions > passes pattern after end-of-options marker (--)", + "b": "test/unit/factory-plugin.test.ts#Factory hook source regressions > passes the pattern after the -- end-of-options marker", + "audit": "distinct", + "reason": "The captured source is a different integration hook implementation." + }, + { + "a": "test/unit/scope-resolution/c/c-captures.test.ts#emitCScopeCaptures \u2014 scopes > captures translation_unit as @scope.module", + "b": "test/unit/scope-resolution/cpp/cpp-captures.test.ts#emitCppScopeCaptures \u2014 scopes > captures translation_unit as @scope.module", + "audit": "distinct", + "reason": "Same snippet and assertion text, but different language parser/provider helpers." + }, + { + "a": "test/unit/scope-resolution/c/c-captures.test.ts#emitCScopeCaptures \u2014 scopes > captures struct_specifier as @scope.class", + "b": "test/unit/scope-resolution/cpp/cpp-captures.test.ts#emitCppScopeCaptures \u2014 scopes > captures struct_specifier as @scope.class", + "audit": "distinct", + "reason": "Same snippet and assertion text, but different language parser/provider helpers." + }, + { + "a": "test/unit/scope-resolution/c/c-captures.test.ts#emitCScopeCaptures \u2014 other declarations > captures typedef as @declaration.typedef", + "b": "test/unit/scope-resolution/cpp/cpp-captures.test.ts#emitCppScopeCaptures \u2014 typedef/alias declarations > captures typedef as @declaration.typedef", + "audit": "distinct", + "reason": "Same snippet and assertion text, but different language parser/provider helpers." + }, + { + "a": "test/unit/scope-resolution/c/c-captures.test.ts#emitCScopeCaptures \u2014 other declarations > captures variable with initializer as @declaration.variable", + "b": "test/unit/scope-resolution/cpp/cpp-captures.test.ts#emitCppScopeCaptures \u2014 variable declarations > captures variable with initializer", + "audit": "distinct", + "reason": "Same snippet and assertion text, but different language parser/provider helpers." + }, + { + "a": "test/unit/scope-resolution/c/c-captures.test.ts#emitCScopeCaptures \u2014 other declarations > captures macro as @declaration.macro", + "b": "test/unit/scope-resolution/cpp/cpp-captures.test.ts#emitCppScopeCaptures \u2014 macro declarations > captures #define as @declaration.macro", + "audit": "distinct", + "reason": "Same snippet and assertion text, but different language parser/provider helpers." + }, + { + "a": "test/unit/setup.test.ts#setupQoder > writes the MCP entry to ~/.qoder.json", + "b": "test/unit/setup.test.ts#setupDroid (Factory) > writes the MCP entry to ~/.factory/mcp.json under mcpServers", + "audit": "distinct", + "reason": "Different selected editors and configPath helpers: Qoder versus Factory." + }, + { + "a": "test/unit/setup.test.ts#setupQoder > leaves a corrupt ~/.qoder.json untouched", + "b": "test/unit/setup.test.ts#setupDroid (Factory) > leaves a corrupt ~/.factory/mcp.json untouched", + "audit": "distinct", + "reason": "Different selected editors and configPath helpers: Qoder versus Factory." + }, + { + "a": "test/unit/verify-workflow-run-pr-identity.test.ts#commit-fork-prebuilds.yml contract > does not call commits/{sha}/pulls (empty for fork SHAs; comments may name it)", + "b": "test/unit/verify-workflow-run-pr-identity.test.ts#pr-autofix-publish.yml contract > does not call commits/{sha}/pulls (empty for fork SHAs; comments may name it)", + "audit": "distinct", + "reason": "The captured workflow source is a different file in each suite." + } + ] +} diff --git a/gitnexus/test/integration/scenario-map-consolidation-inputs.test.ts b/gitnexus/test/integration/scenario-map-consolidation-inputs.test.ts new file mode 100644 index 000000000..50e158be5 --- /dev/null +++ b/gitnexus/test/integration/scenario-map-consolidation-inputs.test.ts @@ -0,0 +1,65 @@ +import { expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +// Ground truth controls: fixture content changes independently of the checks below. +const BASE = 'export function calculate(value: number) { return value + 1; }\n'; +const RENAMED = 'export function compute(amount: number) { return amount + 1; }\n'; +const EXTRA = + BASE + 'export function extra(value: number) { while (value > 0) { value--; } return value; }\n'; +const UNRELATED = 'export interface Settings { enabled: boolean; label: string; }\n'; +let count = 0; + +async function ingest(source: string, extension = 'ts'): Promise { + const root = mkdtempSync(path.join(tmpdir(), 'consolidation-control-')); + try { + writeFileSync(path.join(root, `main.${extension}`), source); + count = (await runPipelineFromRepo(root, () => {}, { skipGraphPhases: true })).graph.nodeCount; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +// dupe-ok: intentional detector control; preserve fixture and capture ground truth +it('exact A', async () => { + await ingest(BASE); + expect(count).toBeGreaterThan(0); +}); +// dupe-ok: intentional detector control; preserve fixture and capture ground truth +it('exact B', async () => { + await ingest(BASE); + expect(count).toBeGreaterThan(0); +}); +// dupe-ok: intentional detector control; preserve fixture and capture ground truth +it('renamed fixture', async () => { + await ingest(RENAMED); + expect(count).toBeGreaterThan(0); +}); +// dupe-ok: intentional detector control; preserve fixture and capture ground truth +it('partial fixture and unique check', async () => { + await ingest(EXTRA); + expect(count).toBeGreaterThan(0); + expect(count).toBeGreaterThan(2); +}); +// dupe-ok: intentional detector control; preserve fixture and capture ground truth +it('different expectation', async () => { + await ingest(BASE); + expect(count).toBeGreaterThan(1); +}); +// dupe-ok: intentional detector control; preserve fixture and capture ground truth +it('unrelated assertion', async () => { + await ingest(BASE); + expect(count).toBeTypeOf('number'); +}); +// dupe-ok: intentional detector control; preserve fixture and capture ground truth +it('unrelated fixture', async () => { + await ingest(UNRELATED); + expect(count).toBeGreaterThan(0); +}); +// dupe-ok: intentional detector control; preserve fixture and capture ground truth +it('equivalent Python scenario', async () => { + await ingest('def calculate(value):\n return value + 1\n', 'py'); + expect(count).toBeGreaterThan(0); +}); diff --git a/gitnexus/test/integration/scenario-map-runtime-inputs.test.ts b/gitnexus/test/integration/scenario-map-runtime-inputs.test.ts new file mode 100644 index 000000000..992d124ff --- /dev/null +++ b/gitnexus/test/integration/scenario-map-runtime-inputs.test.ts @@ -0,0 +1,49 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +// Positive/negative controls for the scenario-map runtime fixture recorder. +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +const inputs = [ + ['typescript', 'ts', 'export function twice(x: number) { return x + x; }'], + ['python', 'py', 'def twice(x):\n return x + x\n'], + ['loop', 'ts', 'export function twice(x: number) { while(x > 0) { x--; } return x; }'], +] as const; + +describe.each(inputs)('%s generated repository', (_name, extension, source) => { + let count = 0; + beforeAll(async () => { + const root = mkdtempSync(path.join(tmpdir(), 'scenario-runtime-')); + try { + writeFileSync(path.join(root, `main.${extension}`), source); + count = (await runPipelineFromRepo(root, () => {}, { skipGraphPhases: true })).graph + .nodeCount; + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 60_000); + // dupe-ok: intentional detector control; preserve fixture and capture ground truth + it('finds declarations', () => { + expect(count).toBeGreaterThan(0); + }); + // dupe-ok: intentional detector control; preserve fixture and capture ground truth + it('retains the result after fixture cleanup', () => { + expect(count).toBeGreaterThan(0); + }); +}); + +// dupe-ok: intentional detector control; preserve fixture and capture ground truth +it.each(['sync', 'promise'])('dynamic %s read survives cleanup', async (api) => { + const root = mkdtempSync(path.join(tmpdir(), 'scenario-runtime-read-')); + let text: string; + try { + const file = path.join(root, 'main.ts'); + writeFileSync(file, inputs[0][2]); + text = api === 'sync' ? readFileSync(file, 'utf8') : await readFile(file, 'utf8'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + expect(text).toContain('function twice'); +}); diff --git a/gitnexus/test/unit/scenario-map/candidates.test.ts b/gitnexus/test/unit/scenario-map/candidates.test.ts index 66f247341..2c594031e 100644 --- a/gitnexus/test/unit/scenario-map/candidates.test.ts +++ b/gitnexus/test/unit/scenario-map/candidates.test.ts @@ -80,6 +80,22 @@ describe('jaccard', () => { }); describe('findCandidates: Jaccard source', () => { + it('retrieves the same body across cleanup/import contexts and dissimilar setup coverage', () => { + const result = findCandidates([ + test('direct', [1, 2], { checksContext: 'direct-context', bodyKey: 'same-body' }), + test('reexport', [1, 2, 3, 4], { checksContext: 'reexport-context', bodyKey: 'same-body' }), + test('other', [5], { checksContext: 'other-context', bodyKey: 'different-body' }), + ]); + expect(result.pairs).toEqual([ + expect.objectContaining({ + a: 'direct', + b: 'reexport', + sources: ['body'], + identicalBlocks: false, + }), + ]); + }); + it.each([ { name: 'one block removed (199/200)', b: range(0, 199) }, { name: 'one block replaced (199/201)', b: [...range(0, 199), 500] }, @@ -147,7 +163,7 @@ describe('findCandidates: checks-and-context source', () => { test('b', [1, 2], { checksContext: 'k' }), ]); expect(result.pairs.map((pair) => pair.sources)).toEqual([['jaccard', 'checks-context']]); - expect(result.stats.bySource).toEqual({ jaccard: 0, checksContext: 0, both: 1 }); + expect(result.stats.bySource).toEqual({ jaccard: 0, checksContext: 0, both: 1, body: 0 }); }); }); diff --git a/gitnexus/test/unit/scenario-map/cli.test.ts b/gitnexus/test/unit/scenario-map/cli.test.ts index 727f9904d..e327d8fd3 100644 --- a/gitnexus/test/unit/scenario-map/cli.test.ts +++ b/gitnexus/test/unit/scenario-map/cli.test.ts @@ -93,19 +93,19 @@ describe('trace selection', () => { 'excludes configured retries from %s traces even when coverage passed', (mutation) => { const pairs = [ - { a: 'a', b: 'b' }, - { a: 'a', b: 'retry' }, - { a: 'a', b: 'failed' }, - { a: 'a', b: 'failed-retry' }, + { a: 'a.ts#a', b: 'b.ts#b' }, + { a: 'a.ts#a', b: 'retry.ts#retry' }, + { a: 'a.ts#a', b: 'failed.ts#failed' }, + { a: 'a.ts#a', b: 'failed-retry.ts#failed-retry' }, ]; expect( passingTracePairs( [ - { id: 'a', state: 'passed', retry: 0 }, - { id: 'b', state: 'passed' }, - { id: 'retry', state: 'passed', retry: 1 }, - { id: 'failed', state: 'failed' }, - { id: 'failed-retry', state: 'failed', retry: 2 }, + { id: 'a.ts#a', state: 'passed', retry: 0 }, + { id: 'b.ts#b', state: 'passed' }, + { id: 'retry.ts#retry', state: 'passed', retry: 1 }, + { id: 'failed.ts#failed', state: 'failed' }, + { id: 'failed-retry.ts#failed-retry', state: 'failed', retry: 2 }, ], pairs, mutation, @@ -116,25 +116,44 @@ describe('trace selection', () => { it('keeps non-passing coverage tests out of the shared trace without losing valid pairs', () => { const pairs = [ - { a: 'a', b: 'b' }, - { a: 'a', b: 'failed' }, - { a: 'skipped', b: 'b' }, - { a: 'pending', b: 'b' }, - { a: 'missing', b: 'b' }, + { a: 'a.ts#a', b: 'b.ts#b' }, + { a: 'a.ts#a', b: 'failed.ts#failed' }, + { a: 'skipped.ts#skipped', b: 'b.ts#b' }, + { a: 'pending.ts#pending', b: 'b.ts#b' }, + { a: 'missing.ts#missing', b: 'b.ts#b' }, ]; expect( passingTracePairs( [ - { id: 'a', state: 'passed' }, - { id: 'b', state: 'passed' }, - { id: 'failed', state: 'failed' }, - { id: 'skipped', state: 'skipped' }, - { id: 'pending', state: 'pending' }, + { id: 'a.ts#a', state: 'passed' }, + { id: 'b.ts#b', state: 'passed' }, + { id: 'failed.ts#failed', state: 'failed' }, + { id: 'skipped.ts#skipped', state: 'skipped' }, + { id: 'pending.ts#pending', state: 'pending' }, ], pairs, ), ).toEqual([pairs[0]]); }); + + it('keeps an entire file inconclusive when a runnable sibling uses retries', () => { + const pairs = [ + { a: 'retry.ts#passing', b: 'stable.ts#a' }, + { a: 'stable.ts#a', b: 'stable.ts#b' }, + ]; + expect( + passingTracePairs( + [ + { id: 'retry.ts#passing', state: 'passed' }, + { id: 'retry.ts#sibling', state: 'passed', retry: 1 }, + { id: 'stable.ts#a', state: 'passed' }, + { id: 'stable.ts#b', state: 'passed' }, + { id: 'stable.ts#skipped', state: 'skipped', retry: 1 }, + ], + pairs, + ), + ).toEqual([pairs[1]]); + }); }); const tempDir = (): string => { const dir = mkdtempSync(path.join(os.tmpdir(), 'scenario-map-u14-')); @@ -335,16 +354,9 @@ describe('runCli usage', () => { ).toEqual(['HEAD^1', 'HEAD^1', 'HEAD^1']); }); - it.each([ - { mutation: 'all' as const, kind: 'normal', mode: { kind: 'normal' as const } }, - { - mutation: 'none' as const, - kind: 'controls', - mode: { kind: 'controls' as const, fixtureRoot: 'gitnexus/test/fixtures/x' }, - }, - ])('runs every file in Layer 1 for mutation $mutation in $kind mode', ({ mutation, mode }) => { + it('runs every file in Layer 1 for a whole-suite run', () => { const files = ['test/unit/a.test.ts', 'test/unit/b.test.ts']; - expect(layer1FilesOf('/no-repo', files, mutation, mode)).toEqual(files); + expect(layer1FilesOf('/no-repo', files)).toEqual(files); }); it('exits 2 for a work directory inside the checkout', async () => { @@ -531,7 +543,18 @@ function writeTwoShardRun(work: string, replan: ShardReplan | null = null): void extraRoots: [], repeats: 1, blocks: ['gitnexus/src/a.ts#f@0'], - files: [], + files: plan.eligibility.fingerprintFiles.map((file) => ({ + file, + recorded: true, + errors: [], + unattributed: { + known: true, + executedFiles: [], + nativeEntries: [], + workerSpawns: [], + excludedCandidates: [], + }, + })), tests: tests.map((id, i) => coverageTest(id, i + 1)), unhandledErrors: [], }; @@ -620,6 +643,46 @@ function writeTwoShardRun(work: string, replan: ShardReplan | null = null): void } describe('merge stage', () => { + it('keeps a fully collected run partial when a captured test failed', async () => { + const { deps, work } = setup(); + writeTwoShardRun(work, ONE_SHARD_REPLAN); + const layout = workLayout(work); + const coverage = JSON.parse(readFileSync(layout.coverage, 'utf8')) as CoverageWorkFile; + coverage.tests[0].state = 'failed'; + coverage.tests[0].errors = ['AssertionError: unexpected capture result']; + writeFileSync(layout.coverage, JSON.stringify(coverage)); + expect(await runCli(['merge', '--work', work], deps)).toBe(1); + const report = JSON.parse(readFileSync(layout.reportJson, 'utf8')) as ScenarioMapReport; + expect(report.inventory?.collectionComplete).toBe(true); + expect(report.completeness.shards.incomplete).toContainEqual({ + shard: 'coverage tests', + reason: '1 failed; 0 unfinished', + }); + expect(report.completeness.complete).toBe(false); + }); + + it('marks missing coverage partial and reports cases independently of candidate pairs', async () => { + const { deps, work } = setup(); + writeTwoShardRun(work, ONE_SHARD_REPLAN); + const layout = workLayout(work); + const coverage = JSON.parse(readFileSync(layout.coverage, 'utf8')) as CoverageWorkFile; + coverage.files[0].recorded = false; + writeFileSync(layout.coverage, JSON.stringify(coverage)); + const code = await runCli(['merge', '--work', work], deps); + const report = JSON.parse(readFileSync(layout.reportJson, 'utf8')) as ScenarioMapReport; + expect(code).toBe(1); + expect(report.completeness.partial).toBe(true); + expect(report.completeness.shards.incomplete).toContainEqual({ + shard: 'coverage', + reason: '1 files missing or errored; 0 unhandled errors', + }); + expect(report.inventory?.tests.collected).toBe(6); + expect(report.inventory?.files.unrecorded).toEqual(['test/unit/a.test.ts']); + expect(readFileSync(layout.reportMarkdown, 'utf8')).toContain( + 'Runtime inventory: 6 test cases collected', + ); + }); + it('reports the fingerprint of every pair and no mutation verdict for a fingerprint-only run', async () => { const { deps, work } = setup(); writeTwoShardRun(work); diff --git a/gitnexus/test/unit/scenario-map/consolidation.test.ts b/gitnexus/test/unit/scenario-map/consolidation.test.ts new file mode 100644 index 000000000..7a8c38a23 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/consolidation.test.ts @@ -0,0 +1,750 @@ +import { expect, it } from 'vitest'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { assertionEvidence } from '../../../tools/scenario-map/consolidation/assertions.js'; +import { + FixtureCloneIndex, + FixtureCloneCandidates, + verifyFixtureClonePairs, + runFixtureClones, + type CloneRun, +} from '../../../tools/scenario-map/consolidation/fixture-clones.js'; +import { + capturedFixtureSources, + mergeFixtureSources, + type FixtureSources, +} from '../../../tools/scenario-map/consolidation/fixture-sources.js'; +import { consolidationReport, overlap } from '../../../tools/scenario-map/consolidation/report.js'; +import { computeChecks } from '../../../tools/scenario-map/facet/checks.js'; +import { expandCases } from '../../../tools/scenario-map/inventory/cases.js'; +import { sha1 } from '../../../tools/scenario-map/util.js'; +import type { FixtureProfile } from '../../../tools/scenario-map/facet/fixture-profile.js'; +import type { + CoverageWorkFile, + CoverageTest, +} from '../../../tools/scenario-map/path-coverage/run-coverage.js'; +import type { Layer2Test } from '../../../tools/scenario-map/scenario-map.js'; + +const SOURCE = 'export function calculate(value: number) { return value + 1; }\n'; +const files: [string, string][] = [['main.ts', SOURCE]]; +const key = sha1(JSON.stringify(files)); +const sources: FixtureSources = { [key]: files }; +const profile: FixtureProfile = { + sourceKeys: [key], + roots: ['/tmp/input'], + files: ['/tmp/input/main.ts'], + contentKey: 'same', + languages: ['typescript'], + features: [['def:Function', 1]], + unresolved: [], +}; +const noClones: CloneRun = { tool: 'jscpd', version: 'test', sourceCount: 1, clones: [] }; +const atom = (value = '2', shape = 'count') => ({ + line: 1, + shape, + shapeKey: shape, + key: `${shape}:${value}`, + expected: value, +}); +const evidence = (atoms = [atom()]) => ({ atoms, unresolved: [] }); +const test = (id: string, blocks = [0, 1]): CoverageTest => ({ + id, + taskId: id, + file: 'test/input.test.ts', + fullName: id, + location: null, + state: 'passed', + concurrent: false, + attempts: 2, + blocks, + suiteBlocks: [], + provenance: { + known: true, + executedFiles: [], + nativeEntries: [], + workerSpawns: [], + excludedCandidates: [], + }, +}); +const detail = (id: string, assertions = evidence(), fixture = profile): Layer2Test => ({ + id, + file: 'test/input.test.ts', + anchor: null, + notOracleValidated: false, + dupeOk: null, + joinProblems: [], + inventoryUnresolved: [], + checksContext: null, + unsupportedForms: [], + unresolvedSelectors: [], + facet: null, + fixture, + assertionEvidence: assertions, +}); +const work = (tests: CoverageTest[]): CoverageWorkFile => ({ + schemaVersion: 1, + fixtureCaptureVersion: 2, + fixtureSources: sources, + mode: 'normal', + extraRoots: [], + repeats: 1, + blocks: [ + 'src/a.ts#one@0|1-2:1', + 'src/a.ts#two@2|3-4:1', + 'src/a.ts#miss@4|5-6:0', + 'src/b.ts#three@0|1-2:1', + ], + files: [], + tests, + unhandledErrors: [], +}); +const report = (tests: CoverageTest[], details: Layer2Test[]) => + consolidationReport(work(tests), details, new FixtureCloneIndex(sources, noClones)); + +it('requires all three axes for observed equality and groups repeated cases without expanding every pair', () => { + const tests = Array.from({ length: 100 }, (_, i) => test(String(i))); + const result = report( + tests, + tests.map((t) => detail(t.id)), + ); + expect(result.opportunities).toHaveLength(1); + expect(result.opportunities[0]).toMatchObject({ + kind: 'same-observed-input-path-assertions', + representedPairs: 4950, + path: { coverageA: 1, coverageB: 1 }, + assertions: { coverageA: 1, coverageB: 1 }, + }); +}); + +it('does not mistake shared uncovered branch ranges for shared execution', () => { + const result = report([test('a', [0, 2]), test('b', [3, 2])], [detail('a'), detail('b')]); + expect(result.opportunities).toEqual([]); +}); + +it('requires a fresh capture before treating archived read profiles as fixture inputs', () => { + const old = { ...work([test('a'), test('b')]), fixtureCaptureVersion: 1 as const }; + const result = consolidationReport( + old, + [detail('a'), detail('b')], + new FixtureCloneIndex(sources, noClones), + ); + expect(result.opportunities).toEqual([]); + expect(result.summary.excluded['unverified-input-provenance']).toBe(2); +}); + +it('reports partial shared checks and retains unique assertions and executed branches', () => { + const result = report( + [test('a', [0, 1]), test('b', [0, 3])], + [ + detail('a', evidence([atom(), atom('a', 'only-a')])), + detail('b', evidence([atom(), atom('b', 'only-b')])), + ], + ); + expect(result.opportunities[0]).toMatchObject({ + kind: 'extract-shared-scenario', + path: { coverageA: 0.5, coverageB: 0.5 }, + assertions: { shared: 1, totalA: 2, totalB: 2 }, + }); + const found = result.opportunities[0]; + expect([...found.onlyAssertionsA, ...found.onlyAssertionsB].join()).toContain('only-a'); + expect([...found.onlyAssertionsA, ...found.onlyAssertionsB].join()).toContain('only-b'); + expect([...found.onlyBlocksA, ...found.onlyBlocksB]).toContain('src/a.ts#two@2|3-4:1'); +}); + +it('keeps different expected values as parameter rows, not equal assertions', () => { + const result = report([test('a'), test('b')], [detail('a'), detail('b', evidence([atom('3')]))]); + expect(result.opportunities[0]).toMatchObject({ + kind: 'parameterize-expectations', + assertions: { shared: 0 }, + assertionShapes: { shared: 1 }, + }); +}); + +it('retains existing tables and intentional controls separately from actionable consolidation', () => { + const cases = [test('row-a'), test('row-b'), test('control'), test('separate')]; + const details = [ + { ...detail('row-a'), declarationKey: 'table' }, + { ...detail('row-b', evidence([atom('3')])), declarationKey: 'table' }, + { ...detail('control'), declarationKey: 'control', dupeOk: 'detector positive control' }, + { ...detail('separate'), declarationKey: 'separate' }, + ]; + const result = report(cases, details); + expect(result.opportunities.some((o) => o.review === 'already-parameterized')).toBe(true); + expect(result.opportunities.some((o) => o.review === 'intentional-keep')).toBe(true); + expect(result.opportunities.some((o) => o.review === 'actionable')).toBe(true); + expect(result.summary.actionableOpportunities).toBeLessThan(result.summary.opportunities); + expect(result.summary.representedPairs).toBe(6); +}); + +it('keeps equal observed table rows actionable and separates keep reasons from evidence gaps', () => { + const equalRows = report( + [test('a'), test('b')], + ['a', 'b'].map((id) => ({ + ...detail(id), + declarationKey: 'same-table', + })), + ); + expect(equalRows.opportunities[0].review).toBe('actionable'); + const kept = report( + [test('a'), test('b')], + ['a', 'b'].map((id) => ({ + ...detail(id), + dupeOk: 'control', + })), + ); + expect(kept.opportunities[0]).toMatchObject({ + review: 'intentional-keep', + kind: 'same-observed-input-path-assertions', + limitations: [], + }); +}); + +it('rejects disjoint checks, failed cases, concurrent paths, missing inputs, and unresolved expectations', () => { + expect( + report([test('a'), test('b')], [detail('a'), detail('b', evidence([atom('2', 'different')]))]) + .opportunities, + ).toEqual([]); + for (const patch of [{ state: 'failed' as const }, { concurrent: true }]) + expect( + report([test('a'), { ...test('b'), ...patch }], [detail('a'), detail('b')]).opportunities, + ).toEqual([]); + expect( + report( + [test('a'), test('b')], + [detail('a'), detail('b', evidence(), { ...profile, unresolved: ['missing file'] })], + ).opportunities, + ).toEqual([]); + expect( + report( + [test('a'), test('b')], + [detail('a'), detail('b', evidence([{ ...atom(), key: null, expected: null }]))], + ).opportunities, + ).toEqual([]); +}); + +it('counts repeated assertions as a multiset and handles empty evidence honestly', () => { + expect(overlap(['a', 'a', 'b'], ['a', 'b'])).toMatchObject({ + shared: 2, + totalA: 3, + totalB: 2, + coverageB: 1, + }); + expect(overlap([], [])).toMatchObject({ shared: 0, jaccard: 0 }); +}); + +it('merges immutable source tables and rejects altered source hashes', () => { + expect(mergeFixtureSources([sources, sources])).toEqual(sources); + expect(() => mergeFixtureSources([{ [key]: [['main.ts', 'changed']] }])).toThrow('hash mismatch'); + expect(capturedFixtureSources([])).toEqual({}); +}); + +it('measures clone coverage using weighted byte spans instead of whole matching lines', () => { + const otherFiles: [string, string][] = [['main.ts', SOURCE.replace('+', '-')]]; + const otherKey = sha1(JSON.stringify(otherFiles)); + const run: CloneRun = { + ...noClones, + sourceCount: 2, + clones: [ + { + a: { source: key, file: 'main.ts', start: 0, end: 20, startLine: 1, endLine: 1 }, + b: { source: otherKey, file: 'main.ts', start: 0, end: 20, startLine: 1, endLine: 1 }, + kind: 'similar', + similarity: 0.5, + tokens: 12, + }, + ], + }; + const index = new FixtureCloneIndex({ ...sources, [otherKey]: otherFiles }, run); + const found = index.compare(profile, { + ...profile, + sourceKeys: [otherKey], + contentKey: 'different', + }); + expect(found?.coverageA).toBeGreaterThan(0); + expect(found?.coverageA).toBeLessThan(0.5); + expect(found?.kind).toBe('clone'); +}); + +it('runs the pinned external clone engine on renamed and partial fixture functions', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'fixture-clone-test-')); + const renamed: [string, string][] = [ + ['main.ts', SOURCE.replaceAll('calculate', 'compute').replaceAll('value', 'amount')], + ]; + const longer: [string, string][] = [ + [ + 'main.ts', + SOURCE + '\nexport function extra(x: number) { while (x > 0) { x--; } return x; }\n', + ], + ]; + const renamedKey = sha1(JSON.stringify(renamed)), + longerKey = sha1(JSON.stringify(longer)); + try { + const all = { ...sources, [renamedKey]: renamed, [longerKey]: longer }; + const run = await runFixtureClones(all, root); + expect(run.version).toContain('5.4.2'); + const index = new FixtureCloneIndex(all, run); + expect( + index.compare(profile, { ...profile, sourceKeys: [renamedKey], contentKey: 'renamed' }) + ?.coverageA, + ).toBeGreaterThan(0.5); + const partial = index.compare(profile, { + ...profile, + sourceKeys: [longerKey], + contentKey: 'longer', + }); + expect(partial?.coverageA).toBeGreaterThan(0.5); + expect(partial?.coverageB).toBeLessThan(0.8); + expect(partial?.unmatchedB.some((span) => span.end > SOURCE.length)).toBe(true); + // The external report may connect both to a representative, omitting this pair. + const transitive = index.compare( + { ...profile, sourceKeys: [renamedKey], contentKey: 'renamed' }, + { ...profile, sourceKeys: [longerKey], contentKey: 'longer' }, + ); + expect(transitive?.coverageA).toBeGreaterThan(0.5); + expect(transitive?.coverageB).toBeLessThan(0.8); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +it('verifies contained representative spans without projecting offsets or treating near misses as transitive', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'fixture-contained-test-')); + const longer: [string, string][] = [ + ['main.ts', SOURCE + 'export function extra(x: number) { while (x > 0) x--; return x; }'], + ]; + const decoy: [string, string][] = [['decoy.ts', '// unrelated wrapper\n' + longer[0][1]]]; + const longerKey = sha1(JSON.stringify(longer)), + decoyKey = sha1(JSON.stringify(decoy)); + const all = { ...sources, [longerKey]: longer, [decoyKey]: decoy }; + const b = { ...profile, sourceKeys: [longerKey], contentKey: 'longer' }; + const span = (source: string, file: string, start: number, end: number) => ({ + source, + file, + start, + end, + startLine: 1, + endLine: 2, + }); + try { + const exported = await runFixtureClones(all, root); + // The full corpus can choose the same representative with DIFFERENT bounds. + // These representative links retrieve a candidate, never manufactured clone spans. + const run: CloneRun = { + ...exported, + clones: [ + { + a: span(key, 'main.ts', 0, SOURCE.length - 1), + b: span(decoyKey, 'decoy.ts', 21, 21 + SOURCE.length - 1), + kind: 'renamed', + similarity: 1, + tokens: 13, + }, + { + a: span(longerKey, 'main.ts', 0, SOURCE.length + 10), + b: span(decoyKey, 'decoy.ts', 21, 21 + SOURCE.length + 10), + kind: 'renamed', + similarity: 1, + tokens: 17, + }, + ], + }; + expect(new FixtureCloneIndex(all, run).compare(profile, b)).toBeNull(); + const candidates = new FixtureCloneCandidates(run); + consolidationReport( + { ...work([test('a'), test('b')]), fixtureSources: all }, + [detail('a'), detail('b', evidence(), b)], + new FixtureCloneIndex(all, run), + (a, b) => candidates.consider(a, b), + ); + expect(candidates.pairs()).toEqual([[key, longerKey].sort()]); + const verified = await verifyFixtureClonePairs( + all, + run, + candidates.pairs(), + path.join(root, 'pairs'), + ); + const result = new FixtureCloneIndex(all, verified).compare(profile, b); + expect(result?.coverageA).toBeGreaterThan(0.8); + expect(result?.coverageB).toBeLessThan(0.8); + expect(result?.unmatchedB.some((s) => s.end > SOURCE.length)).toBe(true); + expect(verified.verifiedPairs).toBe(1); + const nearMiss = new FixtureCloneCandidates({ + ...run, + clones: run.clones.map((c) => ({ ...c, kind: 'similar', similarity: 0.9 })), + }); + nearMiss.consider(profile, b); + expect(nearMiss.pairs()).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +function assertions(source: string) { + return expandCases([{ path: 'gitnexus/test/unit/controls.test.ts', source }]).sites.flatMap( + (site) => + (site.instances ?? []).map((instance) => { + const subject = { record: site.record, instance, fullName: site.fullName }; + return assertionEvidence(subject, computeChecks(subject)); + }), + ); +} + +it('does not consolidate assertions on same-named file-level constants with different values', () => { + const found = expandCases( + [1, 2].map((value) => ({ + path: `gitnexus/test/unit/constant-${value}.test.ts`, + source: `const result = ${value}; it('rule', () => expect(result).toBeGreaterThan(0));`, + })), + ).sites.map((site) => { + const subject = { + record: site.record, + instance: site.instances?.[0] ?? null, + fullName: site.fullName, + }; + return assertionEvidence(subject, computeChecks(subject)); + }); + expect(found[0].atoms[0].key).toBeTruthy(); + expect(found[1].atoms[0].key).not.toBe(found[0].atoms[0].key); + expect( + report([test('a'), test('b')], [detail('a', found[0]), detail('b', found[1])]).opportunities, + ).toEqual([]); +}); + +it.each([ + 'const result = compute();', + 'let result = 1; result++;', + 'const original = 1; const result = original;', + 'const { result } = { result: 1 };', + 'const result = { count: 1 }; result.count++;', +])('case %#: keeps enclosing value origins scoped: %s', (declaration) => { + const found = assertions(` + describe('outer', () => { + ${declaration} + it('direct', () => { expect(result).toBeDefined(); }); + it('alias', () => { const actual = result; expect(actual).toBeDefined(); }); + }); + describe('other', () => { + ${declaration} + it('same name', () => { expect(result).toBeDefined(); }); + }); + `); + expect(found[0].atoms[0].key).toBeTruthy(); + expect(found[1].atoms[0].key).toBe(found[0].atoms[0].key); + expect(found[2].atoms[0].key).not.toBe(found[0].atoms[0].key); +}); + +it('retains partial shared checks without matching different file-level bindings', () => { + const found = expandCases( + [1, 2].map((value) => ({ + path: `gitnexus/test/unit/partial-${value}.test.ts`, + source: `const result = ${value}; + it('rule', () => { + expect(result).toBeGreaterThan(0); + expect(graph.nodes.length).toBe(2); + });`, + })), + ).sites.map((site) => { + const subject = { + record: site.record, + instance: site.instances?.[0] ?? null, + fullName: site.fullName, + }; + return assertionEvidence(subject, computeChecks(subject)); + }); + const [opportunity] = report( + [test('a'), test('b')], + [detail('a', found[0]), detail('b', found[1])], + ).opportunities; + expect(opportunity).toMatchObject({ + kind: 'extract-shared-scenario', + assertions: { shared: 1, totalA: 2, totalB: 2 }, + }); + expect(opportunity.onlyAssertionsA).toHaveLength(1); + expect(opportunity.onlyAssertionsB).toHaveLength(1); +}); + +it('preserves consolidation of checks against the same suite-level pipeline result', () => { + const [a, b] = assertions(` + let count = 0; + async function ingest() { count = (await runPipeline()).graph.nodeCount; } + it('a', async () => { await ingest(); expect(count).toBeGreaterThan(0); }); + it('b', async () => { await ingest(); expect(count).toBeGreaterThan(0); }); + `); + expect( + report([test('a'), test('b')], [detail('a', a), detail('b', b)]).opportunities[0], + ).toMatchObject({ assertions: { shared: 1, coverageA: 1, coverageB: 1 } }); +}); + +it('does not conflate same-named file-local assertion helpers from different engines', () => { + const found = ['fingerprint', 'mutation'].map((engine) => { + const [site] = expandCases([ + { + path: `gitnexus/test/unit/${engine}.test.ts`, + source: ` + import { compute } from './${engine}-engine.js'; + const verdictOf = () => compute(); + it('rule', () => { expect(verdictOf().rule).toBe('same'); }); + `, + }, + ]).sites; + const subject = { record: site.record, instance: site.instances![0], fullName: site.fullName }; + return assertionEvidence(subject, computeChecks(subject)); + }); + expect(found[0].atoms[0].key).not.toBeNull(); + expect(found[0].atoms[0].key).not.toBe(found[1].atoms[0].key); +}); + +it('matches copied helpers with the same import and preserves unresolved closure identity', () => { + const evidenceFor = (file: string, closure: boolean) => { + const [site] = expandCases([ + { + path: `gitnexus/test/unit/${file}.test.ts`, + source: ` + import { compute } from './engine.js'; + let state = 1; + const run = (value) => compute(${closure ? 'state' : 'value'}); + it('rule', () => expect(run(1).rule).toBe('same')); + `, + }, + ]).sites; + const subject = { + record: site.record, + instance: site.instances?.[0] ?? null, + fullName: site.fullName, + }; + return assertionEvidence(subject, computeChecks(subject)); + }; + const a = evidenceFor('a', false), + b = evidenceFor('b', false); + expect(a.atoms[0].key).not.toBeNull(); + expect(a.atoms[0].key).toBe(b.atoms[0].key); + expect(evidenceFor('a', true).atoms[0].key).not.toBe(evidenceFor('b', true).atoms[0].key); +}); + +it('resolves asserted const origins and distinguishes different queried quantities', () => { + const found = assertions(` + it('a', () => { const n = graph.nodes.length; expect(n).toBe(2); }); + it('b', () => { const m = graph.nodes.length; expect(m).toBe(2); }); + it('c', () => { const n = graph.edges.length; expect(n).toBe(2); }); + it('d', () => { let n = graph.nodes.length; n++; expect(n).toBe(2); }); + `); + expect(found[0].atoms[0].key).toBeTruthy(); + expect(found[1].atoms[0].key).toBe(found[0].atoms[0].key); + expect(found[2].atoms[0].key).not.toBe(found[0].atoms[0].key); + expect(found[3].atoms[0].key).toBeNull(); +}); + +it('keeps statement order and callback bindings independent across assertions in one test', () => { + const [combined, direct] = assertions(` + it('combined', () => { + expect(count).toBe(2); + const count = graph.nodes.length; + expect(count).toBe(2); + expect(items.map(item => item.name)).toEqual(items.map(expected => expected.name)); + expect(items.map(value => value.name)).toEqual([]); + expect(items.map(other => other.name)).toEqual([]); + }); + it('direct', () => { expect(graph.nodes.length).toBe(2); }); + `); + const [before, after, dynamic, first, second] = combined.atoms; + expect(before.key).toBeNull(); + expect(after.key).toBeTruthy(); + expect(after.key).toBe(direct.atoms[0].key); + expect(dynamic.key).toBeNull(); + expect(dynamic.observed).toBe(first.observed); + expect(first.key).toBeTruthy(); + expect(first.key).toBe(second.key); +}); + +it('retains exact expected values, negation, selectors, and row values', () => { + const found = assertions(` + it.each([2, 3])('row %i', (n) => { expect(graph.nodes.length).toBe(n); }); + it('literal', () => { expect(graph.nodes.length).toBe(2); }); + it('negative', () => { expect(graph.nodes.length).not.toBe(2); }); + it('selector', () => { expect(graph.get('Function')).toHaveLength(2); }); + it('other selector', () => { expect(graph.get('Class')).toHaveLength(2); }); + `); + expect(found[0].atoms[0].key).toBe(found[2].atoms[0].key); + expect(found[0].atoms[0].key).not.toBe(found[1].atoms[0].key); + expect(found[2].atoms[0].key).not.toBe(found[3].atoms[0].key); + expect(found[4].atoms[0].key).not.toBe(found[5].atoms[0].key); +}); + +it('canonicalizes object and signed numeric expectations and rejects unknown expected values', () => { + const found = assertions(` + it.each([{ b: 2, a: -1 }])('row', (value) => { expect(result).toEqual(value); }); + it('literal', () => { expect(result).toEqual({ a: -1, b: 2 }); }); + it('unknown', () => { expect(result).toEqual(calculateExpected()); }); + it('shadow', () => { const undefined = calculateExpected(); expect(result).toBe(undefined); }); + `); + expect(found[0].atoms[0].expected).toBe(found[1].atoms[0].expected); + expect(found[2].atoms[0].key).toBeNull(); + expect(found[3].atoms[0].key).toBeNull(); +}); + +it('does not hide unknown checks or extra repeated assertions in partial overlaps', () => { + const a = evidence([atom(), atom(), { ...atom(), key: null, expected: null }]); + const result = report([test('a'), test('b')], [detail('a', a), detail('b')]); + const found = result.opportunities[0]; + expect(found.assertions.shared).toBe(1); + expect([found.assertions.totalA, found.assertions.totalB].sort()).toEqual([1, 3]); + expect(found.onlyAssertionsA.length + found.onlyAssertionsB.length).toBe(2); + expect(found.kind).not.toBe('same-observed-input-path-assertions'); +}); + +it('resolves const query chains and lexically bound callback parameters without conflating selectors', () => { + const found = assertions(` + it('a', () => { const calls = getRelationships(result, 'CALLS'); const edge = calls.find(c => c.target === 'save'); expect(edge).toBeDefined(); }); + it('b', () => { const relations = getRelationships(result, 'CALLS'); const match = relations.find(e => e.target === 'save'); expect(match).toBeDefined(); }); + it('c', () => { const calls = getRelationships(result, 'CALLS'); const edge = calls.find(c => c.target === 'getName'); expect(edge).toBeDefined(); }); + it('d', () => { const calls = getRelationships(result, 'IMPORTS'); const edge = calls.find(c => c.target === 'save'); expect(edge).toBeDefined(); }); + `); + expect(found[0].atoms[0].key).toBeTruthy(); + expect(found[1].atoms[0].key).toBe(found[0].atoms[0].key); + expect(found[2].atoms[0].key).not.toBe(found[0].atoms[0].key); + expect(found[3].atoms[0].key).not.toBe(found[0].atoms[0].key); +}); + +it('retains template text when tracing asserted query chains', () => { + const a = assertions( + "it('a', () => { const paths = items.map(e => `${e.source} -> ${e.target}`); expect(paths.some(p => p.includes('service'))).toBe(true); });", + )[0]; + const b = assertions( + "it('b', () => { const paths = items.map(e => `${e.source} => ${e.target}`); expect(paths.some(p => p.includes('service'))).toBe(true); });", + )[0]; + expect(a.atoms[0].key).toBeTruthy(); + expect(a.atoms[0].key).not.toBe(b.atoms[0].key); +}); + +it('does not infer clone families through near-miss similarity', () => { + const thirdFiles: [string, string][] = [['main.ts', SOURCE.replace('+ 1', '* 2')]]; + const third = sha1(JSON.stringify(thirdFiles)); + const middleFiles: [string, string][] = [['main.ts', SOURCE.replace('+ 1', '- 1')]]; + const middle = sha1(JSON.stringify(middleFiles)); + const span = (source: string) => ({ + source, + file: 'main.ts', + start: 0, + end: 20, + startLine: 1, + endLine: 1, + }); + const run: CloneRun = { + ...noClones, + sourceCount: 3, + clones: [ + { a: span(key), b: span(middle), kind: 'similar', similarity: 0.9, tokens: 12 }, + { a: span(middle), b: span(third), kind: 'similar', similarity: 0.9, tokens: 12 }, + ], + }; + const index = new FixtureCloneIndex( + { ...sources, [middle]: middleFiles, [third]: thirdFiles }, + run, + ); + expect( + index.compare(profile, { ...profile, sourceKeys: [third], contentKey: 'third' }), + ).toBeNull(); +}); + +it('surfaces one shared check in a partial scenario and retains all other expectations', () => { + const found = report( + [test('a'), test('b')], + [ + detail('a', evidence([atom(), atom('a', 'unique-a'), atom('b', 'unique-b')])), + detail( + 'b', + evidence([atom(), atom('c', 'unique-c'), atom('d', 'unique-d'), atom('e', 'unique-e')]), + ), + ], + ).opportunities[0]; + expect(found.assertions).toMatchObject({ shared: 1 }); + expect(found.onlyAssertionsA.length + found.onlyAssertionsB.length).toBe(5); +}); + +it('keeps negative zero distinct and locates the real assertion on a shared line', () => { + const found = assertions(` + it('a', () => { helper(result).toBe(99); expect(result).toBe(-0); }); + it('b', () => { expect(result).toBe(0); }); + `); + expect(found[0].atoms[0].expected).toContain('"text":"-0"'); + expect(found[0].atoms[0].key).not.toBe(found[1].atoms[0].key); +}); + +it.each([ + ['duplicate modifier', 'expect(x).not.not.toBe(2);'], + ['conditional', 'if (flag) expect(x).toBe(2);'], + ['loop', 'for (const value of values) expect(x).toBe(2);'], + ['nested function', 'const helper = () => expect(x).toBe(2);'], + ['unsupported expect arguments', "expect(x, 'message', 'extra').toBe(2);"], +])('uses the supported assertion after a same-line %s', (_kind, prefix) => { + const [mixed, direct] = assertions(` + it('mixed', () => { ${prefix} expect(y).toBe(3); }); + it('direct', () => { expect(y).toBe(3); }); + `); + expect(mixed.atoms).toHaveLength(1); + expect(mixed.atoms[0].observed).toBe('y'); + expect(mixed.atoms[0].key).toBeTruthy(); + expect(mixed.atoms[0].key).toBe(direct.atoms[0].key); + expect(mixed.unresolved.length).toBeGreaterThan(0); +}); + +it('reports missing assertion provenance instead of guessing a call for detached checks', () => { + const [site] = expandCases([ + { + path: 'gitnexus/test/unit/provenance.test.ts', + source: "it('case', () => expect(result).toBe(3));", + }, + ]).sites; + const subject = { + record: site.record, + instance: site.instances?.[0] ?? null, + fullName: site.fullName, + }; + const checks = computeChecks(subject); + expect(assertionEvidence(subject, checks).atoms[0].key).toBeTruthy(); + const detached = { ...checks, checks: checks.checks.map((check) => ({ ...check })) }; + expect(JSON.stringify(detached)).toBe(JSON.stringify(checks)); + expect(assertionEvidence(subject, detached)).toEqual({ + atoms: [], + unresolved: ['1: assertion call not located'], + }); +}); + +it('resolves assertion aliases by lexical binding without importing nested declarations', () => { + const found = assertions(` + it('parameter', (value) => { function helper() { const value = graph.nodes.length; } expect(value).toBe(2); }); + it('direct', () => { expect(graph.nodes.length).toBe(2); }); + it('outer alias', () => { const value = graph.nodes.length; function helper() { const value = graph.edges.length; } expect(value).toBe(2); }); + it('nested block', (value) => { { const value = graph.nodes.length; } expect(value).toBe(2); }); + it.each([7])('row', (value) => { function helper() { const value = 99; } expect(result).toBe(value); }); + it('literal row', () => { expect(result).toBe(7); }); + it('callback', () => { const value = 'outer'; expect(items.map(value => value.name)).toEqual([]); }); + it('renamed callback', () => { expect(items.map(item => item.name)).toEqual([]); }); + `); + expect(found[0].atoms[0].key).toBeNull(); + expect(found[2].atoms[0].key).toBe(found[1].atoms[0].key); + expect(found[2].atoms[0].key).toBeTruthy(); + expect(found[3].atoms[0].key).toBeNull(); + // The shared row parser still treats redeclarations conservatively; never import 99. + expect(found[4].atoms[0].key).toBeNull(); + expect(found[5].atoms[0].key).toBeTruthy(); + expect(found[6].atoms[0].key).toBe(found[7].atoms[0].key); + expect(found[6].atoms[0].key).toBeTruthy(); +}); + +it('uses the last static value of duplicate object keys for literals and rows', () => { + const found = assertions(` + it('duplicates', () => { expect(result).toEqual({ a: 1, a: 2, nested: { x: 3, x: 4 } }); }); + it('effective value', () => { expect(result).toEqual({ nested: { x: 4 }, a: 2 }); }); + it.each([{ a: 1, a: 2, nested: { x: 3, x: 4 } }])('row', (value) => { expect(result).toEqual(value); }); + it('numeric aliases', () => { expect(result).toEqual({ 1: 2, '1': 3 }); }); + it('numeric value', () => { expect(result).toEqual({ '1': 3 }); }); + it('different value', () => { expect(result).toEqual({ a: 1, nested: { x: 4 } }); }); + `); + expect(found[0].atoms[0].key).toBeTruthy(); + expect(found[0].atoms[0].key).toBe(found[1].atoms[0].key); + expect(found[2].atoms[0].key).toBe(found[1].atoms[0].key); + expect(found[3].atoms[0].key).toBe(found[4].atoms[0].key); + expect(found[5].atoms[0].key).not.toBe(found[1].atoms[0].key); +}); diff --git a/gitnexus/test/unit/scenario-map/coverage-inventory.test.ts b/gitnexus/test/unit/scenario-map/coverage-inventory.test.ts new file mode 100644 index 000000000..a40e5bd36 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/coverage-inventory.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { coverageInventory } from '../../../tools/scenario-map/inventory/coverage-inventory.js'; +import type { CoverageWorkFile } from '../../../tools/scenario-map/path-coverage/run-coverage.js'; + +const provenance = { + known: false, + executedFiles: [], + nativeEntries: [], + workerSpawns: [], + excludedCandidates: [], +}; +const work = ( + state: 'passed' | 'skipped', + recorded: boolean, + errors: string[] = [], +): CoverageWorkFile => ({ + schemaVersion: 1, + mode: 'normal', + extraRoots: [], + repeats: 1, + blocks: [], + unhandledErrors: [], + files: [{ file: 'a.test.ts', recorded, errors, unattributed: provenance }], + tests: [ + { + id: 'a.test.ts#a', + taskId: 'a', + file: 'a.test.ts', + fullName: 'a', + location: null, + state, + concurrent: false, + attempts: recorded ? 2 : 0, + blocks: [], + suiteBlocks: [], + provenance, + }, + ], +}); + +describe('runtime inventory accounting', () => { + it('does not certify collection after an unhandled runner error', () => { + const input = work('passed', true); + input.unhandledErrors.push('worker exited unexpectedly'); + expect(coverageInventory(input, ['a.test.ts']).collectionComplete).toBe(false); + }); + + it('counts cases independently of files and exposes prefilter omissions', () => { + const result = coverageInventory(work('passed', true), ['a.test.ts', 'b.test.ts']); + expect(result.tests).toEqual({ collected: 1, passed: 1, failed: 0, skipped: 0, pending: 0 }); + expect(result.files.notScheduled).toEqual(['b.test.ts']); + expect(result.collectionComplete).toBe(false); + }); + + it('accounts for fully skipped benchmarks without pretending they executed', () => { + const result = coverageInventory(work('skipped', false), ['a.test.ts']); + expect(result.collectionComplete).toBe(true); + expect(result.tests).toMatchObject({ collected: 1, passed: 0, skipped: 1 }); + }); + + it('reports collection errors even when the file has an execution record', () => { + const result = coverageInventory(work('passed', true, ['collection failed']), ['a.test.ts']); + expect(result.files.unrecorded).toEqual([]); + expect(result.files.errors).toEqual(['a.test.ts']); + expect(result.collectionComplete).toBe(false); + }); + + it.each([ + { state: 'passed' as const, errors: [] }, + { state: 'skipped' as const, errors: ['collection failed'] }, + ])('does not hide missing records or collection errors: $state $errors', ({ state, errors }) => { + const result = coverageInventory(work(state, false, errors), ['a.test.ts']); + expect(result.files.unrecorded).toEqual(['a.test.ts']); + expect(result.collectionComplete).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/coverage-records.test.ts b/gitnexus/test/unit/scenario-map/coverage-records.test.ts index 40d6a01ee..ac1d812a8 100644 --- a/gitnexus/test/unit/scenario-map/coverage-records.test.ts +++ b/gitnexus/test/unit/scenario-map/coverage-records.test.ts @@ -247,6 +247,23 @@ describe('attribution (design §5.2)', () => { }); describe('buildWorkFile: block ids', () => { + it('retains a failed test’s diagnostics even when its coverage window is missing', () => { + const result = work( + [], + [ + rt('failed', 'fails', 3, { + state: 'failed', + errors: ['AssertionError: expected 1 to equal 0'], + }), + ], + ); + expect(result.tests[0]).toMatchObject({ + state: 'failed', + errors: ['AssertionError: expected 1 to equal 0'], + attempts: 0, + }); + }); + const record = new FileRun() .load() .start('t1', ['f']) diff --git a/gitnexus/test/unit/scenario-map/fingerprint.test.ts b/gitnexus/test/unit/scenario-map/fingerprint.test.ts index 7a021bf80..40fa2f79d 100644 --- a/gitnexus/test/unit/scenario-map/fingerprint.test.ts +++ b/gitnexus/test/unit/scenario-map/fingerprint.test.ts @@ -5,6 +5,9 @@ * dupe-ok keep list, the orientation and determinism. */ import { describe, expect, it } from 'vitest'; +import { assertionEvidence } from '../../../tools/scenario-map/consolidation/assertions.js'; +import { computeChecks } from '../../../tools/scenario-map/facet/checks.js'; +import { expandCases } from '../../../tools/scenario-map/inventory/cases.js'; import type { InputFacet } from '../../../tools/scenario-map/facet/input-facet.js'; import { FINGERPRINT_ENGINE_VERSION, @@ -127,6 +130,233 @@ const CASES: { rule: FingerprintRule; verdict: FingerprintVerdict; edit: WorldEd ]; describe('fingerprint rules', () => { + it('does not confirm unresolved file-level subjects through equal coarse checks and clean traces', () => { + const evidence = expandCases( + [1, 2].map((value) => ({ + path: `gitnexus/test/unit/constant-${value}.test.ts`, + source: `const result = ${value}; it('rule', () => expect(result).toBeGreaterThan(0));`, + })), + ).sites.map((site) => { + const subject = { + record: site.record, + instance: site.instances?.[0] ?? null, + fullName: site.fullName, + }; + return assertionEvidence(subject, computeChecks(subject)); + }); + expect( + verdictOf({ + a: { checks: 'same-check', assertionEvidence: evidence[0] }, + b: { checks: 'same-check', assertionEvidence: evidence[1] }, + }), + ).toMatchObject({ verdict: 'possible-duplicate', rule: 'input-unresolved' }); + }); + + it.each([ + ['roundScore(2.4)', 'roundScore(2.5)'], + ['roundScore({ scores: [2.4] })', 'roundScore({ scores: [2.5] })'], + ])('keeps changed literal inputs to %s as table rows', (left, right) => { + const evidence = expandCases([ + { + path: 'gitnexus/test/unit/input-shapes.test.ts', + source: `import { roundScore } from './scores.js'; + it('left', () => { const actual = ${left}; expect(actual).toBe(2); }); + it('right', () => { const actual = ${right}; expect(actual).toBe(3); });`, + }, + ]).sites.map((site) => { + const subject = { + record: site.record, + instance: site.instances![0], + fullName: site.fullName, + }; + return assertionEvidence(subject, computeChecks(subject)); + }); + expect(evidence.every((item) => item.atoms[0].key !== null)).toBe(true); + expect(evidence[0].atoms[0].key).not.toBe(evidence[1].atoms[0].key); + const inputs = { + a: { checks: 'same-check', assertionEvidence: evidence[0], facet: facet(['2.4', '2']) }, + b: { checks: 'same-check', assertionEvidence: evidence[1], facet: facet(['2.5', '3']) }, + }; + expect(verdictOf(inputs)).toMatchObject({ verdict: 'table-row', rule: 'input-values-differ' }); + }); + + it.each([ + [ + "getNodes(result).find(n => n.name === 'save')", + "getNodes(result).find(n => n.name === 'load')", + ], + ["getNodes(result, 'CALLS')", "getNodes(result, 'IMPORTS')"], + ['getNodes(/save/)', 'getNodes(/load/)'], + ['getNodes({ patterns: [/save/] })', 'getNodes({ patterns: [/load/] })'], + ['getNodes(2).length', 'getEdges(2).length'], + ['getNodes(2).length', 'getNodes(2).size'], + ])('case %#: preserves queried subjects when comparing %s', (left, right) => { + const evidence = expandCases([ + { + path: 'gitnexus/test/unit/queried-subjects.test.ts', + source: `import { getNodes, getEdges } from './graph.js'; + it('left', () => { const actual = ${left}; expect(actual).toBeDefined(); }); + it('right', () => { const actual = ${right}; expect(actual).toBeDefined(); });`, + }, + ]).sites.map((site) => { + const subject = { + record: site.record, + instance: site.instances![0], + fullName: site.fullName, + }; + return assertionEvidence(subject, computeChecks(subject)); + }); + expect( + verdictOf({ + a: { checks: 'coarse', assertionEvidence: evidence[0] }, + b: { checks: 'coarse', assertionEvidence: evidence[1] }, + }), + ).toMatchObject({ verdict: 'distinct', rule: 'checks-differ' }); + }); + + it('does not promote different assertion subjects or literals through a coarse checks key', () => { + const assertions = (shapeKey: string, expected: string, known = true) => ({ + atoms: [ + { + line: 1, + shape: 'expect().toContain()', + shapeKey, + key: known ? `${shapeKey}:${expected}` : null, + expected, + }, + ], + unresolved: known ? [] : ['asserted local origin is unresolved'], + }); + const a = { checks: 'coarse', facet: null, assertionEvidence: assertions('nodes', 'greet') }; + for (const b of [assertions('text', 'greet'), assertions('nodes', 'function twice', false)]) { + expect( + verdictOf({ + a, + b: { checks: 'coarse', checksContext: 'other', facet: null, assertionEvidence: b }, + }), + ).toMatchObject({ verdict: 'distinct', rule: 'checks-differ' }); + } + expect(verdictOf({ a, b: { ...a, checksContext: 'other' } }).verdict).toBe( + 'possible-duplicate', + ); + }); + + it('retains existing table rows outside the actionable pair list without hiding other declarations', () => { + const result = computeFingerprints({ + tests: [ + testInput(A, { declarationKey: 'same-table', facet: null }), + testInput(B, { declarationKey: 'same-table', facet: null }), + ], + pairs: [pairInput()], + }); + expect(result.pairs).toEqual([]); + expect(result.kept[0].keep[0].reason).toContain('already parameterized'); + expect( + verdictOf({ a: { declarationKey: 'first' }, b: { declarationKey: 'second' } }).verdict, + ).toBe('duplicate'); + }); + it('leaves proven duplicate rows actionable and uncertain helper equivalence unresolved', () => { + const identical = computeFingerprints({ + tests: [ + testInput(A, { declarationKey: 'same-table' }), + testInput(B, { declarationKey: 'same-table' }), + ], + pairs: [pairInput()], + }); + expect(identical.pairs[0]?.verdict).toBe('duplicate'); + expect(identical.kept).toEqual([]); + const evidence = (file: string) => ({ + atoms: [ + { + line: 1, + shape: 'expect(run()).toBe(1)', + shapeKey: file, + key: file, + expected: '1', + observed: `()`, + }, + ], + unresolved: [], + }); + expect( + verdictOf({ + a: { assertionEvidence: evidence('a') }, + b: { assertionEvidence: evidence('b') }, + }).verdict, + ).toBe('possible-duplicate'); + }); + it.each(['a', 'b'] as const)( + 'does not compare an assertion-only key on side %s with a legacy combined key', + (side) => { + const pair = verdictOf({ [side]: { checks: 'checks-toBe' } }); + expect([pair.verdict, pair.rule, pair.evidence.checks]).toEqual([ + 'possible-duplicate', + 'checks-unknown', + 'missing', + ]); + }, + ); + + it('keeps equal assertions with different setup as a review lead, not different checks', () => { + const pair = verdictOf({ + a: { checks: 'same-expectation' }, + b: { checks: 'same-expectation', checksContext: 'cleanup-context' }, + }); + expect([pair.verdict, pair.rule, pair.evidence.checks]).toEqual([ + 'possible-duplicate', + 'context-differs', + 'same', + ]); + }); + + it('cannot confirm a duplicate when assertion-only keys exist but context is missing', () => { + const pair = verdictOf({ + a: { checks: 'same-expectation', checksContext: null }, + b: { checks: 'same-expectation', checksContext: null }, + }); + expect([pair.verdict, pair.rule]).toEqual(['possible-duplicate', 'context-unknown']); + }); + + it('retains known input differences when setup also differs', () => { + const pair = verdictOf({ + a: { checks: 'same-expectation' }, + b: { + checks: 'same-expectation', + checksContext: 'other-setup', + facet: facet(['Number[]', 'Number']), + }, + }); + expect([pair.verdict, pair.rule]).toEqual(['table-row', 'input-values-differ']); + }); + + it.each(['args-differ', 'receiver-differs'] as const)( + 'retains a proven runtime value difference (%s) when setup also differs', + (reason) => { + const pair = verdictOf({ + a: { checks: 'same-expectation' }, + b: { checks: 'same-expectation', checksContext: 'other-setup' }, + pair: { trace: { status: 'veto', reasons: [reason] } }, + }); + expect([pair.verdict, pair.rule]).toEqual(['table-row', 'runtime-values-differ']); + }, + ); + + it('does not prove distinctness from incomplete native/worker paths', () => { + const pair = verdictOf({ + a: { provenance: { known: true, nativeEntries: ['tree-sitter.node'], workerSpawns: [] } }, + pair: { identicalBlocks: false }, + }); + expect([pair.verdict, pair.rule]).toEqual(['possible-duplicate', 'path-incomplete']); + }); + + it.each(['asymmetric-sites', 'asymmetric-counts'] as const)( + 'does not call a cold/warm trace difference (%s) different input values', + (reason) => { + const pair = verdictOf({ pair: { trace: { status: 'veto', reasons: [reason] } } }); + expect([pair.verdict, pair.rule]).toEqual(['possible-duplicate', 'trace-unknown']); + }, + ); + it.each(CASES)('rule $rule decides $verdict', ({ rule, verdict, edit }) => { const decided = verdictOf(edit); expect([decided.verdict, decided.rule]).toEqual([verdict, rule]); diff --git a/gitnexus/test/unit/scenario-map/fixture-similarity.test.ts b/gitnexus/test/unit/scenario-map/fixture-similarity.test.ts new file mode 100644 index 000000000..550142873 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/fixture-similarity.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + FixtureProfiler, + compareFixtureProfiles, + fixtureSimilarityReport, +} from '../../../tools/scenario-map/facet/fixture-profile.js'; +import { + FixtureInputs, + withFixtureIdentity, +} from '../../../tools/scenario-map/facet/fixture-inputs.js'; +import { expandCases } from '../../../tools/scenario-map/inventory/cases.js'; +import { findCandidates } from '../../../tools/scenario-map/path-coverage/candidates.js'; +import { + computeFingerprints, + type FingerprintTestInput, +} from '../../../tools/scenario-map/verdict/fingerprint.js'; +import type { + FacetSources, + FacetSubject, + InputFacet, +} from '../../../tools/scenario-map/facet/input-facet.js'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { layer1Files } from '../../../tools/scenario-map/path-coverage/prefilter.js'; +import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; + +const ROOT = 'gitnexus/test/fixtures'; +const FILE = 'gitnexus/test/integration/example.test.ts'; +const sources = (files: Record): FacetSources => ({ + read: (file) => files[file] ?? null, + list: (dir) => + Object.keys(files) + .filter((file) => file.startsWith(`${dir}/`)) + .sort(), +}); +const subjects = (source: string): FacetSubject[] => + expandCases([{ path: FILE, source }]).sites.flatMap((site) => + (site.instances ?? []).map((instance, index) => ({ + id: `${site.fullName}:${index}`, + file: site.file, + record: site.record, + instance, + executedFiles: [], + })), + ); +const FIXTURES = { + [`${ROOT}/ts/main.ts`]: 'export function double(x: number) { return x + x; }', + [`${ROOT}/renamed/main.ts`]: + '// renamed\nexport function twice(value: number) { return value + value; }', + [`${ROOT}/py/main.py`]: 'def twice(value):\n return value + value\n', + [`${ROOT}/loop/main.ts`]: + 'export function twice(value: number) { while (value > 0) { value--; } return value; }', +}; + +// Exercise the real native providers, including standalone COBOL and embedded Vue scripts. +const LANGUAGE_FIXTURES: Record = { + typescript: ['ts', 'function twice(x: number) { return x + x; }'], + javascript: ['js', 'function twice(x) { return x + x; }'], + python: ['py', 'def twice(x):\n return x + x\n'], + go: ['go', 'package main\nfunc twice(x int) int { return x + x }'], + rust: ['rs', 'fn twice(x: i32) -> i32 { return x + x; }'], + java: ['java', 'class Sample { static int twice(int x) { return x + x; } }'], + csharp: ['cs', 'class Sample { static int Twice(int x) { return x + x; } }'], + c: ['c', 'int twice(int x) { return x + x; }'], + cpp: ['cpp', 'int twice(int x) { return x + x; }'], + kotlin: ['kt', 'fun twice(x: Int): Int { return x + x }'], + php: ['php', ' Int { return x + x }'], + dart: ['dart', 'int twice(int x) { return x + x; }'], + zig: ['zig', 'fn twice(x: i32) i32 { return x + x; }'], + 'objective-c': [ + 'm', + '@interface Sample\n- (int)twice:(int)x;\n@end\n@implementation Sample\n- (int)twice:(int)x { return x + x; }\n@end', + ], + vue: [ + 'vue', + '', + ], + cobol: [ + 'cbl', + ' IDENTIFICATION DIVISION.\n PROGRAM-ID. SAMPLE.\n PROCEDURE DIVISION.\n MAIN-PARA.\n DISPLAY "HI".\n STOP RUN.\n', + ], +}; + +it.each(Object.entries(LANGUAGE_FIXTURES))( + 'profiles real %s fixtures with the ingestion provider', + async (language, [extension, content]) => { + const root = `${ROOT}/language-${language}`; + const profiler = new FixtureProfiler(sources({ [`${root}/main.${extension}`]: content })); + const result = await profiler.profile([root]); + if ( + language !== SupportedLanguages.Cobol && + !isLanguageAvailable(language as SupportedLanguages, `main.${extension}`) + ) { + expect(result.contentKey).not.toBeNull(); + expect(result.unresolved.length).toBeGreaterThan(0); + return; + } + expect(result.unresolved).toEqual([]); + expect(result.languages).toEqual([language]); + expect(result.features.some(([feature]) => feature.startsWith('def:'))).toBe(true); + expect(result.contentKey).not.toBeNull(); + }, +); + +describe('fixture similarity', () => { + it('retrieves renamed and cross-language fixtures, without conflating a loop with straight-line code', async () => { + const profiler = new FixtureProfiler(sources(FIXTURES)); + const [ts, renamed, py, loop] = await Promise.all( + ['ts', 'renamed', 'py', 'loop'].map((name) => profiler.profile([`${ROOT}/${name}`])), + ); + expect(compareFixtureProfiles(ts, renamed)).toEqual({ + kind: 'structure', + score: 1, + crossLanguage: false, + }); + expect(compareFixtureProfiles(ts, py)).toEqual({ + kind: 'structure', + score: 1, + crossLanguage: true, + }); + expect(compareFixtureProfiles(ts, loop)).toBeNull(); + expect(new Set([ts.contentKey, renamed.contentKey, py.contentKey]).size).toBe(3); + const candidate = (id: string, fixture: typeof ts, checks = 'assertion') => ({ + id, + blocks: [id.charCodeAt(0)], + concurrent: false, + checksContext: id, + bodyKey: id, + checks, + fixture, + }); + const found = findCandidates([ + candidate('a', ts), + candidate('b', py), + candidate('c', loop), + candidate('d', py, 'different'), + ]); + expect(found.pairs).toHaveLength(1); + expect(found.pairs[0]).toMatchObject({ + a: 'a', + b: 'b', + sources: ['fixture'], + fixtureSimilarity: { crossLanguage: true }, + }); + expect(found.stats.bySource.fixture).toBe(1); + const report = fixtureSimilarityReport( + [ + { id: 'a', fixture: ts }, + { id: 'b', fixture: py }, + { id: 'another', fixture: ts }, + ], + found.pairs, + ); + expect(report.summary).toMatchObject({ + tests: 3, + profiles: 2, + matches: 1, + crossLanguageMatches: 1, + }); + expect(report.profiles.find((entry) => entry.profile === ts)?.tests).toEqual(['a', 'another']); + expect(findCandidates([candidate('a', ts), candidate('b', py)], () => false).pairs).toEqual([]); + }); + + it('keeps file multiplicity, layout, and non-source assets in exact input identity', async () => { + const profiler = new FixtureProfiler( + sources({ + [`${ROOT}/a/main.ts`]: FIXTURES[`${ROOT}/ts/main.ts`], + [`${ROOT}/b/main.ts`]: FIXTURES[`${ROOT}/ts/main.ts`], + [`${ROOT}/c/nested/main.ts`]: FIXTURES[`${ROOT}/ts/main.ts`], + [`${ROOT}/d/main.ts`]: FIXTURES[`${ROOT}/ts/main.ts`], + [`${ROOT}/d/package.json`]: '{"type":"module"}', + [`${ROOT}/e/one.ts`]: FIXTURES[`${ROOT}/ts/main.ts`], + [`${ROOT}/e/two.ts`]: FIXTURES[`${ROOT}/ts/main.ts`], + }), + ); + const [a, b, c, d, e] = await Promise.all( + ['a', 'b', 'c', 'd', 'e'].map((name) => profiler.profile([`${ROOT}/${name}`])), + ); + expect(compareFixtureProfiles(a, b)?.kind).toBe('content'); + expect(new Set([a.contentKey, c.contentKey, d.contentKey, e.contentKey]).size).toBe(4); + expect(compareFixtureProfiles(a, e)).toBeNull(); + }); + + it('reports missing, malformed, and oversized fixtures instead of matching partial profiles', async () => { + const profiler = new FixtureProfiler( + sources({ + ...FIXTURES, + [`${ROOT}/broken/main.ts`]: 'export function () { return (', + [`${ROOT}/large/main.ts`]: ' '.repeat(200_001), + }), + ); + const baseline = await profiler.profile([`${ROOT}/ts`]); + for (const root of ['missing', 'broken', 'large']) { + const result = await profiler.profile([`${ROOT}/${root}`]); + if (root === 'broken') { + expect(result.contentKey).not.toBeNull(); + expect(compareFixtureProfiles(result, result)?.kind).toBe('content'); + } else expect(result.contentKey).toBeNull(); + expect(result.unresolved.length).toBeGreaterThan(0); + expect(compareFixtureProfiles(result, baseline)).toBeNull(); + } + }); +}); + +it('retains fixture partners in diff selection even when their setup and callbacks differ', () => { + const root = mkdtempSync(path.join(tmpdir(), 'fixture-similarity-')); + try { + const files = ['test/integration/a.test.ts', 'test/integration/b.test.ts']; + files.forEach((file, i) => { + const target = path.join(root, 'gitnexus', file); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync( + target, + `import path from 'node:path'; + beforeAll(() => setup(${i})); + it('test', () => { const content${i} = readFileSync(path.join(__dirname, '..', 'fixtures', '${i}.ts')); expect(content${i}).toBeDefined(); });`, + ); + mkdirSync(path.join(root, 'gitnexus/test/fixtures'), { recursive: true }); + writeFileSync( + path.join(root, `gitnexus/test/fixtures/${i}.ts`), + `function f${i}() { return 1; }`, + ); + }); + expect(layer1Files(root, files, new Set([files[0]]))).toEqual({ files, skipped: [] }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('fixture input discovery', () => { + it('does not resolve an unknown helper/callback argument using a shadowed outer constant', () => { + const inputs = new FixtureInputs(sources(FIXTURES)); + const [subject] = subjects(` + const root = '${ROOT}/ts'; + const load = (root) => runPipelineFromRepo(root); + it('unknown', () => { load(dynamicRoot()); values.forEach((root) => readFileSync(root)); }); + `); + expect(inputs.resolve(subject).roots).toEqual([]); + expect(inputs.resolve(subject).unresolved).toHaveLength(2); + }); + it('follows imported roots and local setup constants without collecting assertion strings', async () => { + const inputs = new FixtureInputs( + sources({ + ...FIXTURES, + 'gitnexus/test/integration/helpers.ts': `import path from 'node:path'; export const FIXTURES = path.resolve(__dirname, '..', 'fixtures');`, + }), + ); + const [subject] = subjects(` + import path from 'node:path'; import { FIXTURES } from './helpers.js'; + describe('suite', () => { + beforeAll(() => { const root = path.join(FIXTURES, 'ts'); runPipelineFromRepo(root); }); + it('one', () => { expect(result).toContain('${ROOT}/py'); }); + }); + `); + expect(inputs.resolve(subject)).toEqual({ roots: [`${ROOT}/ts`], unresolved: [] }); + expect((await inputs.profile(subject))?.languages).toEqual(['typescript']); + }); + + it('resolves per-row directories and both calls to an imported helper', () => { + const inputs = new FixtureInputs( + sources({ + ...FIXTURES, + 'gitnexus/test/integration/helpers.ts': `import path from 'node:path'; export function analyzeFixture(name) { return runPipelineFromRepo(path.join(__dirname, '..', 'fixtures', name)); }`, + }), + ); + const rows = subjects(`import path from 'node:path'; it.each(['ts','py'])('row %s', (name) => { + const root = path.join(__dirname, '..', 'fixtures', name); runPipelineFromRepo(root); expect(result).toBeDefined(); + });`); + expect(rows.map((row) => inputs.resolve(row).roots)).toEqual([[`${ROOT}/ts`], [`${ROOT}/py`]]); + const [helper] = subjects(`import { analyzeFixture } from './helpers.js'; it('both', () => { + analyzeFixture('ts'); analyzeFixture('py'); expect(result).toBeDefined(); + });`); + expect(inputs.resolve(helper)).toEqual({ roots: [`${ROOT}/py`, `${ROOT}/ts`], unresolved: [] }); + }); + + it('handles file URL inputs and exposes dynamically chosen inputs', () => { + const inputs = new FixtureInputs(sources(FIXTURES)); + const [known, unknown] = + subjects(`it('url', () => { readFileSync(new URL('../fixtures/ts/main.ts', import.meta.url)); }); + it('dynamic', () => { runPipelineFromRepo(selectFixture()); });`); + expect(inputs.resolve(known)).toEqual({ roots: [`${ROOT}/ts/main.ts`], unresolved: [] }); + expect(inputs.resolve(unknown).unresolved).toEqual([ + expect.stringContaining('unresolved fixture input'), + ]); + }); + + it('does not crash on spread arguments and resolves aliased path and ingestion imports', () => { + const inputs = new FixtureInputs(sources(FIXTURES)); + const [spread, alias] = subjects(` + import { join as combine } from 'node:path'; import { runPipelineFromRepo as ingest } from '../../src/core/ingestion/pipeline.js'; + it('spread', () => { runPipelineFromRepo(...args); }); + it('alias', () => { ingest(combine(__dirname, '..', 'fixtures', 'ts')); }); + `); + expect(inputs.resolve(spread).unresolved).toHaveLength(1); + expect(inputs.resolve(alias)).toEqual({ roots: [`${ROOT}/ts`], unresolved: [] }); + }); + + it('handles bigint rows and keeps hook constants separate from test-local shadowing', () => { + const inputs = new FixtureInputs(sources(FIXTURES)); + const rows = + subjects(`import path from 'node:path'; const root = path.join(__dirname, '..', 'fixtures', 'ts'); + beforeAll(() => runPipelineFromRepo(root)); + it.each([1n, 2n])('bigint %s', (n) => { const root = 'not-a-fixture'; expect(n).toBeDefined(); });`); + expect(rows.map((row) => inputs.resolve(row))).toEqual([ + { roots: [`${ROOT}/ts`], unresolved: [] }, + { roots: [`${ROOT}/ts`], unresolved: [] }, + ]); + }); +}); + +it.each([ + ['ts', 'py', 'input-shape-differs'], + ['broken', 'broken', 'input-unresolved'], +])( + 'does not confirm %s/%s fixtures despite equal paths, assertions, and clean traces', + async (left, right, rule) => { + const profiler = new FixtureProfiler( + sources({ ...FIXTURES, [`${ROOT}/broken/main.ts`]: 'function () {' }), + ); + const [a, b] = await Promise.all( + [left, right].map((name) => profiler.profile([`${ROOT}/${name}`])), + ); + const facet: InputFacet = { + fingerprint: 'same', + parts: { language: '-', source: [], fixtures: [], data: [], calls: [], order: [] }, + discarded: [], + unresolved: [], + skeleton: [], + }; + const test = (id: string, profile: typeof a): FingerprintTestInput => ({ + id, + fingerprintEligible: true, + passed: true, + dupeOk: null, + joinProblems: [], + inventoryUnresolved: [], + provenance: { known: true, nativeEntries: [], workerSpawns: [] }, + concurrent: false, + checksContext: 'same', + checks: 'same', + unsupportedForms: [], + unresolvedSelectors: [], + facet: withFixtureIdentity(facet, profile), + }); + const result = computeFingerprints({ + tests: [test('a', a), test('b', b)], + pairs: [ + { + a: 'a', + b: 'b', + group: 'g', + identicalBlocks: true, + pathUnattributable: false, + trace: { status: 'clean', reasons: [] }, + }, + ], + }); + expect(result.pairs[0].verdict).not.toBe('duplicate'); + expect(result.pairs[0].rule).toBe(rule); + }, +); diff --git a/gitnexus/test/unit/scenario-map/partitions.test.ts b/gitnexus/test/unit/scenario-map/partitions.test.ts index eccda8ddf..6e2404afe 100644 --- a/gitnexus/test/unit/scenario-map/partitions.test.ts +++ b/gitnexus/test/unit/scenario-map/partitions.test.ts @@ -5,6 +5,7 @@ * glob matching, the split, the merge of partial runs, the partitioned runner and the build output * copied into a prepared package. */ +import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -182,6 +183,73 @@ describe('copyBuildOutput', () => { for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + it('gives inventory guards a private index without sharing Git configuration or writes', () => { + const base = mkdtempSync(path.join(os.tmpdir(), 'scenario-map-git-inventory-')); + temps.push(base); + const checkout = path.join(base, 'checkout'); + const prepared = path.join(base, 'prepared'); + for (const root of [checkout, prepared]) { + mkdirSync(path.join(root, 'gitnexus'), { recursive: true }); + writeFileSync(path.join(root, 'gitnexus', 'case.ts'), 'export const value = 1;'); + } + writeFileSync(path.join(checkout, 'public-manifest.json'), '{}'); + writeFileSync(path.join(checkout, '.env'), 'fixture-only-private-value'); + writeFileSync(path.join(prepared, 'gitnexus', 'case.ts'), 'export const value = 2;'); + execFileSync('git', ['init', '--quiet', checkout]); + const git = (root: string, ...args: string[]) => + execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); + git(checkout, 'add', '--force', '--', 'gitnexus/case.ts', 'public-manifest.json', '.env'); + git(checkout, 'remote', 'add', 'origin', 'https://example.com/source.git'); + copyBuildOutput('normal', path.join(checkout, 'gitnexus'), path.join(prepared, 'gitnexus')); + expect(git(prepared, 'rev-parse', '--show-toplevel')).toBe(prepared.split(path.sep).join('/')); + expect(git(prepared, 'ls-files', '--stage')).toBe( + git(checkout, 'ls-files', '--stage', '--', 'gitnexus/case.ts', 'public-manifest.json'), + ); + expect(git(prepared, 'remote')).toBe(''); + expect(existsSync(path.join(prepared, '.env'))).toBe(false); + expect(readFileSync(path.join(prepared, 'public-manifest.json'), 'utf8')).toBe('{}'); + expect(readFileSync(path.join(prepared, 'gitnexus', 'case.ts'), 'utf8')).toContain('value = 2'); + git(prepared, 'update-index', '--force-remove', 'gitnexus/case.ts'); + expect(git(checkout, 'ls-files').split('\n')).toEqual([ + '.env', + 'gitnexus/case.ts', + 'public-manifest.json', + ]); + }); + + it('preserves repository contract fixtures needed during test collection', () => { + const base = mkdtempSync(path.join(os.tmpdir(), 'scenario-map-contract-fixtures-')); + temps.push(base); + const checkout = path.join(base, 'checkout', 'gitnexus'); + const prepared = path.join(base, 'prepared', 'gitnexus'); + mkdirSync(checkout, { recursive: true }); + mkdirSync(prepared, { recursive: true }); + const fixtures = [ + '.github/workflows/ci-tests.yml', + '.claude/skills/gitnexus-plan/SKILL.md', + 'gitnexus-factory-plugin/.factory-plugin/plugin.json', + 'Dockerfile.cli', + 'render.yaml', + 'AGENTS.md', + 'CLAUDE.md', + 'eval/workflow_bench/run-evolution.sh', + 'gitnexus/prebuilds/bundled/manifest.json', + ]; + for (const file of fixtures) { + const target = path.join(checkout, '..', file); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, file); + } + writeFileSync(path.join(checkout, '..', '.env'), 'private'); + execFileSync('git', ['init', '--quiet', path.dirname(checkout)]); + execFileSync('git', ['add', '--force', '--', ...fixtures], { cwd: path.dirname(checkout) }); + copyBuildOutput('normal', checkout, prepared); + expect(fixtures.map((file) => readFileSync(path.join(prepared, '..', file), 'utf8'))).toEqual( + fixtures, + ); + expect(existsSync(path.join(prepared, '..', '.env'))).toBe(false); + }); + it('copies vendor/ and dist/ that the checkout has into a normal-mode copy only', () => { const base = mkdtempSync(path.join(os.tmpdir(), 'scenario-map-build-output-')); temps.push(base); @@ -218,6 +286,8 @@ describe('copyBuildOutput', () => { mkdirSync(dir, { recursive: true }); writeFileSync(path.join(dir, 'registry-query.cjs'), plugin); } + execFileSync('git', ['init', '--quiet', path.dirname(checkout)]); + execFileSync('git', ['add', '--', ...hooks], { cwd: path.dirname(checkout) }); copyBuildOutput('normal', checkout, prepared); expect( hooks.map((plugin) => diff --git a/gitnexus/test/unit/scenario-map/recall-audit.test.ts b/gitnexus/test/unit/scenario-map/recall-audit.test.ts new file mode 100644 index 000000000..7e9f53b90 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/recall-audit.test.ts @@ -0,0 +1,53 @@ +/** Source-adjudicated pairs from the PR #3686 audit. Retrieval is not a duplicate verdict. */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { bodyKeyOf } from '../../../tools/scenario-map/facet/body-key.js'; +import { + checksContextKey, + fsModuleReader, + fsSnapshotReader, +} from '../../../tools/scenario-map/facet/checks.js'; +import { loadStaticInventory } from '../../../tools/scenario-map/inventory/cases.js'; +import { findCandidates } from '../../../tools/scenario-map/path-coverage/candidates.js'; +import { layer1Files } from '../../../tools/scenario-map/path-coverage/prefilter.js'; + +const root = fileURLToPath(new URL('../../../../', import.meta.url)); +const audit = JSON.parse( + readFileSync(new URL('../../fixtures/scenario-map-recall/audit.json', import.meta.url), 'utf8'), +) as { + positives: { a: string; b: string; category: string; basis: string }[]; +}; +const ids = new Set(audit.positives.flatMap(({ a, b }) => [a, b])); +const files = [...new Set([...ids].map((id) => id.slice(0, id.indexOf('#'))))]; +const inventory = loadStaticInventory(root, files); +const options = { modules: fsModuleReader(root), snapshots: fsSnapshotReader(root) }; +const sites = new Map(inventory.sites.map((site) => [`${site.file}#${site.fullName}`, site])); + +describe('manual recall audit: candidate retrieval', () => { + it('includes the audited files despite differing hooks and re-export contexts', () => { + expect(layer1Files(root, files).skipped).toEqual([]); + }); + + it.each(audit.positives)( + 'case %#: retrieves $a and $b without relying on warm setup coverage', + ({ a, b }) => { + const tests = [a, b].map((id) => { + const site = sites.get(id); + expect(site, `audited test moved or disappeared: ${id}`).toBeDefined(); + if (!site) throw new Error(id); + return { + id, + blocks: [], // Do not let Jaccard hide a static retrieval regression. + concurrent: false, + checksContext: checksContextKey( + { record: site.record, instance: site.instances?.[0] ?? null, fullName: site.fullName }, + options, + ), + bodyKey: bodyKeyOf(site.record), + }; + }); + expect(findCandidates(tests).pairs).toHaveLength(1); + }, + ); +}); diff --git a/gitnexus/test/unit/scenario-map/report-json.test.ts b/gitnexus/test/unit/scenario-map/report-json.test.ts new file mode 100644 index 000000000..e5ed5c452 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/report-json.test.ts @@ -0,0 +1,124 @@ +/** Full-suite reports exceed V8's string limit; each record must survive streamed I/O. */ +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { compareWithBaseline } from '../../../tools/scenario-map/report/baseline.js'; +import { readReportFields, writeReportJson } from '../../../tools/scenario-map/report/json.js'; +import { buildReport } from '../../../tools/scenario-map/report/report.js'; +import { reportInput } from './report-fixtures.js'; + +const directories: string[] = []; +function output(): string { + const directory = mkdtempSync(path.join(os.tmpdir(), 'scenario-report-json-')); + directories.push(directory); + return path.join(directory, 'report.json'); +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const directory of directories.splice(0)) + rmSync(directory, { recursive: true, force: true }); +}); + +describe('report JSON', () => { + it('preserves every field and record without serializing a whole collection', async () => { + const report = buildReport(reportInput()); + const pair = report.fingerprint.pairs[0]; + report.fingerprint.pairs = Array.from({ length: 100 }, (_, i) => ({ + ...pair, + a: `test-${i}`, + details: ['escaped \"quote\", \\slash, \nnewline and unicode 🧪'], + })); + report.fingerprint.kept = report.fingerprint.pairs.map((entry) => ({ + ...entry, + keep: [{ test: entry.a, reason: 'intentional control' }], + })); + report.fingerprint.pairs[0].details = Array(1_000).fill('nested record detail'); + const file = output(); + const stringify = JSON.stringify; + // Model the V8 cap at a small scale, including a collection nested inside one record. + const limit = 16_384; + expect(stringify(report.fingerprint.pairs).length).toBeGreaterThan(limit); + expect(stringify(report.fingerprint.kept).length).toBeGreaterThan(limit); + expect(stringify(report.fingerprint.pairs[0]).length).toBeGreaterThan(limit); + const capped = vi.spyOn(JSON, 'stringify').mockImplementation((value, replacer, space) => { + const text = stringify(value, replacer, space); + if (text !== undefined && text.length > limit) throw new RangeError('Invalid string length'); + return text; + }); + await writeReportJson(file, report); + capped.mockRestore(); + expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(report); + expect(readdirSync(path.dirname(file))).toEqual(['report.json']); + }); + + it('leaves the prior report intact and removes the temporary file on serialization failure', async () => { + const report = buildReport(reportInput()); + const file = output(); + writeFileSync(file, '{"previous":true}'); + vi.spyOn(JSON, 'stringify').mockImplementation(() => { + throw new Error('cannot serialize'); + }); + await expect(writeReportJson(file, report)).rejects.toThrow('cannot serialize'); + expect(readFileSync(file, 'utf8')).toBe('{"previous":true}'); + expect(readdirSync(path.dirname(file))).toEqual(['report.json']); + }); + + it('propagates filesystem errors', async () => { + const file = output(); + await expect(writeReportJson(path.dirname(file), buildReport(reportInput()))).rejects.toThrow(); + expect(readdirSync(path.dirname(file))).toEqual([]); + }); + + it.each(['streamed', 'legacy'] as const)( + 'reads a %s baseline without retaining findings', + async (format) => { + const report = buildReport(reportInput()); + const file = output(); + if (format === 'streamed') await writeReportJson(file, report); + else writeFileSync(file, JSON.stringify(report, null, 2)); + const baseline = await readReportFields(file, ['schemaVersion', 'run', 'continuity']); + expect(baseline).toEqual({ + schemaVersion: report.schemaVersion, + run: report.run, + continuity: report.continuity, + }); + expect(compareWithBaseline(report.continuity, baseline)).toEqual( + compareWithBaseline(report.continuity, report), + ); + expect(await readReportFields(file, ['run', 'completeness'])).toEqual({ + run: report.run, + completeness: report.completeness, + }); + }, + ); + + it('preserves UTF-8 and escapes across input chunks while skipping large unselected fields', async () => { + const file = output(); + const run = { id: '🧪\n"\\é'.repeat(30_000) }; + writeFileSync(file, JSON.stringify({ ignored: Array(100).fill(run), run, completeness: null })); + expect(await readReportFields(file, ['run', 'completeness'])).toEqual({ + run, + completeness: null, + }); + }); + + it.each([ + ['empty', ''], + ['whitespace', ' \n\t'], + ['truncated selected field', '{"run":{"id":"1"}'], + ['truncated ignored field', '{"run":{"id":"1"},"ignored":[1,'], + ['invalid ignored field', '{"run":{"id":"1"},"ignored":!}'], + ['trailing garbage', '{"run":{"id":"1"}} invalid'], + ['second document', '{"run":{"id":"1"}} {}'], + ])('rejects %s instead of trusting metadata parsed before the error', async (_name, source) => { + const file = output(); + writeFileSync(file, source); + await expect(readReportFields(file, ['run'])).rejects.toThrow(); + }); + + it('propagates a missing file so merge can mark the baseline unreadable', async () => { + await expect(readReportFields(output(), ['run'])).rejects.toThrow(/ENOENT/); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/report.test.ts b/gitnexus/test/unit/scenario-map/report.test.ts index 5fb8434de..39001e98b 100644 --- a/gitnexus/test/unit/scenario-map/report.test.ts +++ b/gitnexus/test/unit/scenario-map/report.test.ts @@ -354,7 +354,7 @@ describe('renderMarkdown', () => { { scope: 'a whole-suite run that skipped files', overrides: { layer1Files: 0 }, - lines: ['Layer 1 ran 0 of them; every test of the other 1 has a checks-and-context key'], + lines: ['Layer 1 ran 0 of them; the other 1 file was not compared.'], }, { scope: 'a diff-scoped run', diff --git a/gitnexus/test/unit/scenario-map/runtime-fixtures.test.ts b/gitnexus/test/unit/scenario-map/runtime-fixtures.test.ts new file mode 100644 index 000000000..ad44666d1 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/runtime-fixtures.test.ts @@ -0,0 +1,430 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import { + mkdtempSync, + mkdirSync, + readFile, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { readFile as readAsync } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { FixtureCapture } from '../../../tools/scenario-map/path-coverage/fixture-capture.js'; +import { fixtureCapturePlugin } from '../../../tools/scenario-map/path-coverage/fixture-plugin.js'; +import { + CoverageAttributor, + CoverageInterner, + type RawCoverageRecord, + type WindowDelta, +} from '../../../tools/scenario-map/path-coverage/records.js'; +import { + profileRuntimeFixtures, + runtimeFixtureFor, +} from '../../../tools/scenario-map/facet/runtime-fixtures.js'; +import { compareFixtureProfiles } from '../../../tools/scenario-map/facet/fixture-profile.js'; +import { buildWorkFile } from '../../../tools/scenario-map/path-coverage/run-coverage.js'; +import { + mergeCoverageSlices, + type CoverageSliceFile, +} from '../../../tools/scenario-map/path-coverage/slices.js'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +const repo = (files: Record) => { + const root = mkdtempSync(path.join(tmpdir(), 'runtime-fixture-')); + roots.push(root); + for (const [name, content] of Object.entries(files)) { + const file = path.join(root, name); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, content); + } + return root; +}; +const delta = (fixtures: string[] = []): WindowDelta => ({ + blocks: new Set(), + scripts: new Set(), + natives: new Set(), + spawns: [], + fixtures, +}); +const record = (capture: FixtureCapture): RawCoverageRecord => { + const owner = new CoverageAttributor(); + owner.testStart('test', ['file'], false, delta()); + owner.testEnd('test', delta(capture.drain())); + return { + ...owner.record('/prep/gitnexus/test/input.test.ts', new CoverageInterner([], new Set())), + fixtureSnapshots: capture.snapshots, + }; +}; +const profile = async (raw: RawCoverageRecord, index = 0, concurrent = false) => { + const result = runtimeFixtureFor( + raw, + raw.tests[index], + (await profileRuntimeFixtures([raw])).get(raw), + concurrent, + ); + if (result === null) throw new Error('expected captured fixture'); + return result; +}; +const TS = 'export function twice(x: number) { return x + x; }'; +const PY = 'def twice(x):\n return x + x\n'; + +it('preserves reads and repository snapshots when the test replaces JSON.stringify', () => { + const root = repo({ 'main.ts': TS }); + const capture = new FixtureCapture(); + const restore = capture.install(); + const stringify = vi.spyOn(JSON, 'stringify').mockImplementation(() => { + throw new Error('test-controlled serialization failure'); + }); + let read: string; + try { + read = readFileSync(path.join(root, 'main.ts'), 'utf8'); + capture.repository(root); + } finally { + stringify.mockRestore(); + restore(); + } + expect(read).toBe(TS); + expect(Object.values(capture.snapshots).map((snapshot) => snapshot.files)).toEqual([ + [['main.ts', TS]], + [['main.ts', TS]], + ]); +}); + +it('does not capture a replacement installed after the file metadata check', () => { + const root = repo({ 'main.ts': TS }); + const file = path.join(root, 'main.ts'); + const original = fs.lstatSync; + let replaced = false; + const stat = vi.spyOn(fs, 'lstatSync').mockImplementation(((...args) => { + const result = Reflect.apply(original, fs, args); + if (args[0] === file && !replaced) { + replaced = true; + fs.renameSync(file, `${file}.old`); + writeFileSync(file, 'replacement contents must not be retained'); + } + return result; + }) as typeof fs.lstatSync); + const capture = new FixtureCapture(); + try { + capture.repository(root); + } finally { + stat.mockRestore(); + } + expect(replaced).toBe(true); + expect(JSON.stringify(capture.snapshots)).not.toContain('replacement contents'); + expect(Object.values(capture.snapshots)[0].unresolved.join()).toMatch(/changed|unreadable/); +}); + +it('bounds reads when a file grows after opening and closes the descriptor', () => { + const root = repo({ 'main.ts': TS }); + const original = fs.fstatSync; + let grew = false; + const stat = vi.spyOn(fs, 'fstatSync').mockImplementation(((...args) => { + const result = Reflect.apply(original, fs, args); + if (!grew) { + grew = true; + fs.appendFileSync(path.join(root, 'main.ts'), 'x'.repeat(250_000)); + } + return result; + }) as typeof fs.fstatSync); + const read = vi.spyOn(fs, 'readSync'); + const close = vi.spyOn(fs, 'closeSync'); + const capture = new FixtureCapture(); + try { + capture.repository(root); + expect(grew).toBe(true); + expect(read.mock.results.reduce((n, result) => n + result.value, 0)).toBe( + Buffer.byteLength(TS) + 1, + ); + expect(close).toHaveBeenCalledOnce(); + expect(Object.values(capture.snapshots)[0]).toMatchObject({ + files: [], + unresolved: ['main.ts: changed during capture'], + }); + } finally { + stat.mockRestore(); + read.mockRestore(); + close.mockRestore(); + } +}); + +it('closes the descriptor when reading fails and marks the snapshot incomplete', () => { + const root = repo({ 'main.ts': TS }); + const read = vi.spyOn(fs, 'readSync').mockImplementation(() => { + throw new Error('read failed'); + }); + const close = vi.spyOn(fs, 'closeSync'); + const capture = new FixtureCapture(); + try { + capture.repository(root); + expect(close).toHaveBeenCalledOnce(); + expect(Object.values(capture.snapshots)[0]).toMatchObject({ + files: [], + unresolved: ['main.ts: unreadable'], + }); + } finally { + read.mockRestore(); + close.mockRestore(); + } +}); + +it('retains generated, deleted multilingual input contents and distinguishes control flow', async () => { + const profiles = []; + for (const files of [ + { 'main.ts': TS }, + { 'main.py': PY }, + { 'main.ts': 'export function twice(x: number) { while(x > 0) { x--; } return x; }' }, + ]) { + const root = repo(files); + const capture = new FixtureCapture(); + capture.repository(root); + const raw = record(capture); + rmSync(root, { recursive: true }); + profiles.push(await profile(JSON.parse(JSON.stringify(raw)))); + } + expect(compareFixtureProfiles(profiles[0], profiles[1])).toMatchObject({ + kind: 'structure', + crossLanguage: true, + }); + expect(compareFixtureProfiles(profiles[0], profiles[2])).toBeNull(); + expect(profiles.map((p) => p.unresolved)).toEqual([[], [], []]); + expect(profiles[0].contentKey).not.toBe(profiles[1].contentKey); +}); + +it('keeps input versions at the same path and ignores random temporary root names', async () => { + const first = repo({ 'main.ts': TS }); + const second = repo({ 'main.ts': TS }); + const capture = new FixtureCapture(); + capture.repository(first); + const a = record(capture); + capture.repository(second); + const b = record(capture); + writeFileSync(path.join(first, 'main.ts'), TS.replace('x + x', 'x + 1')); + capture.repository(first); + const c = record(capture); + expect((await profile(a)).contentKey).toBe((await profile(b)).contentKey); + expect((await profile(a)).contentKey).not.toBe((await profile(c)).contentKey); +}); + +it('compares repeat inputs by content rather than temporary root names', async () => { + const capture = new FixtureCapture(); + capture.repository(repo({ 'main.ts': TS })); + const cold = capture.drain(); + capture.repository(repo({ 'main.ts': TS })); + const raw = record(capture); + raw.tests[0].all.fixtures = [...cold, ...(raw.tests[0].warm.fixtures ?? [])]; + expect((await profile(raw)).unresolved).toEqual([]); + capture.repository(repo({ 'main.ts': TS.replace('x + x', 'x + 1') })); + raw.tests[0].all.fixtures.push(...capture.drain()); + expect((await profile(raw)).unresolved).toContain( + 'runtime fixture inputs changed between attempts', + ); +}); + +it('filters module loading and production housekeeping from direct-read capture', () => { + const capture = new FixtureCapture(); + expect( + capture.accepts( + 'Error\n at read (/repo/tools/scenario-map/path-coverage/fixture-capture.ts:1:1)\n at helper (/repo/test/helpers/fixture.ts:8:1)', + ), + ).toBe(true); + expect( + capture.accepts( + 'Error\n at load (/repo/src/core/parser-loader.ts:8:1)\n at test (/repo/test/example.ts:9:1)', + ), + ).toBe(false); + expect(capture.accepts('Error\n at load (/repo/node_modules/vite/test.js:8:1)')).toBe(false); +}); + +it('marks bounded captures incomplete instead of hashing a truncated repository as complete', async () => { + const root = repo( + Object.fromEntries(Array.from({ length: 201 }, (_, i) => [`file${i}.txt`, 'sample'])), + ); + const capture = new FixtureCapture(); + capture.repository(root); + const p = await profile(record(capture)); + expect(p.contentKey).toBeNull(); + expect(p.unresolved).toContain('runtime repository capture limit'); + expect(p.files.length).toBeLessThanOrEqual(200); +}); + +it('bounds large returned buffers before allocating a decoded copy', () => { + const capture = new FixtureCapture(); + const buffer = Buffer.alloc(200_000); + buffer.toString = () => { + throw new Error('must not decode oversized input'); + }; + expect(() => capture.read('/tmp/large.ts', buffer)).not.toThrow(); + expect(Object.values(capture.snapshots)).toMatchObject([ + { files: [], unresolved: ['oversized text'] }, + ]); +}); + +it('captures real sync, promise, callback and URL reads without changing results or errors', async () => { + const root = repo({ 'main.ts': TS }); + const file = path.join(root, 'main.ts'); + const capture = new FixtureCapture(); + const restore = capture.install(); + try { + expect(readFileSync(file, 'utf8')).toBe(TS); + expect((await readAsync(pathToFileURL(file))).toString()).toBe(TS); + expect( + await new Promise((resolve, reject) => + readFile(file, 'utf8', (error, text) => (error ? reject(error) : resolve(text))), + ), + ).toBe(TS); + expect(() => readFileSync(path.join(root, 'missing.ts'))).toThrow(); + await expect(readAsync(path.join(root, 'missing.ts'))).rejects.toThrow(); + } finally { + restore(); + } + const p = await profile(record(capture)); + expect(p.languages).toEqual(['typescript']); + expect(p.files).toEqual([file]); + expect(p.contentKey).toBeNull(); + expect(p.unresolved).toContain('test read may be assertion output; input role is unverified'); +}); + +it('does not turn an identical generated output into proof of identical fixture inputs', async () => { + const capture = new FixtureCapture(); + capture.read( + '/tmp/setup-home/.claude.json', + '{"mcpServers":{"gitnexus":{"command":"gitnexus.cmd"}}}', + ); + const p = await profile(record(capture)); + expect(p.contentKey).toBeNull(); + expect(p.unresolved).toContain('test read may be assertion output; input role is unverified'); + // Keep the read available for diagnosis without promoting it to an ingestion input. + expect(p.files).toEqual(['/tmp/setup-home/.claude.json']); +}); + +it('reports excluded, missing, oversized and symbolic inputs without leaking contents', async () => { + const root = repo({ + '.env': 'never-retain-this', + 'large.ts': 'x'.repeat(200_000), + 'main.ts': TS, + }); + if (process.platform !== 'win32') + symlinkSync(path.join(root, 'main.ts'), path.join(root, 'alias.ts')); + const capture = new FixtureCapture(); + capture.repository(root); + capture.repository(path.join(root, 'missing')); + const raw = record(capture); + expect(JSON.stringify(raw)).not.toContain('never-retain-this'); + const p = await profile(raw); + expect(p.contentKey).toBeNull(); + expect(p.unresolved.join()).toMatch(/environment/); + expect(p.unresolved.join()).toMatch(/size limit/); + if (process.platform !== 'win32') expect(p.unresolved.join()).toMatch(/symbolic/); + expect(p.unresolved.join()).toMatch(/unreadable/); +}); + +it('attributes nested setup to its suite and retains warm-attempt and concurrent gaps', async () => { + const capture = new FixtureCapture(); + const attributor = new CoverageAttributor(); + const inner = repo({ 'main.ts': TS }); + capture.inSuite('inner', () => capture.repository(inner)); + attributor.testStart('a', ['file', 'outer', 'inner'], false, delta(capture.drain())); + attributor.testEnd('a', delta()); + attributor.testStart('b', ['file', 'outer'], false, delta()); + attributor.testEnd('b', delta()); + const raw = { + ...attributor.record('/prep/gitnexus/test/input.test.ts', new CoverageInterner([], new Set())), + fixtureSnapshots: capture.snapshots, + }; + expect((await profile(raw)).unresolved).toEqual([]); + expect(runtimeFixtureFor(raw, raw.tests[1], undefined, false)).toBeNull(); + expect((await profile(raw, 0, true)).contentKey).toBeNull(); + const profiles = await profileRuntimeFixtures([raw]); + const work = buildWorkFile({ + mode: { kind: 'normal' }, + preparedRoot: '/prep', + repeats: 1, + unhandledErrors: [], + raws: [raw], + fixtureProfiles: profiles, + modules: [ + { + file: 'test/input.test.ts', + errors: [], + tests: raw.tests.map((t) => ({ + taskId: t.taskId, + fullName: t.taskId, + location: undefined, + concurrent: false, + state: 'passed', + })), + }, + ], + }); + expect(work.tests[0].runtimeFixture?.languages).toEqual(['typescript']); + expect(work.tests[1].runtimeFixture).toBeNull(); + raw.tests[0].all.fixtures = ['missing']; + expect((await profile(raw)).unresolved.join()).toContain('changed between attempts'); +}); + +it('leaves old records compatible and refuses missing snapshot evidence', async () => { + const capture = new FixtureCapture(); + capture.repository(repo({ 'main.ts': TS })); + const raw = record(capture); + expect(runtimeFixtureFor(raw, raw.tests[0], undefined, false)?.contentKey).toBeNull(); + delete raw.fixtureSnapshots; + expect(runtimeFixtureFor(raw, raw.tests[0], undefined, false)).toBeNull(); +}); + +it('preserves capture versions and rejects mixed instrumented and archived coverage slices', () => { + const slice = (index: number, captured: boolean): CoverageSliceFile => ({ + schemaVersion: 1, + index, + of: 2, + layer1: [], + mutation: 'none', + changed: null, + revision: null, + work: { + schemaVersion: 1, + ...(captured ? { fixtureCaptureVersion: 1 } : {}), + mode: 'normal', + extraRoots: [], + repeats: 1, + blocks: [], + files: [], + tests: [], + unhandledErrors: [], + }, + }); + expect( + mergeCoverageSlices([[], []], [slice(0, true), slice(1, true)]).fixtureCaptureVersion, + ).toBe(1); + expect( + mergeCoverageSlices([[], []], [slice(0, false), slice(1, false)]).fixtureCaptureVersion, + ).toBeUndefined(); + expect(() => mergeCoverageSlices([[], []], [slice(0, true), slice(1, false)])).toThrow( + 'not the run', + ); +}); + +it('instruments the pipeline entry with a source map and rejects entry point drift', async () => { + const plugin = fixtureCapturePlugin('/prep/src/core/ingestion/pipeline.ts'); + const transform = plugin.transform; + if (typeof transform !== 'function') throw new Error('missing transform'); + const result = await Reflect.apply(transform, {}, [ + 'export const runPipelineFromRepo = async (root: string) => { return root; };', + '/prep/src/core/ingestion/pipeline.ts', + ]); + expect(result.code).toContain('globalThis["__gitnexusScenarioFixtureCapture"]?.(root)'); + expect(JSON.parse(result.map).sources).toEqual(['/prep/src/core/ingestion/pipeline.ts']); + expect(() => + Reflect.apply(transform, {}, [ + 'export const changed = 1;', + '/prep/src/core/ingestion/pipeline.ts', + ]), + ).toThrow('entry point'); +}); diff --git a/gitnexus/test/unit/scenario-map/trace-setup.test.ts b/gitnexus/test/unit/scenario-map/trace-setup.test.ts new file mode 100644 index 000000000..5f43d3edc --- /dev/null +++ b/gitnexus/test/unit/scenario-map/trace-setup.test.ts @@ -0,0 +1,41 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { FIXTURE_CAPTURE_KEY } from '../../../tools/scenario-map/path-coverage/fixture-capture.js'; + +const previousCapture = Reflect.get(globalThis, FIXTURE_CAPTURE_KEY); + +afterEach(() => { + vi.doUnmock('vitest'); + vi.resetModules(); + vi.restoreAllMocks(); + if (previousCapture === undefined) Reflect.deleteProperty(globalThis, FIXTURE_CAPTURE_KEY); + else Reflect.set(globalThis, FIXTURE_CAPTURE_KEY, previousCapture); +}); + +it('captures pipeline fixtures only while a candidate test is active', async () => { + let current: { id: string } | undefined; + // Reload the setup module even when the detector already installed it for this worker. + vi.resetModules(); + const { TraceFixtureRoots } = + await import('../../../tools/scenario-map/value-flow/trace-fixtures.js'); + const capture = vi.spyOn(TraceFixtureRoots.prototype, 'capture').mockImplementation(() => {}); + vi.doMock('vitest', () => ({ + TestRunner: { getCurrentTest: () => current }, + inject: () => ({ taskIds: ['candidate'], preparedRoot: '/prepared', extraRoots: [], wrap: [] }), + aroundAll: vi.fn(), + aroundEach: vi.fn(), + })); + await import('../../../tools/scenario-map/value-flow/trace.setup.js'); + const hook = Reflect.get(globalThis, FIXTURE_CAPTURE_KEY) as (root: string) => void; + + hook('/suite-setup'); + expect(capture).not.toHaveBeenCalled(); + current = { id: 'sibling' }; + hook('/sibling'); + expect(capture).not.toHaveBeenCalled(); + current = { id: 'candidate' }; + hook('/candidate'); + expect(capture).toHaveBeenCalledExactlyOnceWith('/candidate'); + current = undefined; + hook('/suite-teardown'); + expect(capture).toHaveBeenCalledTimes(1); +}); diff --git a/gitnexus/test/unit/scenario-map/trace.test.ts b/gitnexus/test/unit/scenario-map/trace.test.ts index d76e245fa..2a30a3c9f 100644 --- a/gitnexus/test/unit/scenario-map/trace.test.ts +++ b/gitnexus/test/unit/scenario-map/trace.test.ts @@ -16,6 +16,7 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; +import { TraceFixtureRoots } from '../../../tools/scenario-map/value-flow/trace-fixtures.js'; import type { PrepareOptions, PrepareReport } from '../../../tools/mutation/prepare/prepare.js'; import type { OutcomeClass } from '../../../tools/mutation/runner/classify-outcome.js'; import { scriptPath } from '../../../tools/scenario-map/path-coverage/records.js'; @@ -208,6 +209,52 @@ describe('serializeValue', () => { expect(serializeValue([Math], new Map([[Math, 'Math']])).text).toBe('[[Math]]'); }); + it('compares captured repository Dirents independently of their temporary root', async () => { + const fs = await import('node:fs'); + const roots = [tempDir(), tempDir()]; + for (const root of roots) fs.writeFileSync(path.join(root, 'main.ts'), 'export const n = 1;'); + const entries = roots.map((root) => fs.readdirSync(root, { withFileTypes: true })); + const names = new Map(roots.map((root) => [root.replace(/\\/g, '/'), 'fixture:0:same-bytes'])); + expect(serializeValue(entries[0], new Map(), names)).toEqual( + serializeValue(entries[1], new Map(), names), + ); + // Arbitrary strings and lookalike objects can be meaningful application inputs. + expect(serializeValue(roots[0], new Map(), names)).not.toEqual( + serializeValue(roots[1], new Map(), names), + ); + expect(serializeValue({ parentPath: roots[0] }, new Map(), names)).not.toEqual( + serializeValue({ parentPath: roots[1] }, new Map(), names), + ); + expect(serializeValue(entries[0])).not.toEqual(serializeValue(entries[1])); + names.set(roots[1].replace(/\\/g, '/'), 'fixture:0:different-bytes'); + expect(serializeValue(entries[0], new Map(), names)).not.toEqual( + serializeValue(entries[1], new Map(), names), + ); + }); + + it('keeps fixture bytes, layout, aliases, and incomplete captures visible in trace identity', () => { + const a = tempDir(), + b = tempDir(); + for (const root of [a, b]) writeFileSync(path.join(root, 'main.ts'), 'export const n = 1;'); + const left = new TraceFixtureRoots(), + right = new TraceFixtureRoots(); + left.capture(a); + right.capture(b); + const key = (roots: TraceFixtureRoots, root: string) => + roots.roots.get(root.replace(/\\/g, '/')); + expect(key(left, a)).toBe(key(right, b)); + left.capture(b); + expect(key(left, a)).not.toBe(key(left, b)); + writeFileSync(path.join(b, 'main.ts'), 'export const n = 2;'); + right.capture(b); + expect(key(left, a)).not.toBe(key(right, b)); + writeFileSync(path.join(b, 'huge.ts'), 'x'.repeat(200_000)); + right.capture(b); + expect(key(right, b)).toBeUndefined(); + left.clear(); + expect(left.roots.size).toBe(0); + }); + it('serialises an Intl instance by its resolved options', () => { expect(serializeValue(new Intl.NumberFormat('en-US')).text).toMatch( /^Intl\.NumberFormat\(\{"locale":"en-US",/, @@ -978,6 +1025,63 @@ async function runWith( } describe('runTrace', () => { + it('retains failed-test diagnostics while keeping an invalid run inconclusive', async () => { + const baseline: VitestRunResult = { + ...vitestResult(['killed-by-test:assertion', 'survived']), + status: 'killed', + outcome: 'killed-by-test:assertion', + }; + baseline.tests[0].message = 'expected an existing index'; + const { result } = await runWith(fakeRun([], [], { baseline })); + expect(result.baseline?.failures).toEqual([ + { id: A, outcome: 'killed-by-test:assertion', message: 'expected an existing index' }, + ]); + expect(result.issues.length).toBeGreaterThan(0); + expect(result.pairs[0].status).toBe('inconclusive'); + }); + + it('runs sibling tests in both passes while comparing only the candidate tests', async () => { + const manifestRoot = tempDir(); + writeTree(manifestRoot, ''); + const sibling = `${PACKAGE_FILE}#suite setup in an earlier test`; + const baseline = vitestResult(['survived', 'survived']); + baseline.tests.push({ + ...baseline.tests[0], + id: sibling, + taskId: 'sibling-task', + fullName: 'suite setup in an earlier test', + }); + const fake = fakeRun([ROUND('[2]')], [ROUND('[2]')], { baseline }); + const skipped = `${PACKAGE_FILE}#platform-specific test`; + const unrelated = 'test/unit/unrelated.test.ts#unrelated'; + const manifest = buildRunManifest({ + preparedRoot: manifestRoot, + mode: { kind: 'normal' }, + tests: [A, B, sibling, skipped, unrelated], + }); + const result = await runTrace( + { + repoRoot: tempDir(), + workDir: tempDir(), + manifest, + pairs: [{ a: A, b: B }], + // A candidate erroneously marked skipped must still fail closed if it does not run. + skippedTestIds: [skipped, A], + opaqueCallees: [], + }, + fake.deps, + ); + expect(fake.calls.map((call) => [...call.testIds].sort())).toEqual([ + [A, B, sibling].sort(), + [A, B, sibling].sort(), + ]); + expect(result.tests.map((test) => test.id)).toEqual([A, B]); + expect(result.pairs.map((pair) => pair.status)).toEqual(['clean']); + expect(fake.provided.find(([key]) => key === TRACE_KEY)?.[1]).toMatchObject({ + taskIds: ['t_0', 't_1'], + }); + }); + it('runs a baseline and a traced run of the same tests and vetoes differing calls', async () => { const fake = fakeRun([ROUND('[2.4]')], [ROUND('[2.5]')]); const { result, workDir } = await runWith(fake, ['Array.join', 'Map.set']); diff --git a/gitnexus/test/unit/scenario-map/vitest-run.test.ts b/gitnexus/test/unit/scenario-map/vitest-run.test.ts index 277d41fe8..a879b532d 100644 --- a/gitnexus/test/unit/scenario-map/vitest-run.test.ts +++ b/gitnexus/test/unit/scenario-map/vitest-run.test.ts @@ -39,6 +39,38 @@ function fakeCreateVitest(seen: CliOptions[]): CreateVitest { } describe('runVitest worker bound', () => { + it.each([true, false])( + 'forwards plugins only when supplied (instrumented: %s)', + async (instrumented) => { + const plugin = { name: 'fixture-capture-regression' }; + const plugins = instrumented ? [plugin] : undefined; + const create: CreateVitest = async (_options, overrides) => { + expect(overrides.plugins).toEqual(plugins); + return { + projects: [], + close: async () => {}, + collectTests: async () => { + throw new Error('unused'); + }, + runTestSpecifications: async () => { + throw new Error('unused'); + }, + }; + }; + await expect( + runVitest( + { + packageDir: tempDir(), + testIds: ['test/unit/a.test.ts#a'], + vitest: { configFile: 'vitest.config.ts', project: 'p' }, + workDir: tempDir(), + ...(instrumented ? { plugins } : {}), + }, + create, + ), + ).rejects.toThrow(/expected one Vitest project named p/); + }, + ); it.each([ ['the given bound', 3, 3], ['no bound (Vitest default)', undefined, undefined], diff --git a/gitnexus/tools/scenario-map/README.md b/gitnexus/tools/scenario-map/README.md new file mode 100644 index 000000000..edf1a3518 --- /dev/null +++ b/gitnexus/tools/scenario-map/README.md @@ -0,0 +1,226 @@ +# Scenario-map fixture comparison + +The `plan` stage discovers source fixtures read by tests and adds fixture similarity +to candidate retrieval. Run it through the existing CLI: + +```sh +cd gitnexus +npm run test:scenario-map -- plan --work /tmp/gitnexus-scenario-map --mutation none +``` + +This runs coverage for the test inventory. To reuse coverage slices from the same +checkout, add `--coverage /path/to/coverage-slices`. +All slices must use the same capture version; mixing old coverage with newly instrumented +coverage is rejected because instrumentation changes V8 block offsets. + +## Evidence + +- `fixtures.json`: fixture paths, languages, extraction gaps, counted features, + associated tests, and similarity matches. Profiles are deduplicated across tests. +- `coverage/candidates.json`: pairs retrieved through the `fixture` source, with the score, + content/structure match kind, and whether their languages differ. +- `layer2.json`: per-test fixture evidence and input identity used by later stages. +- Fresh coverage records also contain `runtimeFixture` profiles. Raw records retain + bounded input snapshots, so profiling does not depend on temporary files surviving. + +Discovery follows constants, imported test helpers, setup hooks, static string rows, +path joins, and file URLs for ingestion, file-read, and copy calls. It reads source +corpora anywhere under `gitnexus/test/`, including integration-local fixtures. +It never executes a helper to discover its inputs. Diff-scoped runs retain these +tests until their fixture profiles can be compared. + +Fresh coverage also captures generated and dynamically selected inputs. A coverage-only +Vite transform snapshots the repository passed to `runPipelineFromRepo` before ingestion, +including runs that dispatch parsing to workers. It preserves relative file layout and +non-source assets. Pass-through wrappers retain the returned contents of synchronous, +promise, and callback `readFile` calls made directly by tests or test helpers, including +file URLs. Production files on disk are not modified by instrumentation. + +Reads made by a test can also be assertions over generated output. Their contents +remain available for diagnosis, but their input role is marked unverified and they +cannot establish fixture identity or a consolidation lead. Repository-entry snapshots +establish which files were supplied to ingestion; this does not prove that every other +input was observed. Re-capture archived version-1 coverage before evaluating runtime +fixture consolidation: version 2 records this distinction. + +Snapshots follow coverage windows: the last test attempt and its suite setup feed that +test's profile. Setup hooks carry their suite identity across async calls, so nested +setup does not leak into sibling suites. Changed input contents across attempts, +concurrent tests, and reads outside attributable windows remain explicit gaps. Temporary +directory names do not change input identity. The planner parses each distinct captured +input through the same language providers used for static fixtures, after the test ends. + +The profiler reuses GitNexus's language providers for declarations, reference kinds, +call forms, imports, and control-flow graphs. Counts preserve multiplicity. Three +rounds of directed, labeled CFG-neighbor refinement give a topology signature +independent of block IDs and source positions. This is a structural heuristic, +not a graph-isomorphism or program-equivalence proof. + +Two tests become fixture candidates only when their assertion-only keys match and +their fixture profiles have weighted multiset Jaccard similarity of at least 0.90. +CFG signatures have weight 4; other features have weight 1. Exact content matches +also retrieve candidates. File profiles and pair scores are cached within a run. + +Similarity omits identifier spelling and literal values. **Exact fixture text and +relative file layout are separately included in the input identity**, including +non-source assets. A similar fixture cannot make different inputs a confirmed +duplicate. Cross-language cases often provide intentional provider coverage; +a match is a review lead, not a recommendation to remove either test. + +## Consolidation opportunities, including partial matches + +The `plan` stage also writes `consolidation.html` and `consolidation.json`. These +compare three independent axes: captured fixture contents, executed JavaScript +blocks, and individual assertions. A fixture match alone does not create a lead. +The HTML report can be filtered by test name or recommendation. It shows both +directions of overlap and the assertions, blocks, and fixture spans that remain +unique. Groups represent repeated evidence without enumerating every test pair. + +The default HTML view shows actionable opportunities. Cases from one test declaration +(including existing `it.each` and `describe.each` rows) and tests annotated with +`// dupe-ok: ` are retained under separate review filters. The JSON keeps every +group and reports `actionableOpportunities` separately. Detector controls use this +annotation so their intentional overlap remains available for acceptance checks. +The fingerprint report likewise retains existing table pairs in its `kept` list. +The CI plan artifact includes `consolidation.html`, `consolidation.json`, and `fixtures.json`. + +Fixture text is compared locally with the pinned **jscpd 5.4.2** development +dependency. Exact, consistently renamed, and near-miss fragments contribute to +byte overlap; comments and whitespace are ignored by the clone engine. The +coverage denominator conservatively includes all non-whitespace captured bytes. +Overlapping fragments count once; near-miss scores discount their contribution. +Representative exact/renamed clone links form families, so a missing direct link +does not hide another member. When representative spans overlap with different +boundaries, pairs that pass the assertion and execution gates are compared directly +in a separate two-input scan. This avoids inventing byte mappings or losing a partial +match when the corpus chooses a longer representative. The raw pair reports and +verification count are retained. Near-miss links are never treated as transitive. +No source is uploaded and no embedding model is downloaded. + +Source snapshots are content-addressed in `coverage.json` and merged across slices. +The external engine's raw report and exported inputs are under `fixture-clones/`. +The report records captured/scanned file counts and formats: unsupported formats +and fragments below the 12-token threshold can be missed. Source identities still +recognize identical complete inputs independently of tokenization. + +Across languages, GitNexus provider features supply a separate, weaker +`share-language-scenario` suggestion. Full matches require 0.90 weighted Jaccard; +partial matches require shared CFG topology and 0.90 feature containment in one +direction. These are structural scenario leads, not cross-language semantic proofs. +Each language remains a test case when consolidating into a shared harness. + +Assertions retain exact expected literals, row values, snapshots, negation and +selectors. Single preceding `const` bindings and scoped callback parameters are +traced, so `find(edge => edge.target === 'save')` does not equal a `getName` query. +Unknown expected values or local origins cannot become equal assertion atoms. +File- and suite-level values retain their declaration's file and scope identity. +Repeated reads of the same binding can match; equivalence between separate bindings +remains unproven because scope frames do not establish their values or immutability. +File-local callable identities also remain distinct across files. Fingerprint verdicts +use this detailed evidence to reject unrelated assertion subjects or literal expectations +even when the coarse matcher shape agrees. +Unknown checks remain in overlap denominators. This comparison does not execute +arbitrary expected-value expressions or establish general helper semantic equivalence. Copied helpers with identical bodies and resolved imports share identity; helpers that close over other bindings remain scope-specific, and comparisons between those bindings stay unresolved. + +Default retrieval requires 0.50 fixture containment and 0.50 executed-block +containment in either direction, plus 0.25 exact-assertion containment. Fully known +assertions with different expected values can instead qualify through 0.80 +predicate-shape containment, producing parameterization leads. Thresholds and +missing evidence are included in the JSON. This supports sharing a subset of +checks while retaining the remaining assertions and fixture branches. + +Executed blocks exclude uncovered V8 ranges. They describe observed coverage, +not call order or every possible execution. Native work, spawned workers, input +capture gaps, unsupported checks and inventory joins stay visible as limitations. +Even a complete match is a statement about the captured run; consolidation output +does not authorize deleting tests or bypass existing duplicate-confirmation gates. + +Run a complete capture and generate a report (or use the existing coverage-slice +workflow to distribute the capture): + +```sh +node --import tsx tools/scenario-map/scenario-map.ts coverage \ + --index 0 --of 1 --work /tmp/scenario-coverage --mutation none --concurrency 2 +NODE_OPTIONS=--max-old-space-size=8192 node --import tsx tools/scenario-map/scenario-map.ts plan \ + --coverage /tmp/scenario-coverage --work /tmp/scenario-plan --mutation none +node --import tsx tools/scenario-map/consolidation/verify-controls.ts \ + /tmp/scenario-plan/consolidation.json +``` + +Full-suite planning needs more than Node's default heap. The CI Plan step uses +the same 8 GiB budget; allow additional RAM for native allocations and subprocesses. + +The merge stage streams `scenario-map-report.json` one record at a time. Full-suite +reports can exceed Node's single-string limit; use a streaming JSON reader instead +of `JSON.parse(readFileSync(..., 'utf8'))`. The report schema and all candidate +evidence are preserved. Baseline comparison and the CI issue update use +`report/json.ts`'s `readReportFields` to retain only the fields they need while +validating the whole file. A failed runtime trace still produces a partial report +and cannot confirm duplicates. + +Capture preserves the suite's normal lifecycle and does not add per-test repeats: +repeating a destructive test or a cache assertion within one suite changes its input. +Tracing includes runnable siblings in each candidate file in both passes, preserving +setup performed by earlier tests; only candidate tests are recorded and compared. Candidate +task IDs come from the baseline collection. Siblings run without trace stack capture, +argument serialization or per-test fixture snapshots, so unrelated timing checks do not +pay that instrumentation cost. A missing candidate trace remains inconclusive. Explicitly skipped +siblings stay out. If a runnable sibling uses retries, the file's pairs remain inconclusive. +Failed captures retain their error details in `coverage.json` and keep the report +partial even when every file was collected. + +The acceptance command checks all 28 pairs among eight real pipeline controls: +15 exact/renamed/partial/cross-language opportunities and 13 negative pairs. It +also checks that unique evidence and runtime limitations survive in the report. + +## Capture and proof limits + +Archived coverage without runtime snapshots still uses static discovery. Unresolved static +paths (including generated inputs) require a fresh coverage run to obtain runtime evidence. +Missing parsers, parse errors, lossy text +decoding, and extraction limits are recorded as unresolved. Partial profiles do +not retrieve structural matches. Identical, completely read text can still retrieve +an exact-content candidate when parsing fails; that failure remains unresolved +for duplicate confirmation. Incomplete reads never establish input identity. Existing +path, context, assertion, runtime-value, and passing-test checks still apply. + +Runtime capture retrieves review candidates; it does not establish exhaustive input +coverage. Streams, file-descriptor reads, inputs generated inside child processes, and +repository mutations after the entry snapshot are not fully observed. Runtime evidence +cannot clear static input gaps or confirm duplicates by itself. A complete static profile +corroborated by identical runtime contents retains its existing confirmation checks. +Skipped or failed tests cannot establish duplicate safety. + +Value tracing identifies native `Dirent` directory metadata relative to repository +roots captured in that test attempt. The identity includes exact bytes, relative layout, +and separate ordinals for multiple repositories. Incomplete captures, arbitrary path +strings, relative file names, and lookalike objects are not normalized. This removes +temporary-root noise from directory-entry comparisons without relaxing native/worker +or unresolved-input gates. + +Structural fingerprints inherit provider behavior, including parser or CFG instability; +exact text and external clone spans are separate evidence. The comparison uses pre-resolution features. It does not prove cross-file binding, +data-flow, operator, or runtime equivalence. Providers without CFG support contribute +their available capture features. Identical counts can produce false-positive +review leads, and different provider representations can miss similar fixtures. + +Limits per profile: 200 files, fewer than 200,000 characters per file, 2,000,000 +characters total, and 2,000 lines per CFG function. Runtime capture additionally bounds +each test file to 20 MB of unique snapshot text and 2,000 events; repository snapshots +visit at most 1,000 entries and 30 directory levels. Symlinks are reported as incomplete; +`.git`, `.gitnexus`, and `node_modules` directories are omitted. Helper traversal is bounded to +five calls and constant evaluation to eight steps. `.env` files other than +`.env.example` are excluded and reported as incomplete. + +Validate with: + +```sh +npm test -- test/unit/scenario-map --maxWorkers=2 +npm run typecheck:tests +# Fresh real-pipeline controls, including generated repositories deleted in setup: +node --import tsx tools/scenario-map/path-coverage/run-coverage.ts \ + --file test/integration/scenario-map-runtime-inputs.test.ts \ + --out /tmp/scenario-map-runtime-controls --concurrency 1 +``` + +Existing table declarations retain uncertain or differing rows outside the default queue. Proven duplicate rows remain actionable in the fingerprint report; exact observed fixture/path/assertion matches remain actionable in the consolidation report. Keep reasons are separate from evidence gaps and do not change an opportunity’s evidence classification. diff --git a/gitnexus/tools/scenario-map/consolidation/assertions.ts b/gitnexus/tools/scenario-map/consolidation/assertions.ts new file mode 100644 index 000000000..4d10ff6ca --- /dev/null +++ b/gitnexus/tools/scenario-map/consolidation/assertions.ts @@ -0,0 +1,419 @@ +import * as t from '@babel/types'; +import traverse, { type Binding } from '@babel/traverse'; +import { generate } from '@babel/generator'; +import { resolveName } from '../../test-dupes/test-dupes.js'; +import type { RowValue } from '../types.js'; +import { + abstractNode, + assertionCallOf, + buildScope, + rowValueOf, + type CheckSubject, + type InstanceChecks, + type Scope, +} from '../facet/checks.js'; +import { sha1, sortedUnique } from '../util.js'; + +export interface AssertionAtom { + line: number; + shape: string; + shapeKey: string; + /** Subject structure with literal imported-call inputs abstracted; selectors remain exact. */ + subjectShapeKey?: string; + /** Exact expected literals/snapshot and subject shape, or null for unresolved values. */ + key: string | null; + expected: string | null; + observed?: string | null; +} +export interface AssertionEvidence { + atoms: AssertionAtom[]; + unresolved: string[]; +} + +function canonicalRow(row: RowValue): unknown | undefined { + if (row.kind === 'unresolved') return undefined; + if (row.kind === 'array') { + const items = row.items.map(canonicalRow); + return items.includes(undefined) ? undefined : { kind: 'array', items }; + } + if (row.kind === 'object') { + const values = new Map(); + for (const entry of row.entries) { + const value = canonicalRow(entry.value); + if (value === undefined) return undefined; + values.set(entry.key, value); + } + const entries = [...values].map(([key, value]) => ({ key, value })); + return { kind: 'object', entries: entries.sort((a, b) => a.key.localeCompare(b.key)) }; + } + return row; +} + +function literal(node: t.Node, scope: Scope): unknown | undefined { + if (t.isTSAsExpression(node) || t.isTSNonNullExpression(node) || t.isTSTypeAssertion(node)) + return literal(node.expression, scope); + const row = rowValueOf(node, scope); + if (row !== null) return row === 'unknown' ? undefined : canonicalRow(row); + if (t.isStringLiteral(node)) return { kind: 'string', value: node.value }; + if (t.isNumericLiteral(node)) return { kind: 'number', text: String(node.value) }; + if (t.isBooleanLiteral(node)) return { kind: 'boolean', value: node.value }; + if (t.isNullLiteral(node)) return { kind: 'null' }; + if (t.isBigIntLiteral(node)) return { kind: 'bigint', text: String(node.value) }; + if (t.isRegExpLiteral(node)) return { kind: 'regexp', pattern: node.pattern, flags: node.flags }; + if ( + t.isIdentifier(node, { name: 'undefined' }) && + !scope.locals.has('undefined') && + resolveName('undefined', scope.record.chain) === undefined + ) + return { kind: 'undefined' }; + if (t.isUnaryExpression(node) && ['-', '+', '!'].includes(node.operator)) { + if (t.isNumericLiteral(node.argument) && node.operator !== '!') { + const value = node.operator === '-' ? -node.argument.value : +node.argument.value; + return { kind: 'number', text: Object.is(value, -0) ? '-0' : String(value) }; + } + if (t.isBigIntLiteral(node.argument) && node.operator === '-') + return { kind: 'bigint', text: String(-BigInt(node.argument.value)) }; + return undefined; + } + if ( + t.isCallExpression(node) && + t.isMemberExpression(node.callee) && + !node.callee.computed && + t.isIdentifier(node.callee.object, { name: 'expect' }) && + t.isIdentifier(node.callee.property) && + [ + 'objectContaining', + 'arrayContaining', + 'stringContaining', + 'stringMatching', + 'closeTo', + ].includes(node.callee.property.name) + ) { + const args = node.arguments.map((arg) => literal(arg, scope)); + return args.includes(undefined) ? undefined : { asymmetric: node.callee.property.name, args }; + } + if (t.isTemplateLiteral(node) && node.expressions.length === 0) + return { kind: 'string', value: node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw }; + if (t.isArrayExpression(node)) { + const items = node.elements.map((item) => + item === null ? { kind: 'hole' } : literal(item, scope), + ); + return items.includes(undefined) ? undefined : { kind: 'array', items }; + } + if (t.isObjectExpression(node)) { + const values = new Map(); + for (const item of node.properties) { + if (!t.isObjectProperty(item) || item.computed) return undefined; + const key = t.isIdentifier(item.key) + ? item.key.name + : t.isStringLiteral(item.key) || t.isNumericLiteral(item.key) + ? String(item.key.value) + : null; + const value = literal(item.value, scope); + if (key === null || value === undefined) return undefined; + values.set(key, value); + } + const entries = [...values].map(([key, value]) => ({ key, value })); + return { kind: 'object', entries: entries.sort((a, b) => a.key.localeCompare(b.key)) }; + } + return undefined; +} + +/** Expand preceding immutable const bindings in their lexical scope. */ +function localOrigins( + subject: CheckSubject, + scope: Scope, +): (node: t.Node, before: number) => t.Node { + const references = new WeakSet(); + const declarations = new WeakMap(); + traverse(t.file(t.program([t.expressionStatement(subject.record.fn)])), { + Identifier(path) { + if (path.isReferencedIdentifier()) references.add(path.node); + const binding = path.scope.getBinding(path.node.name); + if (binding) declarations.set(path.node, binding); + }, + }); + let nextBinding = 0; + let visited = 0; + const expand = ( + node: t.Node, + before: number, + seen = new Set(), + bindings = new Map(), + ): t.Node => { + const unknown = (): t.Identifier => { + const marker = t.identifier('_unknown'); + marker.name = ''; + return marker; + }; + if (++visited > 5000 || seen.size > 32) { + scope.unresolved.add('assertion origin expansion limit reached'); + return unknown(); + } + const declaration = declarations.get(node); + if (references.has(node) && declaration) { + const bound = bindings.get(declaration); + if (bound) return bound; + // The inventory binds only the test callback's own parameters to table rows. + if (declaration.kind === 'param' && declaration.scope.block === subject.record.fn) + return node; + const decl = declaration.path.node; + if ( + declaration.kind !== 'const' || + !declaration.constant || + !t.isVariableDeclarator(decl) || + !t.isIdentifier(decl.id) || + !decl.init || + (decl.end ?? Infinity) >= before || + seen.has(declaration) + ) + return unknown(); + return expand(decl.init, before, new Set([...seen, declaration]), bindings); + } + const copy = t.cloneNode(node, false); + if (t.isFunction(copy) && copy.params.every((param) => t.isIdentifier(param))) { + bindings = new Map(bindings); + copy.params = copy.params.map((param) => { + const marker = t.identifier('_bound'); + marker.name = ``; + const binding = declarations.get(param); + if (binding) bindings.set(binding, marker); + return marker; + }); + } + const object = copy as unknown as Record; + for (const key of t.VISITOR_KEYS[node.type] ?? []) { + // Noncomputed property names are selectors, not references to local bindings. + if ( + (key === 'property' && + (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && + !node.computed) || + (key === 'key' && t.isObjectProperty(node) && !node.computed) + ) + continue; + const value = object[key]; + object[key] = Array.isArray(value) + ? value.map((child) => (t.isNode(child) ? expand(child, before, seen, bindings) : child)) + : t.isNode(value) + ? expand(value, before, seen, bindings) + : value; + } + return copy; + }; + return (node, before) => { + nextBinding = 0; + visited = 0; + return expand(node, before); + }; +} + +function helperIdentity( + fn: t.FunctionDeclaration | t.FunctionExpression | t.ArrowFunctionExpression, + declaration: t.Node, + scope: Scope, +): string | null { + const copy = t.cloneNode(fn, true); + const statement = t.isFunctionDeclaration(copy) ? copy : t.expressionStatement(copy); + const frame = scope.record.chain.findIndex((item) => + [...item.decls.values()].some((decl) => decl.kind === 'node' && decl.node === declaration), + ); + if (frame < 0) return null; + const chain = scope.record.chain.slice(0, frame + 1); + let closed = true; + traverse(t.file(t.program([statement])), { + ReferencedIdentifier(path) { + if (path.scope.getBinding(path.node.name)) return; + const decl = resolveName(path.node.name, chain); + if (decl?.kind === 'import') path.node.name = `<${decl.key}>`; + else closed = false; + }, + }); + return closed ? sha1(generate(copy, { compact: true, comments: false }).code) : null; +} + +function containsRegExp(value: unknown): boolean { + if (value === null || typeof value !== 'object') return false; + if (Array.isArray(value)) return value.some(containsRegExp); + const record = value as Record; + return record.kind === 'regexp' || Object.values(record).some(containsRegExp); +} + +function preserveValues(node: t.Node, scope: Scope, abstractInputs = false): t.Node { + const value = literal(node, scope); + if (value !== undefined) { + const marker = t.identifier('_value'); + marker.name = ``; + return marker; + } + if (t.isIdentifier(node) && !scope.locals.has(node.name)) { + const decl = resolveName(node.name, scope.record.chain); + if (decl?.kind === 'import') { + const marker = t.identifier('_import'); + marker.name = `<${decl.key}>`; + return marker; + } + if (decl?.kind === 'node') { + const fn = t.isVariableDeclarator(decl.node) ? decl.node.init : decl.node; + if ( + t.isFunctionDeclaration(fn) || + t.isFunctionExpression(fn) || + t.isArrowFunctionExpression(fn) + ) { + const marker = t.identifier('_local'); + const identity = helperIdentity(fn, decl.node, scope); + marker.name = + identity === null + ? `` + : ``; + return marker; + } + // Frames do not prove constant values or immutability. Preserve the binding, + // so repeated reads can match without equating names in different scopes. + const marker = t.identifier('_local'); + marker.name = ``; + return marker; + } + } + if (t.isTemplateLiteral(node)) { + const marker = t.identifier('_template'); + marker.name = '