mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-10 08:27:27 +00:00
fix: recover duplicate candidates and expose test inventory gaps (#3743)
* fix: recover missed test scenarios Run the full core test inventory and retrieve identical callback bodies across setup contexts. Separate assertion evidence from context and retain conservative input, path and runtime provenance checks. Restore prepared test inputs and report missing runtime collection. Add the audited recall corpus and inventory regressions. * Address PR review feedback (#3743) Correct counted nouns in the inventory report, document fully skipped modules, and include unhandled runner errors in the incomplete-collection warning. * Address PR review feedback (#3743) Force-stage explicit temporary fixtures despite inherited Git ignores, and isolate recorded-file collection-error accounting in the inventory tests. * fix(review): simplify selection and guard mixed artifacts * feat(test): detect fixture consolidation opportunities Capture source inputs used by integration tests and compare fixture clones, executed blocks, and assertion subjects and values. Report partial overlaps with the unique checks and fixture spans that consolidation must preserve. Keep cross-language structural suggestions separate from observed equality. Use pinned jscpd with direct pair verification when longer representatives hide partial clones. Retain capture gaps and native/worker limitations. Validate with 1,245 detector regressions, 28 pipeline pair controls, and 12 manually labeled comparisons. Scan 30,698 core cases and find 3,111 opportunity groups; exclude the capture's 103 failed and 89 skipped cases. * Fix scenario-map review findings (#3743) Resolve assertion aliases by lexical binding and normalize duplicate static object keys. Capture fixture files through checked descriptors with bounded reads and guaranteed cleanup. * fix: address test duplicate audit false signals * refactor(test): reuse assertion binding analysis Reuse lexical bindings within each evidence extraction while retaining assertion-specific offsets and expansion limits. Deduplicate fixture coverage endpoints numerically without string conversion. Preserve complete assertion evidence across 29,286 static instances and weighted coverage across 10,000 generated interval cases. * Fix scenario-map review findings (#3743) Bind consolidation evidence to the assertion call accepted by the check extractor, so unsupported same-line calls cannot supply incorrect subjects or expectations. Keep source provenance private and serialized checks intact. Exercise omitted plugins alongside instrumented Vitest runs. * fix: retain input-shaped scenario controls (#3743) Compare assertion subject structure separately from exact input-bearing assertion keys, so changing literal inputs retains table-row and boundary classification. Preserve selectors, callee identity and regex values. Keep the control expectations unchanged. Add regressions for literal inputs, queried subjects and nested regex arguments; bump the fingerprint engine version so reclassified findings resurface. * test: give scenario cases unique titles Include the parameterized case index so repeated input expressions and truncated audit paths cannot collide in the CI execution inventory. * fix: scope assertion subjects to their bindings Prevent same-named declarations in different files or scopes from becoming exact assertion matches. Preserve same-binding consolidation and advance the fingerprint engine to version 5. * fix: raise full-suite planning heap budget The 30,953-case replay aborts at the default 4 GiB heap. Give only the Plan step 8 GiB and document the same local invocation. The identical four coverage slices complete in 4m34s at 8.2 GiB peak RSS, retaining all 116,409 candidate pairs. * fix: stream full-suite scenario reports The full inventory produces a 663 MiB report, exceeding V8's single-string limit. Write records incrementally and stream only required fields when reading baselines and publishing issue updates. Replay preserves all 116,398 pairs from 30,953 collected cases. The report remains partial when the trace run fails. * fix: preserve test lifecycles during capture Forced test repeats changed cache and file state. Capture ordinary runs, retain runnable siblings during tracing, and keep retry-bearing files inconclusive. Preserve failure details and mark failed captures partial. Use the original JSON serializer so test mocks cannot break fixture reads. * fix: limit trace capture to candidates Keep sibling tests in both passes for suite state, while recording only candidate task IDs. This prevents unrelated timing checks and large corpus tests from failing under tracing. * ci: budget full-suite trace collection * fix: address scenario-map feedback (#3743) --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
parent
97ec905b5d
commit
bd9586e008
56 changed files with 6850 additions and 190 deletions
19
.github/workflows/test-scenario-map.yml
vendored
19
.github/workflows/test-scenario-map.yml
vendored
|
|
@ -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')
|
||||
|
|
|
|||
147
gitnexus/package-lock.json
generated
147
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
566
gitnexus/test/fixtures/scenario-map-recall/audit.json
vendored
Normal file
566
gitnexus/test/fixtures/scenario-map-recall/audit.json
vendored
Normal file
|
|
@ -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<User> \u2192 User",
|
||||
"b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > unwraps single-arg generic angle: Array<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 > Vec<User> \u2192 User (Rust)",
|
||||
"b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > unwraps single-arg generic angle: Rust Vec<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 > vector<User> \u2192 User (C++)",
|
||||
"b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > unwraps single-arg generic angle: C++ vector<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 > 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<String, User> \u2192 User (default: last/value arg)",
|
||||
"b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns last arg by default for Map<String, 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<String, User> \u2192 String (pos=first: key arg)",
|
||||
"b": "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns first arg with pos=first for Map<String, User>: \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<String, List<User>> \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<String, List<User>> \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<String, List<User>> \u2192 String (pos=first: key arg)",
|
||||
"b": "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > cross-bracket nesting (bracket depth fix) > Map<String, List<User>> \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<User>",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
750
gitnexus/test/unit/scenario-map/consolidation.test.ts
Normal file
750
gitnexus/test/unit/scenario-map/consolidation.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
76
gitnexus/test/unit/scenario-map/coverage-inventory.test.ts
Normal file
76
gitnexus/test/unit/scenario-map/coverage-inventory.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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(<v1>).toContain(<string>)',
|
||||
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: `<local:${file}:1:run>()`,
|
||||
},
|
||||
],
|
||||
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]);
|
||||
|
|
|
|||
365
gitnexus/test/unit/scenario-map/fixture-similarity.test.ts
Normal file
365
gitnexus/test/unit/scenario-map/fixture-similarity.test.ts
Normal file
|
|
@ -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<string, string>): 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<SupportedLanguages, [string, string]> = {
|
||||
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', '<?php function twice($x) { return $x + $x; }'],
|
||||
ruby: ['rb', 'def twice(x)\n return x + x\nend'],
|
||||
swift: ['swift', 'func twice(_ x: Int) -> 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',
|
||||
'<script lang="ts">function twice(x: number) { return x + x; }</script><template><p>Hello</p></template>',
|
||||
],
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
53
gitnexus/test/unit/scenario-map/recall-audit.test.ts
Normal file
53
gitnexus/test/unit/scenario-map/recall-audit.test.ts
Normal file
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
124
gitnexus/test/unit/scenario-map/report-json.test.ts
Normal file
124
gitnexus/test/unit/scenario-map/report-json.test.ts
Normal file
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
430
gitnexus/test/unit/scenario-map/runtime-fixtures.test.ts
Normal file
430
gitnexus/test/unit/scenario-map/runtime-fixtures.test.ts
Normal file
|
|
@ -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<string, string>) => {
|
||||
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');
|
||||
});
|
||||
41
gitnexus/test/unit/scenario-map/trace-setup.test.ts
Normal file
41
gitnexus/test/unit/scenario-map/trace-setup.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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<object, string>([[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']);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
226
gitnexus/tools/scenario-map/README.md
Normal file
226
gitnexus/tools/scenario-map/README.md
Normal file
|
|
@ -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: <reason>` 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.
|
||||
419
gitnexus/tools/scenario-map/consolidation/assertions.ts
Normal file
419
gitnexus/tools/scenario-map/consolidation/assertions.ts
Normal file
|
|
@ -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<string, unknown>();
|
||||
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<string, unknown>();
|
||||
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<t.Node>();
|
||||
const declarations = new WeakMap<t.Node, Binding>();
|
||||
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<Binding>(),
|
||||
bindings = new Map<Binding, t.Identifier>(),
|
||||
): t.Node => {
|
||||
const unknown = (): t.Identifier => {
|
||||
const marker = t.identifier('_unknown');
|
||||
marker.name = '<unknown>';
|
||||
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 = `<bound:${nextBinding++}>`;
|
||||
const binding = declarations.get(param);
|
||||
if (binding) bindings.set(binding, marker);
|
||||
return marker;
|
||||
});
|
||||
}
|
||||
const object = copy as unknown as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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 = `<value:${JSON.stringify(value)}>`;
|
||||
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
|
||||
? `<local:${scope.record.file}:${decl.node.start}:${node.name}>`
|
||||
: `<helper:${identity}>`;
|
||||
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 = `<local:${scope.record.file}:${decl.node.start}:${node.name}>`;
|
||||
return marker;
|
||||
}
|
||||
}
|
||||
if (t.isTemplateLiteral(node)) {
|
||||
const marker = t.identifier('_template');
|
||||
marker.name = '<template>';
|
||||
return t.callExpression(
|
||||
marker,
|
||||
node.quasis.flatMap((quasi, index) => [
|
||||
preserveValues(
|
||||
t.stringLiteral(quasi.value.cooked ?? quasi.value.raw),
|
||||
scope,
|
||||
abstractInputs,
|
||||
),
|
||||
...(node.expressions[index]
|
||||
? [preserveValues(node.expressions[index], scope, abstractInputs) as t.Expression]
|
||||
: []),
|
||||
]) as t.Expression[],
|
||||
);
|
||||
}
|
||||
const copy = t.cloneNode(node, false);
|
||||
const object = copy as unknown as Record<string, unknown>;
|
||||
const literalInputs =
|
||||
abstractInputs &&
|
||||
t.isCallExpression(node) &&
|
||||
t.isIdentifier(node.callee) &&
|
||||
!scope.locals.has(node.callee.name) &&
|
||||
resolveName(node.callee.name, scope.record.chain)?.kind === 'import' &&
|
||||
node.arguments.every((arg) => {
|
||||
const value = literal(arg, scope);
|
||||
return value !== undefined && !containsRegExp(value);
|
||||
});
|
||||
for (const key of t.VISITOR_KEYS[node.type] ?? []) {
|
||||
if (
|
||||
(key === 'property' &&
|
||||
(t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) &&
|
||||
!node.computed) ||
|
||||
(key === 'key' && t.isObjectProperty(node) && !node.computed)
|
||||
)
|
||||
continue;
|
||||
const child = object[key];
|
||||
object[key] = Array.isArray(child)
|
||||
? child.map((v) =>
|
||||
!t.isNode(v)
|
||||
? v
|
||||
: literalInputs && key === 'arguments'
|
||||
? abstractNode(v, scope, false, false)
|
||||
: preserveValues(v, scope, abstractInputs),
|
||||
)
|
||||
: t.isNode(child)
|
||||
? preserveValues(child, scope, abstractInputs)
|
||||
: child;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function assertedSubject(call: t.CallExpression): t.Node | null {
|
||||
let node: t.Node = call.callee;
|
||||
while (t.isMemberExpression(node)) node = node.object;
|
||||
if (!t.isCallExpression(node)) return null;
|
||||
if (
|
||||
!t.isIdentifier(node.callee, { name: 'expect' }) &&
|
||||
!(
|
||||
t.isMemberExpression(node.callee) &&
|
||||
!node.callee.computed &&
|
||||
t.isIdentifier(node.callee.object, { name: 'expect' }) &&
|
||||
t.isIdentifier(node.callee.property, { name: 'soft' })
|
||||
)
|
||||
)
|
||||
return null;
|
||||
const first = node.arguments[0];
|
||||
return first && !t.isSpreadElement(first) && !t.isArgumentPlaceholder(first) ? first : null;
|
||||
}
|
||||
|
||||
/** Retain exact expected literals; never equate assertions solely because both expect a number. */
|
||||
export function assertionEvidence(
|
||||
subject: CheckSubject,
|
||||
checks: InstanceChecks,
|
||||
): AssertionEvidence {
|
||||
const scope = buildScope(subject);
|
||||
const unresolved = [
|
||||
...checks.unresolved,
|
||||
...checks.unsupported.map((form) => `${form.line}: ${form.reason}`),
|
||||
];
|
||||
const atoms: AssertionAtom[] = [];
|
||||
let origins: ReturnType<typeof localOrigins> | undefined;
|
||||
for (const check of checks.checks) {
|
||||
if (check.matcher === '') continue; // Counts of assertions are not behavioral checks.
|
||||
const call = assertionCallOf(check);
|
||||
if (call === null) {
|
||||
unresolved.push(`${check.line}: assertion call not located`);
|
||||
continue;
|
||||
}
|
||||
const expand = (origins ??= localOrigins(subject, scope));
|
||||
const before = call.start ?? 0;
|
||||
const values = call.arguments.map((arg) => literal(expand(arg, before), scope));
|
||||
const expected =
|
||||
check.snapshot !== null
|
||||
? `snapshot:${check.snapshot}`
|
||||
: values.includes(undefined)
|
||||
? null
|
||||
: JSON.stringify(values);
|
||||
if (expected === null) unresolved.push(`${check.line}: expected value is not static`);
|
||||
// Existing check.shape has the assertion parser's selector/negation rules.
|
||||
const observed = assertedSubject(call);
|
||||
const expanded = observed === null ? null : expand(observed, before);
|
||||
const originNode =
|
||||
expanded === null ? null : abstractNode(preserveValues(expanded, scope), scope, true, false);
|
||||
let knownOrigin = originNode !== null;
|
||||
if (originNode !== null)
|
||||
t.traverseFast(originNode, (node) => {
|
||||
if (t.isIdentifier(node) && /^(?:<v\d+>|<unknown>)$/.test(node.name)) knownOrigin = false;
|
||||
});
|
||||
const origin = originNode === null ? null : generate(originNode, { compact: true }).code;
|
||||
const subjectShape =
|
||||
expanded === null || !knownOrigin
|
||||
? check.shape
|
||||
: generate(abstractNode(preserveValues(expanded, scope, true), scope, true, false), {
|
||||
compact: true,
|
||||
}).code;
|
||||
if (!knownOrigin) unresolved.push(`${check.line}: asserted local origin is unresolved`);
|
||||
const shapeKey = sha1(
|
||||
JSON.stringify([
|
||||
knownOrigin ? origin : check.shape,
|
||||
check.form,
|
||||
check.modifiers,
|
||||
check.matcher,
|
||||
]),
|
||||
);
|
||||
atoms.push({
|
||||
line: check.line,
|
||||
shape: check.shape,
|
||||
shapeKey,
|
||||
subjectShapeKey: sha1(
|
||||
JSON.stringify([subjectShape, check.form, check.modifiers, check.matcher]),
|
||||
),
|
||||
expected,
|
||||
observed: origin,
|
||||
key:
|
||||
expected === null || !knownOrigin
|
||||
? null
|
||||
: sha1(JSON.stringify([origin, check.form, check.modifiers, check.matcher, expected])),
|
||||
});
|
||||
}
|
||||
return { atoms, unresolved: sortedUnique([...unresolved, ...scope.unresolved]) };
|
||||
}
|
||||
533
gitnexus/tools/scenario-map/consolidation/fixture-clones.ts
Normal file
533
gitnexus/tools/scenario-map/consolidation/fixture-clones.ts
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import type { FixtureProfile } from '../facet/fixture-profile.js';
|
||||
import { compareFixtureProfiles } from '../facet/fixture-profile.js';
|
||||
import { sortedUnique } from '../util.js';
|
||||
import type { FixtureSources } from './fixture-sources.js';
|
||||
|
||||
const execute = promisify(execFile);
|
||||
export interface CloneSpan {
|
||||
source: string;
|
||||
file: string;
|
||||
start: number;
|
||||
end: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
}
|
||||
export interface FixtureClone {
|
||||
a: CloneSpan;
|
||||
b: CloneSpan;
|
||||
kind: string;
|
||||
similarity: number;
|
||||
tokens: number;
|
||||
}
|
||||
export interface CloneRun {
|
||||
tool: string;
|
||||
version: string;
|
||||
sourceCount: number;
|
||||
capturedFiles?: number;
|
||||
scannedFiles?: number;
|
||||
formats?: string[];
|
||||
sourceRoot?: string;
|
||||
verifiedPairs?: number;
|
||||
clones: FixtureClone[];
|
||||
}
|
||||
export interface FixtureOverlap {
|
||||
kind: 'content' | 'clone' | 'cross-language-structure';
|
||||
coverageA: number;
|
||||
coverageB: number;
|
||||
sharedBytesA: number;
|
||||
sharedBytesB: number;
|
||||
totalBytesA: number;
|
||||
totalBytesB: number;
|
||||
crossLanguage: boolean;
|
||||
matches: FixtureClone[];
|
||||
unmatchedA: CloneSpan[];
|
||||
unmatchedB: CloneSpan[];
|
||||
}
|
||||
|
||||
/** External clone detection is local, pinned, and receives only captured fixture files. */
|
||||
export async function runFixtureClones(
|
||||
sources: FixtureSources,
|
||||
outDir: string,
|
||||
cli?: string,
|
||||
): Promise<CloneRun> {
|
||||
if (Object.keys(sources).length === 0)
|
||||
return {
|
||||
tool: 'jscpd',
|
||||
version: '5.4.2 (not run: no captured sources)',
|
||||
sourceCount: 0,
|
||||
clones: [],
|
||||
};
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const root = mkdtempSync(path.join(outDir, 'sources-'));
|
||||
for (const [key, files] of Object.entries(sources)) {
|
||||
if (!/^[a-f0-9]{40}$/.test(key)) throw new Error('invalid fixture source key');
|
||||
for (const [file, content] of files) {
|
||||
if (
|
||||
path.isAbsolute(file) ||
|
||||
file.split(/[\\/]/).some((part) => part === '..') ||
|
||||
file.includes('\0')
|
||||
)
|
||||
throw new Error(`invalid captured fixture path: ${file}`);
|
||||
const target = path.join(root, key, file);
|
||||
mkdirSync(path.dirname(target), { recursive: true });
|
||||
writeFileSync(target, content);
|
||||
}
|
||||
}
|
||||
const executable =
|
||||
cli ??
|
||||
process.env.GITNEXUS_SCENARIO_JSCPD ??
|
||||
createRequire(import.meta.url).resolve('jscpd/run-jscpd.js');
|
||||
const version = (
|
||||
await execute(process.execPath, [executable, '--version'], { maxBuffer: 1_000_000 })
|
||||
).stdout.trim();
|
||||
return scanFixtureClones(sources, root, [root], outDir, executable, version);
|
||||
}
|
||||
|
||||
async function scanFixtureClones(
|
||||
sources: FixtureSources,
|
||||
root: string,
|
||||
inputs: string[],
|
||||
outDir: string,
|
||||
executable: string,
|
||||
version: string,
|
||||
): Promise<CloneRun> {
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const config = path.join(outDir, 'jscpd-config.json');
|
||||
writeFileSync(config, '{}');
|
||||
const args = [
|
||||
executable,
|
||||
...inputs,
|
||||
'--config',
|
||||
config,
|
||||
'--min-tokens',
|
||||
'12',
|
||||
'--min-lines',
|
||||
'0',
|
||||
'--mode',
|
||||
'weak',
|
||||
'--ignore-identifiers=consistent',
|
||||
'--similarity=0.8',
|
||||
'--min-nodes',
|
||||
'8',
|
||||
'--max-gap-lines',
|
||||
'2',
|
||||
'--cross-formats',
|
||||
'js-ts',
|
||||
'--workers',
|
||||
'2',
|
||||
'--reporters',
|
||||
'json',
|
||||
'--output',
|
||||
outDir,
|
||||
'--no-gitignore',
|
||||
'--absolute',
|
||||
'--silent',
|
||||
];
|
||||
const result = await execute(process.execPath, args, { cwd: outDir, maxBuffer: 2_000_000 });
|
||||
writeFileSync(path.join(outDir, 'jscpd.log'), result.stdout + result.stderr);
|
||||
const report = JSON.parse(readFileSync(path.join(outDir, 'jscpd-report.json'), 'utf8')) as {
|
||||
duplicates?: unknown[];
|
||||
statistics?: { total?: { sources?: number }; formats?: Record<string, unknown> };
|
||||
};
|
||||
if (!Array.isArray(report.duplicates)) throw new Error('jscpd did not return a clone report');
|
||||
const span = (value: unknown): CloneSpan => {
|
||||
const item = value as {
|
||||
name: string;
|
||||
start: number;
|
||||
end: number;
|
||||
startLoc: { position: number };
|
||||
endLoc: { position: number };
|
||||
};
|
||||
const relative = path.relative(root, item.name).replace(/\\/g, '/');
|
||||
const [source, ...parts] = relative.split('/');
|
||||
const file = parts.join('/');
|
||||
const content = sources[source]?.find(([name]) => name === file)?.[1];
|
||||
if (
|
||||
content === undefined ||
|
||||
!Number.isInteger(item.startLoc?.position) ||
|
||||
!Number.isInteger(item.endLoc?.position) ||
|
||||
item.startLoc.position < 0 ||
|
||||
item.endLoc.position < item.startLoc.position ||
|
||||
item.endLoc.position > Buffer.byteLength(content)
|
||||
)
|
||||
throw new Error('invalid jscpd source span');
|
||||
return {
|
||||
source,
|
||||
file,
|
||||
start: item.startLoc.position,
|
||||
end: item.endLoc.position,
|
||||
startLine: item.start,
|
||||
endLine: item.end,
|
||||
};
|
||||
};
|
||||
const clones = report.duplicates
|
||||
.map((value): FixtureClone => {
|
||||
const item = value as {
|
||||
firstFile: unknown;
|
||||
secondFile: unknown;
|
||||
kind: string;
|
||||
similarity?: number;
|
||||
tokens: number;
|
||||
};
|
||||
return {
|
||||
a: span(item.firstFile),
|
||||
b: span(item.secondFile),
|
||||
kind: item.kind,
|
||||
similarity: Math.max(0, Math.min(1, item.similarity ?? 1)),
|
||||
tokens: item.tokens,
|
||||
};
|
||||
})
|
||||
.filter((clone) => clone.a.source !== clone.b.source);
|
||||
clones.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
||||
return {
|
||||
tool: 'jscpd',
|
||||
version,
|
||||
sourceRoot: root,
|
||||
sourceCount: Object.keys(sources).length,
|
||||
capturedFiles: Object.values(sources).reduce((n, files) => n + files.length, 0),
|
||||
scannedFiles: report.statistics?.total?.sources ?? 0,
|
||||
formats: Object.keys(report.statistics?.formats ?? {}).sort(),
|
||||
clones,
|
||||
};
|
||||
}
|
||||
|
||||
/** A large corpus may suppress contained clones behind a longer representative.
|
||||
* Verify those candidates in isolation; never project byte offsets or transitively
|
||||
* equate near misses. Reuse the immutable export and retain each raw pair report.
|
||||
*/
|
||||
export async function verifyFixtureClonePairs(
|
||||
sources: FixtureSources,
|
||||
run: CloneRun,
|
||||
pairs: readonly [string, string][],
|
||||
outDir: string,
|
||||
cli?: string,
|
||||
): Promise<CloneRun> {
|
||||
if (!pairs.length) return run;
|
||||
const root = run.sourceRoot;
|
||||
if (!root) throw new Error('fixture verification requires exported source inputs');
|
||||
const executable =
|
||||
cli ??
|
||||
process.env.GITNEXUS_SCENARIO_JSCPD ??
|
||||
createRequire(import.meta.url).resolve('jscpd/run-jscpd.js');
|
||||
const unique = [
|
||||
...new Map(
|
||||
pairs.map(([a, b]) => {
|
||||
if (!sources[a] || !sources[b] || !/^[a-f0-9]{40}$/.test(a) || !/^[a-f0-9]{40}$/.test(b))
|
||||
throw new Error('unknown fixture verification source');
|
||||
const pair = [a, b].sort();
|
||||
return [pair.join(':'), pair];
|
||||
}),
|
||||
).values(),
|
||||
];
|
||||
const found: FixtureClone[][] = Array.from({ length: unique.length }, () => []);
|
||||
let next = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: 2 }, async () => {
|
||||
while (next < unique.length) {
|
||||
const index = next++,
|
||||
[a, b] = unique[index];
|
||||
const result = await scanFixtureClones(
|
||||
{ [a]: sources[a], [b]: sources[b] },
|
||||
root,
|
||||
[path.join(root, a), path.join(root, b)],
|
||||
path.join(outDir, `${a}-${b}`),
|
||||
executable,
|
||||
run.version,
|
||||
);
|
||||
found[index] = result.clones;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { ...run, verifiedPairs: unique.length, clones: run.clones.concat(...found) };
|
||||
}
|
||||
|
||||
/** Retrieve candidates from overlapping representative spans, without claiming a match. */
|
||||
export class FixtureCloneCandidates {
|
||||
private readonly anchors = new Map<string, Map<string, CloneSpan[]>>();
|
||||
private readonly requested = new Map<string, [string, string]>();
|
||||
private readonly checked = new Set<string>();
|
||||
constructor(run: CloneRun) {
|
||||
const add = (from: CloneSpan, to: CloneSpan) => {
|
||||
const sources = this.anchors.get(from.source) ?? new Map<string, CloneSpan[]>();
|
||||
const key = `${to.source}/${to.file}`,
|
||||
spans = sources.get(key) ?? [];
|
||||
spans.push(to);
|
||||
sources.set(key, spans);
|
||||
this.anchors.set(from.source, sources);
|
||||
};
|
||||
for (const clone of run.clones) {
|
||||
if (clone.similarity !== 1 || !['exact', 'renamed'].includes(clone.kind)) continue;
|
||||
add(clone.a, clone.b);
|
||||
add(clone.b, clone.a);
|
||||
}
|
||||
}
|
||||
consider(a: FixtureProfile, b: FixtureProfile): void {
|
||||
for (const left of a.sourceKeys ?? [])
|
||||
for (const right of b.sourceKeys ?? []) {
|
||||
if (left === right) continue;
|
||||
const pair: [string, string] = left < right ? [left, right] : [right, left];
|
||||
const key = pair.join(':');
|
||||
if (this.checked.has(key)) continue;
|
||||
this.checked.add(key);
|
||||
for (const [file, x] of this.anchors.get(left) ?? []) {
|
||||
const y = this.anchors.get(right)?.get(file);
|
||||
if (y && x.some((s) => y.some((t) => s.start < t.end && t.start < s.end))) {
|
||||
this.requested.set(key, pair);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs(): [string, string][] {
|
||||
return [...this.requested.values()].sort();
|
||||
}
|
||||
}
|
||||
|
||||
type Interval = { start: number; end: number; weight: number };
|
||||
/** Weighted union, so overlapping clone fragments and near-miss gaps cannot inflate coverage. */
|
||||
function covered(prefix: Uint32Array, intervals: Interval[]): number {
|
||||
const points = [...new Set(intervals.flatMap((i) => [i.start, i.end]))].sort((a, b) => a - b);
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const start = points[i - 1],
|
||||
end = points[i];
|
||||
const weight = Math.max(
|
||||
0,
|
||||
...intervals.filter((v) => v.start <= start && v.end >= end).map((v) => v.weight),
|
||||
);
|
||||
total += (prefix[end] - prefix[start]) * weight;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export class FixtureCloneIndex {
|
||||
private readonly pairs = new Map<string, FixtureClone[]>();
|
||||
private readonly sizes = new Map<string, Uint32Array>();
|
||||
private readonly families = new Map<string, Map<string, { span: CloneSpan; tokens: number }[]>>();
|
||||
constructor(
|
||||
readonly sources: FixtureSources,
|
||||
readonly run: CloneRun,
|
||||
) {
|
||||
// jscpd reports representatives, not every pair in a clone family. Join only exact
|
||||
// or consistent-renaming spans with identical boundaries and token counts. Near
|
||||
// matches are not transitive. Keep families compact; expand only queried pairs.
|
||||
const parent = new Map<string, string>();
|
||||
const members = new Map<string, { span: CloneSpan; tokens: number }>();
|
||||
const find = (key: string): string => {
|
||||
let root = key;
|
||||
while (true) {
|
||||
const next = parent.get(root);
|
||||
if (next === undefined || next === root) break;
|
||||
root = next;
|
||||
}
|
||||
while (key !== root) {
|
||||
const next = parent.get(key);
|
||||
if (next === undefined) break;
|
||||
parent.set(key, root);
|
||||
key = next;
|
||||
}
|
||||
return root;
|
||||
};
|
||||
for (const clone of run.clones) {
|
||||
const key = [clone.a.source, clone.b.source].sort().join(':');
|
||||
const bucket = this.pairs.get(key) ?? [];
|
||||
bucket.push(clone);
|
||||
this.pairs.set(key, bucket);
|
||||
if (clone.similarity === 1 && ['exact', 'renamed'].includes(clone.kind)) {
|
||||
const keys = [clone.a, clone.b].map((span) => {
|
||||
const id = JSON.stringify([span.source, span.file, span.start, span.end, clone.tokens]);
|
||||
if (!parent.has(id)) parent.set(id, id);
|
||||
members.set(id, { span, tokens: clone.tokens });
|
||||
return id;
|
||||
});
|
||||
parent.set(find(keys[1]), find(keys[0]));
|
||||
}
|
||||
}
|
||||
for (const [key, member] of members) {
|
||||
const source = this.families.get(member.span.source) ?? new Map();
|
||||
const family = find(key),
|
||||
bucket = source.get(family) ?? [];
|
||||
bucket.push(member);
|
||||
source.set(family, bucket);
|
||||
this.families.set(member.span.source, source);
|
||||
}
|
||||
for (const [source, files] of Object.entries(sources))
|
||||
for (const [file, text] of files) {
|
||||
const bytes = Buffer.from(text),
|
||||
prefix = new Uint32Array(bytes.length + 1);
|
||||
for (let i = 0; i < bytes.length; i++) prefix[i + 1] = prefix[i] + (bytes[i] > 32 ? 1 : 0);
|
||||
this.sizes.set(`${source}/${file}`, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
compare(a: FixtureProfile, b: FixtureProfile): FixtureOverlap | null {
|
||||
if (a.unresolved.length || b.unresolved.length) return null;
|
||||
const ka = sortedUnique(a.sourceKeys ?? []),
|
||||
kb = sortedUnique(b.sourceKeys ?? []);
|
||||
const crossLanguage = a.languages.join() !== b.languages.join();
|
||||
const matches: FixtureClone[] = [];
|
||||
const ia = new Map<string, Interval[]>(),
|
||||
ib = new Map<string, Interval[]>();
|
||||
const add = (into: Map<string, Interval[]>, span: CloneSpan, weight: number) => {
|
||||
const key = `${span.source}/${span.file}`,
|
||||
bucket = into.get(key) ?? [];
|
||||
bucket.push({ start: span.start, end: span.end, weight });
|
||||
into.set(key, bucket);
|
||||
};
|
||||
for (const left of ka)
|
||||
for (const right of kb) {
|
||||
if (left === right) {
|
||||
for (const [file, text] of this.sources[left] ?? []) {
|
||||
const span = {
|
||||
source: left,
|
||||
file,
|
||||
start: 0,
|
||||
end: Buffer.byteLength(text),
|
||||
startLine: 1,
|
||||
endLine: text.split('\n').length,
|
||||
};
|
||||
add(ia, span, 1);
|
||||
add(ib, span, 1);
|
||||
}
|
||||
} else
|
||||
for (const clone of this.pairs.get([left, right].sort().join(':')) ?? []) {
|
||||
const aligned = clone.a.source === left ? clone : { ...clone, a: clone.b, b: clone.a };
|
||||
add(ia, aligned.a, aligned.similarity);
|
||||
add(ib, aligned.b, aligned.similarity);
|
||||
matches.push(aligned);
|
||||
}
|
||||
if (left !== right)
|
||||
for (const [family, leftSpans] of this.families.get(left) ?? []) {
|
||||
for (const a of leftSpans)
|
||||
for (const b of this.families.get(right)?.get(family) ?? []) {
|
||||
if (
|
||||
matches.some(
|
||||
(m) =>
|
||||
m.a.source === left &&
|
||||
m.a.file === a.span.file &&
|
||||
m.a.start === a.span.start &&
|
||||
m.a.end === a.span.end &&
|
||||
m.b.source === right &&
|
||||
m.b.file === b.span.file &&
|
||||
m.b.start === b.span.start &&
|
||||
m.b.end === b.span.end,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const clone = {
|
||||
a: a.span,
|
||||
b: b.span,
|
||||
kind: 'clone-family',
|
||||
similarity: 1,
|
||||
tokens: a.tokens,
|
||||
};
|
||||
add(ia, clone.a, 1);
|
||||
add(ib, clone.b, 1);
|
||||
matches.push(clone);
|
||||
}
|
||||
}
|
||||
}
|
||||
const measure = (keys: string[], intervals: Map<string, Interval[]>) => {
|
||||
let total = 0,
|
||||
shared = 0;
|
||||
for (const key of keys)
|
||||
for (const [file] of this.sources[key] ?? []) {
|
||||
const name = `${key}/${file}`,
|
||||
prefix = this.sizes.get(name);
|
||||
if (!prefix) continue;
|
||||
total += prefix[prefix.length - 1];
|
||||
shared += covered(prefix, intervals.get(name) ?? []);
|
||||
}
|
||||
return { total, shared };
|
||||
};
|
||||
const x = measure(ka, ia),
|
||||
y = measure(kb, ib);
|
||||
// Near-miss fragments remain reviewable in full: their score does not locate the edits.
|
||||
const unmatched = (keys: string[], intervals: Map<string, Interval[]>): CloneSpan[] => {
|
||||
const spans: CloneSpan[] = [];
|
||||
for (const source of keys)
|
||||
for (const [file, text] of this.sources[source] ?? []) {
|
||||
const bytes = Buffer.from(text);
|
||||
const known = (intervals.get(`${source}/${file}`) ?? [])
|
||||
.filter((i) => i.weight === 1)
|
||||
.sort((a, b) => a.start - b.start);
|
||||
let start = 0;
|
||||
const addGap = (end: number) => {
|
||||
if (bytes.subarray(start, end).toString().trim())
|
||||
spans.push({
|
||||
source,
|
||||
file,
|
||||
start,
|
||||
end,
|
||||
startLine: bytes.subarray(0, start).toString().split('\n').length,
|
||||
endLine: bytes.subarray(0, end).toString().split('\n').length,
|
||||
});
|
||||
};
|
||||
for (const interval of known) {
|
||||
if (interval.start > start) addGap(interval.start);
|
||||
start = Math.max(start, interval.end);
|
||||
}
|
||||
if (start < bytes.length) addGap(bytes.length);
|
||||
}
|
||||
return spans;
|
||||
};
|
||||
if (x.total && y.total && x.shared && y.shared)
|
||||
return {
|
||||
kind: a.contentKey !== null && a.contentKey === b.contentKey ? 'content' : 'clone',
|
||||
coverageA: x.shared / x.total,
|
||||
coverageB: y.shared / y.total,
|
||||
sharedBytesA: x.shared,
|
||||
sharedBytesB: y.shared,
|
||||
totalBytesA: x.total,
|
||||
totalBytesB: y.total,
|
||||
crossLanguage,
|
||||
matches: matches.slice(0, 12),
|
||||
unmatchedA: unmatched(ka, ia),
|
||||
unmatchedB: unmatched(kb, ib),
|
||||
};
|
||||
// Different language providers may describe the same scenario without sharing tokens.
|
||||
// Keep this a separate, weaker template suggestion, never a source-clone claim.
|
||||
const structural = crossLanguage ? compareFixtureProfiles(a, b) : null;
|
||||
// A larger multilingual fixture may contain the smaller scenario plus extra
|
||||
// functions. Require a shared CFG topology and >=90% feature containment;
|
||||
// declaration counts alone cannot establish a partial scenario match.
|
||||
let shared = 0,
|
||||
totalA = 0,
|
||||
totalB = 0,
|
||||
sharedCfg = false;
|
||||
if (crossLanguage && structural === null) {
|
||||
const right = new Map(b.features);
|
||||
for (const [feature, count] of a.features) {
|
||||
const weight = feature.startsWith('cfg:') ? 4 : 1;
|
||||
const common = Math.min(count, right.get(feature) ?? 0);
|
||||
totalA += count * weight;
|
||||
shared += common * weight;
|
||||
if (weight === 4 && common > 0) sharedCfg = true;
|
||||
}
|
||||
for (const [feature, count] of b.features)
|
||||
totalB += count * (feature.startsWith('cfg:') ? 4 : 1);
|
||||
}
|
||||
const partial =
|
||||
sharedCfg && totalA > 0 && totalB > 0 && Math.max(shared / totalA, shared / totalB) >= 0.9;
|
||||
if (structural || partial)
|
||||
return {
|
||||
kind: 'cross-language-structure',
|
||||
coverageA: structural?.score ?? shared / totalA,
|
||||
coverageB: structural?.score ?? shared / totalB,
|
||||
sharedBytesA: 0,
|
||||
sharedBytesB: 0,
|
||||
totalBytesA: x.total,
|
||||
totalBytesB: y.total,
|
||||
crossLanguage,
|
||||
matches: [],
|
||||
unmatchedA: unmatched(ka, ia),
|
||||
unmatchedB: unmatched(kb, ib),
|
||||
};
|
||||
return null;
|
||||
}
|
||||
}
|
||||
27
gitnexus/tools/scenario-map/consolidation/fixture-sources.ts
Normal file
27
gitnexus/tools/scenario-map/consolidation/fixture-sources.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { RawCoverageRecord } from '../path-coverage/records.js';
|
||||
import { sha1 } from '../util.js';
|
||||
|
||||
/** Content-addressed UTF-8 fixture files. Root directories and test IDs do not enter the key. */
|
||||
export type FixtureSources = Record<string, [string, string][]>;
|
||||
|
||||
export function capturedFixtureSources(records: readonly RawCoverageRecord[]): FixtureSources {
|
||||
const sources: FixtureSources = Object.create(null);
|
||||
for (const record of records)
|
||||
for (const snapshot of Object.values(record.fixtureSnapshots ?? {})) {
|
||||
if (snapshot.files.length) sources[sha1(JSON.stringify(snapshot.files))] = snapshot.files;
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
export function mergeFixtureSources(
|
||||
tables: readonly (FixtureSources | undefined)[],
|
||||
): FixtureSources {
|
||||
const merged: FixtureSources = Object.create(null);
|
||||
for (const table of tables)
|
||||
for (const [key, files] of Object.entries(table ?? {})) {
|
||||
if (sha1(JSON.stringify(files)) !== key)
|
||||
throw new Error(`fixture source hash mismatch: ${key}`);
|
||||
merged[key] = files;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
391
gitnexus/tools/scenario-map/consolidation/report.ts
Normal file
391
gitnexus/tools/scenario-map/consolidation/report.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import type { CoverageWorkFile } from '../path-coverage/run-coverage.js';
|
||||
import type { Layer2Test } from '../scenario-map.js';
|
||||
import type { FixtureProfile } from '../facet/fixture-profile.js';
|
||||
import type { AssertionEvidence } from './assertions.js';
|
||||
import { FixtureCloneIndex, type FixtureOverlap } from './fixture-clones.js';
|
||||
import { sha1, sortedUnique } from '../util.js';
|
||||
|
||||
export interface Overlap {
|
||||
shared: number;
|
||||
totalA: number;
|
||||
totalB: number;
|
||||
coverageA: number;
|
||||
coverageB: number;
|
||||
jaccard: number;
|
||||
}
|
||||
export interface ConsolidationGroup {
|
||||
id: string;
|
||||
tests: string[];
|
||||
fixture: FixtureProfile;
|
||||
blocks: number[];
|
||||
assertions: AssertionEvidence;
|
||||
limitations: string[];
|
||||
declarationKey?: string | null;
|
||||
keepReasons?: string[];
|
||||
}
|
||||
export interface ConsolidationOpportunity {
|
||||
a: string;
|
||||
b: string;
|
||||
representedPairs: number;
|
||||
/** Retained evidence stays inspectable without filling the default review queue. */
|
||||
review?: 'actionable' | 'already-parameterized' | 'intentional-keep';
|
||||
keepReasons?: string[];
|
||||
kind:
|
||||
| 'same-observed-input-path-assertions'
|
||||
| 'parameterize-expectations'
|
||||
| 'parameterize-fixtures'
|
||||
| 'share-language-scenario'
|
||||
| 'extract-shared-scenario';
|
||||
fixture: FixtureOverlap;
|
||||
path: Overlap;
|
||||
assertions: Overlap;
|
||||
assertionShapes: Overlap;
|
||||
sharedAssertions: string[];
|
||||
onlyAssertionsA: string[];
|
||||
onlyAssertionsB: string[];
|
||||
onlyBlocksA: string[];
|
||||
onlyBlocksB: string[];
|
||||
sharedBlocks: string[];
|
||||
limitations: string[];
|
||||
}
|
||||
export interface ConsolidationReport {
|
||||
version: 1;
|
||||
thresholds: {
|
||||
fixtureContainment: number;
|
||||
pathContainment: number;
|
||||
assertionContainment: number;
|
||||
assertionShapeContainment: number;
|
||||
};
|
||||
tool: {
|
||||
name: string;
|
||||
version: string;
|
||||
sources: number;
|
||||
fragments: number;
|
||||
capturedFiles?: number;
|
||||
scannedFiles?: number;
|
||||
formats?: string[];
|
||||
verifiedPairs?: number;
|
||||
};
|
||||
summary: {
|
||||
inventoryTests: number;
|
||||
eligibleTests: number;
|
||||
groups: number;
|
||||
comparisons: number;
|
||||
opportunities: number;
|
||||
actionableOpportunities?: number;
|
||||
representedPairs: number;
|
||||
excluded: Record<string, number>;
|
||||
};
|
||||
groups: ConsolidationGroup[];
|
||||
opportunities: ConsolidationOpportunity[];
|
||||
}
|
||||
|
||||
/** Multiset intersection: repeated assertions count, while callers pass distinct coverage blocks. */
|
||||
export function overlap(a: readonly (string | number)[], b: readonly (string | number)[]): Overlap {
|
||||
const counts = new Map<string | number, number>();
|
||||
for (const item of b) counts.set(item, (counts.get(item) ?? 0) + 1);
|
||||
let shared = 0;
|
||||
for (const item of a) {
|
||||
const n = counts.get(item) ?? 0;
|
||||
if (n) {
|
||||
shared++;
|
||||
counts.set(item, n - 1);
|
||||
}
|
||||
}
|
||||
return {
|
||||
shared,
|
||||
totalA: a.length,
|
||||
totalB: b.length,
|
||||
coverageA: a.length ? shared / a.length : 0,
|
||||
coverageB: b.length ? shared / b.length : 0,
|
||||
jaccard: a.length + b.length - shared ? shared / (a.length + b.length - shared) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
const containment = (metric: Pick<Overlap, 'coverageA' | 'coverageB'>): number =>
|
||||
Math.max(metric.coverageA, metric.coverageB);
|
||||
const equal = (metric: Overlap): boolean =>
|
||||
metric.shared > 0 && metric.coverageA === 1 && metric.coverageB === 1;
|
||||
|
||||
/** Conjunctive retrieval: fixture, executed blocks, and assertions must each support the lead. */
|
||||
export function consolidationReport(
|
||||
work: CoverageWorkFile,
|
||||
layer2: readonly Layer2Test[],
|
||||
clones: FixtureCloneIndex,
|
||||
considerFixturePair?: (a: FixtureProfile, b: FixtureProfile) => void,
|
||||
): ConsolidationReport {
|
||||
const thresholds = {
|
||||
fixtureContainment: 0.5,
|
||||
pathContainment: 0.5,
|
||||
assertionContainment: 0.25,
|
||||
assertionShapeContainment: 0.8,
|
||||
};
|
||||
const details = new Map(layer2.map((test) => [test.id, test]));
|
||||
const grouped = new Map<string, ConsolidationGroup>();
|
||||
const excluded: Record<string, number> = {};
|
||||
const reject = (reason: string) => {
|
||||
excluded[reason] = (excluded[reason] ?? 0) + 1;
|
||||
};
|
||||
for (const test of work.tests) {
|
||||
const detail = details.get(test.id),
|
||||
fixture = detail?.fixture,
|
||||
assertions = detail?.assertionEvidence;
|
||||
if (test.state !== 'passed') {
|
||||
reject('not-passing');
|
||||
continue;
|
||||
}
|
||||
if (test.concurrent || !test.provenance.known) {
|
||||
reject('unattributable-execution');
|
||||
continue;
|
||||
}
|
||||
if (!fixture?.files.length) {
|
||||
reject('no-fixture-evidence');
|
||||
continue;
|
||||
}
|
||||
if ((fixture.sourceKeys?.length ?? 0) > 0 && work.fixtureCaptureVersion !== 2) {
|
||||
reject('unverified-input-provenance');
|
||||
continue;
|
||||
}
|
||||
if (fixture.unresolved.length) {
|
||||
reject('incomplete-fixture');
|
||||
continue;
|
||||
}
|
||||
if (!assertions?.atoms.length) {
|
||||
reject('no-assertion-evidence');
|
||||
continue;
|
||||
}
|
||||
if (!assertions.atoms.some((atom) => atom.key !== null)) {
|
||||
reject('no-static-expectation');
|
||||
continue;
|
||||
}
|
||||
const blocks = [...new Set(test.blocks)]
|
||||
.filter((id) => work.blocks[id] !== undefined && !work.blocks[id].endsWith(':0'))
|
||||
.sort((a, b) => a - b);
|
||||
if (!blocks.length) {
|
||||
reject('no-executed-production-blocks');
|
||||
continue;
|
||||
}
|
||||
const limitations = sortedUnique([
|
||||
...assertions.unresolved,
|
||||
...(detail?.facet?.unresolved ?? []),
|
||||
...(detail?.joinProblems ?? []),
|
||||
...(detail?.inventoryUnresolved ?? []),
|
||||
...((fixture.sourceKeys ?? []).length === 0 ? ['captured fixture sources unavailable'] : []),
|
||||
...(test.provenance.nativeEntries.length
|
||||
? ['native execution is not represented by JavaScript blocks']
|
||||
: []),
|
||||
...(test.provenance.workerSpawns.length
|
||||
? ['worker execution is not represented by parent JavaScript blocks']
|
||||
: []),
|
||||
...(test.provenance.excludedCandidates.length
|
||||
? ['some executed files are outside the coverage scope']
|
||||
: []),
|
||||
]);
|
||||
const signature = sha1(
|
||||
JSON.stringify([
|
||||
fixture.sourceKeys,
|
||||
fixture.contentKey,
|
||||
fixture.features,
|
||||
blocks,
|
||||
assertions.atoms.map((a) => [a.shapeKey, a.key]),
|
||||
limitations,
|
||||
detail?.declarationKey,
|
||||
detail?.dupeOk,
|
||||
]),
|
||||
);
|
||||
const group = grouped.get(signature);
|
||||
if (group) group.tests.push(test.id);
|
||||
else
|
||||
grouped.set(signature, {
|
||||
id: `c${signature}`,
|
||||
tests: [test.id],
|
||||
fixture,
|
||||
blocks,
|
||||
assertions,
|
||||
limitations,
|
||||
declarationKey: detail?.declarationKey,
|
||||
keepReasons: detail?.dupeOk ? [detail.dupeOk] : [],
|
||||
});
|
||||
}
|
||||
const groups = [...grouped.values()].sort((a, b) => a.id.localeCompare(b.id));
|
||||
const postings = new Map<string, number[]>();
|
||||
const opportunities: ConsolidationOpportunity[] = [];
|
||||
const fixtureCache = new Map<string, FixtureOverlap | null>();
|
||||
let comparisons = 0;
|
||||
const compare = (a: ConsolidationGroup, b: ConsolidationGroup, representedPairs: number) => {
|
||||
comparisons++;
|
||||
const shapesA = a.assertions.atoms.map((atom) => atom.shapeKey),
|
||||
shapesB = b.assertions.atoms.map((atom) => atom.shapeKey);
|
||||
const assertionShapes = overlap(shapesA, shapesB);
|
||||
const keysA = a.assertions.atoms.flatMap((atom) => (atom.key === null ? [] : [atom.key])),
|
||||
keysB = b.assertions.atoms.flatMap((atom) => (atom.key === null ? [] : [atom.key]));
|
||||
// Unknown atoms stay in the denominator and can never match one another.
|
||||
const assertions = overlap(
|
||||
a.assertions.atoms.map((atom, i) => atom.key ?? `unknown-a:${i}`),
|
||||
b.assertions.atoms.map((atom, i) => atom.key ?? `unknown-b:${i}`),
|
||||
);
|
||||
const parameterRows =
|
||||
keysA.length === a.assertions.atoms.length && keysB.length === b.assertions.atoms.length;
|
||||
if (assertions.shared === 0 && !parameterRows) return;
|
||||
if (
|
||||
containment(assertions) < thresholds.assertionContainment &&
|
||||
containment(assertionShapes) < thresholds.assertionShapeContainment
|
||||
)
|
||||
return;
|
||||
const path = overlap(a.blocks, b.blocks);
|
||||
if (containment(path) < thresholds.pathContainment || path.shared === 0) return;
|
||||
considerFixturePair?.(a.fixture, b.fixture);
|
||||
const fixtureKey = JSON.stringify([
|
||||
a.fixture.sourceKeys,
|
||||
a.fixture.contentKey,
|
||||
a.fixture.features,
|
||||
a.fixture.languages,
|
||||
b.fixture.sourceKeys,
|
||||
b.fixture.contentKey,
|
||||
b.fixture.features,
|
||||
b.fixture.languages,
|
||||
]);
|
||||
if (!fixtureCache.has(fixtureKey))
|
||||
fixtureCache.set(fixtureKey, clones.compare(a.fixture, b.fixture));
|
||||
const fixture = fixtureCache.get(fixtureKey);
|
||||
if (!fixture || containment(fixture) < thresholds.fixtureContainment) return;
|
||||
const limitations = sortedUnique([
|
||||
...a.limitations,
|
||||
...b.limitations,
|
||||
...(fixture.kind === 'cross-language-structure'
|
||||
? [
|
||||
'structural similarity depends on parser/provider output; it does not prove semantic equivalence',
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
let kind: ConsolidationOpportunity['kind'];
|
||||
if (fixture.kind === 'cross-language-structure' || fixture.crossLanguage)
|
||||
kind = 'share-language-scenario';
|
||||
else if (
|
||||
fixture.kind === 'content' &&
|
||||
equal(path) &&
|
||||
equal(assertions) &&
|
||||
limitations.length === 0
|
||||
)
|
||||
kind = 'same-observed-input-path-assertions';
|
||||
else if (
|
||||
assertions.shared === 0 &&
|
||||
containment(assertionShapes) >= thresholds.assertionShapeContainment
|
||||
)
|
||||
kind = 'parameterize-expectations';
|
||||
else if (
|
||||
fixture.kind === 'clone' &&
|
||||
Math.min(fixture.coverageA, fixture.coverageB) >= 0.8 &&
|
||||
equal(assertions)
|
||||
)
|
||||
kind = 'parameterize-fixtures';
|
||||
else kind = 'extract-shared-scenario';
|
||||
const labels = (group: ConsolidationGroup, other: string[], shared: boolean): string[] => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const key of other) counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
return group.assertions.atoms
|
||||
.filter((atom) => {
|
||||
const n = atom.key === null ? 0 : (counts.get(atom.key) ?? 0);
|
||||
if (n && atom.key !== null) counts.set(atom.key, n - 1);
|
||||
return n > 0 === shared;
|
||||
})
|
||||
.map(
|
||||
(atom) =>
|
||||
`${atom.line}: ${atom.shape} subject=${atom.observed ?? 'unresolved'} expected=${atom.expected ?? 'unknown'}`,
|
||||
);
|
||||
};
|
||||
const ba = new Set(a.blocks),
|
||||
bb = new Set(b.blocks);
|
||||
opportunities.push({
|
||||
a: a.id,
|
||||
b: b.id,
|
||||
representedPairs,
|
||||
keepReasons: sortedUnique([...(a.keepReasons ?? []), ...(b.keepReasons ?? [])]),
|
||||
review:
|
||||
(a.keepReasons?.length ?? 0) + (b.keepReasons?.length ?? 0) > 0
|
||||
? 'intentional-keep'
|
||||
: a.declarationKey &&
|
||||
a.declarationKey === b.declarationKey &&
|
||||
!(fixture.kind === 'content' && equal(path) && equal(assertions))
|
||||
? 'already-parameterized'
|
||||
: 'actionable',
|
||||
kind,
|
||||
fixture,
|
||||
path,
|
||||
assertions,
|
||||
assertionShapes,
|
||||
sharedAssertions: labels(a, keysB, true),
|
||||
onlyAssertionsA: labels(a, keysB, false),
|
||||
onlyAssertionsB: labels(b, keysA, false),
|
||||
sharedBlocks: a.blocks
|
||||
.filter((id) => bb.has(id))
|
||||
.slice(0, 20)
|
||||
.map((id) => work.blocks[id]),
|
||||
onlyBlocksA: a.blocks
|
||||
.filter((id) => !bb.has(id))
|
||||
.slice(0, 20)
|
||||
.map((id) => work.blocks[id]),
|
||||
onlyBlocksB: b.blocks
|
||||
.filter((id) => !ba.has(id))
|
||||
.slice(0, 20)
|
||||
.map((id) => work.blocks[id]),
|
||||
limitations,
|
||||
});
|
||||
};
|
||||
groups.forEach((group, index) => {
|
||||
group.tests.sort();
|
||||
if (group.tests.length > 1)
|
||||
compare(group, group, (group.tests.length * (group.tests.length - 1)) / 2);
|
||||
const keys = sortedUnique(group.assertions.atoms.map((atom) => atom.shapeKey));
|
||||
const other = new Set<number>();
|
||||
for (const key of keys) for (const previous of postings.get(key) ?? []) other.add(previous);
|
||||
for (const previous of other)
|
||||
compare(groups[previous], group, groups[previous].tests.length * group.tests.length);
|
||||
for (const key of keys) {
|
||||
const bucket = postings.get(key) ?? [];
|
||||
bucket.push(index);
|
||||
postings.set(key, bucket);
|
||||
}
|
||||
});
|
||||
opportunities.sort(
|
||||
(a, b) =>
|
||||
a.kind.localeCompare(b.kind) ||
|
||||
b.representedPairs - a.representedPairs ||
|
||||
a.a.localeCompare(b.a) ||
|
||||
a.b.localeCompare(b.b),
|
||||
);
|
||||
return {
|
||||
version: 1,
|
||||
thresholds,
|
||||
tool: {
|
||||
name: clones.run.tool,
|
||||
version: clones.run.version,
|
||||
sources: clones.run.sourceCount,
|
||||
fragments: clones.run.clones.length,
|
||||
capturedFiles: clones.run.capturedFiles,
|
||||
scannedFiles: clones.run.scannedFiles,
|
||||
formats: clones.run.formats,
|
||||
verifiedPairs: clones.run.verifiedPairs,
|
||||
},
|
||||
summary: {
|
||||
inventoryTests: work.tests.length,
|
||||
eligibleTests: groups.reduce((n, g) => n + g.tests.length, 0),
|
||||
groups: groups.length,
|
||||
comparisons,
|
||||
opportunities: opportunities.length,
|
||||
actionableOpportunities: opportunities.filter((o) => o.review === 'actionable').length,
|
||||
representedPairs: opportunities.reduce((n, o) => n + o.representedPairs, 0),
|
||||
excluded,
|
||||
},
|
||||
groups,
|
||||
opportunities,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderConsolidationReport(report: ConsolidationReport): string {
|
||||
const data = JSON.stringify({
|
||||
...report,
|
||||
groups: report.groups.map(({ id, tests }) => ({ id, tests })),
|
||||
}).replace(/</g, '\\u003c');
|
||||
return `<!doctype html><html lang="en"><meta charset="utf-8"><title>Test consolidation opportunities</title><style>body{font:16px/1.5 system-ui;margin:36px;color:#173042}input,select{font:inherit;padding:8px}article{border:1px solid #bbcbd5;border-radius:8px;padding:18px;margin:16px 0}pre{white-space:pre-wrap;font-size:12px}small{color:#526777}.metrics{font-weight:600}summary{cursor:pointer}</style><h1>Test consolidation opportunities</h1><p>Each lead compares fixture inputs, executed JavaScript blocks, and assertions. Partial matches retain the unique checks and paths listed below. Cross-language suggestions preserve each language's coverage. Observed equality describes this captured execution, not a proof for all future inputs.</p><p id="summary"></p><details><summary>Inventory coverage and exclusions</summary><pre id="coverage"></pre></details><input id="query" placeholder="Filter test, language, or opportunity"><select id="kind"><option value="">All kinds</option></select><label>Review <select id="review"><option value="actionable">Actionable opportunities</option><option value="">All evidence</option><option value="already-parameterized">Already parameterized</option><option value="intentional-keep">Intentional controls / keeps</option></select></label><main id="results"></main><script>const report=${data};document.getElementById('coverage').textContent=JSON.stringify({inventory:report.summary,fixtureEngine:report.tool},null,2);const groups=new Map(report.groups.map(g=>[g.id,g]));const kinds=[...new Set(report.opportunities.map(o=>o.kind))];const select=document.getElementById('kind');for(const k of kinds){const o=document.createElement('option');o.value=k;o.textContent=k;select.append(o);}function add(parent,tag,text){const el=document.createElement(tag);el.textContent=text;parent.append(el);return el;}function render(){const q=document.getElementById('query').value.toLowerCase();const review=document.getElementById('review').value;const found=report.opportunities.filter(o=>(!review||(o.review||'actionable')===review)&&(!select.value||o.kind===select.value)&&JSON.stringify([o,groups.get(o.a).tests,groups.get(o.b).tests]).toLowerCase().includes(q));const root=document.getElementById('results');root.replaceChildren();document.getElementById('summary').textContent=report.summary.inventoryTests+' inventory cases; '+report.summary.eligibleTests+' eligible cases; '+found.length+' opportunity groups shown by this filter. First 200 rendered; all evidence is in consolidation.json. Fixture engine: '+report.tool.name+' '+report.tool.version;for(const o of found.slice(0,200)){const box=add(root,'article','');add(box,'h2',o.kind+' · '+o.representedPairs+' test pairs');add(box,'p','Review: '+(o.review||'actionable'));add(box,'p',groups.get(o.a).tests.join(' | ')+' ↔ '+groups.get(o.b).tests.join(' | '));add(box,'p',(o.fixture.kind==='cross-language-structure'?'Fixture structure similarity ':'Fixture byte overlap ')+(100*o.fixture.coverageA).toFixed(1)+'% / '+(100*o.fixture.coverageB).toFixed(1)+'%; path overlap '+(100*o.path.coverageA).toFixed(1)+'% / '+(100*o.path.coverageB).toFixed(1)+'%; exact assertions '+o.assertions.shared+' shared ('+o.assertions.totalA+' / '+o.assertions.totalB+').');const details=add(box,'details','');add(details,'summary','Shared and unique evidence to preserve');add(details,'pre',JSON.stringify({keepReasons:o.keepReasons,sharedAssertions:o.sharedAssertions,uniqueAssertionsA:o.onlyAssertionsA,uniqueAssertionsB:o.onlyAssertionsB,sharedPathSamples:o.sharedBlocks,uniquePathSamplesA:o.onlyBlocksA,uniquePathSamplesB:o.onlyBlocksB,fixtureFragments:o.fixture.matches,unmatchedFixtureSpansA:o.fixture.unmatchedA,unmatchedFixtureSpansB:o.fixture.unmatchedB,limitations:o.limitations},null,2));}}document.getElementById('query').addEventListener('input',render);select.addEventListener('change',render);document.getElementById('review').addEventListener('change',render);render();</script></html>`;
|
||||
}
|
||||
27
gitnexus/tools/scenario-map/consolidation/run.ts
Normal file
27
gitnexus/tools/scenario-map/consolidation/run.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { CoverageWorkFile } from '../path-coverage/run-coverage.js';
|
||||
import type { Layer2Test } from '../scenario-map.js';
|
||||
import {
|
||||
FixtureCloneCandidates,
|
||||
FixtureCloneIndex,
|
||||
runFixtureClones,
|
||||
verifyFixtureClonePairs,
|
||||
} from './fixture-clones.js';
|
||||
import { consolidationReport } from './report.js';
|
||||
import path from 'node:path';
|
||||
|
||||
export async function runConsolidation(
|
||||
work: CoverageWorkFile,
|
||||
layer2: readonly Layer2Test[],
|
||||
outDir: string,
|
||||
) {
|
||||
const sources = work.fixtureSources ?? {};
|
||||
const run = await runFixtureClones(sources, outDir);
|
||||
const candidates = new FixtureCloneCandidates(run);
|
||||
const initial = consolidationReport(work, layer2, new FixtureCloneIndex(sources, run), (a, b) =>
|
||||
candidates.consider(a, b),
|
||||
);
|
||||
const pairs = candidates.pairs();
|
||||
if (!pairs.length) return initial;
|
||||
const verified = await verifyFixtureClonePairs(sources, run, pairs, path.join(outDir, 'pairs'));
|
||||
return consolidationReport(work, layer2, new FixtureCloneIndex(sources, verified));
|
||||
}
|
||||
87
gitnexus/tools/scenario-map/consolidation/verify-controls.ts
Normal file
87
gitnexus/tools/scenario-map/consolidation/verify-controls.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/** Acceptance check against fresh pipeline controls; run after scenario-map plan. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { ConsolidationReport } from './report.js';
|
||||
|
||||
const file = process.argv[2];
|
||||
assert(
|
||||
file,
|
||||
'usage: node --import tsx tools/scenario-map/consolidation/verify-controls.ts <consolidation.json>',
|
||||
);
|
||||
const report = JSON.parse(readFileSync(file, 'utf8')) as ConsolidationReport;
|
||||
const groups = new Map(report.groups.map((group) => [group.id, group]));
|
||||
const prefix = 'test/integration/scenario-map-consolidation-inputs.test.ts#';
|
||||
const positive = [
|
||||
'exact A',
|
||||
'exact B',
|
||||
'renamed fixture',
|
||||
'partial fixture and unique check',
|
||||
'different expectation',
|
||||
'equivalent Python scenario',
|
||||
];
|
||||
const negative = ['unrelated assertion', 'unrelated fixture'];
|
||||
const names = [...positive, ...negative];
|
||||
for (const name of names)
|
||||
assert(
|
||||
report.groups.some((group) => group.tests.includes(prefix + name)),
|
||||
`missing control evidence: ${name}`,
|
||||
);
|
||||
const pair = (a: string, b: string) =>
|
||||
report.opportunities.find((opportunity) => {
|
||||
const left = groups.get(opportunity.a)?.tests ?? [],
|
||||
right = groups.get(opportunity.b)?.tests ?? [];
|
||||
return (
|
||||
(left.includes(prefix + a) && right.includes(prefix + b)) ||
|
||||
(left.includes(prefix + b) && right.includes(prefix + a))
|
||||
);
|
||||
});
|
||||
let found = 0,
|
||||
rejected = 0;
|
||||
for (let i = 0; i < names.length; i++)
|
||||
for (let j = i + 1; j < names.length; j++) {
|
||||
const expected = positive.includes(names[i]) && positive.includes(names[j]);
|
||||
assert.equal(Boolean(pair(names[i], names[j])), expected, `${names[i]} / ${names[j]}`);
|
||||
if (expected) found++;
|
||||
else rejected++;
|
||||
}
|
||||
const exact = pair('exact A', 'exact B');
|
||||
assert(exact);
|
||||
assert.equal(exact.fixture.kind, 'content');
|
||||
// A cold first call may initialize a grammar. Preserve that real path difference, while
|
||||
// requiring full containment and allowing only grammar-loading blocks outside the overlap.
|
||||
assert.equal(Math.max(exact.path.coverageA, exact.path.coverageB), 1);
|
||||
for (const block of [...exact.onlyBlocksA, ...exact.onlyBlocksB])
|
||||
assert.match(block, /^gitnexus\/src\/core\/tree-sitter\/(?:grammar-sources|parser-loader)\.ts#/);
|
||||
assert.equal(exact.assertions.coverageA, 1);
|
||||
assert.equal(exact.assertions.coverageB, 1);
|
||||
// Capturing inputs is not exhaustive proof; native execution and static gaps remain visible.
|
||||
assert.equal(exact.review, 'intentional-keep');
|
||||
assert(
|
||||
exact.limitations.includes('native execution is not represented by JavaScript blocks') ||
|
||||
exact.limitations.includes('worker execution is not represented by parent JavaScript blocks'),
|
||||
);
|
||||
assert(exact.limitations.includes('fixture: runtime input coverage is not exhaustive'));
|
||||
assert.notEqual(exact.kind, 'same-observed-input-path-assertions');
|
||||
const partial = pair('exact A', 'partial fixture and unique check');
|
||||
assert(partial);
|
||||
assert.equal(partial.assertions.shared, 1);
|
||||
assert.equal(partial.onlyAssertionsA.length + partial.onlyAssertionsB.length, 1);
|
||||
assert(partial.fixture.unmatchedA.length + partial.fixture.unmatchedB.length > 0);
|
||||
assert(partial.onlyBlocksA.length + partial.onlyBlocksB.length > 0);
|
||||
const rows = pair('exact A', 'different expectation');
|
||||
assert(rows);
|
||||
assert.equal(rows.kind, 'parameterize-expectations');
|
||||
assert.equal(rows.assertions.shared, 0);
|
||||
assert.equal(pair('exact A', 'renamed fixture')?.kind, 'parameterize-fixtures');
|
||||
assert.equal(
|
||||
pair('equivalent Python scenario', 'partial fixture and unique check')?.kind,
|
||||
'share-language-scenario',
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
positivePairsFound: found,
|
||||
negativePairsRejected: rejected,
|
||||
uniqueEvidencePreserved: true,
|
||||
scope: 'eight fresh pipeline controls',
|
||||
}),
|
||||
);
|
||||
8
gitnexus/tools/scenario-map/facet/body-key.ts
Normal file
8
gitnexus/tools/scenario-map/facet/body-key.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Retrieval hint only: the same callback body can occur behind different hooks or imports.
|
||||
* It proves neither equal context nor equal input. Verdicts still require all runtime gates.
|
||||
*/
|
||||
import type { CaseRecord } from '../../test-dupes/test-dupes.js';
|
||||
import { sha1 } from '../util.js';
|
||||
|
||||
export const bodyKeyOf = (record: CaseRecord): string => sha1(record.body);
|
||||
|
|
@ -247,6 +247,13 @@ export interface Check {
|
|||
snapshot: string | null;
|
||||
}
|
||||
|
||||
const assertionCalls = new WeakMap<Check, t.CallExpression>();
|
||||
|
||||
/** Original AST call for a freshly extracted check; absent after serialization. */
|
||||
export function assertionCallOf(check: Check): t.CallExpression | null {
|
||||
return assertionCalls.get(check) ?? null;
|
||||
}
|
||||
|
||||
export interface UnsupportedForm {
|
||||
/** 1-based line of the construct. */
|
||||
line: number;
|
||||
|
|
@ -970,17 +977,16 @@ function recordAssertion(call: t.CallExpression, where: string | null, run: Run)
|
|||
.map((name) => `.${name}`)
|
||||
.join('');
|
||||
const shape = `${head}${chain}(${args.join(',')})${snapshot === null ? '' : `#${snapshot}`}`;
|
||||
run.checks.push({
|
||||
start: call.start ?? 0,
|
||||
check: {
|
||||
line: lineOf(call),
|
||||
form: parsed.form,
|
||||
modifiers: parsed.modifiers,
|
||||
matcher: parsed.matcher,
|
||||
shape,
|
||||
snapshot,
|
||||
},
|
||||
});
|
||||
const check: Check = {
|
||||
line: lineOf(call),
|
||||
form: parsed.form,
|
||||
modifiers: parsed.modifiers,
|
||||
matcher: parsed.matcher,
|
||||
shape,
|
||||
snapshot,
|
||||
};
|
||||
assertionCalls.set(check, call);
|
||||
run.checks.push({ start: call.start ?? 0, check });
|
||||
}
|
||||
|
||||
/** Walks the body; `where` names the first enclosing construct that makes a check conditional. */
|
||||
|
|
|
|||
313
gitnexus/tools/scenario-map/facet/fixture-inputs.ts
Normal file
313
gitnexus/tools/scenario-map/facet/fixture-inputs.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
/** Statically locate fixture inputs without evaluating test/helper code. */
|
||||
import path from 'node:path';
|
||||
import { parse } from '@babel/parser';
|
||||
import * as t from '@babel/types';
|
||||
import {
|
||||
buildFrame,
|
||||
resolveName,
|
||||
staticValue,
|
||||
type Frame,
|
||||
type Programs,
|
||||
} from '../../test-dupes/test-dupes.js';
|
||||
import { buildScope } from './checks.js';
|
||||
import type { FacetSubject, InputFacet } from './input-facet.js';
|
||||
import type { SourceReader } from './source-shape.js';
|
||||
import { FixtureProfiler, type FixtureProfile } from './fixture-profile.js';
|
||||
import { sha1, sortedUnique } from '../util.js';
|
||||
|
||||
// Includes integration/cfg/fixtures and other test-local source corpora, not just test/fixtures.
|
||||
const ROOT = 'gitnexus/test';
|
||||
const INPUT_CALL =
|
||||
/^(?:runPipelineFromRepo|readFileSync|readFile|cpSync|cp|copyFileSync|copyFile)$/;
|
||||
|
||||
export interface FixtureReferences {
|
||||
roots: string[];
|
||||
unresolved: string[];
|
||||
}
|
||||
|
||||
/** Resolve imported constants lazily; importing the pipeline must not scan its entire closure. */
|
||||
class FixturePrograms extends Map<string, { file: string; frame: Frame }> implements Programs {
|
||||
private readonly attempted = new Set<string>();
|
||||
constructor(private readonly sources: SourceReader) {
|
||||
super();
|
||||
}
|
||||
|
||||
override get(key: string) {
|
||||
if (!this.attempted.has(key)) {
|
||||
this.attempted.add(key);
|
||||
if (key.startsWith('gitnexus/test/')) {
|
||||
for (const suffix of ['.ts', '.js', '.mts', '.mjs', '/index.ts', '/index.js']) {
|
||||
const file = key + suffix;
|
||||
const text = this.sources.read(file);
|
||||
if (text === null) continue;
|
||||
try {
|
||||
const ast = parse(text, { sourceType: 'unambiguous', plugins: ['typescript', 'jsx'] });
|
||||
this.set(key, { file, frame: buildFrame(file, ast.program, []) });
|
||||
} catch {
|
||||
/* The caller records an unresolved input instead of executing code. */
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
export class FixtureInputs {
|
||||
private readonly programs: FixturePrograms;
|
||||
private readonly profiler: FixtureProfiler;
|
||||
private readonly known = new Set<string>();
|
||||
private readonly references = new WeakMap<t.Node, Map<string, FixtureReferences>>();
|
||||
|
||||
constructor(sources: SourceReader) {
|
||||
this.programs = new FixturePrograms(sources);
|
||||
this.profiler = new FixtureProfiler(sources);
|
||||
for (const file of sources.list(ROOT)) {
|
||||
this.known.add(file);
|
||||
let dir = path.posix.dirname(file);
|
||||
while (dir.startsWith(`${ROOT}/`)) {
|
||||
this.known.add(dir);
|
||||
dir = path.posix.dirname(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve(subject: FacetSubject): FixtureReferences {
|
||||
const { record, instance } = subject;
|
||||
const rowKey = instance === null ? 'unknown' : `${instance.row}:${instance.rowFingerprint}`;
|
||||
const cached = this.references.get(record.fn)?.get(rowKey);
|
||||
if (cached !== undefined) return cached;
|
||||
const roots = new Set<string>();
|
||||
const unresolved = new Set<string>();
|
||||
const scope = buildScope({ record, instance, fullName: record.title });
|
||||
const rowFrame: Frame = { decls: new Map(), setup: [], extra: [] };
|
||||
for (const [name, value] of scope.rows) {
|
||||
if (value?.kind === 'string')
|
||||
rowFrame.decls.set(name, {
|
||||
kind: 'node',
|
||||
node: t.variableDeclarator(t.identifier(name), t.stringLiteral(value.value)),
|
||||
});
|
||||
}
|
||||
const evaluate = (
|
||||
node: t.Node,
|
||||
file: string,
|
||||
chain: Frame[],
|
||||
depth = 0,
|
||||
): string | undefined => {
|
||||
if (depth > 8 || !t.isExpression(node)) return undefined;
|
||||
if (t.isTSAsExpression(node) || t.isTSNonNullExpression(node))
|
||||
return evaluate(node.expression, file, chain, depth + 1);
|
||||
if (t.isTemplateLiteral(node)) {
|
||||
const values = node.expressions.map((entry) => evaluate(entry, file, chain, depth + 1));
|
||||
if (values.some((value) => value === undefined)) return undefined;
|
||||
return node.quasis
|
||||
.map((part, i) => (part.value.cooked ?? part.value.raw) + (values[i] ?? ''))
|
||||
.join('');
|
||||
}
|
||||
if (t.isBinaryExpression(node, { operator: '+' })) {
|
||||
const left = evaluate(node.left, file, chain, depth + 1);
|
||||
const right = evaluate(node.right, file, chain, depth + 1);
|
||||
return left === undefined || right === undefined ? undefined : left + right;
|
||||
}
|
||||
if (
|
||||
t.isNewExpression(node) &&
|
||||
t.isIdentifier(node.callee, { name: 'URL' }) &&
|
||||
node.arguments.length === 2 &&
|
||||
t.isMemberExpression(node.arguments[1]) &&
|
||||
t.isMetaProperty(node.arguments[1].object) &&
|
||||
t.isIdentifier(node.arguments[1].property, { name: 'url' })
|
||||
) {
|
||||
const relative = evaluate(node.arguments[0], file, chain, depth + 1);
|
||||
return relative === undefined
|
||||
? undefined
|
||||
: path.posix.join(path.posix.dirname(file), relative);
|
||||
}
|
||||
if (t.isCallExpression(node)) {
|
||||
const callee = node.callee;
|
||||
const decl = t.isIdentifier(callee)
|
||||
? resolveName(callee.name, chain)
|
||||
: t.isMemberExpression(callee) && t.isIdentifier(callee.object)
|
||||
? resolveName(callee.object.name, chain)
|
||||
: undefined;
|
||||
const pathImport = decl?.kind === 'import' && /^import:(?:node:)?path#/.test(decl.key);
|
||||
const operation =
|
||||
t.isIdentifier(callee) && pathImport
|
||||
? decl.key.split('#').at(-1)
|
||||
: t.isMemberExpression(callee) && pathImport && t.isIdentifier(callee.property)
|
||||
? callee.property.name
|
||||
: undefined;
|
||||
if (operation === 'join' || operation === 'resolve') {
|
||||
const parts = node.arguments.map((arg) => evaluate(arg, file, chain, depth + 1));
|
||||
if (
|
||||
parts.every(
|
||||
(part): part is string => part !== undefined && !path.posix.isAbsolute(part),
|
||||
)
|
||||
)
|
||||
return path.posix.normalize(path.posix.join(...parts));
|
||||
}
|
||||
}
|
||||
// staticValue already follows constants and imports and handles __dirname/path.join.
|
||||
const value = staticValue(
|
||||
{ kind: 'node', node: t.variableDeclarator(t.identifier('__fixture'), node) },
|
||||
file,
|
||||
chain,
|
||||
this.programs,
|
||||
);
|
||||
if (value !== undefined) return value;
|
||||
if (t.isIdentifier(node)) {
|
||||
const decl = resolveName(node.name, chain);
|
||||
if (decl?.kind === 'node' && t.isVariableDeclarator(decl.node) && decl.node.init)
|
||||
return evaluate(decl.node.init, file, chain, depth + 1);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const seen = new Set<t.Node>();
|
||||
const visit = (node: t.Node, file: string, chain: Frame[], depth: number): void => {
|
||||
if (depth > 5 || seen.has(node)) {
|
||||
unresolved.add(`fixture helper traversal incomplete: ${file}:${node.loc?.start.line ?? 0}`);
|
||||
return;
|
||||
}
|
||||
seen.add(node);
|
||||
const inspect = (call: t.Node, chain: Frame[]): void => {
|
||||
if (!t.isCallExpression(call)) return;
|
||||
const callee = call.callee;
|
||||
const imported = t.isIdentifier(callee) ? resolveName(callee.name, chain) : undefined;
|
||||
const name =
|
||||
imported?.kind === 'import'
|
||||
? (imported.key.split('#').at(-1) ?? '')
|
||||
: t.isIdentifier(callee)
|
||||
? callee.name
|
||||
: t.isMemberExpression(callee) && t.isIdentifier(callee.property)
|
||||
? callee.property.name
|
||||
: '';
|
||||
if (/^before(All|Each)$/.test(name) && t.isIdentifier(call.arguments[0])) {
|
||||
inspect(t.callExpression(call.arguments[0], []), chain);
|
||||
return;
|
||||
}
|
||||
if (INPUT_CALL.test(name)) {
|
||||
const arg = call.arguments[0];
|
||||
const value = arg === undefined ? undefined : evaluate(arg, file, chain);
|
||||
if (value !== undefined) {
|
||||
const normalized = path.posix.normalize(value);
|
||||
if (this.known.has(normalized) && normalized !== ROOT) roots.add(normalized);
|
||||
else unresolved.add(`input path outside the known test tree: ${normalized}`);
|
||||
} else
|
||||
unresolved.add(
|
||||
`unresolved fixture input: ${name} at ${file}:${call.loc?.start.line ?? 0}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!t.isIdentifier(callee)) return;
|
||||
let decl = resolveName(callee.name, chain);
|
||||
let helperFile = file;
|
||||
let helperChain = chain;
|
||||
if (decl?.kind === 'import') {
|
||||
const match = /^import:(.+)#([^#]+)$/.exec(decl.key);
|
||||
if (match === null) return;
|
||||
const program = this.programs.get(match[1]);
|
||||
if (program === undefined) return;
|
||||
decl = program.frame.decls.get(match[2]);
|
||||
helperFile = program.file;
|
||||
helperChain = [program.frame];
|
||||
}
|
||||
const fn =
|
||||
decl?.kind !== 'node'
|
||||
? null
|
||||
: t.isVariableDeclarator(decl.node)
|
||||
? decl.node.init
|
||||
: decl.node;
|
||||
if (!fn || !t.isFunction(fn)) return;
|
||||
const frame = buildFrame(helperFile, fn.body, []);
|
||||
fn.params.forEach((param, i) => {
|
||||
// Unknown arguments still shadow outer constants of the same name.
|
||||
for (const name of Object.keys(t.getBindingIdentifiers(param)))
|
||||
frame.decls.set(name, {
|
||||
kind: 'node',
|
||||
node: t.variableDeclarator(t.identifier(name)),
|
||||
});
|
||||
const arg = call.arguments[i];
|
||||
const value = arg === undefined ? undefined : evaluate(arg, file, chain);
|
||||
if (t.isIdentifier(param) && value !== undefined)
|
||||
frame.decls.set(param.name, {
|
||||
kind: 'node',
|
||||
node: t.variableDeclarator(param, t.stringLiteral(value)),
|
||||
});
|
||||
});
|
||||
visit(fn.body, helperFile, [...helperChain, frame], depth + 1);
|
||||
};
|
||||
const walk = (current: t.Node, frames: Frame[]): void => {
|
||||
// Named helpers are visited only at a call, with their argument bindings.
|
||||
if (t.isFunctionDeclaration(current)) return;
|
||||
if (t.isFunction(current)) {
|
||||
const params: Frame = { decls: new Map(), setup: [], extra: [] };
|
||||
for (const param of current.params)
|
||||
for (const name of Object.keys(t.getBindingIdentifiers(param))) {
|
||||
params.decls.set(name, {
|
||||
kind: 'node',
|
||||
node: t.variableDeclarator(t.identifier(name)),
|
||||
});
|
||||
}
|
||||
frames = [...frames, params];
|
||||
}
|
||||
if (t.isBlockStatement(current)) frames = [...frames, buildFrame(file, current, [])];
|
||||
inspect(current, frames);
|
||||
for (const key of t.VISITOR_KEYS[current.type] ?? []) {
|
||||
const value = (current as unknown as Record<string, unknown>)[key];
|
||||
for (const child of Array.isArray(value) ? value : [value]) {
|
||||
if (t.isVariableDeclarator(current) && key === 'init' && t.isFunction(child)) continue;
|
||||
if (child !== null && typeof child === 'object' && 'type' in child)
|
||||
walk(child as t.Node, frames);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(node, chain);
|
||||
seen.delete(node);
|
||||
};
|
||||
const bodyFrame = buildFrame(record.file, record.fn.body, []);
|
||||
const chain = [...record.chain, rowFrame, bodyFrame];
|
||||
visit(record.fn.body, record.file, chain, 0);
|
||||
const ownParams = new Set(
|
||||
record.fn.params.flatMap((param) => Object.keys(t.getBindingIdentifiers(param))),
|
||||
);
|
||||
const hookRows: Frame = {
|
||||
...rowFrame,
|
||||
decls: new Map([...rowFrame.decls].filter(([name]) => !ownParams.has(name))),
|
||||
};
|
||||
record.chain.forEach((frame, index) => {
|
||||
for (const setup of frame.setup)
|
||||
visit(setup, record.file, [...record.chain.slice(0, index + 1), hookRows], 0);
|
||||
});
|
||||
const result = { roots: [...roots].sort(), unresolved: [...unresolved].sort() };
|
||||
const cache = this.references.get(record.fn) ?? new Map<string, FixtureReferences>();
|
||||
cache.set(rowKey, result);
|
||||
this.references.set(record.fn, cache);
|
||||
return result;
|
||||
}
|
||||
|
||||
async profile(subject: FacetSubject): Promise<FixtureProfile | null> {
|
||||
const refs = this.resolve(subject);
|
||||
if (refs.roots.length === 0 && refs.unresolved.length === 0) return null;
|
||||
const profile = await this.profiler.profile(refs.roots);
|
||||
return {
|
||||
...profile,
|
||||
contentKey: refs.unresolved.length > 0 ? null : profile.contentKey,
|
||||
unresolved: sortedUnique([...profile.unresolved, ...refs.unresolved]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Exact fixture bytes/layout participate in input identity; structural features never do. */
|
||||
export function withFixtureIdentity(facet: InputFacet, profile: FixtureProfile | null): InputFacet {
|
||||
if (profile === null) return facet;
|
||||
const identity = profile.contentKey ?? 'unknown';
|
||||
return {
|
||||
...facet,
|
||||
fingerprint: sha1(`${facet.fingerprint}\0fixture-content:${identity}`),
|
||||
parts: { ...facet.parts, fixtures: [...facet.parts.fixtures, `content:${identity}`] },
|
||||
unresolved: sortedUnique([
|
||||
...facet.unresolved,
|
||||
...profile.unresolved.map((reason) => `fixture: ${reason}`),
|
||||
]),
|
||||
};
|
||||
}
|
||||
318
gitnexus/tools/scenario-map/facet/fixture-profile.ts
Normal file
318
gitnexus/tools/scenario-map/facet/fixture-profile.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/** Fixture similarity is a retrieval hint, never evidence of equivalent test inputs. */
|
||||
import path from 'node:path';
|
||||
import type Parser from 'tree-sitter';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { getProviderForFileContent } from '../../../src/core/ingestion/languages/index.js';
|
||||
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
|
||||
import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js';
|
||||
import { extractVueScript } from '../../../src/core/ingestion/vue-sfc-extractor.js';
|
||||
import { getTreeSitterBufferSize } from '../../../src/core/ingestion/constants.js';
|
||||
import { createParserForLanguage } from '../../../src/core/tree-sitter/parser-loader.js';
|
||||
import { byText, sha1, sortedUnique } from '../util.js';
|
||||
import { MAX_SOURCE_CHARS, type SourceReader } from './source-shape.js';
|
||||
|
||||
export interface FixtureProfile {
|
||||
/** Keys into the coverage artifact's immutable fixture source table, when captured. */
|
||||
sourceKeys?: string[];
|
||||
/** Repository paths, retained for a reviewer to inspect. */
|
||||
roots: string[];
|
||||
files: string[];
|
||||
languages: string[];
|
||||
/** All bytes AND relative paths, including non-source assets; null if incomplete. */
|
||||
contentKey: string | null;
|
||||
/** Language-neutral, counted features from ingestion providers. */
|
||||
features: [string, number][];
|
||||
/** Extraction gaps. A partial profile cannot retrieve structural matches. */
|
||||
unresolved: string[];
|
||||
}
|
||||
|
||||
export interface FixtureSimilarity {
|
||||
kind: 'content' | 'structure';
|
||||
score: number;
|
||||
crossLanguage: boolean;
|
||||
}
|
||||
|
||||
const MAX_FILES = 200;
|
||||
const MAX_TOTAL_CHARS = 2_000_000;
|
||||
export const FIXTURE_SIMILARITY_THRESHOLD = 0.9;
|
||||
|
||||
/** Run-local cache: one parse per file, including fixtures shared by hundreds of cases. */
|
||||
export class FixtureProfiler {
|
||||
private readonly files = new Map<
|
||||
string,
|
||||
Promise<{
|
||||
content: string | null;
|
||||
completeContent: boolean;
|
||||
language: string | null;
|
||||
features: string[];
|
||||
unresolved: string[];
|
||||
}>
|
||||
>();
|
||||
private readonly parsers = new Map<string, Promise<Parser>>();
|
||||
private readonly profiles = new Map<string, Promise<FixtureProfile>>();
|
||||
|
||||
constructor(private readonly reader: SourceReader) {}
|
||||
|
||||
profile(roots: readonly string[]): Promise<FixtureProfile> {
|
||||
const normalized = sortedUnique(roots).filter(
|
||||
(root, _, all) => !all.some((other) => root.startsWith(`${other}/`)),
|
||||
);
|
||||
const key = JSON.stringify(normalized);
|
||||
let result = this.profiles.get(key);
|
||||
if (result === undefined) {
|
||||
result = this.build(normalized);
|
||||
this.profiles.set(key, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async read(file: string) {
|
||||
let result = this.files.get(file);
|
||||
if (result !== undefined) return result;
|
||||
result = (async () => {
|
||||
// Fixture trees can contain credential-shaped samples; never read a real .env.
|
||||
const excluded =
|
||||
/^\.env(?:\.|$)/.test(path.posix.basename(file)) && !file.endsWith('.example');
|
||||
const content = excluded ? null : this.reader.read(file);
|
||||
const features: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const provider = content === null ? null : getProviderForFileContent(file, content);
|
||||
const language = provider?.id ?? null;
|
||||
if (content === null) unresolved.push(`${file}: unreadable`);
|
||||
else if (content.length >= MAX_SOURCE_CHARS) unresolved.push(`${file}: size limit`);
|
||||
else if (content.includes('\uFFFD')) unresolved.push(`${file}: text decoding may be lossy`);
|
||||
else if (provider !== null && content.trim() !== '') {
|
||||
let tree: Parser.Tree | undefined;
|
||||
try {
|
||||
const embedded =
|
||||
provider.id === SupportedLanguages.Vue ? extractVueScript(content) : null;
|
||||
const parseContent = embedded?.scriptContent ?? content;
|
||||
// Standalone providers (e.g. COBOL) own parsing. Providers with CFG support
|
||||
// share this tree between capture extraction and control-flow extraction.
|
||||
if (provider.cfgVisitor !== undefined) {
|
||||
const parserKey = `${provider.id}:${path.posix.extname(file)}:${embedded?.lang ?? ''}`;
|
||||
let parser = this.parsers.get(parserKey);
|
||||
if (parser === undefined) {
|
||||
parser = createParserForLanguage(provider.id, file);
|
||||
this.parsers.set(parserKey, parser);
|
||||
}
|
||||
const nativeParser = await parser;
|
||||
const grammar =
|
||||
embedded === null ? undefined : provider.selectEmbeddedGrammar?.(embedded.lang);
|
||||
if (grammar !== undefined) nativeParser.setLanguage(grammar);
|
||||
const text = provider.preprocessSource?.(parseContent, file) ?? parseContent;
|
||||
tree = nativeParser.parse(text, null, { bufferSize: getTreeSitterBufferSize(text) });
|
||||
if (tree.rootNode.hasError) unresolved.push(`${file}: parse errors`);
|
||||
}
|
||||
const parsed = extractParsedFile(
|
||||
provider,
|
||||
parseContent,
|
||||
file,
|
||||
(warning) => unresolved.push(warning),
|
||||
tree,
|
||||
embedded === null ? 'full-file' : 'pre-extracted-script',
|
||||
undefined,
|
||||
embedded?.lineOffset,
|
||||
embedded?.lang,
|
||||
embedded?.sourceLineMap,
|
||||
);
|
||||
if (parsed === undefined) unresolved.push(`${file}: no scope captures`);
|
||||
else {
|
||||
const scopes = new Map(parsed.scopes.map((scope) => [scope.id, scope]));
|
||||
const owners = new Map(
|
||||
parsed.scopes.flatMap((scope) =>
|
||||
scope.ownedDefs.map((def) => [def.nodeId, scope.kind] as const),
|
||||
),
|
||||
);
|
||||
for (const def of parsed.localDefs) {
|
||||
features.push(
|
||||
`def:${def.type}:owner=${owners.get(def.nodeId) ?? '?'}:arity=${def.parameterCount ?? '?'}`,
|
||||
);
|
||||
}
|
||||
// Ownership and call form matter; declaration spelling and source language do not.
|
||||
for (const ref of parsed.referenceSites) {
|
||||
if (ref.kind === 'read' || ref.kind === 'write' || ref.kind === 'type-reference')
|
||||
continue;
|
||||
features.push(
|
||||
`ref:${scopes.get(ref.inScope)?.kind ?? '?'}:${ref.kind}:${ref.callForm ?? '-'}:${ref.arity ?? '?'}`,
|
||||
);
|
||||
}
|
||||
for (const entry of parsed.parsedImports) features.push(`import:${entry.kind}`);
|
||||
}
|
||||
if (tree !== undefined && provider.cfgVisitor !== undefined) {
|
||||
const { cfgs, skipped } = collectFunctionCfgs(
|
||||
tree.rootNode,
|
||||
provider.cfgVisitor,
|
||||
file,
|
||||
2000,
|
||||
);
|
||||
if (Object.values(skipped).some((count) => count > 0))
|
||||
unresolved.push(`${file}: incomplete control flow`);
|
||||
for (const cfg of cfgs) {
|
||||
// Refinement by directed, labeled neighbors preserves branch/loop topology,
|
||||
// independent of block ids, line numbers, names and literal values.
|
||||
let colors = new Map(cfg.blocks.map((block) => [block.index, block.kind as string]));
|
||||
const outgoing = new Map<number, { kind: string; other: number }[]>();
|
||||
const incoming = new Map<number, { kind: string; other: number }[]>();
|
||||
for (const edge of cfg.edges) {
|
||||
const from = outgoing.get(edge.from) ?? [];
|
||||
from.push({ kind: edge.kind, other: edge.to });
|
||||
outgoing.set(edge.from, from);
|
||||
const to = incoming.get(edge.to) ?? [];
|
||||
to.push({ kind: edge.kind, other: edge.from });
|
||||
incoming.set(edge.to, to);
|
||||
}
|
||||
for (let round = 0; round < 3; round++) {
|
||||
const previous = colors;
|
||||
colors = new Map(
|
||||
cfg.blocks.map((block) => [
|
||||
block.index,
|
||||
sha1(
|
||||
JSON.stringify([
|
||||
previous.get(block.index),
|
||||
(outgoing.get(block.index) ?? [])
|
||||
.map((edge) => `${edge.kind}:${previous.get(edge.other)}`)
|
||||
.sort(),
|
||||
(incoming.get(block.index) ?? [])
|
||||
.map((edge) => `${edge.kind}:${previous.get(edge.other)}`)
|
||||
.sort(),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
features.push(`cfg:${sha1(JSON.stringify([...colors.values()].sort()))}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
unresolved.push(`${file}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
const completeContent =
|
||||
content !== null && content.length < MAX_SOURCE_CHARS && !content.includes('\uFFFD');
|
||||
return { content, completeContent, language, features, unresolved };
|
||||
})();
|
||||
this.files.set(file, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async build(roots: string[]): Promise<FixtureProfile> {
|
||||
const files = sortedUnique(
|
||||
roots.flatMap((root) => {
|
||||
const children = this.reader.list(root);
|
||||
return children.length > 0 ? children : [root];
|
||||
}),
|
||||
);
|
||||
const unresolved: string[] = [];
|
||||
const counts = new Map<string, number>();
|
||||
const identities: string[][] = [];
|
||||
const languages = new Set<string>();
|
||||
if (files.length > MAX_FILES)
|
||||
unresolved.push(`fixture file limit (${files.length} > ${MAX_FILES})`);
|
||||
let completeContent = files.length <= MAX_FILES;
|
||||
let chars = 0;
|
||||
for (const file of files.slice(0, MAX_FILES)) {
|
||||
const item = await this.read(file);
|
||||
completeContent &&= item.completeContent;
|
||||
chars += item.content?.length ?? 0;
|
||||
if (chars > MAX_TOTAL_CHARS) {
|
||||
unresolved.push('fixture total size limit');
|
||||
completeContent = false;
|
||||
break;
|
||||
}
|
||||
unresolved.push(...item.unresolved);
|
||||
if (item.language !== null) languages.add(item.language);
|
||||
if (item.content !== null) {
|
||||
const root = roots.find((root) => file === root || file.startsWith(`${root}/`));
|
||||
if (root === undefined) {
|
||||
unresolved.push(`${file}: outside fixture roots`);
|
||||
completeContent = false;
|
||||
continue;
|
||||
}
|
||||
identities.push([
|
||||
String(roots.indexOf(root)),
|
||||
file === root ? path.posix.basename(file) : path.posix.relative(root, file),
|
||||
sha1(item.content),
|
||||
]);
|
||||
}
|
||||
for (const feature of item.features) counts.set(feature, (counts.get(feature) ?? 0) + 1);
|
||||
}
|
||||
return {
|
||||
roots,
|
||||
files,
|
||||
languages: [...languages].sort(),
|
||||
contentKey: completeContent ? sha1(JSON.stringify(identities)) : null,
|
||||
features: [...counts].sort(([a], [b]) => byText(a, b)),
|
||||
unresolved: sortedUnique(unresolved),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Counted Jaccard. CFG topology carries extra weight so a loop does not look like a branch. */
|
||||
export function compareFixtureProfiles(
|
||||
a: FixtureProfile,
|
||||
b: FixtureProfile,
|
||||
): FixtureSimilarity | null {
|
||||
if (a.files.length === 0 || b.files.length === 0) return null;
|
||||
const crossLanguage = a.languages.join() !== b.languages.join();
|
||||
if (a.contentKey !== null && a.contentKey === b.contentKey)
|
||||
return { kind: 'content', score: 1, crossLanguage };
|
||||
if (
|
||||
a.unresolved.length > 0 ||
|
||||
b.unresolved.length > 0 ||
|
||||
a.features.length === 0 ||
|
||||
b.features.length === 0
|
||||
)
|
||||
return null;
|
||||
const right = new Map(b.features);
|
||||
let shared = 0;
|
||||
let total = 0;
|
||||
for (const [feature, count] of a.features) {
|
||||
const other = right.get(feature) ?? 0;
|
||||
const weight = feature.startsWith('cfg:') ? 4 : 1;
|
||||
shared += Math.min(count, other) * weight;
|
||||
total += Math.max(count, other) * weight;
|
||||
right.delete(feature);
|
||||
}
|
||||
for (const [feature, count] of right) total += count * (feature.startsWith('cfg:') ? 4 : 1);
|
||||
const score = total === 0 ? 0 : shared / total;
|
||||
return score >= FIXTURE_SIMILARITY_THRESHOLD ? { kind: 'structure', score, crossLanguage } : null;
|
||||
}
|
||||
|
||||
/** Reviewable fixture evidence, deduplicated across the tests that share setup. */
|
||||
export function fixtureSimilarityReport(
|
||||
tests: readonly { id: string; fixture?: FixtureProfile | null }[],
|
||||
pairs: readonly { a: string; b: string; fixtureSimilarity?: FixtureSimilarity }[],
|
||||
) {
|
||||
const profiles = new Map<string, { profile: FixtureProfile; tests: string[] }>();
|
||||
for (const test of tests) {
|
||||
if (test.fixture == null) continue;
|
||||
const key = JSON.stringify(test.fixture);
|
||||
const entry = profiles.get(key) ?? { profile: test.fixture, tests: [] };
|
||||
entry.tests.push(test.id);
|
||||
profiles.set(key, entry);
|
||||
}
|
||||
const matches = pairs
|
||||
.filter((pair) => pair.fixtureSimilarity !== undefined)
|
||||
.map((pair) => ({
|
||||
a: pair.a,
|
||||
b: pair.b,
|
||||
...pair.fixtureSimilarity,
|
||||
}));
|
||||
return {
|
||||
version: 1,
|
||||
basis:
|
||||
'Pre-resolution declarations, references, and control-flow topology. Similarity is not semantic equivalence or permission to remove a test.',
|
||||
threshold: FIXTURE_SIMILARITY_THRESHOLD,
|
||||
summary: {
|
||||
tests: tests.length,
|
||||
withFixtureInputs: tests.filter((test) => (test.fixture?.files.length ?? 0) > 0).length,
|
||||
unresolvedTests: tests.filter((test) => (test.fixture?.unresolved.length ?? 0) > 0).length,
|
||||
profiles: profiles.size,
|
||||
matches: matches.length,
|
||||
crossLanguageMatches: matches.filter((match) => match.crossLanguage).length,
|
||||
},
|
||||
profiles: [...profiles.values()],
|
||||
matches,
|
||||
};
|
||||
}
|
||||
122
gitnexus/tools/scenario-map/facet/runtime-fixtures.ts
Normal file
122
gitnexus/tools/scenario-map/facet/runtime-fixtures.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { FixtureProfiler, type FixtureProfile } from './fixture-profile.js';
|
||||
import type { RawCoverageRecord, RawTestCoverage } from '../path-coverage/records.js';
|
||||
import { sha1, sortedUnique } from '../util.js';
|
||||
|
||||
export type RuntimeFixtureProfiles = ReadonlyMap<
|
||||
RawCoverageRecord,
|
||||
ReadonlyMap<string, FixtureProfile>
|
||||
>;
|
||||
|
||||
/** Parse immutable captures in the planner process, once per distinct input across test files. */
|
||||
export async function profileRuntimeFixtures(
|
||||
records: readonly RawCoverageRecord[],
|
||||
): Promise<RuntimeFixtureProfiles> {
|
||||
const contents = new Map<string, string>();
|
||||
const listings = new Map<string, string[]>();
|
||||
const profiler = new FixtureProfiler({
|
||||
read: (file) => contents.get(file) ?? null,
|
||||
list: (root) => listings.get(root) ?? [],
|
||||
});
|
||||
const profiles = new Map<RawCoverageRecord, Map<string, FixtureProfile>>();
|
||||
for (const record of records) {
|
||||
const perFile = new Map<string, FixtureProfile>();
|
||||
profiles.set(record, perFile);
|
||||
for (const [id, snapshot] of Object.entries(record.fixtureSnapshots ?? {})) {
|
||||
const root = `runtime-fixtures/${sha1(JSON.stringify(snapshot.files))}`;
|
||||
if (!listings.has(root)) {
|
||||
listings.set(
|
||||
root,
|
||||
snapshot.files.map(([file, text]) => {
|
||||
const key = `${root}/${file}`;
|
||||
contents.set(key, text);
|
||||
return key;
|
||||
}),
|
||||
);
|
||||
}
|
||||
const profile = await profiler.profile([root]);
|
||||
const unresolved = sortedUnique([
|
||||
...profile.unresolved,
|
||||
...snapshot.unresolved,
|
||||
...(snapshot.kind === 'read'
|
||||
? ['test read may be assertion output; input role is unverified']
|
||||
: []),
|
||||
]);
|
||||
perFile.set(id, {
|
||||
...profile,
|
||||
sourceKeys: [sha1(JSON.stringify(snapshot.files))],
|
||||
roots: [snapshot.root],
|
||||
files: snapshot.files.map(([file]) =>
|
||||
snapshot.kind === 'read' ? snapshot.root : `${snapshot.root}/${file}`,
|
||||
),
|
||||
contentKey: unresolved.length ? null : profile.contentKey,
|
||||
unresolved,
|
||||
});
|
||||
}
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
/** Warm test inputs plus suite setup. Ambiguous windows remain explicit gaps. */
|
||||
export function runtimeFixtureFor(
|
||||
raw: RawCoverageRecord,
|
||||
test: RawTestCoverage,
|
||||
profiles: ReadonlyMap<string, FixtureProfile> | undefined,
|
||||
concurrent: boolean,
|
||||
): FixtureProfile | null {
|
||||
if (raw.fixtureSnapshots === undefined) return null;
|
||||
const chain = new Set(test.suiteChain);
|
||||
const suites = raw.suiteWindows.filter(({ suites }) => chain.has(suites[0]));
|
||||
const own = test.warm.fixtures ?? [];
|
||||
const applies = (id: string): boolean => {
|
||||
const suite = raw.fixtureSnapshots?.[id]?.suite;
|
||||
return suite === undefined || chain.has(suite);
|
||||
};
|
||||
const ids = [...suites.flatMap(({ window }) => window.fixtures ?? []).filter(applies), ...own];
|
||||
// Collection-time reads can supply inputs, but their ownership cannot be proved.
|
||||
ids.push(...(raw.unattributed.fixtures ?? []));
|
||||
if (ids.length === 0) return null;
|
||||
const unresolved: string[] = [];
|
||||
if (concurrent) unresolved.push('runtime fixture attribution: concurrent tests');
|
||||
if (
|
||||
suites.some(
|
||||
({ suites, window }) =>
|
||||
suites.length > 1 &&
|
||||
(window.fixtures ?? []).some((id) => raw.fixtureSnapshots?.[id]?.suite === undefined),
|
||||
)
|
||||
)
|
||||
unresolved.push('runtime fixture attribution: combined suite setup');
|
||||
if ((raw.unattributed.fixtures?.length ?? 0) > 0)
|
||||
unresolved.push('runtime fixture attribution: outside test/setup windows');
|
||||
const warmKeys = new Set(
|
||||
own.map((id) => profiles?.get(id)?.contentKey).filter((key) => key != null),
|
||||
);
|
||||
if (
|
||||
(test.all.fixtures ?? []).some(
|
||||
(id) => !own.includes(id) && !warmKeys.has(profiles?.get(id)?.contentKey ?? ''),
|
||||
)
|
||||
)
|
||||
unresolved.push('runtime fixture inputs changed between attempts');
|
||||
const parts: FixtureProfile[] = [];
|
||||
for (const id of ids) {
|
||||
const profile = profiles?.get(id);
|
||||
if (profile === undefined) unresolved.push(`runtime fixture snapshot missing: ${id}`);
|
||||
else parts.push(profile);
|
||||
}
|
||||
const counts = new Map<string, number>();
|
||||
for (const part of parts)
|
||||
for (const [key, count] of part.features) counts.set(key, (counts.get(key) ?? 0) + count);
|
||||
return {
|
||||
sourceKeys: sortedUnique(parts.flatMap((part) => part.sourceKeys ?? [])),
|
||||
roots: sortedUnique(parts.flatMap((part) => part.roots)),
|
||||
files: sortedUnique(parts.flatMap((part) => part.files)),
|
||||
languages: sortedUnique(parts.flatMap((part) => part.languages)),
|
||||
contentKey:
|
||||
unresolved.length === 0 && parts.every((part) => part.contentKey !== null)
|
||||
? parts.length === 1
|
||||
? parts[0].contentKey
|
||||
: sha1(JSON.stringify(parts.map((part) => part.contentKey)))
|
||||
: null,
|
||||
features: [...counts].sort(([a], [b]) => a.localeCompare(b)),
|
||||
unresolved: sortedUnique([...unresolved, ...parts.flatMap((part) => part.unresolved)]),
|
||||
};
|
||||
}
|
||||
60
gitnexus/tools/scenario-map/inventory/coverage-inventory.ts
Normal file
60
gitnexus/tools/scenario-map/inventory/coverage-inventory.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type { CoverageWorkFile, TestState } from '../path-coverage/run-coverage.js';
|
||||
import { sortedUnique } from '../util.js';
|
||||
|
||||
/** Runtime counts, separate from file counts and from the subset that forms candidate pairs. */
|
||||
export interface CoverageInventory {
|
||||
files: {
|
||||
inScope: number;
|
||||
scheduled: number;
|
||||
notScheduled: string[];
|
||||
unrecorded: string[];
|
||||
errors: string[];
|
||||
};
|
||||
tests: { collected: number } & Record<TestState, number>;
|
||||
/** False when some files may contain cases that the runtime inventory never saw. */
|
||||
collectionComplete: boolean;
|
||||
}
|
||||
|
||||
export function coverageInventory(
|
||||
work: CoverageWorkFile,
|
||||
scope: readonly string[],
|
||||
): CoverageInventory {
|
||||
const scheduled = new Set(work.files.map((file) => file.file));
|
||||
const testsByFile = new Map<string, typeof work.tests>();
|
||||
const tests = { collected: work.tests.length, passed: 0, failed: 0, skipped: 0, pending: 0 };
|
||||
for (const test of work.tests) {
|
||||
tests[test.state]++;
|
||||
const bucket = testsByFile.get(test.file) ?? [];
|
||||
bucket.push(test);
|
||||
testsByFile.set(test.file, bucket);
|
||||
}
|
||||
const files = {
|
||||
inScope: new Set(scope).size,
|
||||
scheduled: scheduled.size,
|
||||
notScheduled: sortedUnique(scope.filter((file) => !scheduled.has(file))),
|
||||
unrecorded: work.files
|
||||
.filter((file) => {
|
||||
if (file.recorded) return false;
|
||||
const own = testsByFile.get(file.file) ?? [];
|
||||
// Vitest does not invoke aroundAll for a fully skipped module (e.g. opt-in benchmarks).
|
||||
return !(
|
||||
file.errors.length === 0 &&
|
||||
own.length > 0 &&
|
||||
own.every((test) => test.state === 'skipped')
|
||||
);
|
||||
})
|
||||
.map((file) => file.file)
|
||||
.sort(),
|
||||
errors: work.files
|
||||
.filter((file) => file.errors.length > 0)
|
||||
.map((file) => file.file)
|
||||
.sort(),
|
||||
};
|
||||
return {
|
||||
files,
|
||||
tests,
|
||||
collectionComplete:
|
||||
files.notScheduled.length + files.unrecorded.length + files.errors.length === 0 &&
|
||||
work.unhandledErrors.length === 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ import { mkdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|||
import path from 'node:path';
|
||||
import { INSTRUMENTER_CONSTANTS } from '@stryker-mutator/api/core';
|
||||
import type { CliOptions, TestModule, TestProject, Vitest } from 'vitest/node';
|
||||
import type { PluginOption } from 'vite';
|
||||
import type { VitestSelection } from '../../mutation/driver/stryker-config.js';
|
||||
import {
|
||||
classifyRun,
|
||||
|
|
@ -99,6 +100,8 @@ export interface VitestRunOptions {
|
|||
* (the value-flow trace hook, U11); with any, setup files run in list order.
|
||||
*/
|
||||
setupFiles?: readonly string[];
|
||||
/** Instrumentation applied only to this prepared run. */
|
||||
plugins?: PluginOption[];
|
||||
/** Called with the project before the run: provides the extra setup files' context. */
|
||||
provide?: (project: Pick<TestProject, 'provide'>) => void;
|
||||
/** Upper bound of the instance's worker processes (the CLI's `--concurrency`). */
|
||||
|
|
@ -144,7 +147,7 @@ export type VitestSession = Pick<
|
|||
/** `vitest/node`'s `createVitest`, narrowed to {@link VitestSession}; unit tests inject a fake. */
|
||||
export type CreateVitest = (
|
||||
options: CliOptions,
|
||||
viteOverrides: { cacheDir: string },
|
||||
viteOverrides: { cacheDir: string; plugins?: PluginOption[] },
|
||||
) => Promise<VitestSession>;
|
||||
|
||||
const loadCreateVitest = async (): Promise<CreateVitest> =>
|
||||
|
|
@ -426,7 +429,7 @@ export async function runVitest(
|
|||
projects: [projectName],
|
||||
maxWorkers: options.maxWorkers,
|
||||
}),
|
||||
{ cacheDir: path.join(options.workDir, 'vite-cache') },
|
||||
{ cacheDir: path.join(options.workDir, 'vite-cache'), plugins: options.plugins },
|
||||
);
|
||||
try {
|
||||
const projects = vitest.projects.filter((candidate) => candidate.name === projectName);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Layer 1 candidate retrieval of the scenario map (U4, R3, KTD5, KTD17, design §5.2).
|
||||
*
|
||||
* Two sources make a pair of tests a candidate:
|
||||
* Four sources make a pair of tests a candidate:
|
||||
* - `jaccard`: exact Jaccard similarity of their V8 block sets (the work file's block indices,
|
||||
* run-coverage.ts) of at least {@link JACCARD_THRESHOLD}, checked in integers. All pairs are
|
||||
* found without comparing every pair: blocks are ordered rarest first (by how many tests cover
|
||||
|
|
@ -11,7 +11,11 @@
|
|||
* filters drop no qualifying pair. Empty block sets (tests that ran no production code) never
|
||||
* pair through this source;
|
||||
* - `checks-context`: both tests have the same checks-and-context key (facet/checks.ts
|
||||
* `checksContextKey`), whatever their blocks.
|
||||
* `checksContextKey`), whatever their blocks;
|
||||
* - `body`: identical callback text, even when setup/import context and blocks differ. This
|
||||
* source only retrieves review leads; it cannot prove equal inputs or expectations.
|
||||
* - `fixture`: source fixtures have similar provider/CFG profiles and assertion-only keys match.
|
||||
* This includes cross-language review leads, never proof of input equivalence.
|
||||
*
|
||||
* Each pair records its exact Jaccard (null when a set is empty), whether the block sets are
|
||||
* identical (KTD5: a pair that is not caps at weak evidence) and whether it is path-unattributable
|
||||
|
|
@ -24,11 +28,16 @@
|
|||
* Library only; pure. The scenario-map CLI (U14) and run-coverage.ts call {@link findCandidates}.
|
||||
*/
|
||||
import { ascending, byText, sha1 } from '../util.js';
|
||||
import {
|
||||
compareFixtureProfiles,
|
||||
type FixtureProfile,
|
||||
type FixtureSimilarity,
|
||||
} from '../facet/fixture-profile.js';
|
||||
|
||||
/** Jaccard >= 99 / 100 makes a candidate (R3). */
|
||||
export const JACCARD_THRESHOLD = Object.freeze({ numerator: 99, denominator: 100 });
|
||||
|
||||
export type CandidateSource = 'jaccard' | 'checks-context';
|
||||
export type CandidateSource = 'jaccard' | 'checks-context' | 'body' | 'fixture';
|
||||
|
||||
export interface CandidateTest {
|
||||
/** Phase 0 test id. */
|
||||
|
|
@ -38,13 +47,18 @@ export interface CandidateTest {
|
|||
concurrent: boolean;
|
||||
/** Checks-and-context key; null when the test has none (no call site, no assertion). */
|
||||
checksContext: string | null;
|
||||
/** Same callback text, independent of surrounding setup. Retrieval only, never proof. */
|
||||
bodyKey?: string | null;
|
||||
/** Fixture retrieval also requires a known, equal assertion-only key. */
|
||||
checks?: string | null;
|
||||
fixture?: FixtureProfile | null;
|
||||
}
|
||||
|
||||
export interface CandidatePair {
|
||||
/** The smaller test id. */
|
||||
a: string;
|
||||
b: string;
|
||||
/** In the order `jaccard`, `checks-context`. */
|
||||
/** In the order `jaccard`, `checks-context`, `body`, `fixture`. */
|
||||
sources: CandidateSource[];
|
||||
/** Exact Jaccard of the block sets; null when either set is empty. */
|
||||
jaccard: number | null;
|
||||
|
|
@ -54,6 +68,8 @@ export interface CandidatePair {
|
|||
pathUnattributable: boolean;
|
||||
/** Id of the pair's group. */
|
||||
group: string;
|
||||
/** Review evidence only. Exact input identity is checked separately by Layer 2. */
|
||||
fixtureSimilarity?: FixtureSimilarity;
|
||||
}
|
||||
|
||||
export interface CandidateGroup {
|
||||
|
|
@ -68,8 +84,14 @@ export interface CandidateStats {
|
|||
/** Tests given. */
|
||||
tests: number;
|
||||
pairs: number;
|
||||
/** Pairs found only by Jaccard, only by checks and context, and by both. */
|
||||
bySource: { jaccard: number; checksContext: number; both: number };
|
||||
/** Disjoint counts: fixture takes precedence over body, then the original three buckets. */
|
||||
bySource: {
|
||||
jaccard: number;
|
||||
checksContext: number;
|
||||
both: number;
|
||||
body: number;
|
||||
fixture?: number;
|
||||
};
|
||||
groups: number;
|
||||
/** The group with the most tests, then the most pairs, then the smallest id. */
|
||||
largestGroup: { id: string; tests: number; pairs: number } | null;
|
||||
|
|
@ -159,12 +181,15 @@ function jaccardPairs(items: readonly Indexed[]): [number, number][] {
|
|||
return found;
|
||||
}
|
||||
|
||||
/** Index pairs (into `items`) sharing a non-null checks-and-context key. */
|
||||
function checksContextPairs(items: readonly Indexed[]): [number, number][] {
|
||||
/** Index pairs (into `items`) sharing a non-null key of the selected field. */
|
||||
function checksContextPairs(
|
||||
items: readonly Indexed[],
|
||||
field: 'checksContext' | 'bodyKey' = 'checksContext',
|
||||
): [number, number][] {
|
||||
const buckets = new Map<string, number[]>();
|
||||
items.forEach((item, index) => {
|
||||
const key = item.test.checksContext;
|
||||
if (key === null) return;
|
||||
const key = item.test[field];
|
||||
if (key == null) return;
|
||||
const bucket = buckets.get(key);
|
||||
if (bucket) bucket.push(index);
|
||||
else buckets.set(key, [index]);
|
||||
|
|
@ -244,6 +269,50 @@ export function findCandidates(
|
|||
};
|
||||
add(jaccardPairs(items), 'jaccard');
|
||||
add(checksContextPairs(items), 'checks-context');
|
||||
add(checksContextPairs(items, 'bodyKey'), 'body');
|
||||
|
||||
// Bucket by assertions and compare each distinct fixture profile once. Many tests share
|
||||
// beforeAll fixtures; do not re-parse/re-score them for every test pair.
|
||||
const fixtureMatches = new Map<string, FixtureSimilarity>();
|
||||
const fixtureBuckets = new Map<string, Map<string, number[]>>();
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
const { checks, fixture } = items[index].test;
|
||||
if (checks == null || fixture == null || fixture.files.length === 0) continue;
|
||||
const key = JSON.stringify([
|
||||
fixture.contentKey,
|
||||
fixture.features,
|
||||
fixture.unresolved,
|
||||
fixture.languages,
|
||||
]);
|
||||
const bucket = fixtureBuckets.get(checks) ?? new Map<string, number[]>();
|
||||
const group = bucket.get(key) ?? [];
|
||||
group.push(index);
|
||||
bucket.set(key, group);
|
||||
fixtureBuckets.set(checks, bucket);
|
||||
}
|
||||
const scores = new Map<string, FixtureSimilarity | null>();
|
||||
for (const bucket of fixtureBuckets.values()) {
|
||||
const entries = [...bucket];
|
||||
for (let x = 0; x < entries.length; x++)
|
||||
for (let y = x; y < entries.length; y++) {
|
||||
const [aKey, a] = entries[x];
|
||||
const [bKey, b] = entries[y];
|
||||
const aProfile = items[a[0]].test.fixture;
|
||||
const bProfile = items[b[0]].test.fixture;
|
||||
if (aProfile == null || bProfile == null) continue;
|
||||
const key = JSON.stringify([aKey, bKey].sort());
|
||||
if (!scores.has(key)) scores.set(key, compareFixtureProfiles(aProfile, bProfile));
|
||||
const similarity = scores.get(key);
|
||||
if (similarity == null) continue;
|
||||
for (let ai = 0; ai < a.length; ai++)
|
||||
for (let bi = x === y ? ai + 1 : 0; bi < b.length; bi++) {
|
||||
const [i, j] = [a[ai], b[bi]].sort(ascending);
|
||||
if (!focus(items[i].test.id) && !focus(items[j].test.id)) continue;
|
||||
add([[i, j]], 'fixture');
|
||||
fixtureMatches.set(`${i}:${j}`, similarity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const raw = [...sources.values()]
|
||||
.sort((p, q) => p.i - q.i || p.j - q.j)
|
||||
|
|
@ -256,7 +325,12 @@ export function findCandidates(
|
|||
return {
|
||||
a: x.test.id,
|
||||
b: y.test.id,
|
||||
sources: (['jaccard', 'checks-context'] as const).filter((s) => found.has(s)),
|
||||
sources: (['jaccard', 'checks-context', 'body', 'fixture'] as const).filter((s) =>
|
||||
found.has(s),
|
||||
),
|
||||
...(fixtureMatches.has(`${i}:${j}`)
|
||||
? { fixtureSimilarity: fixtureMatches.get(`${i}:${j}`) }
|
||||
: {}),
|
||||
jaccard: empty ? null : shared / union,
|
||||
identicalBlocks: shared === x.blocks.length && shared === y.blocks.length,
|
||||
pathUnattributable: x.test.concurrent || y.test.concurrent,
|
||||
|
|
@ -291,6 +365,10 @@ export function findCandidates(
|
|||
jaccard: count(['jaccard']),
|
||||
checksContext: count(['checks-context']),
|
||||
both: count(['jaccard', 'checks-context']),
|
||||
body: pairs.filter(
|
||||
(pair) => pair.sources.includes('body') && !pair.sources.includes('fixture'),
|
||||
).length,
|
||||
...(fixtureMatches.size > 0 ? { fixture: fixtureMatches.size } : {}),
|
||||
},
|
||||
groups: groups.length,
|
||||
largestGroup:
|
||||
|
|
|
|||
|
|
@ -28,8 +28,10 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { Session } from 'node:inspector/promises';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { WorkerOptions } from 'node:worker_threads';
|
||||
import { FixtureCapture } from './fixture-capture.js';
|
||||
import {
|
||||
TestRunner,
|
||||
aroundAll,
|
||||
|
|
@ -60,10 +62,14 @@ if (context === undefined || context === null) {
|
|||
);
|
||||
}
|
||||
|
||||
// Avoid Vite's browser rewrite of new URL(..., import.meta.url), which injects a
|
||||
// reference to `self` while this local binding is still being initialized in jsdom.
|
||||
const hookFile = fileURLToPath(import.meta.url);
|
||||
const self = new Set(
|
||||
[import.meta.url, new URL('./records.ts', import.meta.url).href].map(
|
||||
(url) => scriptPath(url) ?? fileURLToPath(url),
|
||||
),
|
||||
[
|
||||
hookFile,
|
||||
...['records.ts', 'fixture-capture.ts'].map((name) => path.join(path.dirname(hookFile), name)),
|
||||
].map((file) => scriptPath(file) ?? file),
|
||||
);
|
||||
const isSelf = (file: string): boolean => self.has(file);
|
||||
|
||||
|
|
@ -127,10 +133,12 @@ await session.post('Profiler.startPreciseCoverage', { callCount: true, detailed:
|
|||
|
||||
const interner = new CoverageInterner(context.blockRoots, self);
|
||||
const attributor = new CoverageAttributor();
|
||||
const fixtures = new FixtureCapture();
|
||||
const restoreFixtureCapture = fixtures.install();
|
||||
|
||||
async function take(): Promise<WindowDelta> {
|
||||
const { result } = await session.post('Profiler.takePreciseCoverage');
|
||||
return interner.summarize(result, drainEvents());
|
||||
return { ...interner.summarize(result, drainEvents()), fixtures: fixtures.drain() };
|
||||
}
|
||||
|
||||
/** Suite task ids from the file down to the test's parent, each once. */
|
||||
|
|
@ -143,20 +151,33 @@ function suiteChain(test: RunnerTestCase): string[] {
|
|||
return [...new Set(chain.reverse())];
|
||||
}
|
||||
|
||||
/** Tag setup reads precisely even when several nested beforeAll hooks share a V8 window. */
|
||||
function captureSuiteSetup(suite: RunnerTestSuite): void {
|
||||
const hooks = TestRunner.getSuiteHooks(suite);
|
||||
hooks.beforeAll = hooks.beforeAll.map(
|
||||
(hook) =>
|
||||
function (...args) {
|
||||
return fixtures.inSuite(suite.id, () => Reflect.apply(hook, this, args));
|
||||
},
|
||||
);
|
||||
for (const task of suite.tasks) if (task.type === 'suite') captureSuiteSetup(task);
|
||||
}
|
||||
|
||||
// The `{}` is required: Vitest parses this parameter as a fixture pattern.
|
||||
aroundAll(async (runSuite, {}, suite) => {
|
||||
attributor.load(await take());
|
||||
captureSuiteSetup(suite);
|
||||
try {
|
||||
await runSuite();
|
||||
} finally {
|
||||
attributor.finish(await take());
|
||||
const file = suite.file.filepath;
|
||||
const digest = createHash('sha256').update(file).digest('hex').slice(0, 16);
|
||||
writeRawRecord(
|
||||
context.outDir,
|
||||
rawRecordName(file, process.pid, digest),
|
||||
attributor.record(file, interner),
|
||||
);
|
||||
writeRawRecord(context.outDir, rawRecordName(file, process.pid, digest), {
|
||||
...attributor.record(file, interner),
|
||||
fixtureSnapshots: fixtures.snapshots,
|
||||
});
|
||||
restoreFixtureCapture();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
269
gitnexus/tools/scenario-map/path-coverage/fixture-capture.ts
Normal file
269
gitnexus/tools/scenario-map/path-coverage/fixture-capture.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/** Worker-side input snapshots. No parsers are loaded into the test process. */
|
||||
import { createHash } from 'node:crypto';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import fs from 'node:fs';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const FIXTURE_CAPTURE_KEY = '__gitnexusScenarioFixtureCapture';
|
||||
const MAX_FILES = 200;
|
||||
const MAX_BYTES = 200_000;
|
||||
const MAX_TOTAL_BYTES = 2_000_000;
|
||||
const MAX_RECORD_BYTES = 20_000_000;
|
||||
const MAX_EVENTS = 2_000;
|
||||
// Tests may replace JSON.stringify. Observation must not call their mock or change read results.
|
||||
const stringify = JSON.stringify;
|
||||
const posix = (value: string): string => value.replace(/\\/g, '/');
|
||||
const excluded = (name: string): boolean =>
|
||||
/^\.env(?:\.|$)/.test(name) && !name.endsWith('.example');
|
||||
|
||||
export interface CapturedFixture {
|
||||
kind: 'repository' | 'read';
|
||||
root: string;
|
||||
/** Relative paths and immutable UTF-8 text, captured before cleanup. */
|
||||
files: [string, string][];
|
||||
unresolved: string[];
|
||||
suite?: string;
|
||||
}
|
||||
|
||||
export class FixtureCapture {
|
||||
readonly snapshots: Record<string, CapturedFixture> = Object.create(null);
|
||||
private pending: string[] = [];
|
||||
private bytes = 0;
|
||||
private events = 0;
|
||||
private busy = false;
|
||||
private readonly roots = new Map<string, Map<string, string>>();
|
||||
private readonly suite = new AsyncLocalStorage<string>();
|
||||
|
||||
inSuite<T>(id: string, run: () => T): T {
|
||||
return this.suite.run(id, run);
|
||||
}
|
||||
|
||||
drain(): string[] {
|
||||
this.roots.clear();
|
||||
return this.pending.splice(0);
|
||||
}
|
||||
|
||||
private save(snapshot: CapturedFixture): void {
|
||||
const suite = this.suite.getStore();
|
||||
if (suite !== undefined) snapshot = { ...snapshot, suite };
|
||||
if (++this.events > MAX_EVENTS) {
|
||||
snapshot = {
|
||||
kind: 'read',
|
||||
root: '<capture>',
|
||||
files: [],
|
||||
unresolved: ['runtime capture event limit'],
|
||||
};
|
||||
}
|
||||
let key = createHash('sha256').update(stringify(snapshot)).digest('hex');
|
||||
if (this.snapshots[key] === undefined) {
|
||||
const size = snapshot.files.reduce((n, [, text]) => n + Buffer.byteLength(text), 0);
|
||||
if (this.bytes + size > MAX_RECORD_BYTES) {
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
files: [],
|
||||
unresolved: [...snapshot.unresolved, 'runtime capture byte limit'],
|
||||
};
|
||||
key = createHash('sha256').update(stringify(snapshot)).digest('hex');
|
||||
} else this.bytes += size;
|
||||
this.snapshots[key] = snapshot;
|
||||
}
|
||||
// Identical repeated reads do not change the input set in a window.
|
||||
if (!this.pending.includes(key)) this.pending.push(key);
|
||||
}
|
||||
|
||||
/** Snapshot the supplied repository before ingestion (including worker-based ingestion). */
|
||||
repository(root: unknown): void {
|
||||
if (typeof root !== 'string' || this.busy) return;
|
||||
this.busy = true;
|
||||
const absolute = path.resolve(root);
|
||||
const snapshot: CapturedFixture = {
|
||||
kind: 'repository',
|
||||
root: posix(absolute),
|
||||
files: [],
|
||||
unresolved: [],
|
||||
};
|
||||
let total = 0;
|
||||
let visited = 0;
|
||||
const captureFile = (file: string, relative: string, stat: fs.Stats): void => {
|
||||
const fd = fs.openSync(
|
||||
file,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0),
|
||||
);
|
||||
try {
|
||||
const opened = fs.fstatSync(fd);
|
||||
if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino) {
|
||||
snapshot.unresolved.push(`${relative}: changed during capture`);
|
||||
return;
|
||||
}
|
||||
if (opened.size >= MAX_BYTES || total + opened.size > MAX_TOTAL_BYTES) {
|
||||
snapshot.unresolved.push(`${relative}: size limit`);
|
||||
return;
|
||||
}
|
||||
// One extra byte detects growth without reading an unbounded replacement.
|
||||
const bytes = Buffer.alloc(opened.size + 1);
|
||||
let length = 0;
|
||||
while (length < bytes.length) {
|
||||
const count = fs.readSync(fd, bytes, length, bytes.length - length, length);
|
||||
if (count === 0) break;
|
||||
length += count;
|
||||
}
|
||||
const after = fs.fstatSync(fd);
|
||||
if (
|
||||
length !== opened.size ||
|
||||
after.size !== opened.size ||
|
||||
after.mtimeMs !== opened.mtimeMs ||
|
||||
after.ctimeMs !== opened.ctimeMs
|
||||
) {
|
||||
snapshot.unresolved.push(`${relative}: changed during capture`);
|
||||
return;
|
||||
}
|
||||
total += length;
|
||||
const text = bytes.subarray(0, length).toString('utf8');
|
||||
if (text.includes('\uFFFD') || text.includes('\0')) {
|
||||
snapshot.unresolved.push(`${relative}: binary or lossy text`);
|
||||
} else snapshot.files.push([relative || path.basename(file), text]);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
};
|
||||
const visit = (file: string, relative: string, depth: number): void => {
|
||||
if (
|
||||
++visited > 1_000 ||
|
||||
depth > 30 ||
|
||||
snapshot.files.length >= MAX_FILES ||
|
||||
total >= MAX_TOTAL_BYTES
|
||||
) {
|
||||
if (!snapshot.unresolved.includes('runtime repository capture limit'))
|
||||
snapshot.unresolved.push('runtime repository capture limit');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stat = fs.lstatSync(file);
|
||||
if (stat.isSymbolicLink()) {
|
||||
snapshot.unresolved.push(`${relative}: symbolic link not captured`);
|
||||
return;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
for (const name of fs.readdirSync(file).sort()) {
|
||||
if (name === '.git' || name === 'node_modules' || name === '.gitnexus') continue;
|
||||
visit(path.join(file, name), relative ? `${relative}/${name}` : name, depth + 1);
|
||||
if (visited > 1_000 || snapshot.files.length >= MAX_FILES || total >= MAX_TOTAL_BYTES)
|
||||
break;
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (excluded(path.basename(file))) {
|
||||
snapshot.unresolved.push(`${relative}: excluded environment file`);
|
||||
return;
|
||||
}
|
||||
captureFile(file, relative, stat);
|
||||
}
|
||||
} catch {
|
||||
snapshot.unresolved.push(`${relative || '.'}: unreadable`);
|
||||
}
|
||||
};
|
||||
try {
|
||||
visit(absolute, '', 0);
|
||||
if (visited > 1_000 || snapshot.files.length >= MAX_FILES || total >= MAX_TOTAL_BYTES)
|
||||
snapshot.unresolved.push('runtime repository capture limit');
|
||||
this.roots.set(absolute, new Map(snapshot.files));
|
||||
this.save(snapshot);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
read(file: unknown, value: unknown): void {
|
||||
if (this.busy) return;
|
||||
let absolute: string;
|
||||
try {
|
||||
absolute = path.resolve(
|
||||
file instanceof URL ? fileURLToPath(file) : typeof file === 'string' ? file : '',
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!(typeof file === 'string' || file instanceof URL)) return;
|
||||
if (Buffer.isBuffer(value) && value.length >= MAX_BYTES) {
|
||||
this.save({ kind: 'read', root: posix(absolute), files: [], unresolved: ['oversized text'] });
|
||||
return;
|
||||
}
|
||||
const text =
|
||||
typeof value === 'string' ? value : Buffer.isBuffer(value) ? value.toString('utf8') : null;
|
||||
if (text === null) return;
|
||||
for (const [root, files] of this.roots) {
|
||||
if (files.get(posix(path.relative(root, absolute))) === text) return;
|
||||
}
|
||||
const unresolved: string[] = [];
|
||||
if (excluded(path.basename(absolute))) unresolved.push('excluded environment file');
|
||||
if (Buffer.byteLength(text) >= MAX_BYTES || text.includes('\uFFFD') || text.includes('\0'))
|
||||
unresolved.push('binary, lossy, or oversized text');
|
||||
this.save({
|
||||
kind: 'read',
|
||||
root: posix(absolute),
|
||||
files: unresolved.length ? [] : [[path.basename(absolute), text]],
|
||||
unresolved,
|
||||
});
|
||||
}
|
||||
|
||||
/** Called at read initiation, so async completion cannot change which caller qualifies. */
|
||||
accepts(stack: string | undefined): boolean {
|
||||
if (this.busy) return false;
|
||||
const caller = (stack ?? '')
|
||||
.split('\n')
|
||||
.slice(1)
|
||||
.find(
|
||||
(line) =>
|
||||
!line.includes('node:') &&
|
||||
!line.includes('/path-coverage/fixture-capture.') &&
|
||||
/[/\\]/.test(line),
|
||||
);
|
||||
return (
|
||||
caller !== undefined && !caller.includes('node_modules') && /[/\\]test[/\\]/.test(caller)
|
||||
);
|
||||
}
|
||||
|
||||
/** Install pass-through wrappers; errors and returned values keep their original behavior. */
|
||||
install(): () => void {
|
||||
const builtin = process.getBuiltinModule('node:fs');
|
||||
const promises = process.getBuiltinModule('node:fs/promises');
|
||||
const sync = builtin.readFileSync;
|
||||
const asyncRead = promises.readFile;
|
||||
const callback = builtin.readFile;
|
||||
const capture = this;
|
||||
builtin.readFileSync = function (...args: Parameters<typeof sync>) {
|
||||
const accepted = capture.accepts(new Error().stack);
|
||||
const result = Reflect.apply(sync, this, args);
|
||||
if (accepted) capture.read(args[0], result);
|
||||
return result;
|
||||
} as typeof sync;
|
||||
promises.readFile = async function (...args: Parameters<typeof asyncRead>) {
|
||||
const accepted = capture.accepts(new Error().stack);
|
||||
const result = await Reflect.apply(asyncRead, this, args);
|
||||
if (accepted) capture.read(args[0], result);
|
||||
return result;
|
||||
} as typeof asyncRead;
|
||||
builtin.readFile = function (...args: unknown[]) {
|
||||
const accepted = capture.accepts(new Error().stack);
|
||||
const done = args.at(-1);
|
||||
if (accepted && typeof done === 'function')
|
||||
args[args.length - 1] = (error: unknown, value: unknown) => {
|
||||
if (error == null) capture.read(args[0], value);
|
||||
Reflect.apply(done, undefined, [error, value]);
|
||||
};
|
||||
return Reflect.apply(callback, this, args);
|
||||
} as typeof callback;
|
||||
const previous = Reflect.get(globalThis, FIXTURE_CAPTURE_KEY);
|
||||
Reflect.set(globalThis, FIXTURE_CAPTURE_KEY, (root: unknown) => this.repository(root));
|
||||
syncBuiltinESMExports();
|
||||
return () => {
|
||||
builtin.readFileSync = sync;
|
||||
promises.readFile = asyncRead;
|
||||
builtin.readFile = callback;
|
||||
if (previous === undefined) Reflect.deleteProperty(globalThis, FIXTURE_CAPTURE_KEY);
|
||||
else Reflect.set(globalThis, FIXTURE_CAPTURE_KEY, previous);
|
||||
syncBuiltinESMExports();
|
||||
};
|
||||
}
|
||||
}
|
||||
53
gitnexus/tools/scenario-map/path-coverage/fixture-plugin.ts
Normal file
53
gitnexus/tools/scenario-map/path-coverage/fixture-plugin.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { parse } from '@babel/parser';
|
||||
import { generate } from '@babel/generator';
|
||||
import * as t from '@babel/types';
|
||||
import type { Plugin } from 'vite';
|
||||
import { FIXTURE_CAPTURE_KEY } from './fixture-capture.js';
|
||||
|
||||
/** Instrument only the prepared coverage run; production sources remain unchanged. */
|
||||
export function fixtureCapturePlugin(pipelineFile: string): Plugin {
|
||||
return {
|
||||
name: 'scenario-map-fixture-inputs',
|
||||
enforce: 'pre',
|
||||
transform(code, id) {
|
||||
if (id.replace(/\\/g, '/').split('?')[0] !== pipelineFile.replace(/\\/g, '/')) return null;
|
||||
const ast = parse(code, { sourceType: 'module', plugins: ['typescript'] });
|
||||
let found = false;
|
||||
for (const statement of ast.program.body) {
|
||||
const declaration = t.isExportNamedDeclaration(statement)
|
||||
? statement.declaration
|
||||
: statement;
|
||||
if (!t.isVariableDeclaration(declaration)) continue;
|
||||
for (const variable of declaration.declarations) {
|
||||
if (
|
||||
!t.isIdentifier(variable.id, { name: 'runPipelineFromRepo' }) ||
|
||||
!t.isArrowFunctionExpression(variable.init) ||
|
||||
!t.isBlockStatement(variable.init.body)
|
||||
)
|
||||
continue;
|
||||
const parameter = variable.init.params[0];
|
||||
if (!t.isIdentifier(parameter))
|
||||
throw new Error('fixture capture: unsupported pipeline root parameter');
|
||||
const hook = t.optionalCallExpression(
|
||||
t.memberExpression(
|
||||
t.identifier('globalThis'),
|
||||
t.stringLiteral(FIXTURE_CAPTURE_KEY),
|
||||
true,
|
||||
),
|
||||
[t.identifier(parameter.name)],
|
||||
true,
|
||||
);
|
||||
variable.init.body.body.unshift(t.expressionStatement(hook));
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) throw new Error('fixture capture: pipeline entry point was not instrumented');
|
||||
const result = generate(
|
||||
ast,
|
||||
{ sourceMaps: true, sourceFileName: id, retainLines: true },
|
||||
code,
|
||||
);
|
||||
return { code: result.code, map: result.map === null ? null : JSON.stringify(result.map) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -18,7 +18,8 @@
|
|||
* Library only; pure but for {@link sequentialMatcherFor}, which reads the base config module,
|
||||
* {@link runVitestPartitioned}, which runs the injected Vitest runner, and {@link copyBuildOutput}.
|
||||
*/
|
||||
import { cpSync, existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { cpSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import baseConfig from '../../../vitest.config.js';
|
||||
import { parseTestId } from '../../mutation/runner/test-ids.js';
|
||||
|
|
@ -118,7 +119,53 @@ export async function runVitestPartitioned(
|
|||
}
|
||||
|
||||
/** Package entries the prepare step leaves out and integration tests load (E6). */
|
||||
const BUILD_OUTPUT = ['vendor', 'dist'] as const;
|
||||
const BUILD_OUTPUT = ['vendor', 'prebuilds', 'dist'] as const;
|
||||
|
||||
/** Restore tracked inventory inputs without sharing the checkout's writable Git state. */
|
||||
function copyGitInventory(checkoutRoot: string, preparedRoot: string): void {
|
||||
if (!existsSync(path.join(checkoutRoot, '.git'))) return;
|
||||
const env = { ...process.env };
|
||||
for (const key of [
|
||||
'GIT_DIR',
|
||||
'GIT_COMMON_DIR',
|
||||
'GIT_WORK_TREE',
|
||||
'GIT_INDEX_FILE',
|
||||
'GIT_OBJECT_DIRECTORY',
|
||||
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
||||
])
|
||||
delete env[key];
|
||||
const listing = execFileSync('git', ['ls-files', '--stage', '-z'], {
|
||||
cwd: checkoutRoot,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
const entries: string[] = [];
|
||||
for (const entry of listing.split('\0')) {
|
||||
const tab = entry.indexOf('\t');
|
||||
if (tab < 0 || entry.startsWith('160000 ')) continue; // Do not enter submodule repositories.
|
||||
const file = entry.slice(tab + 1);
|
||||
const basename = path.basename(file);
|
||||
if (basename.startsWith('.env') && !basename.endsWith('.example')) continue;
|
||||
const from = path.join(checkoutRoot, file);
|
||||
const to = path.join(preparedRoot, file);
|
||||
// Repo-wide source guards need the public files outside the prepared package too.
|
||||
// Existing prepared files may be mutated: never overwrite them with checkout bytes.
|
||||
if (!existsSync(to) && existsSync(from)) {
|
||||
mkdirSync(path.dirname(to), { recursive: true });
|
||||
cpSync(from, to, { verbatimSymlinks: true });
|
||||
}
|
||||
if (existsSync(to)) entries.push(entry);
|
||||
}
|
||||
execFileSync('git', ['init', '--quiet', '--template=', preparedRoot], { env });
|
||||
execFileSync('git', ['read-tree', '--empty'], { cwd: preparedRoot, env });
|
||||
// Index metadata only: no source object store, history, hooks or remote configuration.
|
||||
execFileSync('git', ['update-index', '-z', '--index-info'], {
|
||||
cwd: preparedRoot,
|
||||
env,
|
||||
input: entries.length === 0 ? '' : `${entries.join('\0')}\0`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies {@link BUILD_OUTPUT} from the checkout's package into a prepared package of a normal-mode
|
||||
|
|
@ -135,15 +182,5 @@ export function copyBuildOutput(
|
|||
const from = path.join(checkoutPackage, entry);
|
||||
if (existsSync(from)) cpSync(from, path.join(preparedPackage, entry), { recursive: true });
|
||||
}
|
||||
// Package tests compare and execute the shipped copies of the registry hook.
|
||||
for (const plugin of [
|
||||
'gitnexus-claude-plugin',
|
||||
'gitnexus-cursor-integration',
|
||||
'gitnexus-factory-plugin',
|
||||
]) {
|
||||
const from = path.join(checkoutPackage, '..', plugin, 'hooks');
|
||||
if (existsSync(from)) {
|
||||
cpSync(from, path.join(preparedPackage, '..', plugin, 'hooks'), { recursive: true });
|
||||
}
|
||||
}
|
||||
copyGitInventory(path.resolve(checkoutPackage, '..'), path.resolve(preparedPackage, '..'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,18 @@
|
|||
/**
|
||||
* The test files Layer 1 must run for the fingerprint (fingerprint plan E7).
|
||||
*
|
||||
* Past the path rule, a pair is a duplicate, a possible duplicate or a table row only when both
|
||||
* tests have the same checks-and-context key or a key is unknown: `checks-differ` makes every other
|
||||
* pair Distinct unless its inputs are equal (verdict/fingerprint.ts). The key is static (KTD5,
|
||||
* facet/checks.ts over the static inventory), so a file whose every test has a key no other test
|
||||
* shares needs no run. A file stays when one of its tests has a key another test shares or no key,
|
||||
* when a call's instance count is not static, when a body reads a file snapshot (the runtime full
|
||||
* name picks the entry, and the static one may differ), when it may register tests the inventory
|
||||
* cannot see (inventory/cases.ts `incomplete`: a helper such as
|
||||
* test/helpers/rename-integration-cases.ts `defineRenameFixtureTests`), or when it did not parse.
|
||||
*
|
||||
* Given up: `different-checks` (the same path and input, other checks) between a test of a skipped
|
||||
* file and any other test; the last full run over the unit tests had none in 6,264 candidate pairs.
|
||||
* The plan and coverage stages apply it unless mutation scope is `all` or the run is the controls.
|
||||
* Used only for diff-scoped runs. Keep files sharing an assertion/context key or an exact
|
||||
* callback-body key, plus files whose test inventory or checks are unknown. Body matches are
|
||||
* retrieval hints: setup, inputs, paths and trace evidence still decide the verdict. Whole-suite
|
||||
* runs execute every fingerprint file so a unique static context cannot hide a runtime match.
|
||||
*
|
||||
* Library only; it reads the test files and their snapshot and helper modules.
|
||||
*/
|
||||
import { checksContextKey, fsModuleReader, fsSnapshotReader } from '../facet/checks.js';
|
||||
import { bodyKeyOf } from '../facet/body-key.js';
|
||||
import { loadStaticInventory } from '../inventory/cases.js';
|
||||
import { FixtureInputs } from '../facet/fixture-inputs.js';
|
||||
import { fsFacetSources } from '../facet/input-facet.js';
|
||||
|
||||
/** Matchers that read a `.snap` file entry named after the runtime test name. */
|
||||
const FILE_SNAPSHOT = /\.(?:toMatchSnapshot|toMatchFileSnapshot|toThrowErrorMatchingSnapshot)\(/;
|
||||
|
|
@ -27,7 +21,7 @@ export interface Layer1Files {
|
|||
/** The files Layer 1 runs, in input order. */
|
||||
files: string[];
|
||||
/**
|
||||
* The others: every test has a checks-and-context key no other test shares, or (diff scope) a
|
||||
* The others: every test has static retrieval keys no other test shares, or (diff scope) a
|
||||
* static key, none of which a changed file's test has.
|
||||
*/
|
||||
skipped: string[];
|
||||
|
|
@ -46,6 +40,7 @@ export function layer1Files(
|
|||
): Layer1Files {
|
||||
const inventory = loadStaticInventory(repoRoot, files);
|
||||
const options = { snapshots: fsSnapshotReader(repoRoot), modules: fsModuleReader(repoRoot) };
|
||||
const fixtureInputs = new FixtureInputs(fsFacetSources(repoRoot));
|
||||
// Files with a test of no static key: it can pair with any test.
|
||||
const unknown = new Set([...inventory.skipped, ...inventory.incomplete]);
|
||||
const keys: { file: string; key: string }[] = [];
|
||||
|
|
@ -55,12 +50,26 @@ export function layer1Files(
|
|||
continue;
|
||||
}
|
||||
for (const instance of site.instances) {
|
||||
const fixtures = fixtureInputs.resolve({
|
||||
id: '',
|
||||
file: site.file,
|
||||
record: site.record,
|
||||
instance,
|
||||
executedFiles: null,
|
||||
});
|
||||
// Source-language similarity is only available after parsing. Keep these files so
|
||||
// a diff run cannot discard a fixture partner on static context uniqueness alone.
|
||||
if (fixtures.roots.length > 0 || fixtures.unresolved.length > 0) unknown.add(site.file);
|
||||
const key = checksContextKey(
|
||||
{ record: site.record, instance, fullName: site.fullName },
|
||||
options,
|
||||
);
|
||||
if (key === null) unknown.add(site.file);
|
||||
else keys.push({ file: site.file, key });
|
||||
else
|
||||
keys.push(
|
||||
{ file: site.file, key: `context:${key}` },
|
||||
{ file: site.file, key: `body:${bodyKeyOf(site.record)}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
const count = new Map<string, number>();
|
||||
|
|
|
|||
|
|
@ -27,12 +27,13 @@
|
|||
* stack frame outside the hook.
|
||||
*
|
||||
* Self-contained on purpose: this module runs inside every window, so it imports only `node:`
|
||||
* builtins and the hook can leave exactly two files out of the provenance it records.
|
||||
* builtins and type-only declarations; the hook excludes its own modules from provenance.
|
||||
*/
|
||||
import { renameSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import type { CapturedFixture } from './fixture-capture.js';
|
||||
|
||||
/** provide/inject key of the hook's context (run-coverage.ts provides it). */
|
||||
export const COVERAGE_KEY = 'gitnexusScenarioCoverage';
|
||||
|
|
@ -96,6 +97,8 @@ export interface RawWindow {
|
|||
scripts: number[];
|
||||
natives: string[];
|
||||
spawns: SpawnEvent[];
|
||||
/** Content-addressed input snapshots observed in this window. Older records omit them. */
|
||||
fixtures?: string[];
|
||||
}
|
||||
|
||||
/** A window that ended when a test's first attempt started, and the suites that started in it. */
|
||||
|
|
@ -133,6 +136,7 @@ export interface RawCoverageRecord {
|
|||
/** Load window, windows that start no suite, and the tail. */
|
||||
unattributed: RawWindow;
|
||||
tests: RawTestCoverage[];
|
||||
fixtureSnapshots?: Record<string, CapturedFixture>;
|
||||
}
|
||||
|
||||
/** Longest spawn target kept; a Worker with `eval: true` passes its whole source. */
|
||||
|
|
@ -187,6 +191,7 @@ export interface WindowDelta {
|
|||
scripts: Set<number>;
|
||||
natives: Set<string>;
|
||||
spawns: SpawnEvent[];
|
||||
fixtures?: string[];
|
||||
}
|
||||
|
||||
const emptyWindow = (): WindowDelta => ({
|
||||
|
|
@ -201,6 +206,9 @@ function mergeInto(target: WindowDelta, delta: WindowDelta): void {
|
|||
for (const script of delta.scripts) target.scripts.add(script);
|
||||
for (const native of delta.natives) target.natives.add(native);
|
||||
target.spawns.push(...delta.spawns);
|
||||
if (delta.fixtures !== undefined) {
|
||||
target.fixtures = [...new Set([...(target.fixtures ?? []), ...delta.fixtures])];
|
||||
}
|
||||
}
|
||||
|
||||
const ascending = (a: number, b: number): number => a - b;
|
||||
|
|
@ -210,6 +218,7 @@ const toRaw = (window: WindowDelta): RawWindow => ({
|
|||
scripts: [...window.scripts].sort(ascending),
|
||||
natives: [...window.natives].sort(),
|
||||
spawns: [...window.spawns],
|
||||
...(window.fixtures === undefined ? {} : { fixtures: [...window.fixtures] }),
|
||||
});
|
||||
|
||||
/** Interns the scripts and block keys of one test file's windows. */
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@
|
|||
* package, through the Vitest Node API with the environment `vitest run` sets, under
|
||||
* tools/mutation/vitest.mutation.config.ts (project `mutation`) or, in controls mode, the
|
||||
* fixture project's own vitest.config.ts, with at most `maxWorkers` worker processes
|
||||
* (`--concurrency`; the scenario-map CLI passes its own). Every test runs twice (`repeats: 1`,
|
||||
* design §5.2: the warm run gives the blocks). The coverage hook (coverage.setup.ts) is put first among the
|
||||
* (`--concurrency`; the scenario-map CLI passes its own). Tests retain their ordinary
|
||||
* lifecycle: forcing per-test repeats corrupts suite state and module caches. The coverage
|
||||
* hook (coverage.setup.ts) is put first among the
|
||||
* setup files and writes one raw record per test file (records.ts).
|
||||
* 4. The raw records become the work file `<out>/coverage.json` ({@link buildWorkFile}).
|
||||
* 5. The tests are joined to the static inventory (inventory/identity.ts), their
|
||||
|
|
@ -35,7 +36,7 @@
|
|||
* id Phase 0 test id (tools/mutation/runner/test-ids.ts)
|
||||
* taskId, file, fullName, location what inventory/identity.ts `joinRuntime` takes
|
||||
* state, concurrent, attempts Vitest result; concurrent task; windows seen (0: not run)
|
||||
* blocks: number[] indices into `blocks`: the warm attempt's own blocks and those of
|
||||
* blocks: number[] indices into `blocks`: the last attempt's own blocks and those of
|
||||
* the `beforeAll` windows of its suites; empty for a concurrent test,
|
||||
* whose windows interleave with its siblings' (path unattributable)
|
||||
* suiteBlocks: number[] the part of `blocks` that came from its suites' `beforeAll`
|
||||
|
|
@ -92,6 +93,14 @@ import {
|
|||
import { ascending, byText, sortedUnique, toPosixSeparators } from '../util.js';
|
||||
import { findCandidates, type CandidateResult, type CandidateTest } from './candidates.js';
|
||||
import { copyBuildOutput, partitionFiles, sequentialMatcherFor } from './partitions.js';
|
||||
import { fixtureCapturePlugin } from './fixture-plugin.js';
|
||||
import {
|
||||
profileRuntimeFixtures,
|
||||
runtimeFixtureFor,
|
||||
type RuntimeFixtureProfiles,
|
||||
} from '../facet/runtime-fixtures.js';
|
||||
import type { FixtureProfile } from '../facet/fixture-profile.js';
|
||||
import { capturedFixtureSources, type FixtureSources } from '../consolidation/fixture-sources.js';
|
||||
import {
|
||||
COVERAGE_KEY,
|
||||
RAW_RECORD_PREFIX,
|
||||
|
|
@ -103,8 +112,8 @@ import {
|
|||
} from './records.js';
|
||||
|
||||
export const WORK_FILE_SCHEMA_VERSION = 1;
|
||||
/** Vitest `repeats` of the coverage run: each test runs twice, the second (warm) run is kept. */
|
||||
export const COVERAGE_REPEATS = 1;
|
||||
/** Do not inject repeats into tests that were not written to repeat within the same suite. */
|
||||
export const COVERAGE_REPEATS = 0;
|
||||
/** The default controls fixture project (Phase 1 U17). */
|
||||
export const DEFAULT_FIXTURE_ROOT = 'gitnexus/test/fixtures/mutation-scenario-map';
|
||||
/** Marks an output directory as created by this tool, so a re-run may replace it. */
|
||||
|
|
@ -140,6 +149,8 @@ export interface CoverageTest {
|
|||
fullName: string;
|
||||
location: TestLocation | null;
|
||||
state: TestState;
|
||||
/** Assertion or runtime failures; absent on passing tests and in older captures. */
|
||||
errors?: string[];
|
||||
concurrent: boolean;
|
||||
/** Configured retries, even if no retry ran; absent in older version 1 work files. */
|
||||
retry?: number;
|
||||
|
|
@ -147,6 +158,8 @@ export interface CoverageTest {
|
|||
blocks: number[];
|
||||
suiteBlocks: number[];
|
||||
provenance: Provenance;
|
||||
/** Observed inputs; absent in archived coverage captured before fixture instrumentation. */
|
||||
runtimeFixture?: FixtureProfile | null;
|
||||
}
|
||||
|
||||
export interface CoverageFile {
|
||||
|
|
@ -159,6 +172,9 @@ export interface CoverageFile {
|
|||
|
||||
export interface CoverageWorkFile {
|
||||
schemaVersion: typeof WORK_FILE_SCHEMA_VERSION;
|
||||
/** Pipeline instrumentation changes V8 offsets; do not mix with uninstrumented slices. */
|
||||
fixtureCaptureVersion?: 1 | 2;
|
||||
fixtureSources?: FixtureSources;
|
||||
mode: RunMode['kind'];
|
||||
extraRoots: string[];
|
||||
repeats: number;
|
||||
|
|
@ -174,6 +190,7 @@ export interface RuntimeTest {
|
|||
fullName: string;
|
||||
location: TestLocation | undefined;
|
||||
state: TestState;
|
||||
errors?: string[];
|
||||
concurrent: boolean;
|
||||
retry?: number | { count?: number };
|
||||
}
|
||||
|
|
@ -194,6 +211,7 @@ export interface WorkFileInput {
|
|||
modules: readonly RuntimeModule[];
|
||||
raws: readonly RawCoverageRecord[];
|
||||
unhandledErrors: readonly string[];
|
||||
fixtureProfiles?: RuntimeFixtureProfiles;
|
||||
}
|
||||
|
||||
/** Where the workers ran: the prepared root and its path map. */
|
||||
|
|
@ -369,6 +387,7 @@ export function buildWorkFile(input: WorkFileInput): CoverageWorkFile {
|
|||
fullName: portableTestName(test.fullName, packageDir),
|
||||
location: test.location ?? null,
|
||||
state: test.state,
|
||||
...(test.errors?.length ? { errors: [...test.errors] } : {}),
|
||||
concurrent,
|
||||
retry: typeof test.retry === 'number' ? test.retry : (test.retry?.count ?? 0),
|
||||
attempts,
|
||||
|
|
@ -392,7 +411,20 @@ export function buildWorkFile(input: WorkFileInput): CoverageWorkFile {
|
|||
const suiteRaw = concurrent ? [] : blockWindows.flatMap(({ window }) => window.blocks);
|
||||
const ownRaw = concurrent ? [] : rawTest.warm.blocks;
|
||||
drafts.push({
|
||||
test: { ...base, provenance },
|
||||
test: {
|
||||
...base,
|
||||
provenance,
|
||||
...(raw.fixtureSnapshots === undefined
|
||||
? {}
|
||||
: {
|
||||
runtimeFixture: runtimeFixtureFor(
|
||||
raw,
|
||||
rawTest,
|
||||
input.fixtureProfiles?.get(raw),
|
||||
concurrent,
|
||||
),
|
||||
}),
|
||||
},
|
||||
own: idsOf(translated, ownRaw),
|
||||
suite: idsOf(translated, suiteRaw),
|
||||
});
|
||||
|
|
@ -465,6 +497,7 @@ export function checksContextKeys(
|
|||
export function candidateTestsOf(
|
||||
work: CoverageWorkFile,
|
||||
keys: ReadonlyMap<string, string | null>,
|
||||
bodyKeys?: ReadonlyMap<string, string | null>,
|
||||
): CandidateTest[] {
|
||||
return work.tests
|
||||
.filter((test) => test.attempts > 0 || test.state === 'passed' || test.state === 'failed')
|
||||
|
|
@ -473,6 +506,7 @@ export function candidateTestsOf(
|
|||
blocks: test.blocks,
|
||||
concurrent: test.concurrent,
|
||||
checksContext: keys.get(test.id) ?? null,
|
||||
...(bodyKeys === undefined ? {} : { bodyKey: bodyKeys.get(test.id) ?? null }),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -598,6 +632,7 @@ export async function runCoverage(options: CoverageOptions): Promise<CoverageRun
|
|||
raws: [],
|
||||
unhandledErrors: [],
|
||||
});
|
||||
work.fixtureCaptureVersion = 2;
|
||||
writeFileSync(layout.workFile, JSON.stringify(work));
|
||||
return { work, workFile: layout.workFile, wallClockMs: 0 };
|
||||
}
|
||||
|
|
@ -651,7 +686,10 @@ export async function runCoverage(options: CoverageOptions): Promise<CoverageRun
|
|||
maxWorkers: partition.maxWorkers,
|
||||
repeats: COVERAGE_REPEATS,
|
||||
}),
|
||||
{ cacheDir: path.join(layout.cacheDir, partition.name) },
|
||||
{
|
||||
cacheDir: path.join(layout.cacheDir, partition.name),
|
||||
plugins: [fixtureCapturePlugin(path.join(packageDir, 'src/core/ingestion/pipeline.ts'))],
|
||||
},
|
||||
);
|
||||
try {
|
||||
if (vitest.projects.length !== 1) {
|
||||
|
|
@ -694,6 +732,9 @@ export async function runCoverage(options: CoverageOptions): Promise<CoverageRun
|
|||
fullName: test.fullName,
|
||||
location: test.location,
|
||||
state: test.result().state,
|
||||
errors: (test.result().errors ?? []).map(
|
||||
(error) => error.stack ?? errorText(error),
|
||||
),
|
||||
concurrent: test.options.concurrent === true,
|
||||
retry: test.options.retry,
|
||||
})),
|
||||
|
|
@ -712,15 +753,20 @@ export async function runCoverage(options: CoverageOptions): Promise<CoverageRun
|
|||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
const raws = readRawRecords(layout.rawDir);
|
||||
const fixtureProfiles = await profileRuntimeFixtures(raws);
|
||||
const work = buildWorkFile({
|
||||
mode: options.mode,
|
||||
preparedRoot,
|
||||
repeats: COVERAGE_REPEATS,
|
||||
modules,
|
||||
raws: readRawRecords(layout.rawDir),
|
||||
raws,
|
||||
fixtureProfiles,
|
||||
unhandledErrors,
|
||||
});
|
||||
const wallClockMs = Math.round(performance.now() - started);
|
||||
work.fixtureCaptureVersion = 2;
|
||||
work.fixtureSources = capturedFixtureSources(raws);
|
||||
writeFileSync(layout.workFile, JSON.stringify(work));
|
||||
return { work, workFile: layout.workFile, wallClockMs };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { assignShards, specWeight } from '../../../test/helpers/shard-balance.js
|
|||
import { readJsonFile } from '../../mutation/shared/fs.js';
|
||||
import { MUTATION_SCOPES, type MutationScope } from '../types.js';
|
||||
import { byText, isRecord, sameList, sortedUnique } from '../util.js';
|
||||
import { mergeFixtureSources } from '../consolidation/fixture-sources.js';
|
||||
import {
|
||||
WORK_FILE_SCHEMA_VERSION,
|
||||
unknownProvenance,
|
||||
|
|
@ -118,6 +119,7 @@ export function mergeCoverageSlices(
|
|||
slice.revision !== first.revision ||
|
||||
mode !== first.work.mode ||
|
||||
repeats !== first.work.repeats ||
|
||||
slice.work.fixtureCaptureVersion !== first.work.fixtureCaptureVersion ||
|
||||
!sameList(extraRoots, first.work.extraRoots)
|
||||
) {
|
||||
throw new Error(`coverage: slice ${slice.index} is not the run of slice ${first.index}`);
|
||||
|
|
@ -150,6 +152,12 @@ export function mergeCoverageSlices(
|
|||
);
|
||||
return {
|
||||
schemaVersion: WORK_FILE_SCHEMA_VERSION,
|
||||
...(slices.some((slice) => slice.work.fixtureSources !== undefined)
|
||||
? { fixtureSources: mergeFixtureSources(slices.map((slice) => slice.work.fixtureSources)) }
|
||||
: {}),
|
||||
...(first.work.fixtureCaptureVersion === undefined
|
||||
? {}
|
||||
: { fixtureCaptureVersion: first.work.fixtureCaptureVersion }),
|
||||
mode: first.work.mode,
|
||||
extraRoots: [...first.work.extraRoots],
|
||||
repeats: first.work.repeats,
|
||||
|
|
|
|||
79
gitnexus/tools/scenario-map/report/json.ts
Normal file
79
gitnexus/tools/scenario-map/report/json.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/** Report JSON can exceed V8's single-string limit. Serialize nested collections incrementally. */
|
||||
import { JSONParser } from '@streamparser/json';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdir, rename, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { isRecord } from '../util.js';
|
||||
import type { ScenarioMapReport } from './report.js';
|
||||
|
||||
/** Recurse through collections so even a single oversized record never becomes one string. */
|
||||
function* jsonChunks(value: unknown): Generator<string> {
|
||||
if (Array.isArray(value)) {
|
||||
yield '[';
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (i > 0) yield ',\n';
|
||||
yield* jsonChunks(value[i]);
|
||||
}
|
||||
yield ']';
|
||||
} else if (isRecord(value)) {
|
||||
yield '{';
|
||||
let first = true;
|
||||
for (const [key, member] of Object.entries(value)) {
|
||||
if (member === undefined) continue;
|
||||
if (!first) yield ',\n';
|
||||
first = false;
|
||||
yield `${JSON.stringify(key)}:`;
|
||||
yield* jsonChunks(member);
|
||||
}
|
||||
yield '}';
|
||||
} else {
|
||||
yield JSON.stringify(value) ?? 'null';
|
||||
}
|
||||
}
|
||||
|
||||
/** Same report schema, without constructing a single string or publishing a truncated file. */
|
||||
export async function writeReportJson(file: string, report: ScenarioMapReport): Promise<void> {
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
const temporary = `${file}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await pipeline(
|
||||
Readable.from(jsonChunks(report), { objectMode: false }),
|
||||
createWriteStream(temporary, { flags: 'wx' }),
|
||||
);
|
||||
await rename(temporary, file);
|
||||
} finally {
|
||||
await rm(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read selected top-level fields, validating the entire document but retaining only those fields.
|
||||
* Baseline comparison needs schemaVersion/run/continuity; issue publication needs run/completeness.
|
||||
* Values remain unknown so existing readers can validate old or incompatible report schemas.
|
||||
*/
|
||||
export async function readReportFields(
|
||||
file: string,
|
||||
fields: readonly string[],
|
||||
): Promise<Record<string, unknown>> {
|
||||
const selected = new Set(fields);
|
||||
const result: Record<string, unknown> = Object.create(null);
|
||||
const parser = new JSONParser({
|
||||
paths: fields.map((field) => `$.${field}`),
|
||||
keepStack: false,
|
||||
stringBufferSize: 64 * 1024,
|
||||
});
|
||||
let hasToken = false;
|
||||
parser.onToken = () => {
|
||||
hasToken = true;
|
||||
};
|
||||
parser.onValue = ({ key, value }) => {
|
||||
if (typeof key === 'string' && selected.has(key)) result[key] = value;
|
||||
};
|
||||
for await (const chunk of createReadStream(file)) parser.write(chunk);
|
||||
if (!parser.isEnded) parser.end();
|
||||
if (!hasToken) throw new SyntaxError('Empty report JSON');
|
||||
return result;
|
||||
}
|
||||
|
|
@ -251,9 +251,9 @@ function fingerprintKeptSection(kept: readonly ReportFingerprintPair[]): string[
|
|||
if (kept.length === 0) return [];
|
||||
return [
|
||||
'',
|
||||
`## Fingerprint pairs kept by \`// dupe-ok:\` (${kept.length})`,
|
||||
`## Retained fingerprint pairs (${kept.length})`,
|
||||
'',
|
||||
'A human keep on either test (R15); the verdict and evidence are still recorded.',
|
||||
'Explicit keeps and existing parameterized cases; the verdict and evidence are still recorded. Proven duplicate rows remain actionable.',
|
||||
'',
|
||||
'| Tests | Keep | Verdict | Rule |',
|
||||
'|-------|------|---------|------|',
|
||||
|
|
@ -505,12 +505,27 @@ function layer0Lines(report: ScenarioMapReport): string[] {
|
|||
|
||||
function eligibilitySection(report: ScenarioMapReport): string[] {
|
||||
const { eligibility } = report;
|
||||
const inventory = report.inventory;
|
||||
const uncomparedFiles = eligibility.fingerprintFiles - eligibility.layer1Files;
|
||||
const lines = [
|
||||
'',
|
||||
'## Eligibility',
|
||||
'',
|
||||
`${eligibility.mode} mode: the fingerprint covers ${plural(eligibility.fingerprintFiles, 'test file')}; ` +
|
||||
`${plural(eligibility.files.length, 'test file')} are mutation-eligible.`,
|
||||
...(inventory == null
|
||||
? []
|
||||
: [
|
||||
`Runtime inventory: ${plural(inventory.tests.collected, 'test case')} collected; ` +
|
||||
`${inventory.tests.passed} passed, ${inventory.tests.failed} failed, ` +
|
||||
`${inventory.tests.skipped} skipped, ${inventory.tests.pending} pending.`,
|
||||
...(inventory.collectionComplete
|
||||
? []
|
||||
: [
|
||||
'Collection is incomplete: files may be omitted, unrecorded or errored, ' +
|
||||
'or the runner may have unhandled errors. These counts are not a verified whole-suite total.',
|
||||
]),
|
||||
]),
|
||||
...(eligibility.changedFiles === null
|
||||
? []
|
||||
: [
|
||||
|
|
@ -521,15 +536,13 @@ function eligibilitySection(report: ScenarioMapReport): string[] {
|
|||
? []
|
||||
: [
|
||||
eligibility.changedFiles === null
|
||||
? `Layer 1 ran ${eligibility.layer1Files} of them; every test of the other ` +
|
||||
`${eligibility.fingerprintFiles - eligibility.layer1Files} has a checks-and-context ` +
|
||||
'key no other test shares, so none of their pairs can be a duplicate, a possible ' +
|
||||
'duplicate or a table row.'
|
||||
? `Layer 1 ran ${eligibility.layer1Files} of them; the other ` +
|
||||
`${plural(uncomparedFiles, 'file')} ${uncomparedFiles === 1 ? 'was' : 'were'} not compared. ` +
|
||||
'No duplicate-recall claim applies to their tests.'
|
||||
: `Layer 1 ran ${eligibility.layer1Files} of them: the changed files whose tests can ` +
|
||||
"pair with another test, the files sharing their tests' checks-and-context keys, " +
|
||||
"pair with another test, the files sharing their tests' checks-and-context or body keys, " +
|
||||
'and every file with a test of no static key (helper-registered, dynamic ' +
|
||||
"`it.each` counts, file snapshots), which can pair with any test. No other file's " +
|
||||
"test can be a changed test's duplicate, possible duplicate or table row.",
|
||||
'`it.each` counts, file snapshots), which can pair with any test. Other files were not compared.',
|
||||
]),
|
||||
...(eligibility.unrecordedFiles === 0
|
||||
? []
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import os from 'node:os';
|
|||
import path from 'node:path';
|
||||
import { readJsonFile } from '../../mutation/shared/fs.js';
|
||||
import type { Finding, Kind } from '../../test-dupes/test-dupes.js';
|
||||
import type { CoverageInventory } from '../inventory/coverage-inventory.js';
|
||||
import {
|
||||
MAIN_LINEAGE,
|
||||
comparePair,
|
||||
|
|
@ -296,7 +297,9 @@ export interface ReportInput {
|
|||
seed: string;
|
||||
provenance: CollectedProvenance;
|
||||
eligibility: Eligibility;
|
||||
/** How many test files Layer 1 was given (the work file's files; E7 skips the rest). */
|
||||
/** All collected runtime cases, including tests that formed no candidate pair. */
|
||||
inventory?: CoverageInventory;
|
||||
/** How many test files Layer 1 was given (every fingerprint file for a whole-suite run). */
|
||||
layer1Files: number;
|
||||
/** How many of them wrote no record: a failed run, or a coverage slice that did not report. */
|
||||
unrecordedFiles: number;
|
||||
|
|
@ -441,6 +444,7 @@ export interface FingerprintSection {
|
|||
}
|
||||
|
||||
export interface ScenarioMapReport {
|
||||
inventory: CoverageInventory | null;
|
||||
schemaVersion: number;
|
||||
engineVersion: number;
|
||||
run: RunInfo;
|
||||
|
|
@ -460,8 +464,7 @@ export interface ScenarioMapReport {
|
|||
/** How many test files the fingerprint covers. */
|
||||
fingerprintFiles: number;
|
||||
/**
|
||||
* How many of them Layer 1 ran; the others hold only tests whose checks-and-context key no
|
||||
* other test shares (path-coverage/prefilter.ts).
|
||||
* How many of them Layer 1 ran. A diff scope can select fewer; omitted tests were not compared.
|
||||
*/
|
||||
layer1Files: number;
|
||||
/** How many of those wrote no record (a failed run, or a coverage slice that did not report). */
|
||||
|
|
@ -942,6 +945,7 @@ export function buildReport(input: ReportInput): ScenarioMapReport {
|
|||
|
||||
return {
|
||||
schemaVersion: SCENARIO_MAP_SCHEMA_VERSION,
|
||||
inventory: input.inventory ?? null,
|
||||
engineVersion,
|
||||
run: { ...input.run },
|
||||
seed: input.seed,
|
||||
|
|
|
|||
|
|
@ -18,11 +18,9 @@
|
|||
* plan eligibility (R1); the Layer 0 scan (test-dupes over the `--all` corpus plus the
|
||||
* eligible files, findings on eligible files kept); a prepared copy and the run
|
||||
* manifest built right after it (KTD14); Layer 1 over {@link layer1FilesOf}: every
|
||||
* fingerprint file for `--mutation all` and controls, otherwise only the files with a
|
||||
* test whose static checks-and-context key another test shares or that has none
|
||||
* (path-coverage/prefilter.ts; E7), or with `--changed-since <ref>` (a diff-scoped
|
||||
* fingerprint file in whole-suite mode, or with `--changed-since <ref>` (a diff-scoped
|
||||
* run, E8: the test files added or modified since the merge base of `ref` and HEAD)
|
||||
* only those files and the files sharing their tests' keys, the candidate pairs
|
||||
* only those files and the files sharing their tests' context or body keys, the candidate pairs
|
||||
* then kept only when they have a test of a changed file (path-coverage `runCoverage`, or
|
||||
* with `--coverage <dir>` the merge of the coverage stage's slice files in that
|
||||
* directory, read before the work directory is emptied and checked against the
|
||||
|
|
@ -30,7 +28,7 @@
|
|||
* prefilter, and a slice that did not report leaves its files unrecorded); the
|
||||
* static inventory and runtime join (U3); checks and context (U5, with the file
|
||||
* snapshot and helper-module readers); input facets (U6); candidates (U4, Jaccard
|
||||
* and checks-and-context sources); each group's KTD7 mutate scope (U7); the
|
||||
* checks-and-context and body sources); each group's KTD7 mutate scope (U7); the
|
||||
* row-deletion plans of the facet-equal pairs (U9); the mutation target of
|
||||
* `--mutation` ({@link mutationTarget}: every group, none, or the groups with a
|
||||
* possible duplicate on the fingerprint before the trace, trimmed to its tests and the
|
||||
|
|
@ -294,6 +292,13 @@ import {
|
|||
type ManifestCheck,
|
||||
type ScenarioRunManifest,
|
||||
} from './mutation/run-group.js';
|
||||
import { bodyKeyOf } from './facet/body-key.js';
|
||||
import { FixtureInputs, withFixtureIdentity } from './facet/fixture-inputs.js';
|
||||
import { fixtureSimilarityReport, type FixtureProfile } from './facet/fixture-profile.js';
|
||||
import { assertionEvidence, type AssertionEvidence } from './consolidation/assertions.js';
|
||||
import { runConsolidation } from './consolidation/run.js';
|
||||
import { renderConsolidationReport } from './consolidation/report.js';
|
||||
import { coverageInventory } from './inventory/coverage-inventory.js';
|
||||
import { findCandidates, type CandidatePair } from './path-coverage/candidates.js';
|
||||
import {
|
||||
DEFAULT_FIXTURE_ROOT,
|
||||
|
|
@ -317,6 +322,7 @@ import {
|
|||
import { fingerprintMutants } from './report/anchors.js';
|
||||
import { UnreadableBaseline } from './report/baseline.js';
|
||||
import { renderIssueBody } from './report/issue-body.js';
|
||||
import { readReportFields, writeReportJson } from './report/json.js';
|
||||
import { renderMarkdown } from './report/markdown.js';
|
||||
import {
|
||||
buildReport,
|
||||
|
|
@ -895,6 +901,14 @@ export interface Layer2Test {
|
|||
joinProblems: JoinProblem[];
|
||||
inventoryUnresolved: string[];
|
||||
checksContext: string | null;
|
||||
/** Assertion-only key and body retrieval hint; absent in older work files. */
|
||||
checks?: string | null;
|
||||
bodyKey?: string | null;
|
||||
/** Fixture content and structural retrieval evidence; absent in older artifacts. */
|
||||
fixture?: FixtureProfile | null;
|
||||
assertionEvidence?: AssertionEvidence;
|
||||
/** File and exact declaration location, independent of the table row. */
|
||||
declarationKey?: string | null;
|
||||
unsupportedForms: UnsupportedForm[];
|
||||
unresolvedSelectors: string[];
|
||||
facet: InputFacet | null;
|
||||
|
|
@ -1507,6 +1521,9 @@ export function assembleVerdictInput(input: VerdictAssembly): VerdictInput {
|
|||
concurrent: test.concurrent,
|
||||
excludedFiles: excluded.get(test.id) ?? [],
|
||||
checksContext: record.checksContext,
|
||||
checks: record.checks,
|
||||
assertionEvidence: record.assertionEvidence,
|
||||
declarationKey: record.declarationKey,
|
||||
unsupportedForms: record.unsupportedForms,
|
||||
unresolvedSelectors: record.unresolvedSelectors,
|
||||
facet: record.facet,
|
||||
|
|
@ -1673,6 +1690,7 @@ const BEST_TRACE: VerdictPairInput['trace'] = { status: 'clean', reasons: [] };
|
|||
function layer2Maps(tests: readonly Layer2Test[]): {
|
||||
facets: Map<string, InputFacet>;
|
||||
checksContext: Map<string, string | null>;
|
||||
bodyKeys: Map<string, string | null>;
|
||||
} {
|
||||
return {
|
||||
facets: new Map(
|
||||
|
|
@ -1681,6 +1699,7 @@ function layer2Maps(tests: readonly Layer2Test[]): {
|
|||
),
|
||||
),
|
||||
checksContext: new Map(tests.map((test) => [test.id, test.checksContext])),
|
||||
bodyKeys: new Map(tests.map((test) => [test.id, test.bodyKey ?? null])),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1777,13 +1796,39 @@ async function computeLayer2(repoRoot: string, work: CoverageWorkFile): Promise<
|
|||
verdicts,
|
||||
});
|
||||
const provenance = new Map(work.tests.map((test) => [test.id, test.provenance]));
|
||||
const runtimeFixtures = new Map(work.tests.map((test) => [test.id, test.runtimeFixture]));
|
||||
const subjects = new Map<string, FacetSubject>();
|
||||
const tests: Layer2Test[] = [];
|
||||
const fixtureInputs = new FixtureInputs(fsFacetSources(repoRoot));
|
||||
for (const instance of instances) {
|
||||
const checkSubject = subjectOf(instance);
|
||||
const checks = checkSubject === null ? null : computeChecks(checkSubject, checkOptions);
|
||||
const hasChecks =
|
||||
checks !== null && (checks.checks.length > 0 || checks.unsupported.length > 0);
|
||||
const facetSubject = facetSubjectOf(instance, provenance.get(instance.id) ?? null);
|
||||
if (facetSubject !== null) subjects.set(instance.id, facetSubject);
|
||||
const staticFixture = facetSubject === null ? null : await fixtureInputs.profile(facetSubject);
|
||||
const runtimeFixture = runtimeFixtures.get(instance.id) ?? null;
|
||||
const fixture = runtimeFixture ?? staticFixture;
|
||||
let facet =
|
||||
facetSubject === null
|
||||
? null
|
||||
: withFixtureIdentity(await computeFacet(facetSubject, facetContext), staticFixture);
|
||||
if (facet !== null && runtimeFixture !== null) {
|
||||
facet = withFixtureIdentity(facet, runtimeFixture);
|
||||
// Runtime observation retrieves inputs static analysis cannot name. It does not
|
||||
// prove every input API (streams, descriptors, child processes) was observed.
|
||||
if (
|
||||
staticFixture?.contentKey == null ||
|
||||
staticFixture.contentKey !== runtimeFixture.contentKey ||
|
||||
staticFixture.unresolved.length > 0
|
||||
) {
|
||||
facet.unresolved = sortedUnique([
|
||||
...facet.unresolved,
|
||||
'fixture: runtime input coverage is not exhaustive',
|
||||
]);
|
||||
}
|
||||
}
|
||||
tests.push({
|
||||
id: instance.id,
|
||||
file: instance.file,
|
||||
|
|
@ -1793,9 +1838,23 @@ async function computeLayer2(repoRoot: string, work: CoverageWorkFile): Promise<
|
|||
joinProblems: instance.unsupported,
|
||||
inventoryUnresolved: instance.unresolved,
|
||||
checksContext: checksContextKey(instance, checkOptions),
|
||||
checks: hasChecks ? checks.fingerprint : null,
|
||||
declarationKey:
|
||||
instance.site === null
|
||||
? null
|
||||
: JSON.stringify([
|
||||
instance.file,
|
||||
instance.site.location.line,
|
||||
instance.site.location.column,
|
||||
]),
|
||||
bodyKey: checkSubject === null || !hasChecks ? null : bodyKeyOf(checkSubject.record),
|
||||
unsupportedForms: checks?.unsupported ?? [],
|
||||
unresolvedSelectors: checks?.unresolved ?? [],
|
||||
facet: facetSubject === null ? null : await computeFacet(facetSubject, facetContext),
|
||||
fixture,
|
||||
...(checkSubject === null || checks === null
|
||||
? {}
|
||||
: { assertionEvidence: assertionEvidence(checkSubject, checks) }),
|
||||
facet,
|
||||
});
|
||||
}
|
||||
return { tests, facetContext, subjects, verdicts };
|
||||
|
|
@ -1808,28 +1867,25 @@ const facetEqual = (facets: ReadonlyMap<string, InputFacet>, pair: CandidatePair
|
|||
};
|
||||
|
||||
/**
|
||||
* The test files Layer 1 runs (E7): path-coverage/prefilter.ts's for a diff-scoped run (E8, the
|
||||
* changed files and their key partners) and for mutation scope `none` and `possible-duplicates`;
|
||||
* every fingerprint file for scope `all` and in controls mode (every candidate group's evidence).
|
||||
* Whole-suite runs execute every fingerprint file. Static context uniqueness is not proof that
|
||||
* no duplicate scenario exists: cleanup hooks and re-exported imports can change that key.
|
||||
* Diff-scoped runs retain the prefilter's changed-file and candidate-partner selection.
|
||||
*/
|
||||
export function layer1FilesOf(
|
||||
repoRoot: string,
|
||||
files: readonly string[],
|
||||
mutation: MutationScope,
|
||||
mode: RunMode,
|
||||
changed: ReadonlySet<string> | null = null,
|
||||
): string[] {
|
||||
if (changed === null && (mutation === 'all' || mode.kind === 'controls')) return [...files];
|
||||
if (changed === null) return [...files];
|
||||
return layer1Files(repoRoot, files, changed).files;
|
||||
}
|
||||
|
||||
/** Why Layer 1 left `skipped` files out (the plan's log); empty when it left none. */
|
||||
function skippedNote(skipped: number, diffScoped: boolean): string {
|
||||
if (skipped === 0) return '';
|
||||
if (diffScoped) return `; the other ${skipped} can hold no finding with a changed test`;
|
||||
return (
|
||||
`; the other ${skipped} hold only tests whose checks-and-context key no other test shares, ` +
|
||||
'so none of their pairs can be a duplicate'
|
||||
`; ${skipped} files excluded by ${diffScoped ? 'diff' : 'imported coverage'} selection; ` +
|
||||
'excluded tests have not been compared'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1924,8 +1980,8 @@ export interface CoverageStageOptions {
|
|||
|
||||
/**
|
||||
* The coverage stage: Layer 1 over slice `index` of `of` (path-coverage/slices.ts), written as
|
||||
* the slice file the plan stage's `--coverage` merges. True when every file of the slice was
|
||||
* recorded without errors.
|
||||
* the slice file the plan stage's `--coverage` merges. True when every slice file is accounted
|
||||
* for without file or unhandled runner errors. Fully skipped modules need no execution record.
|
||||
*/
|
||||
export async function runCoverageStage(
|
||||
ctx: StageContext,
|
||||
|
|
@ -1941,8 +1997,6 @@ export async function runCoverageStage(
|
|||
const layer1 = layer1FilesOf(
|
||||
repoRoot,
|
||||
fingerprintFiles,
|
||||
options.mutation,
|
||||
options.mode,
|
||||
changed === null ? null : new Set(changed),
|
||||
);
|
||||
const assignment = coverageSlices(
|
||||
|
|
@ -1974,12 +2028,13 @@ export async function runCoverageStage(
|
|||
work,
|
||||
};
|
||||
writeJson(layout.coverageSlice(options.index), slice);
|
||||
const failed = work.files.filter((file) => !file.recorded || file.errors.length > 0);
|
||||
const inventory = coverageInventory(work, files);
|
||||
const failed = sortedUnique([...inventory.files.unrecorded, ...inventory.files.errors]);
|
||||
log(
|
||||
`coverage: ${work.tests.length} tests, ${failed.length} files failed or unrecorded, ` +
|
||||
minutes(wallClockMs),
|
||||
);
|
||||
return failed.length === 0;
|
||||
return failed.length === 0 && work.unhandledErrors.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2083,13 +2138,7 @@ export async function runPlanStage(
|
|||
const layer1 =
|
||||
slices?.[0].layer1 ??
|
||||
(await timed('layer 1 files', () =>
|
||||
layer1FilesOf(
|
||||
repoRoot,
|
||||
eligibility.fingerprintFiles,
|
||||
options.mutation,
|
||||
options.mode,
|
||||
changed,
|
||||
),
|
||||
layer1FilesOf(repoRoot, eligibility.fingerprintFiles, changed),
|
||||
));
|
||||
log(
|
||||
`plan: Layer 1 runs ${layer1.length} of ${eligibility.fingerprintFiles.length} test files` +
|
||||
|
|
@ -2114,14 +2163,37 @@ export async function runPlanStage(
|
|||
writeScenarioRunManifest(layout.root, manifest);
|
||||
|
||||
const layer2 = await timed('layer 2', () => computeLayer2(repoRoot, work));
|
||||
const { facets, checksContext } = layer2Maps(layer2.tests);
|
||||
const consolidation = await timed('fixture consolidation', () =>
|
||||
runConsolidation(work, layer2.tests, path.join(layout.root, 'fixture-clones')),
|
||||
);
|
||||
writeJson(path.join(layout.root, 'consolidation.json'), consolidation);
|
||||
writeFileSync(
|
||||
path.join(layout.root, 'consolidation.html'),
|
||||
renderConsolidationReport(consolidation),
|
||||
);
|
||||
log(
|
||||
`plan: consolidation: ${consolidation.summary.opportunities} groups representing ${consolidation.summary.representedPairs} pairs; ${consolidation.summary.eligibleTests} cases have fixture/path/assertion evidence`,
|
||||
);
|
||||
const { facets, checksContext, bodyKeys } = layer2Maps(layer2.tests);
|
||||
const fixtureTests = new Map(layer2.tests.map((test) => [test.id, test]));
|
||||
const candidates = await timed('candidates', () =>
|
||||
findCandidates(
|
||||
candidateTestsOf(work, checksContext),
|
||||
candidateTestsOf(work, checksContext, bodyKeys).map((test) => ({
|
||||
...test,
|
||||
checks: fixtureTests.get(test.id)?.checks,
|
||||
fixture: fixtureTests.get(test.id)?.fixture,
|
||||
})),
|
||||
(id) => changed === null || changed.has(parseTestId(id).file),
|
||||
),
|
||||
);
|
||||
writeJson(layout.candidates, { schemaVersion: WORK_FILE_SCHEMA_VERSION, ...candidates });
|
||||
const fixtureReport = fixtureSimilarityReport(layer2.tests, candidates.pairs);
|
||||
writeJson(path.join(layout.root, 'fixtures.json'), fixtureReport);
|
||||
log(
|
||||
`plan: fixture similarity: ${fixtureReport.summary.withFixtureInputs} tests with inputs, ` +
|
||||
`${fixtureReport.summary.matches} pairs (${fixtureReport.summary.crossLanguageMatches} cross-language), ` +
|
||||
`${fixtureReport.summary.unresolvedTests} tests with unresolved inputs`,
|
||||
);
|
||||
|
||||
const scopes = await timed('scopes', () => {
|
||||
// The verdicts layer 2 classified (the same test files).
|
||||
|
|
@ -3007,17 +3079,27 @@ export interface ValueFlowStageOptions {
|
|||
}
|
||||
|
||||
/**
|
||||
* Retries make a trace inconclusive even when coverage passed. Full mutation runs still admit
|
||||
* non-passing tests to investigate order dependence; fingerprint runs cannot promote them.
|
||||
* Retries make a trace inconclusive even when coverage passed. Trace runs need every runnable
|
||||
* sibling, so keep the whole file inconclusive if one uses retries. Full mutation runs still
|
||||
* admit non-passing tests to investigate order dependence; fingerprint runs cannot promote them.
|
||||
*/
|
||||
export function passingTracePairs<T extends { a: string; b: string }>(
|
||||
tests: readonly Pick<CoverageTest, 'id' | 'state' | 'retry'>[],
|
||||
pairs: readonly T[],
|
||||
mutation: MutationScope = 'none',
|
||||
): T[] {
|
||||
const retryFiles = new Set(
|
||||
tests
|
||||
.filter((test) => test.state !== 'skipped' && (test.retry ?? 0) > 0)
|
||||
.map((test) => parseTestId(test.id).file),
|
||||
);
|
||||
const eligible = new Set(
|
||||
tests
|
||||
.filter((test) => (mutation === 'all' || test.state === 'passed') && (test.retry ?? 0) === 0)
|
||||
.filter(
|
||||
(test) =>
|
||||
(mutation === 'all' || test.state === 'passed') &&
|
||||
!retryFiles.has(parseTestId(test.id).file),
|
||||
)
|
||||
.map((test) => test.id),
|
||||
);
|
||||
return pairs.filter((pair) => eligible.has(pair.a) && eligible.has(pair.b));
|
||||
|
|
@ -3146,6 +3228,7 @@ export async function runValueFlowStage(
|
|||
workDir: layout.root,
|
||||
manifest,
|
||||
pairs: tracePairs.map(({ a, b }) => ({ a, b })),
|
||||
skippedTestIds: work.tests.filter((test) => test.state === 'skipped').map((test) => test.id),
|
||||
opaqueCallees: sortedUnique(
|
||||
staticRun.pairs.flatMap((pair) => pair.opaqueSinks.map((sink) => sink.callee)),
|
||||
),
|
||||
|
|
@ -3252,7 +3335,7 @@ export async function runMergeStage(
|
|||
let baseline: unknown = null;
|
||||
if (options.baseline !== null) {
|
||||
try {
|
||||
baseline = readJsonFile(options.baseline);
|
||||
baseline = await readReportFields(options.baseline, ['schemaVersion', 'run', 'continuity']);
|
||||
} catch (error) {
|
||||
baseline = new UnreadableBaseline(errorMessage(error));
|
||||
}
|
||||
|
|
@ -3362,6 +3445,24 @@ export async function runMergeStage(
|
|||
});
|
||||
const fingerprint = computeFingerprints(evidence);
|
||||
const verdicts = computeVerdicts(mutationEvidence(plan.mutation, evidence, shardPlan.groups));
|
||||
const inventory = coverageInventory(work, plan.eligibility.fingerprintFiles);
|
||||
const missingCoverage = sortedUnique([
|
||||
...inventory.files.unrecorded,
|
||||
...inventory.files.errors,
|
||||
...(plan.changedFiles === null ? inventory.files.notScheduled : []),
|
||||
]);
|
||||
if (missingCoverage.length > 0 || work.unhandledErrors.length > 0) {
|
||||
incomplete.push({
|
||||
shard: 'coverage',
|
||||
reason: `${missingCoverage.length} files missing or errored; ${work.unhandledErrors.length} unhandled errors`,
|
||||
});
|
||||
}
|
||||
if (inventory.tests.failed > 0 || inventory.tests.pending > 0) {
|
||||
incomplete.push({
|
||||
shard: 'coverage tests',
|
||||
reason: `${inventory.tests.failed} failed; ${inventory.tests.pending} unfinished`,
|
||||
});
|
||||
}
|
||||
|
||||
const read = (file: string): string | null => {
|
||||
try {
|
||||
|
|
@ -3381,13 +3482,14 @@ export async function runMergeStage(
|
|||
sourceRevision: ctx.sourceRevision(),
|
||||
}),
|
||||
eligibility: plan.eligibility,
|
||||
inventory,
|
||||
// The M of "N of M eligible tests evaluated": the mutation-eligible tests.
|
||||
eligibleTests: work.tests
|
||||
.filter((test) => mutationEligible.has(test.file))
|
||||
.map((test) => test.id),
|
||||
shards: { planned: shardPlan.shards.length, complete: completeShards, incomplete },
|
||||
layer1Files: work.files.length,
|
||||
unrecordedFiles: work.files.filter((file) => !file.recorded).length,
|
||||
unrecordedFiles: inventory.files.unrecorded.length,
|
||||
changedFiles: plan.changedFiles,
|
||||
notRun: {
|
||||
skipped: work.tests.filter((test) => test.state === 'skipped').length,
|
||||
|
|
@ -3410,7 +3512,7 @@ export async function runMergeStage(
|
|||
);
|
||||
}
|
||||
// The work directory exists: the plan was read from it.
|
||||
writeJson(layout.reportJson, report, true);
|
||||
await writeReportJson(layout.reportJson, report);
|
||||
writeFileSync(layout.reportMarkdown, renderMarkdown(report));
|
||||
writeFileSync(layout.issueBody, renderIssueBody(report));
|
||||
log(
|
||||
|
|
|
|||
26
gitnexus/tools/scenario-map/value-flow/trace-fixtures.ts
Normal file
26
gitnexus/tools/scenario-map/value-flow/trace-fixtures.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { FixtureCapture } from '../path-coverage/fixture-capture.js';
|
||||
|
||||
/** Attempt-local identities; separate identical repositories retain distinct ordinals. */
|
||||
export class TraceFixtureRoots {
|
||||
readonly roots = new Map<string, string>();
|
||||
private readonly ordinals = new Map<string, number>();
|
||||
|
||||
clear(): void {
|
||||
this.roots.clear();
|
||||
this.ordinals.clear();
|
||||
}
|
||||
|
||||
capture(root: unknown): void {
|
||||
const capture = new FixtureCapture();
|
||||
capture.repository(root);
|
||||
const snapshot = Object.values(capture.snapshots)[0];
|
||||
if (!snapshot) return;
|
||||
this.roots.delete(snapshot.root);
|
||||
if (snapshot.unresolved.length || !snapshot.files.length) return;
|
||||
const ordinal = this.ordinals.get(snapshot.root) ?? this.ordinals.size;
|
||||
this.ordinals.set(snapshot.root, ordinal);
|
||||
const digest = createHash('sha256').update(JSON.stringify(snapshot.files)).digest('hex');
|
||||
this.roots.set(snapshot.root, `fixture:${ordinal}:${digest}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
* loads nothing heavy before the test file.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { renameSync, writeFileSync } from 'node:fs';
|
||||
import { Dirent, renameSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { types } from 'node:util';
|
||||
import { buildPathMap, toRepoPath } from '../../mutation/prepare/path-map.js';
|
||||
|
|
@ -73,6 +73,8 @@ export interface TraceContext {
|
|||
preparedRoot: string;
|
||||
/** Extra path-map roots (controls mode). */
|
||||
extraRoots: string[];
|
||||
/** Candidate Vitest task ids from the baseline collection; siblings still execute. */
|
||||
taskIds: string[];
|
||||
/** The wrap list (builtins.ts `wrapListOf`). */
|
||||
wrap: WrapEntry[];
|
||||
}
|
||||
|
|
@ -205,6 +207,7 @@ export interface Serialized {
|
|||
|
||||
interface WalkState {
|
||||
known: ReadonlyMap<object, string>;
|
||||
fixtureRoots: ReadonlyMap<string, string>;
|
||||
seen: Set<object>;
|
||||
nodes: number;
|
||||
complete: boolean;
|
||||
|
|
@ -279,12 +282,29 @@ function ownEntries(
|
|||
const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor === undefined || descriptor.enumerable !== true) continue;
|
||||
const label = typeof key === 'symbol' ? `[${walk(key, state, depth + 1)}]` : quote(key);
|
||||
const text = 'value' in descriptor ? walk(descriptor.value, state, depth + 1) : '<accessor>';
|
||||
const item =
|
||||
'value' in descriptor &&
|
||||
(key === 'parentPath' || key === 'path') &&
|
||||
Reflect.getPrototypeOf(value) === Dirent.prototype
|
||||
? fixtureDirectory(descriptor.value, state.fixtureRoots)
|
||||
: descriptor.value;
|
||||
const text = 'value' in descriptor ? walk(item, state, depth + 1) : '<accessor>';
|
||||
parts.push(`${label}:${text}`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Only native directory-entry metadata under a fully captured input root is relocatable. */
|
||||
function fixtureDirectory(value: unknown, roots: ReadonlyMap<string, string>): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
const directory = value.replace(/\\/g, '/');
|
||||
let match = '';
|
||||
for (const root of roots.keys())
|
||||
if ((directory === root || directory.startsWith(`${root}/`)) && root.length > match.length)
|
||||
match = root;
|
||||
return match ? `<${roots.get(match)}>${directory.slice(match.length)}` : value;
|
||||
}
|
||||
|
||||
const ARRAY_INDEX = /^(?:0|[1-9]\d*)$/;
|
||||
|
||||
function walkArray(value: unknown[], state: WalkState, depth: number): string {
|
||||
|
|
@ -397,8 +417,9 @@ function walkObject(value: object, state: WalkState, depth: number): string {
|
|||
export function serializeValue(
|
||||
value: unknown,
|
||||
known: ReadonlyMap<object, string> = new Map(),
|
||||
fixtureRoots: ReadonlyMap<string, string> = new Map(),
|
||||
): Serialized {
|
||||
const state: WalkState = { known, seen: new Set(), nodes: 0, complete: true };
|
||||
const state: WalkState = { known, fixtureRoots, seen: new Set(), nodes: 0, complete: true };
|
||||
try {
|
||||
return { text: walk(value, state, 0), complete: state.complete };
|
||||
} catch {
|
||||
|
|
@ -528,6 +549,8 @@ export interface RecorderDeps {
|
|||
currentTest: () => CurrentTest | null;
|
||||
/** Repo path of a production script, else null ({@link productionResolver}). */
|
||||
production: (script: string) => string | null;
|
||||
/** Current attempt's repository roots, identified by exact captured bytes and layout. */
|
||||
fixtureRoots?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
interface TestState {
|
||||
|
|
@ -674,7 +697,7 @@ export class TraceRecorder {
|
|||
}
|
||||
|
||||
#serialize(value: unknown): Serialized {
|
||||
return serializeValue(value, this.#known);
|
||||
return serializeValue(value, this.#known, this.#deps.fixtureRoots);
|
||||
}
|
||||
|
||||
#productionOf(file: string): string | null {
|
||||
|
|
@ -710,7 +733,7 @@ export class TraceRecorder {
|
|||
this.#positions.set(at, position);
|
||||
}
|
||||
const recv = receiver();
|
||||
const list = serializeValue(args, this.#known);
|
||||
const list = this.#serialize(args);
|
||||
this.#add(test, {
|
||||
site: `${chosen.repoPath}:${position}`,
|
||||
api,
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@
|
|||
*
|
||||
* - It wraps every entry of the context's wrap list (builtins.ts) on the global object with
|
||||
* trace-records.ts `TraceRecorder`: pass-through proxies that record calls from production
|
||||
* frames per current test (`TestRunner.getCurrentTest()`), and never change what the wrapped
|
||||
* function does. Nothing is restored: the fork ends with the file.
|
||||
* frames for candidate tests only. Siblings execute without stack capture or serialization.
|
||||
* Nothing is restored: the fork ends with the file.
|
||||
* - The file-level `aroundEach` is the outermost wrapper of every attempt (repeat or retry): each
|
||||
* attempt starts the test's calls over, and a test that ran is listed even with no call.
|
||||
* candidate attempt starts its calls over and is listed even with no call.
|
||||
* - The file-level `aroundAll` writes the raw record when the file's run ends, after every
|
||||
* `afterAll` hook, even a failing one.
|
||||
*
|
||||
|
|
@ -19,6 +19,8 @@
|
|||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { TestRunner, aroundAll, aroundEach, inject, type RunnerTestCase } from 'vitest';
|
||||
import { FIXTURE_CAPTURE_KEY } from '../path-coverage/fixture-capture.js';
|
||||
import { TraceFixtureRoots } from './trace-fixtures.js';
|
||||
import {
|
||||
TRACE_KEY,
|
||||
TraceRecorder,
|
||||
|
|
@ -33,14 +35,22 @@ if (context === undefined || context === null) {
|
|||
throw new Error('scenario-map trace: no context was provided (run it through trace.ts)');
|
||||
}
|
||||
|
||||
const candidateTaskIds = new Set(context.taskIds);
|
||||
const currentTest = (): CurrentTest | null => {
|
||||
const test = TestRunner.getCurrentTest<RunnerTestCase | undefined>();
|
||||
return test === undefined ? null : { id: test.id, concurrent: test.concurrent === true };
|
||||
return test === undefined || !candidateTaskIds.has(test.id)
|
||||
? null
|
||||
: { id: test.id, concurrent: test.concurrent === true };
|
||||
};
|
||||
|
||||
const fixtures = new TraceFixtureRoots();
|
||||
Reflect.set(globalThis, FIXTURE_CAPTURE_KEY, (root: unknown) => {
|
||||
if (currentTest() !== null) fixtures.capture(root);
|
||||
});
|
||||
const recorder = new TraceRecorder({
|
||||
currentTest,
|
||||
production: productionResolver(context.preparedRoot, context.extraRoots),
|
||||
fixtureRoots: fixtures.roots,
|
||||
});
|
||||
recorder.install(globalThis, context.wrap);
|
||||
|
||||
|
|
@ -57,6 +67,7 @@ aroundAll(async (runSuite, {}, suite) => {
|
|||
|
||||
// The `{}` is required: Vitest parses this parameter as a fixture pattern.
|
||||
aroundEach(async (runTest, {}) => {
|
||||
fixtures.clear();
|
||||
const test = currentTest();
|
||||
if (test !== null) recorder.testStarted(test);
|
||||
await runTest();
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
*
|
||||
* Run ({@link runTrace}). One throwaway copy, `<work>/trace/prepared`, prepared like a shard's
|
||||
* copy (tools/mutation/prepare, nothing blanked) and verified against the run manifest (run-group.ts
|
||||
* `verifyPreparedCopy`; a mismatch runs nothing). The tests of every pair that the manifest lists
|
||||
* run twice through vitest-run.ts `runVitest`, each a fresh Vitest instance with the same
|
||||
* `verifyPreparedCopy`; a mismatch runs nothing). Every runnable manifest test in the candidate tests'
|
||||
* files runs twice through vitest-run.ts `runVitest`, each a fresh Vitest instance with the same
|
||||
* selection: a baseline, then the traced run with trace.setup.ts added (`setupFiles`) and its
|
||||
* context provided: the raw-record directory `<work>/trace/raw`, the prepared root, the extra
|
||||
* path-map roots and the wrap list (builtins.ts `wrapListOf`: the documented base list plus U10's
|
||||
|
|
@ -72,6 +72,7 @@ import {
|
|||
sequentialMatcherFor,
|
||||
} from '../path-coverage/partitions.js';
|
||||
import { wrapListOf } from './builtins.js';
|
||||
import { fixtureCapturePlugin } from '../path-coverage/fixture-plugin.js';
|
||||
import {
|
||||
RAW_TRACE_PREFIX,
|
||||
RAW_TRACE_SCHEMA_VERSION,
|
||||
|
|
@ -391,6 +392,8 @@ export interface TraceRunOptions {
|
|||
manifest: ScenarioRunManifest;
|
||||
/** The facet-equal candidate pairs. */
|
||||
pairs: readonly TracePair[];
|
||||
/** Siblings explicitly skipped during coverage; candidate members are still requested. */
|
||||
skippedTestIds?: readonly string[];
|
||||
/** U10's opaque-sink callees (pdg-walk.ts `opaqueSinks[].callee`), added to the wrap list. */
|
||||
opaqueCallees?: readonly string[];
|
||||
/** Upper bound of each Vitest run's worker processes (the CLI's `--concurrency`). */
|
||||
|
|
@ -402,6 +405,8 @@ export interface TraceRunSummary {
|
|||
outcome: OutcomeClass;
|
||||
/** Selection problems and unhandled errors. */
|
||||
problems: string[];
|
||||
/** Includes failing sibling tests that are needed for setup but are not candidate members. */
|
||||
failures?: { id: string; message: string | null; outcome: OutcomeClass | null }[];
|
||||
}
|
||||
|
||||
export interface TraceRunResult {
|
||||
|
|
@ -478,6 +483,9 @@ const summaryOf = (run: VitestRunResult): TraceRunSummary => ({
|
|||
wallClockMs: run.wallClockMs,
|
||||
outcome: run.outcome,
|
||||
problems: [...run.problems, ...run.unhandledErrors.map((error) => `unhandled error: ${error}`)],
|
||||
failures: run.tests
|
||||
.filter((test) => test.state === 'failed')
|
||||
.map(({ id, message, outcome }) => ({ id, message, outcome })),
|
||||
});
|
||||
|
||||
/** Runs the pairs' tests with and without the trace hook and compares them (module header). */
|
||||
|
|
@ -512,6 +520,14 @@ export async function runTrace(
|
|||
listed.has(id),
|
||||
);
|
||||
if (ids.length === 0) return notRun('no pair test is in the run manifest');
|
||||
// Preserve each file's normal suite lifecycle. A selected test can depend on work performed
|
||||
// by an earlier sibling (e.g. analyze before repair); filtering siblings changes its input.
|
||||
const files = new Set(ids.map((id) => parseTestId(id).file));
|
||||
const skipped = new Set(options.skippedTestIds);
|
||||
const candidates = new Set(ids);
|
||||
const runIds = manifest.tests
|
||||
.map((test) => test.id)
|
||||
.filter((id) => files.has(parseTestId(id).file) && (!skipped.has(id) || candidates.has(id)));
|
||||
|
||||
const layout = traceLayout(workDir);
|
||||
const extraRoots = extraRootsOf(manifest.mode);
|
||||
|
|
@ -527,12 +543,6 @@ export async function runTrace(
|
|||
const packageDir = path.posix.join(preparedRoot, PACKAGE_DIR);
|
||||
copyBuildOutput(manifest.mode.kind, path.join(repoRoot, PACKAGE_DIR), packageDir);
|
||||
const vitest = vitestSelectionOf(manifest.mode);
|
||||
const context: TraceContext = {
|
||||
outDir: layout.rawDir,
|
||||
preparedRoot,
|
||||
extraRoots,
|
||||
wrap: wrap.entries,
|
||||
};
|
||||
let baseline: VitestRunResult;
|
||||
let traced: VitestRunResult;
|
||||
try {
|
||||
|
|
@ -542,23 +552,33 @@ export async function runTrace(
|
|||
deps.runVitest,
|
||||
{
|
||||
packageDir,
|
||||
testIds: ids,
|
||||
testIds: runIds,
|
||||
vitest,
|
||||
workDir: layout.baselineDir,
|
||||
maxWorkers: options.maxWorkers,
|
||||
},
|
||||
isSequential,
|
||||
);
|
||||
const context: TraceContext = {
|
||||
outDir: layout.rawDir,
|
||||
preparedRoot,
|
||||
extraRoots,
|
||||
taskIds: baseline.tests.flatMap((test) =>
|
||||
candidates.has(test.id) && test.taskId !== null ? [test.taskId] : [],
|
||||
),
|
||||
wrap: wrap.entries,
|
||||
};
|
||||
rmSync(layout.rawDir, { recursive: true, force: true });
|
||||
mkdirSync(layout.rawDir, { recursive: true });
|
||||
traced = await runVitestPartitioned(
|
||||
deps.runVitest,
|
||||
{
|
||||
packageDir,
|
||||
testIds: ids,
|
||||
testIds: runIds,
|
||||
vitest,
|
||||
workDir: layout.tracedDir,
|
||||
setupFiles: [TRACE_SETUP_FILE],
|
||||
plugins: [fixtureCapturePlugin(path.join(packageDir, 'src/core/ingestion/pipeline.ts'))],
|
||||
provide: (project) => project.provide(TRACE_KEY, context),
|
||||
maxWorkers: options.maxWorkers,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,18 +12,21 @@
|
|||
* discarded values (every literal, row value and resolved constant), compared with
|
||||
* the residual-difference guard (D1, ./verdict.ts `facetRelation`), and the trace
|
||||
* run's comparison of the values reaching opaque built-ins at runtime (U11);
|
||||
* expectation its checks-and-context key (U5: matchers, selectors, mocks and environment); the
|
||||
* expectation its assertion key (U5: matchers and selectors), gated separately on setup context; the
|
||||
* expected values are part of the discarded values.
|
||||
*
|
||||
* Per pair, the first rule that holds ({@link FINGERPRINT_RULES}):
|
||||
* out-of-scope a test outside the fingerprint's scope;
|
||||
* inconclusive a test did not pass its Layer 1 run (`not-passing`), or ran concurrently
|
||||
* (`path-unattributable`);
|
||||
* distinct the block sets differ (`path-differs`);
|
||||
* possible-duplicate equal assertions but missing/different context, or differing blocks with
|
||||
* incomplete native/worker provenance, unless input values are known to differ;
|
||||
* distinct the remaining block sets differ (`path-differs`);
|
||||
* different-checks same path and the same, resolved input with a clean trace, other checks;
|
||||
* distinct other checks otherwise (`checks-differ`), or another call shape;
|
||||
* table-row same shape, other values (`input-values-differ`), or equal static values
|
||||
* that differ at runtime (`runtime-values-differ`, a trace veto);
|
||||
* that differ at runtime (`runtime-values-differ`, arguments or receiver);
|
||||
* asymmetric sites/counts alone remain trace uncertainty;
|
||||
* possible-duplicate nothing known differs, but a checks key or facet is missing, an input is
|
||||
* unresolved (a facet, inventory or selector input, a residual difference,
|
||||
* an unsupported assertion or join, a path V8 does not fully show), or the
|
||||
|
|
@ -39,7 +42,7 @@ import { byText, sortedUnique } from '../util.js';
|
|||
import { facetRelation, type VerdictPairInput, type VerdictTestInput } from './verdict.js';
|
||||
|
||||
/** Folded into the report: a rule change resurfaces every pair it reclassifies. */
|
||||
export const FINGERPRINT_ENGINE_VERSION = 1;
|
||||
export const FINGERPRINT_ENGINE_VERSION = 5;
|
||||
|
||||
export type FingerprintVerdict =
|
||||
| 'duplicate'
|
||||
|
|
@ -66,6 +69,9 @@ export type FingerprintRule =
|
|||
| 'not-passing'
|
||||
| 'path-unattributable'
|
||||
| 'path-differs'
|
||||
| 'path-incomplete'
|
||||
| 'context-differs'
|
||||
| 'context-unknown'
|
||||
| 'same-input-different-checks'
|
||||
| 'checks-differ'
|
||||
| 'input-shape-differs'
|
||||
|
|
@ -120,6 +126,9 @@ export type FingerprintTestInput = Pick<
|
|||
| 'provenance'
|
||||
| 'concurrent'
|
||||
| 'checksContext'
|
||||
| 'checks'
|
||||
| 'assertionEvidence'
|
||||
| 'declarationKey'
|
||||
| 'unsupportedForms'
|
||||
| 'unresolvedSelectors'
|
||||
| 'facet'
|
||||
|
|
@ -139,6 +148,7 @@ function unresolvedOf(test: FingerprintTestInput): string[] {
|
|||
...test.unresolvedSelectors.map((name) => `selector ${name}`),
|
||||
...test.joinProblems.map((problem) => `join ${problem}`),
|
||||
...test.unsupportedForms.map((form) => `assertion ${form.form} (${form.reason})`),
|
||||
...(test.assertionEvidence?.unresolved ?? []).map((reason) => `assertion ${reason}`),
|
||||
...(provenance === null
|
||||
? ['path: no provenance record']
|
||||
: [
|
||||
|
|
@ -156,6 +166,7 @@ interface Facts {
|
|||
evidence: FingerprintEvidence;
|
||||
/** Same input as far as the extraction sees: equal facet and values, nothing unresolved. */
|
||||
sameInput: boolean;
|
||||
runtimeValuesDiffer: boolean;
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
|
|
@ -200,6 +211,45 @@ export const FINGERPRINT_RULES: readonly Rule[] = [
|
|||
...(f.pair.pathUnattributable ? ['path unattributable'] : []),
|
||||
]),
|
||||
},
|
||||
{
|
||||
verdict: 'possible-duplicate',
|
||||
decide: (f) =>
|
||||
when(
|
||||
f.evidence.checks === 'same' &&
|
||||
f.evidence.values !== 'differ' &&
|
||||
!f.runtimeValuesDiffer &&
|
||||
(f.a.checksContext === null || f.b.checksContext === null),
|
||||
'context-unknown',
|
||||
['Assertion keys are known, but setup/import context is missing'],
|
||||
),
|
||||
},
|
||||
{
|
||||
verdict: 'possible-duplicate',
|
||||
decide: (f) =>
|
||||
when(
|
||||
f.evidence.checks === 'same' &&
|
||||
f.a.checksContext !== f.b.checksContext &&
|
||||
f.evidence.values !== 'differ' &&
|
||||
!f.runtimeValuesDiffer,
|
||||
'context-differs',
|
||||
['Assertions match, but setup/import contexts differ; equivalence is unproven'],
|
||||
),
|
||||
},
|
||||
{
|
||||
verdict: 'possible-duplicate',
|
||||
decide: (f) =>
|
||||
when(
|
||||
f.evidence.path === 'differs' &&
|
||||
f.evidence.values !== 'differ' &&
|
||||
!f.runtimeValuesDiffer &&
|
||||
f.evidence.checks !== 'differs' &&
|
||||
f.evidence.unresolved.some((item) => item.includes(': path: ')),
|
||||
'path-incomplete',
|
||||
[
|
||||
'V8 block sets differ, but native/worker or unknown provenance prevents a complete comparison',
|
||||
],
|
||||
),
|
||||
},
|
||||
{
|
||||
verdict: 'distinct',
|
||||
decide: (f) => when(f.evidence.path === 'differs', 'path-differs', ['V8 block sets differ']),
|
||||
|
|
@ -228,7 +278,7 @@ export const FINGERPRINT_RULES: readonly Rule[] = [
|
|||
verdict: 'table-row',
|
||||
decide: (f) =>
|
||||
when(
|
||||
f.evidence.trace === 'veto' && f.evidence.values === 'equal',
|
||||
f.runtimeValuesDiffer && f.evidence.values === 'equal',
|
||||
'runtime-values-differ',
|
||||
f.pair.trace?.reasons ?? [],
|
||||
),
|
||||
|
|
@ -280,12 +330,44 @@ function factsOf(
|
|||
b: FingerprintTestInput,
|
||||
): Facts {
|
||||
const relation = facetRelation(a.facet, b.facet);
|
||||
const keys = [a.checksContext, b.checksContext];
|
||||
const unresolved = sortedUnique([...unresolvedOf(a), ...unresolvedOf(b)]);
|
||||
// Older artifacts have only the combined key. Never compare a new assertion key with one.
|
||||
const legacy = a.checks === undefined && b.checks === undefined;
|
||||
const keys = legacy ? [a.checksContext, b.checksContext] : [a.checks ?? null, b.checks ?? null];
|
||||
const atomsA = a.assertionEvidence?.atoms,
|
||||
atomsB = b.assertionEvidence?.atoms;
|
||||
const detailed = atomsA?.length && atomsB?.length;
|
||||
const localHelpers = (atoms: typeof atomsA) =>
|
||||
(atoms ?? []).flatMap((atom) => atom.observed?.match(/<local:[^>]+>/g) ?? []);
|
||||
const helperBindingsDiffer =
|
||||
JSON.stringify(localHelpers(atomsA)) !== JSON.stringify(localHelpers(atomsB));
|
||||
const subjectsDiffer =
|
||||
detailed &&
|
||||
!helperBindingsDiffer &&
|
||||
[...atomsA, ...atomsB].every((atom) => atom.key !== null) &&
|
||||
JSON.stringify(atomsA.map((atom) => atom.subjectShapeKey ?? atom.shapeKey)) !==
|
||||
JSON.stringify(atomsB.map((atom) => atom.subjectShapeKey ?? atom.shapeKey));
|
||||
const expectationsDiffer =
|
||||
detailed &&
|
||||
[...atomsA, ...atomsB].every((atom) => atom.expected !== null) &&
|
||||
JSON.stringify(atomsA.map((atom) => atom.expected)) !==
|
||||
JSON.stringify(atomsB.map((atom) => atom.expected));
|
||||
const checksDiffer =
|
||||
subjectsDiffer ||
|
||||
(expectationsDiffer && !(relation.facet === 'equal' && relation.discarded === 'differ'));
|
||||
const unresolved = sortedUnique([
|
||||
...unresolvedOf(a),
|
||||
...unresolvedOf(b),
|
||||
...(helperBindingsDiffer ? ['assertion binding equivalence is unresolved'] : []),
|
||||
]);
|
||||
const evidence: FingerprintEvidence = {
|
||||
path: pair.identicalBlocks ? 'same' : 'differs',
|
||||
checks:
|
||||
keys[0] === null || keys[1] === null ? 'missing' : keys[0] === keys[1] ? 'same' : 'differs',
|
||||
checks: checksDiffer
|
||||
? 'differs'
|
||||
: keys[0] === null || keys[1] === null
|
||||
? 'missing'
|
||||
: keys[0] === keys[1]
|
||||
? 'same'
|
||||
: 'differs',
|
||||
facet: relation.facet,
|
||||
values: relation.discarded,
|
||||
trace: pair.trace?.status ?? 'not-run',
|
||||
|
|
@ -296,6 +378,11 @@ function factsOf(
|
|||
a,
|
||||
b,
|
||||
evidence,
|
||||
runtimeValuesDiffer:
|
||||
pair.trace?.status === 'veto' &&
|
||||
pair.trace.reasons.some(
|
||||
(reason) => reason === 'args-differ' || reason === 'receiver-differs',
|
||||
),
|
||||
sameInput:
|
||||
relation.facet === 'equal' && relation.discarded === 'equal' && unresolved.length === 0,
|
||||
};
|
||||
|
|
@ -336,9 +423,21 @@ export function computeFingerprints(input: {
|
|||
rule: decided.rule,
|
||||
details: decided.details,
|
||||
evidence: facts.evidence,
|
||||
keep: [a, b].flatMap((side) =>
|
||||
side.dupeOk === null ? [] : [{ test: side.id, reason: side.dupeOk }],
|
||||
),
|
||||
keep: [
|
||||
...[a, b].flatMap((side) =>
|
||||
side.dupeOk === null ? [] : [{ test: side.id, reason: side.dupeOk }],
|
||||
),
|
||||
...(decided.verdict !== 'duplicate' &&
|
||||
a.declarationKey &&
|
||||
a.declarationKey === b.declarationKey
|
||||
? [
|
||||
{
|
||||
test: a.id,
|
||||
reason: 'already parameterized: both instances share one test declaration',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
});
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@
|
|||
*/
|
||||
import type { OutcomeClass } from '../../mutation/runner/classify-outcome.js';
|
||||
import type { UnsupportedForm } from '../facet/checks.js';
|
||||
import type { AssertionEvidence } from '../consolidation/assertions.js';
|
||||
import { residualDifference, sameDiscardedValues, type InputFacet } from '../facet/input-facet.js';
|
||||
import type { IsolationResult, IsolationStatus } from '../mutation/isolation.js';
|
||||
import {
|
||||
|
|
@ -220,6 +221,11 @@ export interface VerdictTestInput {
|
|||
excludedFiles: readonly string[];
|
||||
/** U5 checks-and-context key; null when the test has none. */
|
||||
checksContext: string | null;
|
||||
/** Assertion fingerprint without setup context; absent in older stage artifacts. */
|
||||
checks?: string | null;
|
||||
assertionEvidence?: AssertionEvidence;
|
||||
/** Source declaration identity; multiple runtime instances already share this test body. */
|
||||
declarationKey?: string | null;
|
||||
/** U5 unsupported assertion forms (`InstanceChecks.unsupported`). */
|
||||
unsupportedForms: readonly UnsupportedForm[];
|
||||
/** U5 selectors whose instance value is not static (`InstanceChecks.unresolved`). */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue