diff --git a/docs/adr/0016-port-occurrence-attachment-before-semantic-categories.md b/docs/adr/0016-port-occurrence-attachment-before-semantic-categories.md new file mode 100644 index 0000000000..a9a492d3f1 --- /dev/null +++ b/docs/adr/0016-port-occurrence-attachment-before-semantic-categories.md @@ -0,0 +1,105 @@ +# ADR-0016: Port occurrence attachment before semantic categories + +- Status: accepted +- Date: 2026-08-17 +- Deciders: TypeScript Semantic Kernel maintainers +- Supersedes: — +- Superseded by: — + +## Context + +RFC 0001 requires measured TS7-to-OXC/Rust evidence before selecting a Rust +migration category. ADR-0014 isolated live OXC traversal and ADR-0015 attached +the producer's response-local graph without copying identity. Neither result +compared the complete representative Go corpus through that internal boundary +or established a replacement gate. + +Syntax correlation alone cannot demonstrate TypeScript semantic equivalence. +The OXC harness observes source identities and consumes Go-produced TypeFacts; +it does not independently reproduce checker type construction, inference, +overload resolution, contextual typing, or control-flow narrowing. + +## Measured result + +The reproducible command and complete machine-readable record are in +[`docs/evidence/ts7-oxc-spike-2026-08-17.json`](../evidence/ts7-oxc-spike-2026-08-17.json). +On macOS arm64 with Go 1.26.6 and Rust 1.95.0, six corpus cases exported 25 +Go-oracle facts, 651 types, 556 declarations, 866 symbols, 482 signatures, and +6,368 graph edges. Repeated observations were identical for every case. + +OXC parsed five of six selected source files. All 22 facts in those files +mapped and attached: 15 exact and seven normalized, with zero unmapped, +multiply-mapped, or actual-root transport mismatches. The mapped sample covered +21 `KindIdentifier` facts and one `KindStringLiteral` fact. The intentional +syntax-recovery file did not parse in OXC, leaving three Go facts explicitly +consumer-failed rather than silently discarded. + +The graph contained 3,838 repeated edge targets out of 6,368 edges, a 602,701 +parts-per-million sharing ratio. Producer output included 1,361 truncated and +seven error-state entities, no unsupported-state entities, and five cases with +producer budget truncation. Bounded inspection visited 2,883 nodes and 5,950 +edges, reported 15 depth cutoffs, and truncated one fact at the independent +consumer budget. The two-pass snapshots occupied 1,315,514 bytes. The debug +consumer executable was 13,425,232 bytes and peak consumer RSS was 11,829,248 +bytes. + +The aggregate first one-shot pass was 634,312,462 ns and the immediately +repeated pass was 247,002,000 ns. These include a new `tsfacts` process for each +case and consumer decode/parse/inspection. They are local spike evidence, not a +general benchmark and not evidence for warm in-process project reuse. + +## Decision + +The first Go-owned category safe to mechanically port is schema-v1 occurrence +identity and attachment plumbing: request selection offsets, portable +occurrence tuples, OXC correlation, response-global fact indexing, and +TypeFacts side-table attachment. It may be ported only behind differential +comparison to the Go producer. + +No Go semantic category is approved for replacement by this spike. Primitive +and literal type-record construction is the first proposed independent Rust +semantic candidate because its protocol surface is smallest, but it remains +Go-authoritative until an independent Rust producer exists and passes the +replacement gates below. Decoding a Go-produced primitive or correlating its +syntax is not semantic equivalence. + +The producer protocol is unchanged. The evidence report is an internal schema, +and project-wide attachment preserves response-global fact indices entirely +inside the Rust consumer. + +## Compatibility gates + +Mechanical occurrence/attachment porting requires, on the complete corpus: + +1. 1,000,000 parts-per-million mapping coverage for OXC-parseable sources; +2. zero multiply-mapped facts and zero actual-root transport mismatches; +3. byte-stable non-timing observations across the first and repeated pass; +4. explicit diagnostics for producer/exporter, protocol, mapping, and consumer + failures; and +5. Go fallback for sources OXC cannot parse without losing recovery facts. + +Replacing the first semantic category additionally requires an independent +Rust producer compared against the normalized Go oracle, with 100% exact +structured agreement for complete in-category facts, zero completeness or +state downgrades, zero new unsupported forms, deterministic response-local +identity and ordering, and differential CI across the corpus. New or changed +checker behavior remains Go-authoritative until it meets the same gate. + +## Consequences + +- The Rust seam can expand around transport and attachment without creating a + second semantic authority. +- Intentional invalid syntax remains an explicit compatibility gap; OXC parser + recovery cannot be inferred from valid-file coverage. +- Unmapped, ambiguous, unsupported, error, producer-truncated, and + consumer-budget-truncated outcomes remain separate machine-readable states. +- Project loading, resolution, binding, symbol identity, type construction, + inference, contextual typing, widening, overload/generic instantiation, + control-flow narrowing, and recovery remain Go-authoritative. + +## References + +- [RFC 0001](../../rfcs/0001-semantic-facts-kernel.md) +- [ADR-0014](0014-isolate-the-oxc-reference-consumer.md) +- [ADR-0015](0015-attach-semantic-facts-without-expanding-graph-identity.md) +- [Issue #20](https://github.com/swernerx/typescript-semantic-kernel/issues/20) diff --git a/docs/adr/README.md b/docs/adr/README.md index 94418d4af5..2ce8f9db84 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -23,3 +23,4 @@ decision is recorded in a successor ADR that links to the record it replaces. | [0013](0013-correlate-occurrences-through-consumer-node-anchors.md) | Accepted | Correlate occurrences through consumer node anchors | | [0014](0014-isolate-the-oxc-reference-consumer.md) | Accepted | Isolate the OXC reference consumer | | [0015](0015-attach-semantic-facts-without-expanding-graph-identity.md) | Accepted | Attach semantic facts without expanding graph identity | +| [0016](0016-port-occurrence-attachment-before-semantic-categories.md) | Accepted | Port occurrence attachment before semantic categories | diff --git a/docs/evidence/README.md b/docs/evidence/README.md new file mode 100644 index 0000000000..746f15cdee --- /dev/null +++ b/docs/evidence/README.md @@ -0,0 +1,24 @@ +# Spike evidence + +Evidence files in this directory are machine-readable records from bounded, +repository-local migration spikes. They are not general performance benchmarks +and do not transfer semantic authority away from the TypeScript 7 Go checker. + +Regenerate the TS7-to-OXC/Rust record from the repository root with: + +```sh +./internal/oxc_reference/run-evidence.sh \ + --output docs/evidence/ts7-oxc-spike-2026-08-17.json +``` + +The command builds the real `cmd/tsfacts` producer, runs every semantic corpus +case twice through the JSON Lines process boundary, decodes and attaches each +response in the internal OXC/Rust consumer, and performs bounded graph +inspection. Stable observations are compared byte-for-byte in memory; timing +and resident-memory values are recorded separately because they vary by host. + +Diagnostics name their failure layer: `protocol`, `exporter`, `mapping`, or +`consumer`. Mapping outcomes distinguish unmapped and multiply-mapped facts. +Inspector counters retain unavailable roots, unsupported/error/truncated +entities, and independent depth/node/edge budget cutoffs even when a measured +count is zero. diff --git a/docs/evidence/ts7-oxc-spike-2026-08-17.json b/docs/evidence/ts7-oxc-spike-2026-08-17.json new file mode 100644 index 0000000000..8f20deab74 --- /dev/null +++ b/docs/evidence/ts7-oxc-spike-2026-08-17.json @@ -0,0 +1,761 @@ +{ + "schemaVersion": 1, + "evidenceKind": "ts7-go-to-internal-oxc-rust-spike", + "comparisonScope": "wire-identity-attachment-and-bounded-inspection-only", + "environment": { + "operatingSystem": "macos", + "architecture": "aarch64", + "rustc": "rustc 1.95.0 (59807616e 2026-04-14)", + "go": "go version go1.26.6 darwin/arm64", + "command": "./internal/oxc_reference/run-evidence.sh --output " + }, + "inspectorLimits": { + "maxDepth": 32, + "maxNodes": 4096, + "maxEdges": 16384 + }, + "cases": [ + { + "name": "advanced-types", + "sourceFiles": [ + "src/advanced.ts" + ], + "firstPass": { + "producerNanoseconds": 410904667, + "consumerNanoseconds": 14694333, + "totalNanoseconds": 425599000, + "snapshotBytes": 97176 + }, + "repeatedPass": { + "producerNanoseconds": 31503500, + "consumerNanoseconds": 9212583, + "totalNanoseconds": 40716083, + "snapshotBytes": 97176 + }, + "repeatedObservationsEqual": true, + "observation": { + "producer": { + "typescriptVersion": "7.1.0-dev", + "typescriptRevision": "1bcfa18d79a3be41772223d5c05dfe4480e614ff", + "diagnosticCount": 6, + "budgets": { + "limits": { + "maxTypeNodes": 128, + "maxTypeDepth": 8 + }, + "typeNodesUsed": 128, + "maxTypeDepthObserved": 8, + "truncated": true + }, + "files": 12, + "facts": 6, + "records": { + "types": 129, + "declarations": 162, + "symbols": 131, + "signatures": 35, + "edges": 753 + }, + "entityStates": { + "complete": 223, + "truncated": 71, + "unsupported": 0, + "error": 1 + }, + "sharedEdgeReferences": 304, + "edgeReferences": 753, + "sharingRatioPpm": 403718 + }, + "consumer": { + "sourceFiles": 1, + "parsedFiles": 1, + "failedFiles": 0, + "mapping": { + "facts": 6, + "mapped": 6, + "exact": 0, + "normalized": 6, + "unmapped": 0, + "multiplyMapped": 0 + }, + "bySyntaxKind": [ + { + "syntaxKind": "KindIdentifier", + "metrics": { + "facts": 6, + "mapped": 6, + "exact": 0, + "normalized": 6, + "unmapped": 0, + "multiplyMapped": 0 + } + } + ], + "attachedFacts": 6, + "inspectedNodes": 453, + "inspectedEdges": 741, + "inspectorBudgetTruncatedFacts": 0, + "inspectionDiagnostics": { + "entity-error": 3, + "entity-truncated": 69, + "root-inapplicable": 6 + } + }, + "comparison": { + "goOracleFacts": 6, + "identityMappedFacts": 6, + "actualRootsTransportPreserved": 6, + "semanticEquivalenceClaims": 0, + "differences": [] + }, + "diagnostics": [] + } + }, + { + "name": "callables-generics", + "sourceFiles": [ + "src/callables.ts" + ], + "firstPass": { + "producerNanoseconds": 33103167, + "consumerNanoseconds": 27337667, + "totalNanoseconds": 60440834, + "snapshotBytes": 319704 + }, + "repeatedPass": { + "producerNanoseconds": 32238208, + "consumerNanoseconds": 27054041, + "totalNanoseconds": 59292249, + "snapshotBytes": 319704 + }, + "repeatedObservationsEqual": true, + "observation": { + "producer": { + "typescriptVersion": "7.1.0-dev", + "typescriptRevision": "1bcfa18d79a3be41772223d5c05dfe4480e614ff", + "diagnosticCount": 0, + "budgets": { + "limits": { + "maxTypeNodes": 4096, + "maxTypeDepth": 32 + }, + "typeNodesUsed": 299, + "maxTypeDepthObserved": 33, + "truncated": true + }, + "files": 1, + "facts": 2, + "records": { + "types": 300, + "declarations": 21, + "symbols": 307, + "signatures": 389, + "edges": 3716 + }, + "entityStates": { + "complete": 27, + "truncated": 968, + "unsupported": 0, + "error": 1 + }, + "sharedEdgeReferences": 2701, + "edgeReferences": 3716, + "sharingRatioPpm": 726856 + }, + "consumer": { + "sourceFiles": 1, + "parsedFiles": 1, + "failedFiles": 0, + "mapping": { + "facts": 2, + "mapped": 2, + "exact": 2, + "normalized": 0, + "unmapped": 0, + "multiplyMapped": 0 + }, + "bySyntaxKind": [ + { + "syntaxKind": "KindIdentifier", + "metrics": { + "facts": 2, + "mapped": 2, + "exact": 2, + "normalized": 0, + "unmapped": 0, + "multiplyMapped": 0 + } + } + ], + "attachedFacts": 2, + "inspectedNodes": 522, + "inspectedEdges": 1798, + "inspectorBudgetTruncatedFacts": 1, + "inspectionDiagnostics": { + "entity-error": 1, + "entity-truncated": 475, + "max-depth": 15, + "root-unavailable": 2 + } + }, + "comparison": { + "goOracleFacts": 2, + "identityMappedFacts": 2, + "actualRootsTransportPreserved": 2, + "semanticEquivalenceClaims": 0, + "differences": [] + }, + "diagnostics": [] + } + }, + { + "name": "core-graph", + "sourceFiles": [ + "src/core.ts" + ], + "firstPass": { + "producerNanoseconds": 29939167, + "consumerNanoseconds": 3022625, + "totalNanoseconds": 32961792, + "snapshotBytes": 24108 + }, + "repeatedPass": { + "producerNanoseconds": 30175500, + "consumerNanoseconds": 2926208, + "totalNanoseconds": 33101708, + "snapshotBytes": 24108 + }, + "repeatedObservationsEqual": true, + "observation": { + "producer": { + "typescriptVersion": "7.1.0-dev", + "typescriptRevision": "1bcfa18d79a3be41772223d5c05dfe4480e614ff", + "diagnosticCount": 0, + "budgets": { + "limits": { + "maxTypeNodes": 4096, + "maxTypeDepth": 32 + }, + "typeNodesUsed": 50, + "maxTypeDepthObserved": 5, + "truncated": false + }, + "files": 1, + "facts": 3, + "records": { + "types": 50, + "declarations": 21, + "symbols": 31, + "signatures": 4, + "edges": 171 + }, + "entityStates": { + "complete": 52, + "truncated": 32, + "unsupported": 0, + "error": 1 + }, + "sharedEdgeReferences": 68, + "edgeReferences": 171, + "sharingRatioPpm": 397660 + }, + "consumer": { + "sourceFiles": 1, + "parsedFiles": 1, + "failedFiles": 0, + "mapping": { + "facts": 3, + "mapped": 3, + "exact": 2, + "normalized": 1, + "unmapped": 0, + "multiplyMapped": 0 + }, + "bySyntaxKind": [ + { + "syntaxKind": "KindIdentifier", + "metrics": { + "facts": 3, + "mapped": 3, + "exact": 2, + "normalized": 1, + "unmapped": 0, + "multiplyMapped": 0 + } + } + ], + "attachedFacts": 3, + "inspectedNodes": 105, + "inspectedEdges": 165, + "inspectorBudgetTruncatedFacts": 0, + "inspectionDiagnostics": { + "entity-error": 2, + "entity-truncated": 30, + "root-inapplicable": 1, + "root-unavailable": 2 + } + }, + "comparison": { + "goOracleFacts": 3, + "identityMappedFacts": 3, + "actualRootsTransportPreserved": 3, + "semanticEquivalenceClaims": 0, + "differences": [] + }, + "diagnostics": [] + } + }, + { + "name": "occurrence-contexts", + "sourceFiles": [ + "src/app.tsx" + ], + "firstPass": { + "producerNanoseconds": 33288459, + "consumerNanoseconds": 20287584, + "totalNanoseconds": 53576043, + "snapshotBytes": 187076 + }, + "repeatedPass": { + "producerNanoseconds": 33201959, + "consumerNanoseconds": 20090917, + "totalNanoseconds": 53292876, + "snapshotBytes": 187076 + }, + "repeatedObservationsEqual": true, + "observation": { + "producer": { + "typescriptVersion": "7.1.0-dev", + "typescriptRevision": "1bcfa18d79a3be41772223d5c05dfe4480e614ff", + "diagnosticCount": 1, + "budgets": { + "limits": { + "maxTypeNodes": 128, + "maxTypeDepth": 8 + }, + "typeNodesUsed": 128, + "maxTypeDepthObserved": 9, + "truncated": true + }, + "files": 23, + "facts": 7, + "records": { + "types": 130, + "declarations": 331, + "symbols": 354, + "signatures": 52, + "edges": 1556 + }, + "entityStates": { + "complete": 304, + "truncated": 231, + "unsupported": 0, + "error": 1 + }, + "sharedEdgeReferences": 695, + "edgeReferences": 1556, + "sharingRatioPpm": 446658 + }, + "consumer": { + "sourceFiles": 1, + "parsedFiles": 1, + "failedFiles": 0, + "mapping": { + "facts": 7, + "mapped": 7, + "exact": 7, + "normalized": 0, + "unmapped": 0, + "multiplyMapped": 0 + }, + "bySyntaxKind": [ + { + "syntaxKind": "KindIdentifier", + "metrics": { + "facts": 6, + "mapped": 6, + "exact": 6, + "normalized": 0, + "unmapped": 0, + "multiplyMapped": 0 + } + }, + { + "syntaxKind": "KindStringLiteral", + "metrics": { + "facts": 1, + "mapped": 1, + "exact": 1, + "normalized": 0, + "unmapped": 0, + "multiplyMapped": 0 + } + } + ], + "attachedFacts": 7, + "inspectedNodes": 1689, + "inspectedEdges": 3052, + "inspectorBudgetTruncatedFacts": 0, + "inspectionDiagnostics": { + "entity-error": 5, + "entity-truncated": 448, + "root-inapplicable": 1, + "root-unavailable": 5 + } + }, + "comparison": { + "goOracleFacts": 7, + "identityMappedFacts": 7, + "actualRootsTransportPreserved": 7, + "semanticEquivalenceClaims": 0, + "differences": [] + }, + "diagnostics": [] + } + }, + { + "name": "recovery-budgets", + "sourceFiles": [ + "src/recovery.ts" + ], + "firstPass": { + "producerNanoseconds": 29371250, + "consumerNanoseconds": 1029834, + "totalNanoseconds": 30401084, + "snapshotBytes": 10175 + }, + "repeatedPass": { + "producerNanoseconds": 28880625, + "consumerNanoseconds": 1057042, + "totalNanoseconds": 29937667, + "snapshotBytes": 10175 + }, + "repeatedObservationsEqual": true, + "observation": { + "producer": { + "typescriptVersion": "7.1.0-dev", + "typescriptRevision": "1bcfa18d79a3be41772223d5c05dfe4480e614ff", + "diagnosticCount": 2, + "budgets": { + "limits": { + "maxTypeNodes": 128, + "maxTypeDepth": 2 + }, + "typeNodesUsed": 14, + "maxTypeDepthObserved": 3, + "truncated": true + }, + "files": 1, + "facts": 3, + "records": { + "types": 15, + "declarations": 6, + "symbols": 14, + "signatures": 0, + "edges": 49 + }, + "entityStates": { + "complete": 9, + "truncated": 18, + "unsupported": 0, + "error": 2 + }, + "sharedEdgeReferences": 17, + "edgeReferences": 49, + "sharingRatioPpm": 346938 + }, + "consumer": { + "sourceFiles": 1, + "parsedFiles": 0, + "failedFiles": 1, + "mapping": { + "facts": 0, + "mapped": 0, + "exact": 0, + "normalized": 0, + "unmapped": 0, + "multiplyMapped": 0 + }, + "bySyntaxKind": [], + "attachedFacts": 0, + "inspectedNodes": 0, + "inspectedEdges": 0, + "inspectorBudgetTruncatedFacts": 0, + "inspectionDiagnostics": {} + }, + "comparison": { + "goOracleFacts": 3, + "identityMappedFacts": 0, + "actualRootsTransportPreserved": 0, + "semanticEquivalenceClaims": 0, + "differences": [ + { + "factIndex": 0, + "file": "src/recovery.ts", + "syntaxKind": "KindIdentifier", + "code": "consumer-file-failed" + }, + { + "factIndex": 1, + "file": "src/recovery.ts", + "syntaxKind": "KindIdentifier", + "code": "consumer-file-failed" + }, + { + "factIndex": 2, + "file": "src/recovery.ts", + "syntaxKind": "KindIdentifier", + "code": "consumer-file-failed" + } + ] + }, + "diagnostics": [ + { + "layer": "consumer", + "code": "oxc-parse-or-semantic-error", + "file": "src/recovery.ts", + "message": "OXC failed to parse \"src/recovery.ts\": 1 diagnostic(s), panicked=true" + } + ] + } + }, + { + "name": "vertical-slice", + "sourceFiles": [ + "src/vertical.ts" + ], + "firstPass": { + "producerNanoseconds": 28862250, + "consumerNanoseconds": 2471459, + "totalNanoseconds": 31333709, + "snapshotBytes": 19518 + }, + "repeatedPass": { + "producerNanoseconds": 28236750, + "consumerNanoseconds": 2424667, + "totalNanoseconds": 30661417, + "snapshotBytes": 19518 + }, + "repeatedObservationsEqual": true, + "observation": { + "producer": { + "typescriptVersion": "7.1.0-dev", + "typescriptRevision": "1bcfa18d79a3be41772223d5c05dfe4480e614ff", + "diagnosticCount": 0, + "budgets": { + "limits": { + "maxTypeNodes": 128, + "maxTypeDepth": 3 + }, + "typeNodesUsed": 26, + "maxTypeDepthObserved": 4, + "truncated": true + }, + "files": 1, + "facts": 4, + "records": { + "types": 27, + "declarations": 15, + "symbols": 29, + "signatures": 2, + "edges": 123 + }, + "entityStates": { + "complete": 16, + "truncated": 41, + "unsupported": 0, + "error": 1 + }, + "sharedEdgeReferences": 53, + "edgeReferences": 123, + "sharingRatioPpm": 430894 + }, + "consumer": { + "sourceFiles": 1, + "parsedFiles": 1, + "failedFiles": 0, + "mapping": { + "facts": 4, + "mapped": 4, + "exact": 4, + "normalized": 0, + "unmapped": 0, + "multiplyMapped": 0 + }, + "bySyntaxKind": [ + { + "syntaxKind": "KindIdentifier", + "metrics": { + "facts": 4, + "mapped": 4, + "exact": 4, + "normalized": 0, + "unmapped": 0, + "multiplyMapped": 0 + } + } + ], + "attachedFacts": 4, + "inspectedNodes": 114, + "inspectedEdges": 194, + "inspectorBudgetTruncatedFacts": 0, + "inspectionDiagnostics": { + "entity-error": 1, + "entity-truncated": 60, + "root-unavailable": 3 + } + }, + "comparison": { + "goOracleFacts": 4, + "identityMappedFacts": 4, + "actualRootsTransportPreserved": 4, + "semanticEquivalenceClaims": 0, + "differences": [] + }, + "diagnostics": [] + } + } + ], + "totals": { + "producer": { + "typescriptVersion": "7.1.0-dev", + "typescriptRevision": "1bcfa18d79a3be41772223d5c05dfe4480e614ff", + "diagnosticCount": 9, + "budgets": { + "limits": { + "maxTypeNodes": 4096, + "maxTypeDepth": 32 + }, + "typeNodesUsed": 645, + "maxTypeDepthObserved": 33, + "truncated": true + }, + "files": 39, + "facts": 25, + "records": { + "types": 651, + "declarations": 556, + "symbols": 866, + "signatures": 482, + "edges": 6368 + }, + "entityStates": { + "complete": 631, + "truncated": 1361, + "unsupported": 0, + "error": 7 + }, + "sharedEdgeReferences": 3838, + "edgeReferences": 6368, + "sharingRatioPpm": 602701 + }, + "consumer": { + "sourceFiles": 6, + "parsedFiles": 5, + "failedFiles": 1, + "mapping": { + "facts": 22, + "mapped": 22, + "exact": 15, + "normalized": 7, + "unmapped": 0, + "multiplyMapped": 0 + }, + "bySyntaxKind": [ + { + "syntaxKind": "KindIdentifier", + "metrics": { + "facts": 21, + "mapped": 21, + "exact": 14, + "normalized": 7, + "unmapped": 0, + "multiplyMapped": 0 + } + }, + { + "syntaxKind": "KindStringLiteral", + "metrics": { + "facts": 1, + "mapped": 1, + "exact": 1, + "normalized": 0, + "unmapped": 0, + "multiplyMapped": 0 + } + } + ], + "attachedFacts": 22, + "inspectedNodes": 2883, + "inspectedEdges": 5950, + "inspectorBudgetTruncatedFacts": 1, + "inspectionDiagnostics": { + "entity-error": 12, + "entity-truncated": 1082, + "max-depth": 15, + "root-inapplicable": 8, + "root-unavailable": 12 + } + }, + "comparison": { + "goOracleFacts": 25, + "identityMappedFacts": 22, + "actualRootsTransportPreserved": 22, + "semanticEquivalenceClaims": 0, + "differences": [ + { + "factIndex": 0, + "file": "src/recovery.ts", + "syntaxKind": "KindIdentifier", + "code": "consumer-file-failed" + }, + { + "factIndex": 1, + "file": "src/recovery.ts", + "syntaxKind": "KindIdentifier", + "code": "consumer-file-failed" + }, + { + "factIndex": 2, + "file": "src/recovery.ts", + "syntaxKind": "KindIdentifier", + "code": "consumer-file-failed" + } + ] + }, + "diagnostics": [ + { + "layer": "consumer", + "code": "oxc-parse-or-semantic-error", + "file": "src/recovery.ts", + "message": "OXC failed to parse \"src/recovery.ts\": 1 diagnostic(s), panicked=true" + } + ] + }, + "timing": { + "firstPassNanoseconds": 634312462, + "repeatedPassNanoseconds": 247002000, + "caseCount": 6 + }, + "artifacts": { + "consumerExecutableBytes": 13425232, + "peakOrCurrentResidentBytes": 11829248, + "residentMeasurement": "macos-getrusage-peak", + "totalSnapshotBytes": 1315514 + }, + "compatibilityGate": { + "firstCandidate": "schema-v1 occurrence identity and attachment plumbing", + "decision": "safe-to-mechanically-port-behind-go-oracle; not-safe-to-replace-go-semantics", + "requiredMappingCoveragePpm": 1000000, + "requiredAmbiguityCount": 0, + "requiredTransportMismatchCount": 0, + "goAuthoritative": [ + "project loading and module resolution", + "binding and symbol identity", + "type construction, inference, contextual typing, and widening", + "overload and generic instantiation", + "control-flow narrowing", + "semantic entity completeness and recovery" + ] + } +} diff --git a/docs/porting/migration-contract.md b/docs/porting/migration-contract.md index f3d2865fb0..9b8205729f 100644 --- a/docs/porting/migration-contract.md +++ b/docs/porting/migration-contract.md @@ -10,8 +10,8 @@ evidence justifies pruning. - **Authority:** This repository may implement, test, and merge the adapter and its conformance corpus. Replacing checker categories with another backend - requires the compatibility threshold described by a later accepted RFC or - ADR. + requires the compatibility threshold in ADR-0016 and fresh differential + evidence from an independent semantic producer. ## Equivalence boundary @@ -80,6 +80,21 @@ invariants, and re-encode identically. The corpus will grow to differential fixtures against TypeScript's observable type and symbol behavior and will later be shared by alternate backends. +The Issue #20 evidence runner now exercises the complete representative corpus +through the real process boundary and internal Rust consumer. On OXC-parseable +sources it requires full occurrence mapping, no ambiguity, and no transport +mismatch. That is an identity/attachment gate only. The intentional recovery +source remains Go-only because the current OXC parse fails; its facts are +reported as consumer differences rather than omitted. + +ADR-0016 selects occurrence identity and attachment plumbing as the first safe +mechanical port. Primitive/literal type construction is the first proposed +semantic implementation candidate, but no semantic category may replace Go +until independent Rust output matches the normalized structured oracle with no +completeness/state downgrade or new unsupported form. Project loading, +resolution, binding, symbols, inference, contextual/widened types, overloads, +generic instantiation, narrowing, and recovery remain Go-authoritative. + ## Gate ladder 1. Focused `internal/semanticfacts` and `internal/tsfacts` tests pass, including @@ -94,6 +109,8 @@ later be shared by alternate backends. 6. CI passes on the upstream-supported matrix. 7. Phase 0 meets every acceptance criterion in RFC 0001 before that RFC becomes Accepted. +8. `./internal/oxc_reference/run-evidence.sh --output ` produces stable + repeated observations and passes ADR-0016's occurrence/attachment gate. ## Upstream synchronization diff --git a/internal/oxc_reference/README.md b/internal/oxc_reference/README.md index fff243c633..b92630374b 100644 --- a/internal/oxc_reference/README.md +++ b/internal/oxc_reference/README.md @@ -33,6 +33,26 @@ The optional final argument overrides the normalized logical file ID used by the semantic response. A single-file snapshot otherwise supplies that ID from its first fact. +Run the complete TS7-to-consumer evidence slice with one command: + +```sh +./internal/oxc_reference/run-evidence.sh \ + --output docs/evidence/ts7-oxc-spike-2026-08-17.json +``` + +The script builds the real Go `tsfacts` binary and runs every semantic corpus +case twice. The Rust runner preserves response-global fact indices while +attaching each project file, compares stable non-timing observations, and emits +ordered JSON with producer graph counts/budgets, OXC mapping coverage, +inspection depth/node/edge use, artifact/timing measurements, and diagnostics +classified as protocol, exporter, mapping, or consumer failures. Timings are +first and immediately repeated one-shot measurements, not daemon benchmarks. + +The checked evidence maps every fact from OXC-parseable sources but records the +intentional syntax-recovery source as a consumer failure. It does not infer +semantic equivalence from those mappings. See +[ADR-0016](../../docs/adr/0016-port-occurrence-attachment-before-semantic-categories.md). + The first test suite applies the Rust implementation of the portable contract to every shared JSON fixture in `internal/occurrencemap/testdata/v1` and checks its complete expected report. It separately parses those fixture sources with @@ -68,5 +88,10 @@ negotiation in Go. The intended migration sequence is: 3. replace one category at a time only after its compatibility threshold is explicit and met. +Occurrence identity and attachment plumbing is the first approved mechanical +port category. Primitive/literal record construction is only the next proposed +semantic candidate; all semantic answers remain Go-authoritative until an +independent Rust producer passes ADR-0016's differential gate. + Adding a projection does not transfer semantic authority. New parser-boundary normalizations require a shared fixture and the versioned portable contract. diff --git a/internal/oxc_reference/crates/occurrence-consumer/src/evidence.rs b/internal/oxc_reference/crates/occurrence-consumer/src/evidence.rs new file mode 100644 index 0000000000..2e85af1742 --- /dev/null +++ b/internal/oxc_reference/crates/occurrence-consumer/src/evidence.rs @@ -0,0 +1,893 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + io::{BufReader, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::Arc, + time::Instant, +}; + +use oxc_allocator::Allocator; +use serde::{Deserialize, Serialize}; + +use crate::{ + contract::{DiagnosticCode, KindCoverage, Metrics}, + facts::{EntityStateCounts, GraphRecordCounts, ProducerBudgetReport, SemanticSnapshot}, + inspector::{InspectionDiagnosticCode, InspectorLimits}, + oxc::OxcConsumer, +}; + +pub const EVIDENCE_SCHEMA_VERSION: u32 = 1; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EvidenceReport { + pub schema_version: u32, + pub evidence_kind: &'static str, + pub comparison_scope: &'static str, + pub environment: EnvironmentEvidence, + pub inspector_limits: InspectorLimits, + pub cases: Vec, + pub totals: ObservationEvidence, + pub timing: TimingSummary, + pub artifacts: ArtifactEvidence, + pub compatibility_gate: CompatibilityGate, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentEvidence { + pub operating_system: &'static str, + pub architecture: &'static str, + pub rustc: String, + pub go: String, + pub command: &'static str, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CaseEvidence { + pub name: String, + pub source_files: Vec, + pub first_pass: PassTiming, + pub repeated_pass: PassTiming, + pub repeated_observations_equal: bool, + pub observation: ObservationEvidence, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PassTiming { + pub producer_nanoseconds: u64, + pub consumer_nanoseconds: u64, + pub total_nanoseconds: u64, + pub snapshot_bytes: usize, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ObservationEvidence { + pub producer: ProducerEvidence, + pub consumer: ConsumerEvidence, + pub comparison: ComparisonEvidence, + pub diagnostics: Vec, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProducerEvidence { + pub typescript_version: String, + pub typescript_revision: String, + pub diagnostic_count: u32, + pub budgets: ProducerBudgetReport, + pub files: usize, + pub facts: usize, + pub records: GraphRecordCounts, + pub entity_states: EntityStateCounts, + pub shared_edge_references: usize, + pub edge_references: usize, + pub sharing_ratio_ppm: u64, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConsumerEvidence { + pub source_files: usize, + pub parsed_files: usize, + pub failed_files: usize, + pub mapping: Metrics, + pub by_syntax_kind: Vec, + pub attached_facts: usize, + pub inspected_nodes: usize, + pub inspected_edges: usize, + pub inspector_budget_truncated_facts: usize, + pub inspection_diagnostics: BTreeMap, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ComparisonEvidence { + pub go_oracle_facts: usize, + pub identity_mapped_facts: usize, + pub actual_roots_transport_preserved: usize, + pub semantic_equivalence_claims: usize, + pub differences: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FactDifference { + pub fact_index: usize, + pub file: String, + pub syntax_kind: String, + pub code: String, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum FailureLayer { + Protocol, + Exporter, + Mapping, + Consumer, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LayerDiagnostic { + pub layer: FailureLayer, + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub fact_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option, + pub message: String, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TimingSummary { + pub first_pass_nanoseconds: u64, + pub repeated_pass_nanoseconds: u64, + pub case_count: usize, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactEvidence { + pub consumer_executable_bytes: u64, + pub peak_or_current_resident_bytes: Option, + pub resident_measurement: String, + pub total_snapshot_bytes: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompatibilityGate { + pub first_candidate: &'static str, + pub decision: &'static str, + pub required_mapping_coverage_ppm: u64, + pub required_ambiguity_count: usize, + pub required_transport_mismatch_count: usize, + pub go_authoritative: Vec<&'static str>, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CorpusManifest { + name: String, + project: String, + capabilities: Vec, + #[serde(default)] + budgets: ProducerBudgetReportRequest, + selections: Vec, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProducerBudgetReportRequest { + max_type_nodes: u32, + max_type_depth: u32, +} + +#[derive(Clone, Debug, Deserialize)] +struct CorpusSelection { + file: String, + text: String, + occurrence: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProducerRequest<'a> { + schema_version: u32, + project: &'a str, + required_capabilities: &'a [String], + budgets: ProducerBudgetReportRequest, + selections: Vec, +} + +#[derive(Clone, Debug, Serialize)] +struct ProducerSelection { + file: String, + start: usize, + end: usize, +} + +struct CompletedPass { + timing: PassTiming, + observation: ObservationEvidence, +} + +pub fn run_evidence( + tsfacts_binary: &Path, + corpus_root: &Path, + limits: InspectorLimits, +) -> Result { + let tsfacts_binary = tsfacts_binary + .canonicalize() + .map_err(|error| format!("exporter: resolve {}: {error}", tsfacts_binary.display()))?; + let corpus_root = corpus_root + .canonicalize() + .map_err(|error| format!("consumer: resolve {}: {error}", corpus_root.display()))?; + let case_directories = sorted_case_directories(&corpus_root)?; + let mut cases = Vec::with_capacity(case_directories.len()); + + for case_directory in case_directories { + let manifest = read_manifest(&case_directory)?; + let request = build_request(&case_directory, &manifest)?; + let source_files = request + .selections + .iter() + .map(|selection| selection.file.clone()) + .collect::>() + .into_iter() + .collect::>(); + let first = run_pass( + &tsfacts_binary, + &case_directory, + &request, + &source_files, + limits, + )?; + let repeated = run_pass( + &tsfacts_binary, + &case_directory, + &request, + &source_files, + limits, + )?; + let observations_equal = first.observation == repeated.observation; + let mut observation = repeated.observation; + if !observations_equal { + observation.diagnostics.push(LayerDiagnostic { + layer: FailureLayer::Protocol, + code: "non-deterministic-observation".to_owned(), + fact_index: None, + file: None, + message: "first and repeated one-shot observations differ".to_owned(), + }); + } + cases.push(CaseEvidence { + name: manifest.name, + source_files, + first_pass: first.timing, + repeated_pass: repeated.timing, + repeated_observations_equal: observations_equal, + observation, + }); + } + + let totals = aggregate_observations(cases.iter().map(|case| &case.observation)); + let timing = TimingSummary { + first_pass_nanoseconds: cases + .iter() + .map(|case| case.first_pass.total_nanoseconds) + .sum(), + repeated_pass_nanoseconds: cases + .iter() + .map(|case| case.repeated_pass.total_nanoseconds) + .sum(), + case_count: cases.len(), + }; + let total_snapshot_bytes = cases + .iter() + .map(|case| case.first_pass.snapshot_bytes + case.repeated_pass.snapshot_bytes) + .sum(); + let (resident_bytes, resident_measurement) = resident_memory(); + let consumer_executable_bytes = std::env::current_exe() + .ok() + .and_then(|path| fs::metadata(path).ok()) + .map_or(0, |metadata| metadata.len()); + + Ok(EvidenceReport { + schema_version: EVIDENCE_SCHEMA_VERSION, + evidence_kind: "ts7-go-to-internal-oxc-rust-spike", + comparison_scope: "wire-identity-attachment-and-bounded-inspection-only", + environment: EnvironmentEvidence { + operating_system: std::env::consts::OS, + architecture: std::env::consts::ARCH, + rustc: tool_version("rustc", &["--version"]), + go: tool_version("go", &["version"]), + command: "./internal/oxc_reference/run-evidence.sh --output ", + }, + inspector_limits: limits, + cases, + totals, + timing, + artifacts: ArtifactEvidence { + consumer_executable_bytes, + peak_or_current_resident_bytes: resident_bytes, + resident_measurement, + total_snapshot_bytes, + }, + compatibility_gate: CompatibilityGate { + first_candidate: "schema-v1 occurrence identity and attachment plumbing", + decision: "safe-to-mechanically-port-behind-go-oracle; not-safe-to-replace-go-semantics", + required_mapping_coverage_ppm: 1_000_000, + required_ambiguity_count: 0, + required_transport_mismatch_count: 0, + go_authoritative: vec![ + "project loading and module resolution", + "binding and symbol identity", + "type construction, inference, contextual typing, and widening", + "overload and generic instantiation", + "control-flow narrowing", + "semantic entity completeness and recovery", + ], + }, + }) +} + +fn run_pass( + tsfacts_binary: &Path, + case_directory: &Path, + request: &ProducerRequest<'_>, + source_files: &[String], + limits: InspectorLimits, +) -> Result { + let request_json = serde_json::to_vec(request) + .map_err(|error| format!("protocol: encode producer request: {error}"))?; + let producer_started = Instant::now(); + let mut child = Command::new(tsfacts_binary) + .current_dir(case_directory) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("exporter: launch {}: {error}", tsfacts_binary.display()))?; + child + .stdin + .take() + .ok_or_else(|| "exporter: producer stdin is unavailable".to_owned())? + .write_all(&request_json) + .map_err(|error| format!("exporter: write producer request: {error}"))?; + let output = child + .wait_with_output() + .map_err(|error| format!("exporter: wait for producer: {error}"))?; + let producer_nanoseconds = duration_nanoseconds(producer_started.elapsed().as_nanos()); + if !output.status.success() { + return Err(format!( + "exporter: tsfacts exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let consumer_started = Instant::now(); + let snapshot = Arc::new( + SemanticSnapshot::from_json_lines(BufReader::new(output.stdout.as_slice())) + .map_err(|error| format!("protocol: decode producer output: {error}"))?, + ); + let observation = observe_snapshot(case_directory, source_files, snapshot, limits)?; + let consumer_nanoseconds = duration_nanoseconds(consumer_started.elapsed().as_nanos()); + + Ok(CompletedPass { + timing: PassTiming { + producer_nanoseconds, + consumer_nanoseconds, + total_nanoseconds: producer_nanoseconds.saturating_add(consumer_nanoseconds), + snapshot_bytes: output.stdout.len(), + }, + observation, + }) +} + +fn observe_snapshot( + case_directory: &Path, + source_files: &[String], + snapshot: Arc, + limits: InspectorLimits, +) -> Result { + let graph = snapshot.graph(); + let records = graph.record_counts(); + let entity_states = graph.state_counts(); + let (shared_edge_references, edge_references) = graph.sharing_counts(); + let mut observation = ObservationEvidence { + producer: ProducerEvidence { + typescript_version: snapshot.typescript_version.clone(), + typescript_revision: snapshot.typescript_revision.clone(), + diagnostic_count: snapshot.diagnostic_count, + budgets: snapshot.budgets, + files: snapshot.file_count(), + facts: snapshot.facts().len(), + records, + entity_states, + shared_edge_references, + edge_references, + sharing_ratio_ppm: ratio_ppm(shared_edge_references, edge_references), + }, + comparison: ComparisonEvidence { + go_oracle_facts: snapshot.facts().len(), + ..ComparisonEvidence::default() + }, + ..ObservationEvidence::default() + }; + observation.consumer.source_files = source_files.len(); + + for file in source_files { + let source_path = case_directory.join(file); + let source = fs::read_to_string(&source_path) + .map_err(|error| format!("consumer: read {}: {error}", source_path.display()))?; + let allocator = Allocator::default(); + let mut consumer = match OxcConsumer::parse(&allocator, file, &source) { + Ok(consumer) => consumer, + Err(error) => { + observation.consumer.failed_files += 1; + observation.diagnostics.push(LayerDiagnostic { + layer: FailureLayer::Consumer, + code: "oxc-parse-or-semantic-error".to_owned(), + fact_index: None, + file: Some(file.clone()), + message: error, + }); + for (fact_index, fact) in snapshot + .facts() + .iter() + .enumerate() + .filter(|(_, fact)| fact.file == *file) + { + observation.comparison.differences.push(FactDifference { + fact_index, + file: fact.file.clone(), + syntax_kind: fact.syntax_kind.clone(), + code: "consumer-file-failed".to_owned(), + }); + } + continue; + } + }; + observation.consumer.parsed_files += 1; + let report = consumer + .attach_file(Arc::clone(&snapshot)) + .map_err(|error| format!("consumer: attach {file}: {error}"))?; + add_metrics(&mut observation.consumer.mapping, &report.summary); + merge_kind_coverage( + &mut observation.consumer.by_syntax_kind, + &report.by_syntax_kind, + ); + + for diagnostic in &report.diagnostics { + let code = match diagnostic.code { + DiagnosticCode::Unmapped => "unmapped", + DiagnosticCode::MultiplyMapped => "multiply-mapped", + }; + observation.diagnostics.push(LayerDiagnostic { + layer: FailureLayer::Mapping, + code: code.to_owned(), + fact_index: Some(diagnostic.fact_index), + file: Some(diagnostic.file.clone()), + message: format!( + "{} candidate(s) for {}:{}-{} {}", + diagnostic.candidates.len(), + diagnostic.file, + diagnostic.span.start, + diagnostic.span.end, + diagnostic.syntax_kind + ), + }); + let fact = &snapshot.facts()[diagnostic.fact_index]; + observation.comparison.differences.push(FactDifference { + fact_index: diagnostic.fact_index, + file: fact.file.clone(), + syntax_kind: fact.syntax_kind.clone(), + code: code.to_owned(), + }); + } + + for mapping in &report.mappings { + observation.comparison.identity_mapped_facts += 1; + let node_id = consumer + .node_for_fact(mapping.fact_index) + .ok_or_else(|| format!("consumer: fact {} lost its NodeId", mapping.fact_index))?; + let attached = consumer + .type_facts_for_node(node_id) + .find(|facts| facts.fact_index == mapping.fact_index) + .ok_or_else(|| { + format!("consumer: fact {} lost its attachment", mapping.fact_index) + })?; + observation.consumer.attached_facts += 1; + let inspection = attached.inspect(limits); + observation.consumer.inspected_nodes += inspection.summary.nodes; + observation.consumer.inspected_edges += inspection.summary.edges; + if inspection.summary.truncated { + observation.consumer.inspector_budget_truncated_facts += 1; + } + for diagnostic in &inspection.diagnostics { + *observation + .consumer + .inspection_diagnostics + .entry(wire_name(diagnostic.code)) + .or_default() += 1; + } + let actual_root = inspection + .roots + .first() + .and_then(|root| root.type_id.as_deref()); + if actual_root == Some(attached.facts.actual_type.as_str()) { + observation.comparison.actual_roots_transport_preserved += 1; + } else { + observation.comparison.differences.push(FactDifference { + fact_index: mapping.fact_index, + file: attached.facts.file.clone(), + syntax_kind: attached.facts.syntax_kind.clone(), + code: "actual-root-transport-mismatch".to_owned(), + }); + } + } + } + + observation.diagnostics.sort(); + observation.comparison.differences.sort_by(|left, right| { + left.fact_index + .cmp(&right.fact_index) + .then_with(|| left.code.cmp(&right.code)) + }); + Ok(observation) +} + +fn sorted_case_directories(corpus_root: &Path) -> Result, String> { + let mut directories = fs::read_dir(corpus_root) + .map_err(|error| format!("consumer: read {}: {error}", corpus_root.display()))? + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .map(|entry| entry.path()) + .collect::>(); + directories.sort(); + Ok(directories) +} + +fn read_manifest(case_directory: &Path) -> Result { + let path = case_directory.join("case.json"); + let source = + fs::read(&path).map_err(|error| format!("protocol: read {}: {error}", path.display()))?; + serde_json::from_slice(&source) + .map_err(|error| format!("protocol: decode {}: {error}", path.display())) +} + +fn build_request<'a>( + case_directory: &Path, + manifest: &'a CorpusManifest, +) -> Result, String> { + let mut source_cache = BTreeMap::new(); + let mut selections = Vec::with_capacity(manifest.selections.len()); + for selection in &manifest.selections { + let source = match source_cache.get(&selection.file) { + Some(source) => source, + None => { + let path = case_directory.join(&selection.file); + let source = fs::read_to_string(&path) + .map_err(|error| format!("protocol: read {}: {error}", path.display()))?; + source_cache.entry(selection.file.clone()).or_insert(source) + } + }; + let start = + nth_occurrence(source, &selection.text, selection.occurrence).ok_or_else(|| { + format!( + "protocol: selection {:?} occurrence {} is absent from {}", + selection.text, selection.occurrence, selection.file + ) + })?; + selections.push(ProducerSelection { + file: selection.file.clone(), + start, + end: start + selection.text.len(), + }); + } + Ok(ProducerRequest { + schema_version: 1, + project: &manifest.project, + required_capabilities: &manifest.capabilities, + budgets: manifest.budgets, + selections, + }) +} + +fn nth_occurrence(source: &str, text: &str, occurrence: usize) -> Option { + source + .match_indices(text) + .nth(occurrence) + .map(|(start, _)| start) +} + +fn aggregate_observations<'a>( + observations: impl Iterator, +) -> ObservationEvidence { + let mut total = ObservationEvidence::default(); + for observation in observations { + if total.producer.typescript_version.is_empty() { + total.producer.typescript_version = observation.producer.typescript_version.clone(); + total.producer.typescript_revision = observation.producer.typescript_revision.clone(); + } + total.producer.diagnostic_count += observation.producer.diagnostic_count; + total.producer.budgets.limits.max_type_nodes = total + .producer + .budgets + .limits + .max_type_nodes + .max(observation.producer.budgets.limits.max_type_nodes); + total.producer.budgets.limits.max_type_depth = total + .producer + .budgets + .limits + .max_type_depth + .max(observation.producer.budgets.limits.max_type_depth); + total.producer.budgets.type_nodes_used += observation.producer.budgets.type_nodes_used; + total.producer.budgets.max_type_depth_observed = total + .producer + .budgets + .max_type_depth_observed + .max(observation.producer.budgets.max_type_depth_observed); + total.producer.budgets.truncated |= observation.producer.budgets.truncated; + total.producer.files += observation.producer.files; + total.producer.facts += observation.producer.facts; + total.producer.records.types += observation.producer.records.types; + total.producer.records.declarations += observation.producer.records.declarations; + total.producer.records.symbols += observation.producer.records.symbols; + total.producer.records.signatures += observation.producer.records.signatures; + total.producer.records.edges += observation.producer.records.edges; + total.producer.entity_states.complete += observation.producer.entity_states.complete; + total.producer.entity_states.truncated += observation.producer.entity_states.truncated; + total.producer.entity_states.unsupported += observation.producer.entity_states.unsupported; + total.producer.entity_states.error += observation.producer.entity_states.error; + total.producer.shared_edge_references += observation.producer.shared_edge_references; + total.producer.edge_references += observation.producer.edge_references; + total.consumer.source_files += observation.consumer.source_files; + total.consumer.parsed_files += observation.consumer.parsed_files; + total.consumer.failed_files += observation.consumer.failed_files; + add_metrics(&mut total.consumer.mapping, &observation.consumer.mapping); + merge_kind_coverage( + &mut total.consumer.by_syntax_kind, + &observation.consumer.by_syntax_kind, + ); + total.consumer.attached_facts += observation.consumer.attached_facts; + total.consumer.inspected_nodes += observation.consumer.inspected_nodes; + total.consumer.inspected_edges += observation.consumer.inspected_edges; + total.consumer.inspector_budget_truncated_facts += + observation.consumer.inspector_budget_truncated_facts; + for (code, count) in &observation.consumer.inspection_diagnostics { + *total + .consumer + .inspection_diagnostics + .entry(code.clone()) + .or_default() += count; + } + total.comparison.go_oracle_facts += observation.comparison.go_oracle_facts; + total.comparison.identity_mapped_facts += observation.comparison.identity_mapped_facts; + total.comparison.actual_roots_transport_preserved += + observation.comparison.actual_roots_transport_preserved; + total + .comparison + .differences + .extend(observation.comparison.differences.clone()); + total.diagnostics.extend(observation.diagnostics.clone()); + } + total.producer.sharing_ratio_ppm = ratio_ppm( + total.producer.shared_edge_references, + total.producer.edge_references, + ); + total +} + +fn add_metrics(total: &mut Metrics, metrics: &Metrics) { + total.facts += metrics.facts; + total.mapped += metrics.mapped; + total.exact += metrics.exact; + total.normalized += metrics.normalized; + total.unmapped += metrics.unmapped; + total.multiply_mapped += metrics.multiply_mapped; +} + +fn merge_kind_coverage(total: &mut Vec, additions: &[KindCoverage]) { + let mut coverage = total + .drain(..) + .map(|item| (item.syntax_kind, item.metrics)) + .collect::>(); + for item in additions { + add_metrics( + coverage.entry(item.syntax_kind.clone()).or_default(), + &item.metrics, + ); + } + *total = coverage + .into_iter() + .map(|(syntax_kind, metrics)| KindCoverage { + syntax_kind, + metrics, + }) + .collect(); +} + +fn ratio_ppm(numerator: usize, denominator: usize) -> u64 { + if denominator == 0 { + 0 + } else { + (numerator as u64).saturating_mul(1_000_000) / denominator as u64 + } +} + +fn duration_nanoseconds(value: u128) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +fn wire_name(value: InspectionDiagnosticCode) -> String { + serde_json::to_value(value) + .expect("serialize closed inspection diagnostic code") + .as_str() + .expect("inspection diagnostic code serializes as a string") + .to_owned() +} + +fn tool_version(command: &str, args: &[&str]) -> String { + Command::new(command) + .args(args) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned()) + .unwrap_or_else(|| "unavailable".to_owned()) +} + +fn resident_memory() -> (Option, String) { + #[cfg(target_os = "linux")] + { + let bytes = fs::read_to_string("/proc/self/status") + .ok() + .and_then(|status| { + status.lines().find_map(|line| { + line.strip_prefix("VmHWM:") + .and_then(|value| value.split_whitespace().next()) + .and_then(|value| value.parse::().ok()) + }) + }) + .map(|kilobytes| kilobytes.saturating_mul(1024)); + return (bytes, "linux-proc-vmhwm-peak".to_owned()); + } + #[cfg(target_os = "macos")] + { + #[repr(C)] + #[derive(Clone, Copy, Default)] + struct Timeval { + seconds: i64, + microseconds: i32, + padding: i32, + } + + #[repr(C)] + #[derive(Clone, Copy, Default)] + struct Rusage { + user_time: Timeval, + system_time: Timeval, + max_resident_bytes: i64, + remaining_fields: [i64; 13], + } + + unsafe extern "C" { + fn getrusage(who: i32, usage: *mut Rusage) -> i32; + } + + let mut usage = Rusage::default(); + // macOS defines RUSAGE_SELF as zero and reports ru_maxrss in bytes. + let result = unsafe { getrusage(0, &raw mut usage) }; + let bytes = (result == 0) + .then(|| u64::try_from(usage.max_resident_bytes).ok()) + .flatten(); + (bytes, "macos-getrusage-peak".to_owned()) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + (None, "unavailable".to_owned()) + } +} + +#[cfg(test)] +mod tests { + use std::io::BufReader; + + use super::*; + + #[test] + fn occurrence_offsets_are_utf8_bytes_and_zero_based() { + let source = "const café = 'x'; café;"; + assert_eq!(nth_occurrence(source, "café", 0), Some(6)); + assert_eq!(nth_occurrence(source, "café", 1), Some(19)); + assert_eq!(nth_occurrence(source, "missing", 0), None); + } + + #[test] + fn kind_coverage_is_sorted_and_additive() { + let mut total = vec![KindCoverage { + syntax_kind: "KindStringLiteral".to_owned(), + metrics: Metrics { + facts: 1, + mapped: 1, + exact: 1, + ..Metrics::default() + }, + }]; + merge_kind_coverage( + &mut total, + &[ + KindCoverage { + syntax_kind: "KindIdentifier".to_owned(), + metrics: Metrics { + facts: 2, + mapped: 1, + unmapped: 1, + ..Metrics::default() + }, + }, + KindCoverage { + syntax_kind: "KindStringLiteral".to_owned(), + metrics: Metrics { + facts: 1, + mapped: 1, + normalized: 1, + ..Metrics::default() + }, + }, + ], + ); + assert_eq!(total[0].syntax_kind, "KindIdentifier"); + assert_eq!(total[1].metrics.facts, 2); + assert_eq!(total[1].metrics.mapped, 2); + } + + #[test] + fn evidence_keeps_mapping_and_entity_state_diagnostics_distinct() { + let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../..") + .join("internal/tsfacts/testdata/canonical/v0"); + let fixture = fs::File::open(fixture_root.join("evidence-diagnostics.jsonl")) + .expect("open shared evidence fixture"); + let snapshot = Arc::new( + SemanticSnapshot::from_json_lines(BufReader::new(fixture)) + .expect("decode shared evidence fixture"), + ); + let observation = observe_snapshot( + &fixture_root, + &["src/evidence-diagnostics.ts".to_owned()], + snapshot, + InspectorLimits::default(), + ) + .expect("observe shared evidence fixture"); + + assert_eq!(observation.consumer.mapping.facts, 2); + assert_eq!(observation.consumer.mapping.mapped, 1); + assert_eq!(observation.consumer.mapping.unmapped, 1); + assert_eq!(observation.producer.entity_states.unsupported, 1); + assert_eq!(observation.producer.entity_states.truncated, 1); + assert_eq!( + observation + .consumer + .inspection_diagnostics + .get("entity-unsupported"), + Some(&1) + ); + assert!(observation.diagnostics.iter().any(|diagnostic| { + diagnostic.layer == FailureLayer::Mapping && diagnostic.code == "unmapped" + })); + assert_eq!(observation.comparison.differences[0].code, "unmapped"); + } +} diff --git a/internal/oxc_reference/crates/occurrence-consumer/src/facts.rs b/internal/oxc_reference/crates/occurrence-consumer/src/facts.rs index 298d8fee7c..f143d0d341 100644 --- a/internal/oxc_reference/crates/occurrence-consumer/src/facts.rs +++ b/internal/oxc_reference/crates/occurrence-consumer/src/facts.rs @@ -465,6 +465,25 @@ pub struct TypeGraph { signatures: BTreeMap, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphRecordCounts { + pub types: usize, + pub declarations: usize, + pub symbols: usize, + pub signatures: usize, + pub edges: usize, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EntityStateCounts { + pub complete: usize, + pub truncated: usize, + pub unsupported: usize, + pub error: usize, +} + impl TypeGraph { pub fn type_record(&self, id: &TypeId) -> Option<&TypeRecord> { self.types.get(id) @@ -486,6 +505,54 @@ impl TypeGraph { self.types.len() + self.declarations.len() + self.symbols.len() + self.signatures.len() } + pub fn record_counts(&self) -> GraphRecordCounts { + GraphRecordCounts { + types: self.types.len(), + declarations: self.declarations.len(), + symbols: self.symbols.len(), + signatures: self.signatures.len(), + edges: self + .references() + .map(|reference| { + self.edges(&reference) + .expect("indexed graph node has an edge list") + .len() + }) + .sum(), + } + } + + pub fn state_counts(&self) -> EntityStateCounts { + self.types + .values() + .map(|record| record.state) + .chain(self.symbols.values().map(|record| record.state)) + .chain(self.signatures.values().map(|record| record.state)) + .fold(EntityStateCounts::default(), |mut counts, state| { + match state { + EntityState::Complete => counts.complete += 1, + EntityState::Truncated => counts.truncated += 1, + EntityState::Unsupported => counts.unsupported += 1, + EntityState::Error => counts.error += 1, + } + counts + }) + } + + pub fn sharing_counts(&self) -> (usize, usize) { + let targets = self + .references() + .flat_map(|reference| { + self.edges(&reference) + .expect("indexed graph node has an edge list") + .into_iter() + .map(|edge| edge.target) + }) + .collect::>(); + let unique_targets = targets.iter().collect::>().len(); + (targets.len().saturating_sub(unique_targets), targets.len()) + } + pub fn contains(&self, reference: &GraphRef) -> bool { match reference { GraphRef::Type(id) => self.types.contains_key(id), @@ -505,15 +572,7 @@ impl TypeGraph { } fn validate(&self) -> Result<(), String> { - for reference in self - .types - .keys() - .cloned() - .map(GraphRef::Type) - .chain(self.symbols.keys().cloned().map(GraphRef::Symbol)) - .chain(self.signatures.keys().cloned().map(GraphRef::Signature)) - .chain(self.declarations.keys().cloned().map(GraphRef::Declaration)) - { + for reference in self.references() { for edge in self.edges(&reference).expect("indexed graph node") { if !self.contains(&edge.target) { return Err(format!( @@ -525,13 +584,44 @@ impl TypeGraph { } Ok(()) } + + fn references(&self) -> impl Iterator + '_ { + self.types + .keys() + .cloned() + .map(GraphRef::Type) + .chain(self.symbols.keys().cloned().map(GraphRef::Symbol)) + .chain(self.signatures.keys().cloned().map(GraphRef::Signature)) + .chain(self.declarations.keys().cloned().map(GraphRef::Declaration)) + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProducerBudgetLimits { + pub max_type_nodes: u32, + pub max_type_depth: u32, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProducerBudgetReport { + pub limits: ProducerBudgetLimits, + pub type_nodes_used: u32, + pub max_type_depth_observed: u32, + pub truncated: bool, } #[derive(Debug)] pub struct SemanticSnapshot { pub schema_version: u32, + pub typescript_version: String, + pub typescript_revision: String, pub offset_encoding: String, pub capabilities: Vec, + pub budgets: ProducerBudgetReport, + pub diagnostic_count: u32, + file_count: usize, graph: Arc, facts: Vec, } @@ -544,6 +634,7 @@ impl SemanticSnapshot { let mut symbols = BTreeMap::new(); let mut signatures = BTreeMap::new(); let mut facts = Vec::new(); + let mut file_count = 0; for (line_index, line) in reader.lines().enumerate() { let line = line.map_err(|error| format!("read line {}: {error}", line_index + 1))?; @@ -566,7 +657,7 @@ impl SemanticSnapshot { .map_err(|error| format!("decode header: {error}"))?, ); } - "file" => {} + "file" => file_count += 1, "type" => insert_record( &mut types, serde_json::from_value::(value) @@ -665,8 +756,13 @@ impl SemanticSnapshot { Ok(Self { schema_version: header.schema_version, + typescript_version: header.typescript_version, + typescript_revision: header.typescript_revision, offset_encoding: header.offset_encoding, capabilities: header.capabilities, + budgets: header.budgets, + diagnostic_count: header.diagnostic_count, + file_count, graph, facts, }) @@ -679,15 +775,27 @@ impl SemanticSnapshot { pub fn facts(&self) -> &[OccurrenceTypeFacts] { &self.facts } + + pub fn file_count(&self) -> usize { + self.file_count + } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct HeaderRecord { schema_version: u32, + #[serde(default)] + typescript_version: String, + #[serde(default)] + typescript_revision: String, offset_encoding: String, #[serde(default)] capabilities: Vec, + #[serde(default)] + budgets: ProducerBudgetReport, + #[serde(default)] + diagnostic_count: u32, } fn insert_record( diff --git a/internal/oxc_reference/crates/occurrence-consumer/src/lib.rs b/internal/oxc_reference/crates/occurrence-consumer/src/lib.rs index 693b7b7252..cb2b21fb85 100644 --- a/internal/oxc_reference/crates/occurrence-consumer/src/lib.rs +++ b/internal/oxc_reference/crates/occurrence-consumer/src/lib.rs @@ -1,4 +1,5 @@ pub mod contract; +pub mod evidence; pub mod facts; pub mod fixture; pub mod inspector; @@ -315,6 +316,32 @@ mod tests { assert!(std::ptr::eq(attached[0].graph(), attached[1].graph())); } + #[test] + fn project_snapshot_attachment_preserves_response_global_fact_indices() { + let snapshot = Arc::new( + SemanticSnapshot::from_json_lines(Cursor::new( + "{\"record\":\"header\",\"schemaVersion\":1,\"offsetEncoding\":\"utf8-bytes\",\"capabilities\":[]}\n\ + {\"record\":\"file\",\"id\":\"src/a.ts\"}\n\ + {\"record\":\"file\",\"id\":\"src/b.ts\"}\n\ + {\"record\":\"type\",\"id\":\"type:1\",\"typeKind\":\"string\",\"display\":\"string\",\"flags\":[],\"state\":\"complete\",\"complete\":true,\"truncated\":false}\n\ + {\"record\":\"fact\",\"file\":\"src/a.ts\",\"span\":{\"start\":0,\"end\":5},\"syntaxKind\":\"KindIdentifier\",\"actualType\":\"type:1\",\"typeAtLocation\":\"type:1\",\"typeViewStates\":{\"actual\":\"available\",\"contextual\":\"inapplicable\",\"widened\":\"same-as-actual\",\"apparent\":\"same-as-actual\",\"declared\":\"same-as-actual\"},\"complete\":true,\"recovered\":false,\"truncated\":false}\n\ + {\"record\":\"fact\",\"file\":\"src/b.ts\",\"span\":{\"start\":0,\"end\":5},\"syntaxKind\":\"KindIdentifier\",\"actualType\":\"type:1\",\"typeAtLocation\":\"type:1\",\"typeViewStates\":{\"actual\":\"available\",\"contextual\":\"inapplicable\",\"widened\":\"same-as-actual\",\"apparent\":\"same-as-actual\",\"declared\":\"same-as-actual\"},\"complete\":true,\"recovered\":false,\"truncated\":false}\n", + )) + .expect("decode multi-file snapshot"), + ); + + for (file, expected_fact_index) in [("src/a.ts", 0), ("src/b.ts", 1)] { + let allocator = Allocator::default(); + let mut consumer = + OxcConsumer::parse(&allocator, file, "value;\n").expect("parse multi-file source"); + let report = consumer + .attach_file(Arc::clone(&snapshot)) + .expect("attach one project file"); + assert_eq!(report.mappings[0].fact_index, expected_fact_index); + assert!(consumer.node_for_fact(expected_fact_index).is_some()); + } + } + fn canonical_fixture_path(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../../..") diff --git a/internal/oxc_reference/crates/occurrence-consumer/src/main.rs b/internal/oxc_reference/crates/occurrence-consumer/src/main.rs index 76e8625cb4..b9566ea15d 100644 --- a/internal/oxc_reference/crates/occurrence-consumer/src/main.rs +++ b/internal/oxc_reference/crates/occurrence-consumer/src/main.rs @@ -3,6 +3,7 @@ use std::{collections::BTreeMap, fs, io::BufReader, sync::Arc}; use oxc_allocator::Allocator; use oxc_occurrence_consumer::{ contract::{Report, correlate}, + evidence::run_evidence, facts::SemanticSnapshot, fixture::load_fixtures, inspector::{InspectionReport, InspectorLimits}, @@ -53,13 +54,43 @@ fn main() -> Result<(), String> { [command, snapshot, source, logical_file] if command == "inspect" => { inspect_snapshot(snapshot, source, Some(logical_file.as_str())) } + [command, tsfacts, corpus] if command == "evidence" => { + print_evidence(tsfacts, corpus, None) + } + [command, tsfacts, corpus, output_flag, output] + if command == "evidence" && output_flag == "--output" => + { + print_evidence(tsfacts, corpus, Some(output)) + } _ => Err( - "usage: oxc-occurrence-map fixtures | inspect [logical-file]" - .to_owned(), + "usage: oxc-occurrence-map fixtures | inspect [logical-file] | evidence [--output ]".to_owned(), ), } } +fn print_evidence(tsfacts: &str, corpus: &str, output: Option<&String>) -> Result<(), String> { + let report = run_evidence( + std::path::Path::new(tsfacts), + std::path::Path::new(corpus), + InspectorLimits::default(), + )?; + let mut bytes = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut serializer = serde_json::Serializer::with_formatter(&mut bytes, formatter); + report + .serialize(&mut serializer) + .map_err(|error| format!("serialize evidence: {error}"))?; + let serialized = String::from_utf8(bytes) + .map_err(|error| format!("serialize evidence as UTF-8: {error}"))?; + if let Some(output) = output { + fs::write(output, format!("{serialized}\n")) + .map_err(|error| format!("write evidence {output:?}: {error}"))?; + } else { + println!("{serialized}"); + } + Ok(()) +} + fn print_fixture_reports() -> Result<(), String> { let mut output = Vec::new(); for (path, fixture) in load_fixtures()? { diff --git a/internal/oxc_reference/crates/occurrence-consumer/src/oxc.rs b/internal/oxc_reference/crates/occurrence-consumer/src/oxc.rs index e0fc262611..8f84d40d13 100644 --- a/internal/oxc_reference/crates/occurrence-consumer/src/oxc.rs +++ b/internal/oxc_reference/crates/occurrence-consumer/src/oxc.rs @@ -99,12 +99,60 @@ impl<'a> OxcConsumer<'a> { /// ownership exactly once. Repeated fact selections are preserved in fact /// order rather than overwriting an earlier attachment to the same node. pub fn attach(&mut self, snapshot: Arc) -> Result { - let occurrences = snapshot + if snapshot.facts().iter().any(|fact| fact.file != self.file) { + return Err(format!( + "all facts must belong to consumer file {:?}", + self.file + )); + } + let fact_indices = (0..snapshot.facts().len()).collect::>(); + self.attach_fact_indices(snapshot, &fact_indices) + } + + /// Attaches the facts for this source file from a project-wide snapshot. + /// Mapping and attachment indices remain response-global fact indices. + pub fn attach_file(&mut self, snapshot: Arc) -> Result { + let fact_indices = snapshot .facts() .iter() - .map(OccurrenceTypeFacts::occurrence) + .enumerate() + .filter_map(|(index, fact)| (fact.file == self.file).then_some(index)) .collect::>(); - let report = self.correlate(&occurrences)?; + self.attach_fact_indices(snapshot, &fact_indices) + } + + fn attach_fact_indices( + &mut self, + snapshot: Arc, + fact_indices: &[usize], + ) -> Result { + let occurrences = fact_indices + .iter() + .map(|index| { + snapshot + .facts() + .get(*index) + .ok_or_else(|| format!("fact index {index} is outside the snapshot")) + .map(OccurrenceTypeFacts::occurrence) + }) + .collect::, _>>()?; + let mut report = self.correlate(&occurrences)?; + + let local_nodes = std::mem::take(&mut self.mapped_nodes); + for mapping in &mut report.mappings { + let local_index = mapping.fact_index; + let global_index = fact_indices[local_index]; + let node_id = local_nodes + .get(&local_index) + .copied() + .ok_or_else(|| format!("mapping for local fact {local_index} lost its NodeId"))?; + mapping.fact_index = global_index; + self.mapped_nodes.insert(global_index, node_id); + } + for diagnostic in &mut report.diagnostics { + diagnostic.fact_index = fact_indices[diagnostic.fact_index]; + } + self.fact_indices_by_node.clear(); for mapping in &report.mappings { let node_id = self diff --git a/internal/oxc_reference/run-evidence.sh b/internal/oxc_reference/run-evidence.sh new file mode 100755 index 0000000000..21b7151a90 --- /dev/null +++ b/internal/oxc_reference/run-evidence.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +evidence_tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/ts7-oxc-evidence.XXXXXX")" +trap 'rm -rf -- "$evidence_tmpdir"' EXIT + +output_args=() +if [[ $# -gt 0 ]]; then + if [[ $# -ne 2 || $1 != "--output" ]]; then + echo "usage: $0 [--output ]" >&2 + exit 2 + fi + output_args=("--output" "$2") +fi + +cd "$repository_root" +GOCACHE="$repository_root/internal/oxc_reference/target/go-build-cache" \ + go build -o "$evidence_tmpdir/tsfacts" ./cmd/tsfacts +cargo run --quiet --locked \ + --manifest-path internal/oxc_reference/Cargo.toml \ + --bin oxc-occurrence-map -- \ + evidence "$evidence_tmpdir/tsfacts" \ + internal/semanticfacts/testdata/corpus/v0 \ + "${output_args[@]}" diff --git a/internal/tsfacts/canonical_test.go b/internal/tsfacts/canonical_test.go index ea45513f04..f9d6827a32 100644 --- a/internal/tsfacts/canonical_test.go +++ b/internal/tsfacts/canonical_test.go @@ -15,7 +15,7 @@ func TestCanonicalJSONLinesFixturesRoundTrip(t *testing.T) { t.Parallel() fixtures, err := filepath.Glob("testdata/canonical/v0/*.jsonl") assert.NilError(t, err) - assert.Equal(t, len(fixtures), 5) + assert.Equal(t, len(fixtures), 6) for _, fixture := range fixtures { t.Run(filepath.Base(fixture), func(t *testing.T) { t.Parallel() diff --git a/internal/tsfacts/testdata/canonical/v0/evidence-diagnostics.jsonl b/internal/tsfacts/testdata/canonical/v0/evidence-diagnostics.jsonl new file mode 100644 index 0000000000..9f8cadeee5 --- /dev/null +++ b/internal/tsfacts/testdata/canonical/v0/evidence-diagnostics.jsonl @@ -0,0 +1,7 @@ +{"record":"header","schemaVersion":1,"typescriptVersion":"7.0.0-dev","typescriptRevision":"1bcfa18d79a3be41772223d5c05dfe4480e614ff","offsetEncoding":"utf8-bytes","capabilities":["graph.references","graph.signatures","limits.type-graph","occurrence.file-wide","occurrence.type-views","protocol.explicit-states","protocol.fixtures.v0","types.advanced","types.core-composite"],"budgets":{"limits":{"maxTypeNodes":2,"maxTypeDepth":1},"typeNodesUsed":2,"maxTypeDepthObserved":2,"truncated":true},"project":"tsconfig.json","compilerOptions":{},"diagnosticCount":0} +{"record":"file","id":"src/evidence-diagnostics.ts","origin":"project","selected":true,"diagnosticCount":0} +{"record":"type","id":"type:1","typeKind":"string","display":"string","flags":["String"],"state":"complete","complete":true,"truncated":false} +{"record":"type","id":"type:2","typeKind":"opaque","display":"intrinsic","flags":["NonPrimitive"],"state":"unsupported","issues":[{"code":"unsupported-type-form"}],"complete":false,"truncated":false} +{"record":"type","id":"type:3","typeKind":"truncated","display":"","flags":[],"state":"truncated","issues":[{"code":"max-type-nodes","limit":2}],"complete":false,"truncated":true} +{"record":"fact","file":"src/evidence-diagnostics.ts","span":{"start":0,"end":5},"syntaxKind":"KindIdentifier","actualType":"type:1","typeAtLocation":"type:1","declaredType":"type:2","typeViewStates":{"actual":"available","contextual":"inapplicable","widened":"same-as-actual","apparent":"same-as-actual","declared":"available"},"complete":false,"recovered":false,"truncated":false} +{"record":"fact","file":"src/evidence-diagnostics.ts","span":{"start":0,"end":5},"syntaxKind":"KindUnprojected","actualType":"type:3","typeAtLocation":"type:3","typeViewStates":{"actual":"available","contextual":"unavailable","widened":"same-as-actual","apparent":"same-as-actual","declared":"same-as-actual"},"complete":false,"recovered":false,"truncated":true} diff --git a/internal/tsfacts/testdata/canonical/v0/src/evidence-diagnostics.ts b/internal/tsfacts/testdata/canonical/v0/src/evidence-diagnostics.ts new file mode 100644 index 0000000000..91aa6b6c03 --- /dev/null +++ b/internal/tsfacts/testdata/canonical/v0/src/evidence-diagnostics.ts @@ -0,0 +1 @@ +value; diff --git a/rfcs/0001-semantic-facts-kernel.md b/rfcs/0001-semantic-facts-kernel.md index f3471b57e5..9023e757ef 100644 --- a/rfcs/0001-semantic-facts-kernel.md +++ b/rfcs/0001-semantic-facts-kernel.md @@ -234,6 +234,37 @@ IDs rather than mechanically translating Go pointer graphs. Compare every Rust result with the Go oracle. Replace the Go backend only category by category and only at an explicit compatibility threshold. +## Phase 3/4 spike evidence (2026-08-17) + +Issue #20 ran all six representative corpus cases through the real one-shot +`tsfacts` process, schema-v1 JSON Lines decoder, project-file OXC correlation, +TypeFacts attachment, and bounded Rust graph inspector. The exact command, +host/toolchain details, per-case timings, counters, diagnostics, and artifact +measurements are recorded in +[`docs/evidence/ts7-oxc-spike-2026-08-17.json`](../docs/evidence/ts7-oxc-spike-2026-08-17.json). + +The Go producer emitted 25 facts. OXC parsed five of six selected files and all +22 facts in those valid files mapped and attached: 15 exact, seven normalized, +zero unmapped or ambiguous, and zero actual-root transport mismatches. The +intentional syntax-recovery file remained a named consumer failure with three +Go facts; it was not removed from the denominator or treated as an exporter +failure. The inspected graph preserved 6,368 edges and substantial shared +identity while separately reporting producer states and consumer cutoffs. + +The local aggregate first/repeated one-shot times were 634,312,462 ns and +247,002,000 ns. The debug Rust executable was 13,425,232 bytes and peak measured +RSS was 11,829,248 bytes. These numbers characterize this spike host only. They +do not answer the open daemon/long-lived-process question or establish a +general performance claim. + +This evidence approves mechanical porting of occurrence identity, correlation, +response-global fact indexing, and side-table attachment behind the Go oracle. +It approves no semantic checker category for replacement. Primitive/literal +record construction is the first proposed independent Rust semantic candidate, +but decoding Go-produced roots and correlating syntax is not semantic +equivalence. ADR-0016 defines the compatibility gates and lists the semantic +areas that remain Go-authoritative. + ## Considered alternatives ### Prune TypeScript 7 immediately @@ -307,7 +338,8 @@ This RFC can move from Proposed to Accepted when a Phase 0 spike demonstrates: - Which portions of the type graph require exact structure in version 1, and which may initially be represented as opaque or truncated? - What compatibility threshold should a future Rust category meet before it can - replace the Go result? + replace the Go result? ADR-0016 defines the first gate; it must be revisited + with independent Rust semantic output before replacement. - Should the eventual public name be `tsfacts`, `TypeScript Semantic Kernel`, or another name that avoids implying an alternate TypeScript language?