@intentius/tsad-conformance 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/dist/adapter.d.ts +65 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +2 -0
- package/dist/adapter.js.map +1 -0
- package/dist/bundled.d.ts +5 -0
- package/dist/bundled.d.ts.map +1 -0
- package/dist/bundled.js +28 -0
- package/dist/bundled.js.map +1 -0
- package/dist/fixture.d.ts +49 -0
- package/dist/fixture.d.ts.map +1 -0
- package/dist/fixture.js +95 -0
- package/dist/fixture.js.map +1 -0
- package/dist/host.d.ts +33 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +79 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/rules.d.ts +46 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +135 -0
- package/dist/rules.js.map +1 -0
- package/dist/runner.d.ts +26 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +152 -0
- package/dist/runner.js.map +1 -0
- package/fixtures/F-CallLeak/a-call-returns-a-live-object/expect.json +8 -0
- package/fixtures/F-CallLeak/a-call-returns-a-live-object/project/caller.ts +5 -0
- package/fixtures/F-CallLeak/a-call-returns-a-live-object/project/leaky.ts +7 -0
- package/fixtures/F-CallLeak/a-call-returns-a-live-object/project/pure.ts +5 -0
- package/fixtures/F-Cycle/mutual-imports/expect.json +6 -0
- package/fixtures/F-Cycle/mutual-imports/project/a.ts +3 -0
- package/fixtures/F-Cycle/mutual-imports/project/b.ts +3 -0
- package/fixtures/F-Depth/exhaustion-is-a-fallback/expect.json +19 -0
- package/fixtures/F-Depth/exhaustion-is-a-fallback/project/deep.ts +3 -0
- package/fixtures/F-Depth/exhaustion-is-a-fallback/project/rec.ts +3 -0
- package/fixtures/F-Div-Depth/new-inside-function-body/expect.json +21 -0
- package/fixtures/F-Div-Depth/new-inside-function-body/project/app.ts +3 -0
- package/fixtures/F-Div-Depth/new-inside-function-body/project/fn.ts +3 -0
- package/fixtures/F-Div-Eager/referenced-not-called/expect.json +8 -0
- package/fixtures/F-Div-Eager/referenced-not-called/project/app.ts +3 -0
- package/fixtures/F-Div-NsNew/namespace-callee/expect.json +18 -0
- package/fixtures/F-Div-NsNew/namespace-callee/input.ts +3 -0
- package/fixtures/F-Div-Provenance/helper-name-from-project-import/expect.json +20 -0
- package/fixtures/F-Div-Provenance/helper-name-from-project-import/project/app.ts +3 -0
- package/fixtures/F-Div-Provenance/helper-name-from-project-import/project/local.ts +3 -0
- package/fixtures/F-Div-SameFileNew/no-module-graph/expect.json +8 -0
- package/fixtures/F-Div-SameFileNew/no-module-graph/input.ts +5 -0
- package/fixtures/F-Div-Tag/unregistered-tag/expect.json +15 -0
- package/fixtures/F-Div-Tag/unregistered-tag/input.ts +1 -0
- package/fixtures/F-Eval-Array/spread-string-rejected/expect.json +14 -0
- package/fixtures/F-Eval-Array/spread-string-rejected/input.ts +1 -0
- package/fixtures/F-Eval-Binary/division-and-float/expect.json +19 -0
- package/fixtures/F-Eval-Binary/division-and-float/input.ts +1 -0
- package/fixtures/F-Eval-Binary/logical-return-operand/expect.json +20 -0
- package/fixtures/F-Eval-Binary/logical-return-operand/input.ts +1 -0
- package/fixtures/F-Eval-Binary/plus-dispatch/expect.json +19 -0
- package/fixtures/F-Eval-Binary/plus-dispatch/input.ts +1 -0
- package/fixtures/F-Eval-Binary/relational-on-strings/expect.json +18 -0
- package/fixtures/F-Eval-Binary/relational-on-strings/input.ts +1 -0
- package/fixtures/F-Eval-CallEager/evaluated-at-fold-time/expect.json +8 -0
- package/fixtures/F-Eval-CallEager/evaluated-at-fold-time/project/app.ts +3 -0
- package/fixtures/F-Eval-CallEager/not-a-function/expect.json +22 -0
- package/fixtures/F-Eval-CallEager/not-a-function/project/app.ts +3 -0
- package/fixtures/F-Eval-CallEager/not-a-function/project/local.ts +1 -0
- package/fixtures/F-Eval-CallHelper/helper-folds/expect.json +8 -0
- package/fixtures/F-Eval-CallHelper/helper-folds/project/app.ts +3 -0
- package/fixtures/F-Eval-CallHelper/inside-function-body/expect.json +22 -0
- package/fixtures/F-Eval-CallHelper/inside-function-body/project/app.ts +3 -0
- package/fixtures/F-Eval-CallHelper/inside-function-body/project/fn.ts +5 -0
- package/fixtures/F-Eval-CallIntrinsic/call-form-folds/expect.json +8 -0
- package/fixtures/F-Eval-CallIntrinsic/call-form-folds/project/app.ts +3 -0
- package/fixtures/F-Eval-CallIntrinsic/inside-function-body/expect.json +22 -0
- package/fixtures/F-Eval-CallIntrinsic/inside-function-body/project/app.ts +3 -0
- package/fixtures/F-Eval-CallIntrinsic/inside-function-body/project/fn.ts +5 -0
- package/fixtures/F-Eval-CallLocal/local-function-folds/expect.json +7 -0
- package/fixtures/F-Eval-CallLocal/local-function-folds/project/app.ts +3 -0
- package/fixtures/F-Eval-CallLocal/local-function-folds/project/pure.ts +3 -0
- package/fixtures/F-Eval-CallLocal/recursion-folds/expect.json +17 -0
- package/fixtures/F-Eval-CallLocal/recursion-folds/project/rec.ts +3 -0
- package/fixtures/F-Eval-CallLocal/recursion-folds/project/shallow.ts +3 -0
- package/fixtures/F-Eval-CallLocal/spread-argument-rejected/expect.json +20 -0
- package/fixtures/F-Eval-CallLocal/spread-argument-rejected/project/app.ts +5 -0
- package/fixtures/F-Eval-CallLocal/spread-argument-rejected/project/pure.ts +3 -0
- package/fixtures/F-Eval-CallMethod/method-missing/expect.json +17 -0
- package/fixtures/F-Eval-CallMethod/method-missing/input.ts +1 -0
- package/fixtures/F-Eval-CallMethod/null-receiver-dot/expect.json +13 -0
- package/fixtures/F-Eval-CallMethod/null-receiver-dot/input.ts +2 -0
- package/fixtures/F-Eval-CallMethod/null-receiver-optional/expect.json +2 -0
- package/fixtures/F-Eval-CallMethod/null-receiver-optional/input.ts +2 -0
- package/fixtures/F-Eval-CallMethod/real-receiver/expect.json +2 -0
- package/fixtures/F-Eval-CallMethod/real-receiver/input.ts +1 -0
- package/fixtures/F-Eval-Template/number-formatting/expect.json +11 -0
- package/fixtures/F-Eval-Template/number-formatting/input.ts +1 -0
- package/fixtures/F-Eval-Template/tostring-of-non-numbers/expect.json +10 -0
- package/fixtures/F-Eval-Template/tostring-of-non-numbers/input.ts +1 -0
- package/fixtures/F-Eval-Unary/coercion/expect.json +19 -0
- package/fixtures/F-Eval-Unary/coercion/input.ts +1 -0
- package/fixtures/F-Eval-Undefined/bare-undefined/expect.json +2 -0
- package/fixtures/F-Eval-Undefined/bare-undefined/input.ts +1 -0
- package/fixtures/F-Exc-Registry/call-form-without-registry/expect.json +15 -0
- package/fixtures/F-Exc-Registry/call-form-without-registry/input.ts +1 -0
- package/fixtures/F-Host-Closed-vs-Open/package-call-is-closed/expect.json +9 -0
- package/fixtures/F-Host-Closed-vs-Open/package-call-is-closed/project/closed.ts +3 -0
- package/fixtures/F-Host-Closed-vs-Open/package-call-is-closed/project/open.ts +3 -0
- package/fixtures/F-Host-Closed-vs-Open/package-call-is-closed/project/pure.ts +3 -0
- package/fixtures/F-Host-DataExports/plain-data-export/expect.json +8 -0
- package/fixtures/F-Host-DataExports/plain-data-export/project/app.ts +3 -0
- package/fixtures/F-Host-Registry/tag-called-as-function/expect.json +8 -0
- package/fixtures/F-Host-Registry/tag-called-as-function/project/app.ts +3 -0
- package/fixtures/F-Host-Trust/unowned-package/expect.json +6 -0
- package/fixtures/F-Host-Trust/unowned-package/project/app.ts +3 -0
- package/fixtures/F-Namespace/synthetic-namespace-object/expect.json +7 -0
- package/fixtures/F-Namespace/synthetic-namespace-object/project/use.ts +3 -0
- package/fixtures/F-Namespace/synthetic-namespace-object/project/values.ts +2 -0
- package/fixtures/F-NoExports/nothing-to-produce/expect.json +7 -0
- package/fixtures/F-NoExports/nothing-to-produce/project/defaulted.ts +1 -0
- package/fixtures/F-NoExports/nothing-to-produce/project/empty.ts +3 -0
- package/fixtures/F-Prebuild/same-file-construction-shared/expect.json +19 -0
- package/fixtures/F-Prebuild/same-file-construction-shared/project/shared.ts +6 -0
- package/fixtures/F-Profile-DataHost/plain-data-across-files/expect.json +39 -0
- package/fixtures/F-Profile-DataHost/plain-data-across-files/project/defaults.ts +5 -0
- package/fixtures/F-Profile-DataHost/plain-data-across-files/project/service.ts +10 -0
- package/fixtures/F-Seed/seeded-by-its-own-body/expect.json +8 -0
- package/fixtures/F-Seed/seeded-by-its-own-body/project/app.ts +8 -0
- package/fixtures/F-Seed/seeded-by-its-own-body/project/lone.ts +1 -0
- package/fixtures/F-Succ/backward-along-captures/expect.json +26 -0
- package/fixtures/F-Succ/backward-along-captures/project/breaker.ts +10 -0
- package/fixtures/F-Succ/backward-along-captures/project/shared.ts +1 -0
- package/fixtures/F-Succ/backward-along-captures/project/sibling.ts +3 -0
- package/fixtures/F-Succ/forward-into-imports/expect.json +8 -0
- package/fixtures/F-Succ/forward-into-imports/project/app.ts +10 -0
- package/fixtures/F-Succ/forward-into-imports/project/config.ts +1 -0
- package/fixtures/F-Taint/independent-file-still-folds/expect.json +9 -0
- package/fixtures/F-Taint/independent-file-still-folds/project/alone.ts +1 -0
- package/fixtures/F-Taint/independent-file-still-folds/project/app.ts +8 -0
- package/fixtures/F-Total/one-bad-declarator-fails-the-file/expect.json +7 -0
- package/fixtures/F-Total/one-bad-declarator-fails-the-file/project/mixed.ts +3 -0
- package/fixtures/F-Val-Arity/props-attributes-and-spread/expect.json +14 -0
- package/fixtures/F-Val-Arity/props-attributes-and-spread/project/arities.ts +5 -0
- package/fixtures/F-Val-Callable/host-function-as-value/expect.json +16 -0
- package/fixtures/F-Val-Callable/host-function-as-value/project/app.ts +3 -0
- package/fixtures/F-Val-Fate/revive-every-envelope/expect.json +15 -0
- package/fixtures/F-Val-Fate/revive-every-envelope/project/entities.ts +6 -0
- package/fixtures/F-Val-Live/plain-data-is-not-live/expect.json +9 -0
- package/fixtures/F-Val-Live/plain-data-is-not-live/project/breaker.ts +10 -0
- package/fixtures/F-Val-Live/plain-data-is-not-live/project/quiet.ts +3 -0
- package/fixtures/F-Val-Live/plain-data-is-not-live/project/source.ts +5 -0
- package/fixtures/F-Val-Position/attrref-in-host-arguments/expect.json +8 -0
- package/fixtures/F-Val-Position/attrref-in-host-arguments/project/rejected.ts +4 -0
- package/fixtures/F-Val-Undefined/array-element-undefined/expect.json +15 -0
- package/fixtures/F-Val-Undefined/array-element-undefined/input.ts +1 -0
- package/fixtures/F-Val-Undefined/conditional-yields-undefined/expect.json +13 -0
- package/fixtures/F-Val-Undefined/conditional-yields-undefined/input.ts +2 -0
- package/fixtures/F-Val-Undefined/optional-chain-yields-undefined-property/expect.json +13 -0
- package/fixtures/F-Val-Undefined/optional-chain-yields-undefined-property/input.ts +2 -0
- package/fixtures/F-Val-Undefined/property-literal-undefined/expect.json +16 -0
- package/fixtures/F-Val-Undefined/property-literal-undefined/input.ts +1 -0
- package/fixtures/F-Val-Undefined/spread-carries-undefined/expect.json +14 -0
- package/fixtures/F-Val-Undefined/spread-carries-undefined/input.ts +2 -0
- package/fixtures/S-Array/spread/expect.json +1 -0
- package/fixtures/S-Array/spread/input.ts +1 -0
- package/fixtures/S-Binary/arithmetic-and-concat/expect.json +1 -0
- package/fixtures/S-Binary/arithmetic-and-concat/input.ts +1 -0
- package/fixtures/S-Binary/lazy-untaken-unfoldable/expect.json +1 -0
- package/fixtures/S-Binary/lazy-untaken-unfoldable/input.ts +1 -0
- package/fixtures/S-Binary/nullish-coalesce/expect.json +1 -0
- package/fixtures/S-Binary/nullish-coalesce/input.ts +1 -0
- package/fixtures/S-CallLocal/imported-callee-is-shape-valid/expect.json +10 -0
- package/fixtures/S-CallLocal/imported-callee-is-shape-valid/input.ts +3 -0
- package/fixtures/S-Conditional/taken-branch-only/expect.json +1 -0
- package/fixtures/S-Conditional/taken-branch-only/input.ts +1 -0
- package/fixtures/S-ExportDefault/imported-by-sibling/expect.json +30 -0
- package/fixtures/S-ExportDefault/imported-by-sibling/project/app.ts +3 -0
- package/fixtures/S-ExportDefault/imported-by-sibling/project/policy.ts +1 -0
- package/fixtures/S-ExportDefault/plain-object/expect.json +21 -0
- package/fixtures/S-ExportDefault/plain-object/input.ts +1 -0
- package/fixtures/S-Ident/const-chain/expect.json +1 -0
- package/fixtures/S-Ident/const-chain/input.ts +1 -0
- package/fixtures/S-Ident/process/expect.json +1 -0
- package/fixtures/S-Ident/process/input.ts +1 -0
- package/fixtures/S-Ident/unresolved/expect.json +1 -0
- package/fixtures/S-Ident/unresolved/input.ts +1 -0
- package/fixtures/S-Index/dynamic-key/expect.json +1 -0
- package/fixtures/S-Index/dynamic-key/input.ts +1 -0
- package/fixtures/S-Index/literal-key/expect.json +1 -0
- package/fixtures/S-Index/literal-key/input.ts +1 -0
- package/fixtures/S-Literal/null-and-booleans/expect.json +1 -0
- package/fixtures/S-Literal/null-and-booleans/input.ts +1 -0
- package/fixtures/S-Literal/numeric/expect.json +1 -0
- package/fixtures/S-Literal/numeric/input.ts +1 -0
- package/fixtures/S-LocalFunction/arrow-const-call/expect.json +21 -0
- package/fixtures/S-LocalFunction/arrow-const-call/project/label.ts +3 -0
- package/fixtures/S-LocalFunction/function-as-value-still-rejects/expect.json +11 -0
- package/fixtures/S-LocalFunction/function-as-value-still-rejects/input.ts +3 -0
- package/fixtures/S-LocalFunction/same-file-declaration-call/expect.json +19 -0
- package/fixtures/S-LocalFunction/same-file-declaration-call/project/twice.ts +5 -0
- package/fixtures/S-Member/nested/expect.json +1 -0
- package/fixtures/S-Member/nested/input.ts +1 -0
- package/fixtures/S-Member/nullish-plain-refuses/expect.json +1 -0
- package/fixtures/S-Member/nullish-plain-refuses/input.ts +1 -0
- package/fixtures/S-Member/optional-chain/expect.json +1 -0
- package/fixtures/S-Member/optional-chain/input.ts +1 -0
- package/fixtures/S-Object/spread-later-wins/expect.json +1 -0
- package/fixtures/S-Object/spread-later-wins/input.ts +1 -0
- package/fixtures/S-Object/spread-non-object/expect.json +1 -0
- package/fixtures/S-Object/spread-non-object/input.ts +1 -0
- package/fixtures/S-Reject/arrow-as-value/expect.json +1 -0
- package/fixtures/S-Reject/arrow-as-value/input.ts +1 -0
- package/fixtures/S-Reject/call-as-value/expect.json +1 -0
- package/fixtures/S-Reject/call-as-value/input.ts +1 -0
- package/fixtures/S-Template/coercion/expect.json +2 -0
- package/fixtures/S-Template/coercion/input.ts +2 -0
- package/fixtures/S-Template/composite-step-span/expect.json +2 -0
- package/fixtures/S-Template/composite-step-span/input.ts +1 -0
- package/fixtures/S-Template/const-span/expect.json +2 -0
- package/fixtures/S-Template/const-span/input.ts +2 -0
- package/fixtures/S-Template/envelope-span/expect.json +2 -0
- package/fixtures/S-Template/envelope-span/input.ts +2 -0
- package/fixtures/S-Template/nested-construction-span/expect.json +2 -0
- package/fixtures/S-Template/nested-construction-span/input.ts +1 -0
- package/fixtures/S-Template/no-substitution/expect.json +2 -0
- package/fixtures/S-Template/no-substitution/input.ts +1 -0
- package/fixtures/S-Template/unfoldable-span/expect.json +16 -0
- package/fixtures/S-Template/unfoldable-span/input.ts +1 -0
- package/fixtures/S-Unary/not-and-negate/expect.json +1 -0
- package/fixtures/S-Unary/not-and-negate/input.ts +1 -0
- package/fixtures/S-Unclaimed/composite-step/expect.json +3 -0
- package/fixtures/S-Unclaimed/composite-step/input.ts +1 -0
- package/fixtures/S-Unwrap/as-and-parens/expect.json +1 -0
- package/fixtures/S-Unwrap/as-and-parens/input.ts +1 -0
- package/fixtures/UNCOVERED.md +37 -0
- package/package.json +35 -0
- package/spec/VERSION +1 -0
- package/spec/divergence.md +129 -0
- package/spec/grammar.md +277 -0
- package/spec/hosts.md +175 -0
- package/spec/judgments.md +1049 -0
- package/spec/rules.md +102 -0
- package/spec/values.md +247 -0
- package/src/adapter.ts +52 -0
- package/src/adapters/chant.ts +125 -0
- package/src/bundled.ts +27 -0
- package/src/chant-agreement.test.ts +67 -0
- package/src/corpus.test.ts +66 -0
- package/src/corpus.ts +540 -0
- package/src/fixture.ts +117 -0
- package/src/host.ts +105 -0
- package/src/index.ts +6 -0
- package/src/rules.test.ts +50 -0
- package/src/rules.ts +140 -0
- package/src/runner.test.ts +46 -0
- package/src/runner.ts +119 -0
- package/src/version.test.ts +31 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #25 — chant's whole example corpus through both implementations.
|
|
3
|
+
*
|
|
4
|
+
* The suite skips unless `TSAD_CHANT_REPO` names a chant checkout to read the
|
|
5
|
+
* corpus from, because the corpus is not in this repository and not in the
|
|
6
|
+
* published package. The skip is reported, not silent. `npm run corpus` also
|
|
7
|
+
* writes `packages/conformance/corpus-report.md`, which is the artifact the
|
|
8
|
+
* paper cites; the number there is taken with the checkout at the pinned tag.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, test, expect, beforeAll } from "vitest";
|
|
11
|
+
import { writeFileSync } from "node:fs";
|
|
12
|
+
import { resolve, dirname } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { chantAdapter } from "./adapters/chant";
|
|
15
|
+
import {
|
|
16
|
+
findChantCheckout, discoverCorpus, runCorpusEntry, summarize, renderCorpusReport,
|
|
17
|
+
type ChantCheckout, type CorpusSummary, type EntryReport,
|
|
18
|
+
} from "./corpus";
|
|
19
|
+
|
|
20
|
+
const checkout = findChantCheckout();
|
|
21
|
+
|
|
22
|
+
describe.skipIf(!checkout)("the corpus against both implementations (#25)", () => {
|
|
23
|
+
let reports: EntryReport[];
|
|
24
|
+
let summary: CorpusSummary;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
const entries = await discoverCorpus(checkout as ChantCheckout);
|
|
28
|
+
reports = [];
|
|
29
|
+
for (const entry of entries) reports.push(await runCorpusEntry(checkout as ChantCheckout, entry));
|
|
30
|
+
summary = summarize(reports);
|
|
31
|
+
if (process.env.TSAD_CORPUS_REPORT) {
|
|
32
|
+
const out = resolve(dirname(fileURLToPath(import.meta.url)), "..", "corpus-report.md");
|
|
33
|
+
writeFileSync(out, renderCorpusReport(checkout as ChantCheckout, { name: chantAdapter.name, specVersion: chantAdapter.specVersion }, summary, reports), "utf8");
|
|
34
|
+
}
|
|
35
|
+
}, 600_000);
|
|
36
|
+
|
|
37
|
+
test("the corpus is the whole one, not a fragment of it", () => {
|
|
38
|
+
// A checkout whose dependencies are not installed, or a corpus discovery
|
|
39
|
+
// that quietly returned less, would otherwise read as a clean run.
|
|
40
|
+
expect(summary.entries).toBeGreaterThanOrEqual(100);
|
|
41
|
+
expect(summary.files).toBeGreaterThanOrEqual(380);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("every file is either comparable or limited by a named limit, and enough are comparable to mean something", () => {
|
|
45
|
+
const limited = Object.values(summary.limited).reduce((n, c) => n + c, 0);
|
|
46
|
+
expect(summary.comparable + limited).toBe(summary.files);
|
|
47
|
+
// A run in which nothing is comparable agrees vacuously, which is what the
|
|
48
|
+
// hostless first draft of this check did (#25). The floor is the guard.
|
|
49
|
+
expect(summary.comparable).toBeGreaterThanOrEqual(50);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("the two implementations agree on every comparable file", () => {
|
|
53
|
+
const lines = summary.disagreements.map(
|
|
54
|
+
(d) => `${d.entry}/${d.file}: chant=${d.chant} reference=${d.reference} values=${d.values ?? "-"} ${d.referenceRule ?? ""} ${d.referenceReason ?? d.chantReason ?? ""}`,
|
|
55
|
+
);
|
|
56
|
+
expect(lines, lines.join("\n")).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the reference never folds what chant runs", () => {
|
|
60
|
+
// The asymmetry is the point: both limits are the reference's and can only
|
|
61
|
+
// make it refuse more. A fold it reaches and chant does not is a real
|
|
62
|
+
// disagreement wherever it appears.
|
|
63
|
+
const lines = summary.referenceMorePermissive.map((d) => `${d.entry}/${d.file}: ${d.chantReason ?? ""}`);
|
|
64
|
+
expect(lines, lines.join("\n")).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/corpus.ts
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #25 — the whole corpus against both implementations.
|
|
3
|
+
*
|
|
4
|
+
* The fixtures in `spec/fixtures/` were written to exercise particular rules,
|
|
5
|
+
* so agreement on them is agreement on cases somebody chose. chant's example
|
|
6
|
+
* corpus was not written for this purpose at all, which is the only reason
|
|
7
|
+
* running it is worth anything.
|
|
8
|
+
*
|
|
9
|
+
* The corpus lives in a chant checkout, not in this repository and not in the
|
|
10
|
+
* published package, so this module is inert without one: point
|
|
11
|
+
* `TSAD_CHANT_REPO` at a checkout whose dependencies are installed. There is
|
|
12
|
+
* no default on purpose. A sibling checkout on a developer's machine is at
|
|
13
|
+
* whatever commit they last pulled, and a gate that reads a moving corpus
|
|
14
|
+
* fails for reasons that have nothing to do with the change under test; the
|
|
15
|
+
* number the paper cites is taken at the pinned tag. The enumeration is chant's own
|
|
16
|
+
* `examples/differential-corpus.ts` — the same list, lexicon selection and
|
|
17
|
+
* intrinsic wiring its own differentials use, imported rather than
|
|
18
|
+
* reimplemented, so a corpus entry added or a network fixture excluded there
|
|
19
|
+
* is picked up here without an edit.
|
|
20
|
+
*
|
|
21
|
+
* What a difference between the two implementations is allowed to mean is
|
|
22
|
+
* `classify`'s subject, below, and it is most of this file.
|
|
23
|
+
*/
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
26
|
+
import { createRequire } from "node:module";
|
|
27
|
+
import { relative, resolve, dirname, posix } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import * as ts from "typescript";
|
|
30
|
+
import { foldProject as chantFoldProject } from "@intentius/chant";
|
|
31
|
+
import { findInfraFiles } from "@intentius/chant/discovery/files";
|
|
32
|
+
import { FOLDABLE_AUTHORING_HELPERS, isChantOwnedSpecifier } from "@intentius/chant/fold/foldable-helpers";
|
|
33
|
+
import { foldProject as referenceFoldProject, type Host } from "@intentius/tsad-reference";
|
|
34
|
+
|
|
35
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
36
|
+
|
|
37
|
+
/** An intrinsic registration, as chant's lexicon plugins publish it (F-Host-Registry). */
|
|
38
|
+
interface ChantIntrinsic {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly isTag: boolean;
|
|
41
|
+
readonly foldsAsCall?: boolean;
|
|
42
|
+
readonly foldsEagerly?: boolean;
|
|
43
|
+
readonly outputKey?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One buildable source directory, as chant's own corpus discovery reports it. */
|
|
47
|
+
export interface CorpusEntry {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly srcDir: string;
|
|
50
|
+
/** The intrinsic registry this entry builds with, from the lexicons its own config declares. */
|
|
51
|
+
readonly intrinsics: readonly ChantIntrinsic[];
|
|
52
|
+
/** The lexicon names the entry's own config declares: F-Host-Trust arm 1's active set, which chant's entry takes since chant#2422. */
|
|
53
|
+
readonly lexicons: readonly string[];
|
|
54
|
+
/** The entry's build parameters, resolved the way the CLI resolves them, so a file reading `params.<name>` folds on chant's side. */
|
|
55
|
+
readonly buildParams: Readonly<Record<string, string | number | boolean>>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ChantCheckout {
|
|
59
|
+
readonly root: string;
|
|
60
|
+
/** The checkout's own chant version, which need not be the pinned one this runs against. */
|
|
61
|
+
readonly corpusVersion: string;
|
|
62
|
+
/** The commit the corpus was read at, and whether the tree had uncommitted changes. A number is only reproducible against a clean, named revision. */
|
|
63
|
+
readonly revision: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The checkout `TSAD_CHANT_REPO` names, or `undefined` when it is unset or names nothing readable. */
|
|
67
|
+
export function findChantCheckout(): ChantCheckout | undefined {
|
|
68
|
+
if (!process.env.TSAD_CHANT_REPO) return undefined;
|
|
69
|
+
const root = resolve(REPO_ROOT, process.env.TSAD_CHANT_REPO);
|
|
70
|
+
if (!existsSync(resolve(root, "examples", "differential-corpus.ts"))) return undefined;
|
|
71
|
+
const manifest = resolve(root, "packages", "core", "package.json");
|
|
72
|
+
const corpusVersion = existsSync(manifest)
|
|
73
|
+
? ((JSON.parse(readFileSync(manifest, "utf8")) as { version?: string }).version ?? "unknown")
|
|
74
|
+
: "unknown";
|
|
75
|
+
let revision = "unknown";
|
|
76
|
+
try {
|
|
77
|
+
const git = (...args: string[]) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
|
|
78
|
+
// Tracked files only: a seeded worktree carries untracked generated
|
|
79
|
+
// artifacts and a `node_modules` link, neither of which is corpus source.
|
|
80
|
+
const dirty = git("status", "--porcelain", "--untracked-files=no").length > 0;
|
|
81
|
+
revision = `${git("rev-parse", "--short", "HEAD")}${dirty ? " (dirty)" : ""}`;
|
|
82
|
+
} catch {
|
|
83
|
+
// Not a git checkout, or no git: the report says so rather than guessing.
|
|
84
|
+
}
|
|
85
|
+
return { root, corpusVersion, revision };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* chant's corpus, from chant's own discovery. The import is dynamic and by
|
|
90
|
+
* absolute path because the file is not a module of this project: it imports
|
|
91
|
+
* eleven lexicon packages, which resolve from the checkout's own
|
|
92
|
+
* `node_modules`. A checkout whose dependencies are not installed cannot
|
|
93
|
+
* answer at all, and the import throws rather than producing a shorter corpus.
|
|
94
|
+
*/
|
|
95
|
+
export async function discoverCorpus(checkout: ChantCheckout): Promise<CorpusEntry[]> {
|
|
96
|
+
const mod = (await import(/* @vite-ignore */ resolve(checkout.root, "examples", "differential-corpus.ts"))) as {
|
|
97
|
+
discoverCorpus(): Promise<{ name: string; srcDir: string; intrinsics: readonly ChantIntrinsic[]; lexicons: readonly string[] }[]>;
|
|
98
|
+
entryBuildParams(entry: unknown): Promise<readonly { name: string; value: string | number | boolean }[]>;
|
|
99
|
+
};
|
|
100
|
+
const entries = await mod.discoverCorpus();
|
|
101
|
+
return Promise.all(
|
|
102
|
+
entries.map(async (e) => ({
|
|
103
|
+
name: e.name,
|
|
104
|
+
srcDir: e.srcDir,
|
|
105
|
+
intrinsics: e.intrinsics,
|
|
106
|
+
lexicons: e.lexicons,
|
|
107
|
+
buildParams: Object.fromEntries((await mod.entryBuildParams(e)).map((p) => [p.name, p.value])),
|
|
108
|
+
})),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── the host ────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* chant's host, in the reference's terms (F-Host-Interface).
|
|
116
|
+
*
|
|
117
|
+
* Assembled from chant's own registries rather than transcribed: the
|
|
118
|
+
* authoring-helper allowlist and the owned-specifier test come from chant,
|
|
119
|
+
* and the intrinsic registry is the entry's own, so the host the reference
|
|
120
|
+
* folds against is the host chant folds against. A transcription would make
|
|
121
|
+
* the cross-check a test of the transcription.
|
|
122
|
+
*
|
|
123
|
+
* `values` holds the REAL module exports for every host-owned specifier the
|
|
124
|
+
* entry imports, which is what lets revival construct entities (F-Val-Fate).
|
|
125
|
+
* A specifier whose module will not load is reported, not skipped silently:
|
|
126
|
+
* every file that imports it is host-limited, and the count has to show it.
|
|
127
|
+
*/
|
|
128
|
+
export interface CorpusHost extends Host {
|
|
129
|
+
/** Host-owned specifiers whose module could not be loaded. */
|
|
130
|
+
readonly unloadable: ReadonlySet<string>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function buildHost(checkout: ChantCheckout, entry: CorpusEntry, sources: ReadonlyMap<string, string>): Promise<CorpusHost> {
|
|
134
|
+
const require = createRequire(resolve(checkout.root, "package.json"));
|
|
135
|
+
const values = new Map<string, ReadonlyMap<string, unknown>>();
|
|
136
|
+
const unloadable = new Set<string>();
|
|
137
|
+
const specifiers = new Set<string>();
|
|
138
|
+
for (const [path, source] of sources) for (const s of specifiersOf(source, path).bare) if (isChantOwnedSpecifier(s)) specifiers.add(s);
|
|
139
|
+
for (const specifier of specifiers) {
|
|
140
|
+
try {
|
|
141
|
+
const mod = (await import(/* @vite-ignore */ require.resolve(specifier))) as Record<string, unknown>;
|
|
142
|
+
values.set(specifier, new Map(Object.entries(mod)));
|
|
143
|
+
} catch {
|
|
144
|
+
unloadable.add(specifier);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// F-Import's first arm: `params` is the build's binding, not the module's
|
|
148
|
+
// live export. chant substitutes the resolved parameters at fold time; the
|
|
149
|
+
// reference is handed the same values through the host.
|
|
150
|
+
if (specifiers.has("@intentius/chant/params")) {
|
|
151
|
+
values.set("@intentius/chant/params", new Map([["params", entry.buildParams]]));
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
intrinsics: entry.intrinsics,
|
|
155
|
+
helpers: FOLDABLE_AUTHORING_HELPERS,
|
|
156
|
+
// chant's own `CHANT_PACKAGE_SPECIFIERS`, which is private; the predicate
|
|
157
|
+
// over it is not, and the two prefixes are its whole content.
|
|
158
|
+
ownedSpecifierPrefixes: ["@intentius/chant", "@intentius/chant-lexicon-"],
|
|
159
|
+
values,
|
|
160
|
+
unloadable,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── what a difference between the two implementations can mean ──────────────
|
|
165
|
+
|
|
166
|
+
export type Side = "fold" | "run";
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Why a file's two verdicts cannot be compared, or `undefined` when they can.
|
|
170
|
+
*
|
|
171
|
+
* These are the three ways the comparison is disarmed before it starts, and
|
|
172
|
+
* naming them is the point of #25. None of them is drift, and reporting one
|
|
173
|
+
* as drift would be a lie in the direction that flatters the specification.
|
|
174
|
+
*/
|
|
175
|
+
export type Limit =
|
|
176
|
+
/** The reference has no bindings for a package it cannot load (#20 is not finished). */
|
|
177
|
+
| "host"
|
|
178
|
+
/** The reference implements no composite factory form; see `packages/reference/CAVEATS.md`. */
|
|
179
|
+
| "composite";
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Both limits disarm the reference, and a limit can only make its own side
|
|
183
|
+
* refuse more. Two chant-side limits used to sit here, for a lexicon list and
|
|
184
|
+
* build parameters chant's entry could not be given; chant-v0.71.0 takes both
|
|
185
|
+
* (chant#2422) and they are gone (#96).
|
|
186
|
+
*/
|
|
187
|
+
export const LIMIT_SIDE: Readonly<Record<Limit, "reference">> = { host: "reference", composite: "reference" };
|
|
188
|
+
|
|
189
|
+
export interface FileComparison {
|
|
190
|
+
readonly file: string;
|
|
191
|
+
readonly chant: Side;
|
|
192
|
+
readonly reference: Side;
|
|
193
|
+
/** The first limit that applies, for the comparable-set accounting. */
|
|
194
|
+
readonly limit?: Limit;
|
|
195
|
+
/** Both folded, and their export namespaces were compared as data. */
|
|
196
|
+
readonly values?: "equal" | "differ" | "not-data";
|
|
197
|
+
/** Where the two encodings first differ, with a little context either side, so a difference is a diff and not a verdict. */
|
|
198
|
+
readonly valuesDiff?: string;
|
|
199
|
+
readonly referenceRule?: string;
|
|
200
|
+
readonly referenceReason?: string;
|
|
201
|
+
readonly chantReason?: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface EntryReport {
|
|
205
|
+
readonly name: string;
|
|
206
|
+
readonly files: number;
|
|
207
|
+
readonly comparisons: readonly FileComparison[];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const isProjectSpecifier = (s: string) => s.startsWith(".") || s.startsWith("/");
|
|
211
|
+
|
|
212
|
+
/** Every specifier a file imports or re-exports for its values, split by kind. */
|
|
213
|
+
function specifiersOf(source: string, path: string): { bare: string[]; project: string[] } {
|
|
214
|
+
const sf = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true);
|
|
215
|
+
const bare: string[] = [];
|
|
216
|
+
const project: string[] = [];
|
|
217
|
+
for (const st of sf.statements) {
|
|
218
|
+
if (ts.isImportDeclaration(st)) {
|
|
219
|
+
if (!st.importClause || st.importClause.isTypeOnly) continue;
|
|
220
|
+
} else if (ts.isExportDeclaration(st)) {
|
|
221
|
+
if (st.isTypeOnly || !st.moduleSpecifier) continue;
|
|
222
|
+
} else continue;
|
|
223
|
+
const spec = st.moduleSpecifier;
|
|
224
|
+
if (!spec || !ts.isStringLiteral(spec)) continue;
|
|
225
|
+
(isProjectSpecifier(spec.text) ? project : bare).push(spec.text);
|
|
226
|
+
}
|
|
227
|
+
return { bare, project };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The names a file binds by importing from a host-owned specifier. */
|
|
231
|
+
function hostBoundNames(source: string, path: string, host: CorpusHost): Set<string> {
|
|
232
|
+
const sf = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true);
|
|
233
|
+
const names = new Set<string>();
|
|
234
|
+
for (const st of sf.statements) {
|
|
235
|
+
if (!ts.isImportDeclaration(st) || !ts.isStringLiteral(st.moduleSpecifier)) continue;
|
|
236
|
+
if (!host.values.has(st.moduleSpecifier.text)) continue;
|
|
237
|
+
const clause = st.importClause;
|
|
238
|
+
if (!clause || clause.isTypeOnly) continue;
|
|
239
|
+
if (clause.name) names.add(clause.name.text);
|
|
240
|
+
const nb = clause.namedBindings;
|
|
241
|
+
if (nb && ts.isNamespaceImport(nb)) names.add(nb.name.text);
|
|
242
|
+
if (nb && ts.isNamedImports(nb)) for (const el of nb.elements) if (!el.isTypeOnly) names.add(el.name.text);
|
|
243
|
+
}
|
|
244
|
+
return names;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* True when the file calls a host-owned binding that is neither a registered
|
|
249
|
+
* intrinsic nor a registered authoring helper — the composite factory form.
|
|
250
|
+
*
|
|
251
|
+
* Decided from syntax and the import table, never from a rejection message:
|
|
252
|
+
* message wording is explicitly non-normative, and a classifier that reads it
|
|
253
|
+
* would silently stop classifying the day the wording changed.
|
|
254
|
+
*/
|
|
255
|
+
function usesCompositeFactory(source: string, path: string, host: CorpusHost): boolean {
|
|
256
|
+
const bound = hostBoundNames(source, path, host);
|
|
257
|
+
if (bound.size === 0) return false;
|
|
258
|
+
const known = new Set([...host.intrinsics.map((i) => i.name), ...host.helpers.map((h) => h.name)]);
|
|
259
|
+
const sf = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true);
|
|
260
|
+
let found = false;
|
|
261
|
+
const visit = (node: ts.Node): void => {
|
|
262
|
+
if (found) return;
|
|
263
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
264
|
+
const name = node.expression.text;
|
|
265
|
+
if (bound.has(name) && !known.has(name)) found = true;
|
|
266
|
+
}
|
|
267
|
+
ts.forEachChild(node, visit);
|
|
268
|
+
};
|
|
269
|
+
ts.forEachChild(sf, visit);
|
|
270
|
+
return found;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The reference's own resolution: a relative specifier against the project's key set. */
|
|
274
|
+
function resolveKey(from: string, spec: string, keys: ReadonlySet<string>): string | undefined {
|
|
275
|
+
const base = from.includes("/") ? from.slice(0, from.lastIndexOf("/") + 1) : "";
|
|
276
|
+
const joined = posix.normalize(base + spec).replace(/^\.\//, "");
|
|
277
|
+
for (const cand of [joined, `${joined}.ts`, `${joined}/index.ts`]) if (keys.has(cand)) return cand;
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Spread one limit from the files that have it to the files that inherit it.
|
|
283
|
+
*
|
|
284
|
+
* Two propagation rules, matching the two ways a file's condition reaches
|
|
285
|
+
* another file. `F-Import`: a file that imports an unfoldable module cannot
|
|
286
|
+
* resolve what it imported, so an importer of a limited file is limited.
|
|
287
|
+
* `F-Taint`: J3's edge, taken from the reference's own reported `taintSource`
|
|
288
|
+
* rather than recomputed, so this walk cannot disagree with the walk it is
|
|
289
|
+
* classifying.
|
|
290
|
+
*
|
|
291
|
+
* This over-approximates: a file may be in a limited set and also have a real
|
|
292
|
+
* disagreement hiding under it. That is the cost of the limit, and it is what
|
|
293
|
+
* bounds the whole measurement — the comparable set is the part of the corpus
|
|
294
|
+
* the cross-check can speak about.
|
|
295
|
+
*/
|
|
296
|
+
function spread(
|
|
297
|
+
seed: Set<string>,
|
|
298
|
+
sources: ReadonlyMap<string, string>,
|
|
299
|
+
taintSource: ReadonlyMap<string, string>,
|
|
300
|
+
): Set<string> {
|
|
301
|
+
const keys = new Set(sources.keys());
|
|
302
|
+
const importers = new Map<string, string[]>();
|
|
303
|
+
for (const [key, source] of sources) {
|
|
304
|
+
for (const s of specifiersOf(source, key).project) {
|
|
305
|
+
const target = resolveKey(key, s, keys);
|
|
306
|
+
if (target) importers.set(target, [...(importers.get(target) ?? []), key]);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const taintedBy = new Map<string, string[]>();
|
|
310
|
+
for (const [file, from] of taintSource) taintedBy.set(from, [...(taintedBy.get(from) ?? []), file]);
|
|
311
|
+
|
|
312
|
+
const out = new Set(seed);
|
|
313
|
+
const queue = [...seed];
|
|
314
|
+
while (queue.length > 0) {
|
|
315
|
+
const current = queue.shift()!;
|
|
316
|
+
for (const next of [...(importers.get(current) ?? []), ...(taintedBy.get(current) ?? [])]) {
|
|
317
|
+
if (out.has(next)) continue;
|
|
318
|
+
out.add(next);
|
|
319
|
+
queue.push(next);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Structural equality over two export namespaces.
|
|
327
|
+
*
|
|
328
|
+
* Entities are compared rather than skipped, which is only sound because both
|
|
329
|
+
* implementations construct them from the SAME host classes: the reference is
|
|
330
|
+
* handed the lexicon packages' real exports, so a `Bucket` on one side and a
|
|
331
|
+
* `Bucket` on the other are instances of one class, and comparing the class
|
|
332
|
+
* name and the data properties compares two folds rather than two hosts. Own
|
|
333
|
+
* data properties only, enumerable or not, accessors skipped: an accessor can
|
|
334
|
+
* throw (chant's `AttrRef.toJSON` does, for a reference whose logical name is
|
|
335
|
+
* not yet assigned) and computes a value rather than holding one.
|
|
336
|
+
*
|
|
337
|
+
* `not-data` is reserved for a namespace holding something with no structural
|
|
338
|
+
* encoding at all, a function or a symbol, where only the verdict can be
|
|
339
|
+
* compared.
|
|
340
|
+
*/
|
|
341
|
+
function compareData(a: unknown, b: unknown): { values: "equal" | "differ" | "not-data"; valuesDiff?: string } {
|
|
342
|
+
const encode = (v: unknown): string | undefined => {
|
|
343
|
+
// Ancestors on the current path, not everything seen: an object reached
|
|
344
|
+
// twice by different paths is shared, not cyclic, and F-Memo makes sharing
|
|
345
|
+
// the normal case. Marking it would report a difference between an
|
|
346
|
+
// implementation that shares and one that copies, which is not a value
|
|
347
|
+
// difference.
|
|
348
|
+
const ancestors = new Set<object>();
|
|
349
|
+
let clean = true;
|
|
350
|
+
const walk = (x: unknown, depth: number): unknown => {
|
|
351
|
+
if (x === null || typeof x !== "object") {
|
|
352
|
+
if (typeof x === "function" || typeof x === "symbol") clean = false;
|
|
353
|
+
if (typeof x === "bigint") return `${x}n`;
|
|
354
|
+
return typeof x === "number" && !Number.isFinite(x) ? String(x) : x;
|
|
355
|
+
}
|
|
356
|
+
// A cycle and an exhausted budget both encode as a marker rather than as
|
|
357
|
+
// a value, so two encodings that reach one agree there instead of both
|
|
358
|
+
// becoming incomparable.
|
|
359
|
+
if (ancestors.has(x)) return "[cycle]";
|
|
360
|
+
if (depth > 24) return "[deep]";
|
|
361
|
+
ancestors.add(x);
|
|
362
|
+
let result: unknown;
|
|
363
|
+
if (Array.isArray(x)) result = x.map((e) => walk(e, depth + 1));
|
|
364
|
+
else {
|
|
365
|
+
const out: Record<string, unknown> = {};
|
|
366
|
+
const proto = Object.getPrototypeOf(x) as object | null;
|
|
367
|
+
if (proto !== Object.prototype && proto !== null) out["[class]"] = (x.constructor as { name?: string })?.name ?? "?";
|
|
368
|
+
for (const key of Reflect.ownKeys(x).filter((k): k is string => typeof k === "string").sort()) {
|
|
369
|
+
const descriptor = Object.getOwnPropertyDescriptor(x, key);
|
|
370
|
+
if (descriptor && "value" in descriptor) out[key] = walk(descriptor.value, depth + 1);
|
|
371
|
+
}
|
|
372
|
+
result = out;
|
|
373
|
+
}
|
|
374
|
+
ancestors.delete(x);
|
|
375
|
+
return result;
|
|
376
|
+
};
|
|
377
|
+
try {
|
|
378
|
+
const encoded = JSON.stringify(walk(v, 0));
|
|
379
|
+
return clean ? encoded : undefined;
|
|
380
|
+
} catch {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
const left = encode(a);
|
|
385
|
+
const right = encode(b);
|
|
386
|
+
if (left === undefined || right === undefined) return { values: "not-data" };
|
|
387
|
+
if (left === right) return { values: "equal" };
|
|
388
|
+
let i = 0;
|
|
389
|
+
while (i < left.length && left[i] === right[i]) i++;
|
|
390
|
+
const at = (s: string) => s.slice(Math.max(0, i - 80), i + 120);
|
|
391
|
+
return { values: "differ", valuesDiff: `at ${i}: chant …${at(left)}… reference …${at(right)}…` };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Run one corpus entry through both implementations and classify every file. */
|
|
395
|
+
export async function runCorpusEntry(checkout: ChantCheckout, entry: CorpusEntry): Promise<EntryReport> {
|
|
396
|
+
const paths = await findInfraFiles(entry.srcDir);
|
|
397
|
+
const keyOf = (abs: string) => relative(entry.srcDir, abs).split("\\").join("/");
|
|
398
|
+
const sources = new Map(paths.map((p) => [keyOf(p), readFileSync(p, "utf8")]));
|
|
399
|
+
|
|
400
|
+
const host = await buildHost(checkout, entry, sources);
|
|
401
|
+
const chant = await chantFoldProject(paths, entry.intrinsics as never, { lexicons: entry.lexicons, buildParams: entry.buildParams });
|
|
402
|
+
const reference = referenceFoldProject(sources, host);
|
|
403
|
+
|
|
404
|
+
const hostSeed = new Set<string>();
|
|
405
|
+
const compositeSeed = new Set<string>();
|
|
406
|
+
for (const [key, source] of sources) {
|
|
407
|
+
const { bare } = specifiersOf(source, key);
|
|
408
|
+
if (bare.some((s) => !isChantOwnedSpecifier(s) || host.unloadable.has(s))) hostSeed.add(key);
|
|
409
|
+
if (usesCompositeFactory(source, key, host)) compositeSeed.add(key);
|
|
410
|
+
}
|
|
411
|
+
const hostLimited = spread(hostSeed, sources, reference.taintSource);
|
|
412
|
+
const compositeLimited = spread(compositeSeed, sources, reference.taintSource);
|
|
413
|
+
|
|
414
|
+
const comparisons: FileComparison[] = paths.map((abs) => {
|
|
415
|
+
const file = keyOf(abs);
|
|
416
|
+
const cv = chant.get(abs);
|
|
417
|
+
const rv = reference.verdicts.get(file);
|
|
418
|
+
const chantSide: Side = cv?.verdict === "fold" ? "fold" : "run";
|
|
419
|
+
const referenceSide: Side = rv?.kind === "fold" ? "fold" : "run";
|
|
420
|
+
const limit: Limit | undefined = hostLimited.has(file) ? "host" : compositeLimited.has(file) ? "composite" : undefined;
|
|
421
|
+
const base: FileComparison = {
|
|
422
|
+
file,
|
|
423
|
+
chant: chantSide,
|
|
424
|
+
reference: referenceSide,
|
|
425
|
+
limit,
|
|
426
|
+
referenceRule: rv?.kind === "run" ? rv.rule : undefined,
|
|
427
|
+
referenceReason: rv?.kind === "run" ? rv.reason : undefined,
|
|
428
|
+
chantReason:
|
|
429
|
+
cv?.verdict === "run"
|
|
430
|
+
? (cv.reason ?? (cv.taintedBy ? `tainted by ${keyOf(cv.taintedBy.from)}` : undefined))
|
|
431
|
+
: undefined,
|
|
432
|
+
};
|
|
433
|
+
if (!cv || !rv || rv.kind !== "fold" || chantSide !== "fold") return base;
|
|
434
|
+
const left = Object.fromEntries(cv.exports ?? new Map());
|
|
435
|
+
const right = Object.fromEntries(rv.exports);
|
|
436
|
+
return { ...base, ...compareData(left, right) };
|
|
437
|
+
});
|
|
438
|
+
return { name: entry.name, files: paths.length, comparisons };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── the summary the paper cites ─────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
export type Disagreement = FileComparison & { entry: string };
|
|
444
|
+
|
|
445
|
+
export interface CorpusSummary {
|
|
446
|
+
readonly entries: number;
|
|
447
|
+
readonly files: number;
|
|
448
|
+
/** Files neither implementation is disarmed on. */
|
|
449
|
+
readonly comparable: number;
|
|
450
|
+
readonly comparableAgreed: number;
|
|
451
|
+
/** Comparable files both folded, with export namespaces equal as data. */
|
|
452
|
+
readonly comparableBothFold: number;
|
|
453
|
+
readonly comparableValuesNotData: number;
|
|
454
|
+
/** Files that are not comparable, by which limit disarmed them first. */
|
|
455
|
+
readonly limited: Readonly<Record<Limit, number>>;
|
|
456
|
+
/** Differences inside the comparable set: real, and each one triaged. */
|
|
457
|
+
readonly disagreements: readonly Disagreement[];
|
|
458
|
+
/**
|
|
459
|
+
* A reference fold where chant runs, anywhere in the corpus. Both limits are
|
|
460
|
+
* the reference's and can only make it refuse more, never less, so a fold it
|
|
461
|
+
* reaches and chant does not has no benign explanation wherever it appears.
|
|
462
|
+
*/
|
|
463
|
+
readonly referenceMorePermissive: readonly Disagreement[];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function summarize(reports: readonly EntryReport[]): CorpusSummary {
|
|
467
|
+
let files = 0, comparable = 0, comparableAgreed = 0, comparableBothFold = 0, comparableValuesNotData = 0;
|
|
468
|
+
const limited: Record<Limit, number> = { host: 0, composite: 0 };
|
|
469
|
+
const disagreements: Disagreement[] = [];
|
|
470
|
+
const referenceMorePermissive: Disagreement[] = [];
|
|
471
|
+
for (const report of reports) {
|
|
472
|
+
files += report.files;
|
|
473
|
+
for (const c of report.comparisons) {
|
|
474
|
+
if (c.reference === "fold" && c.chant === "run") referenceMorePermissive.push({ ...c, entry: report.name });
|
|
475
|
+
if (c.limit) { limited[c.limit]++; continue; }
|
|
476
|
+
comparable++;
|
|
477
|
+
if (c.chant === c.reference && c.values !== "differ") comparableAgreed++;
|
|
478
|
+
else disagreements.push({ ...c, entry: report.name });
|
|
479
|
+
if (c.values === "equal") comparableBothFold++;
|
|
480
|
+
if (c.values === "not-data") comparableValuesNotData++;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
entries: reports.length, files, comparable, comparableAgreed, comparableBothFold,
|
|
485
|
+
comparableValuesNotData, limited, disagreements, referenceMorePermissive,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The committed evidence artifact. Numbers here are produced by the run, never typed in. */
|
|
490
|
+
export function renderCorpusReport(
|
|
491
|
+
checkout: ChantCheckout,
|
|
492
|
+
engine: { name: string; specVersion: string },
|
|
493
|
+
summary: CorpusSummary,
|
|
494
|
+
reports: readonly EntryReport[],
|
|
495
|
+
): string {
|
|
496
|
+
const rows = reports.map((r) => {
|
|
497
|
+
const s = summarize([r]);
|
|
498
|
+
return `| \`${r.name}\` | ${r.files} | ${s.comparable} | ${s.comparableAgreed} | ${s.limited.host} | ${s.limited.composite} |`;
|
|
499
|
+
});
|
|
500
|
+
const line = (d: Disagreement) =>
|
|
501
|
+
`- \`${d.entry}/${d.file}\`: chant ${d.chant}, reference ${d.reference}${d.values ? `, values ${d.values}` : ""}${d.valuesDiff ? ` (${d.valuesDiff})` : ""}` +
|
|
502
|
+
`${d.referenceReason ? ` — reference ${d.referenceRule}: ${d.referenceReason}` : ""}` +
|
|
503
|
+
`${d.chantReason ? ` — chant: ${d.chantReason}` : ""}`;
|
|
504
|
+
return [
|
|
505
|
+
"# Corpus cross-check",
|
|
506
|
+
"",
|
|
507
|
+
"Generated by `npm run corpus` (#25). Do not edit.",
|
|
508
|
+
"",
|
|
509
|
+
`- engine under test: \`${engine.name}\`, declaring spec \`${engine.specVersion}\``,
|
|
510
|
+
`- corpus: chant \`${checkout.corpusVersion}\` at \`${checkout.revision}\`, ${summary.entries} entries, ${summary.files} files`,
|
|
511
|
+
"",
|
|
512
|
+
"## Totals",
|
|
513
|
+
"",
|
|
514
|
+
"| Files | Comparable | Agreed | Both fold | No host | No composite form |",
|
|
515
|
+
"|---|---|---|---|---|---|",
|
|
516
|
+
`| ${summary.files} | ${summary.comparable} | ${summary.comparableAgreed} | ${summary.comparableBothFold} |` +
|
|
517
|
+
` ${summary.limited.host} | ${summary.limited.composite} |`,
|
|
518
|
+
"",
|
|
519
|
+
"Both limits disarm the reference. chant is given the entry's lexicons and build parameters, the inputs a real build has.",
|
|
520
|
+
"",
|
|
521
|
+
`Of the comparable files both implementations folded, ${summary.comparableValuesNotData} held something that is not data on one side or the other, so only the verdict was compared there.`,
|
|
522
|
+
"",
|
|
523
|
+
"## Disagreements inside the comparable set",
|
|
524
|
+
"",
|
|
525
|
+
summary.disagreements.length === 0 ? "None." : summary.disagreements.map(line).join("\n"),
|
|
526
|
+
"",
|
|
527
|
+
"## Reference folds where chant runs",
|
|
528
|
+
"",
|
|
529
|
+
summary.referenceMorePermissive.length === 0
|
|
530
|
+
? "None."
|
|
531
|
+
: summary.referenceMorePermissive.map(line).join("\n"),
|
|
532
|
+
"",
|
|
533
|
+
"## Per entry",
|
|
534
|
+
"",
|
|
535
|
+
"| Entry | Files | Comparable | Agreed | No host | No composite form |",
|
|
536
|
+
"|---|---|---|---|---|---|",
|
|
537
|
+
...rows,
|
|
538
|
+
"",
|
|
539
|
+
].join("\n");
|
|
540
|
+
}
|