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 = '