@intentius/tsad-conformance 1.4.0 → 1.6.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/dist/adapter.d.ts +53 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/fixture.d.ts +28 -1
- package/dist/fixture.d.ts.map +1 -1
- package/dist/fixture.js +27 -2
- package/dist/fixture.js.map +1 -1
- package/dist/host.d.ts +13 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +33 -0
- package/dist/host.js.map +1 -1
- package/dist/runner.d.ts +28 -2
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +129 -3
- package/dist/runner.js.map +1 -1
- package/fixtures/F-Call/a-host-factory-is-invoked/expect.json +45 -0
- package/fixtures/F-Call/a-host-factory-is-invoked/project/app.ts +4 -0
- package/fixtures/F-Call/a-host-factory-is-invoked/project/nested.ts +3 -0
- package/fixtures/F-Call/a-host-factory-is-invoked/project/plain.ts +3 -0
- package/fixtures/F-Declarator/a-const-alias-of-a-call-resolves-at-the-declarator/expect.json +50 -0
- package/fixtures/F-Declarator/a-const-alias-of-a-call-resolves-at-the-declarator/project/app.ts +13 -0
- package/fixtures/F-Declarator/a-const-alias-of-a-call-resolves-at-the-declarator/project/nested.ts +5 -0
- package/fixtures/F-Declarator/a-const-alias-of-a-call-resolves-at-the-declarator/project/store.ts +3 -0
- package/fixtures/F-Div-Step/step-reads-the-resolved-composite/expect.json +48 -0
- package/fixtures/F-Div-Step/step-reads-the-resolved-composite/project/app.ts +4 -0
- package/fixtures/F-Div-Step/step-reads-the-resolved-composite/project/pipeline.ts +3 -0
- package/fixtures/F-Host-Closed-vs-Open/package-call-is-closed/expect.json +18 -5
- package/fixtures/F-Host-Composite/a-registered-composite-is-interpreted/expect.json +47 -0
- package/fixtures/F-Host-Composite/a-registered-composite-is-interpreted/project/app.ts +4 -0
- package/fixtures/F-Host-Composite/a-registered-composite-is-interpreted/project/store.ts +6 -0
- package/fixtures/F-IsolatedRefusal/a-project-factory-is-refused-not-invoked/expect.json +18 -0
- package/fixtures/F-IsolatedRefusal/a-project-factory-is-refused-not-invoked/project/app.ts +3 -0
- package/fixtures/F-IsolatedRefusal/a-project-factory-is-refused-not-invoked/project/shapes.ts +5 -0
- package/fixtures/F-Obs-Counters/a-build-reports-its-counts/expect.json +43 -0
- package/fixtures/F-Obs-Counters/a-build-reports-its-counts/project/app.ts +5 -0
- package/fixtures/F-Obs-Counters/a-build-reports-its-counts/project/shapes.ts +3 -0
- package/fixtures/F-Rule-Equivalence/same-source-same-findings/expect.json +21 -0
- package/fixtures/F-Rule-Equivalence/same-source-same-findings/project/buckets.ts +8 -0
- package/fixtures/F-Rule-Equivalence/same-source-same-findings/project/helpers.ts +3 -0
- package/fixtures/F-Rule-Input/a-rule-sees-the-folded-namespace/expect.json +21 -0
- package/fixtures/F-Rule-Input/a-rule-sees-the-folded-namespace/project/buckets.ts +4 -0
- package/fixtures/F-Rule-Phase/a-post-rule-reads-the-artifact/expect.json +21 -0
- package/fixtures/F-Rule-Phase/a-post-rule-reads-the-artifact/project/pairs.ts +3 -0
- package/fixtures/F-Rule-Pure/findings-are-a-function-of-the-input/expect.json +21 -0
- package/fixtures/F-Rule-Pure/findings-are-a-function-of-the-input/project/buckets.ts +4 -0
- package/fixtures/F-Rule-Pure/findings-are-a-function-of-the-input/project/clock.ts +1 -0
- package/fixtures/F-Rule-Supply/the-host-names-the-rule/expect.json +21 -0
- package/fixtures/F-Rule-Supply/the-host-names-the-rule/project/app.ts +4 -0
- package/fixtures/F-Val-Domain/every-case-is-a-value/expect.json +49 -0
- package/fixtures/F-Val-Domain/every-case-is-a-value/project/app.ts +6 -0
- package/fixtures/F-Val-Envelope/envelopes-are-the-output-in-data-host/expect.json +41 -0
- package/fixtures/F-Val-Envelope/envelopes-are-the-output-in-data-host/project/app.ts +5 -0
- package/fixtures/F-Val-Source/a-form-per-case-in-data-host/expect.json +74 -0
- package/fixtures/F-Val-Source/a-form-per-case-in-data-host/project/app.ts +8 -0
- package/fixtures/F-Val-Source/a-form-per-case-in-full/expect.json +35 -0
- package/fixtures/F-Val-Source/a-form-per-case-in-full/project/app.ts +7 -0
- package/fixtures/F-Val-Symbol-Scope/a-symbol-only-inside-an-intrinsic/expect.json +32 -0
- package/fixtures/F-Val-Symbol-Scope/a-symbol-only-inside-an-intrinsic/project/interior.ts +3 -0
- package/fixtures/F-Val-Symbol-Scope/a-symbol-only-inside-an-intrinsic/project/top.ts +1 -0
- package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/expect.json +34 -0
- package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/array.ts +1 -0
- package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/class.ts +1 -0
- package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/default-function.ts +3 -0
- package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/let.ts +1 -0
- package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/ok.ts +1 -0
- package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/rest.ts +1 -0
- package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/star.ts +1 -0
- package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/uninitialized.ts +1 -0
- package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/var.ts +1 -0
- package/fixtures/S-ExportDestructure/plain-elements/expect.json +16 -0
- package/fixtures/S-ExportDestructure/plain-elements/project/app.ts +3 -0
- package/fixtures/S-ExportNamed/local-names-with-an-alias/expect.json +19 -0
- package/fixtures/S-ExportNamed/local-names-with-an-alias/project/app.ts +4 -0
- package/fixtures/S-ExportResource/new-with-props/expect.json +26 -0
- package/fixtures/S-ExportResource/new-with-props/project/app.ts +3 -0
- package/fixtures/S-ExportSingle/an-expression-initializer/expect.json +21 -0
- package/fixtures/S-ExportSingle/an-expression-initializer/input.ts +1 -0
- package/fixtures/S-ExportTypeOnly/type-exports-are-invisible/expect.json +19 -0
- package/fixtures/S-ExportTypeOnly/type-exports-are-invisible/project/app.ts +12 -0
- package/fixtures/S-FactoryBody/consts-then-a-final-return/expect.json +38 -0
- package/fixtures/S-FactoryBody/consts-then-a-final-return/project/bad-shapes.ts +7 -0
- package/fixtures/S-FactoryBody/consts-then-a-final-return/project/empty.ts +3 -0
- package/fixtures/S-FactoryBody/consts-then-a-final-return/project/good-shapes.ts +7 -0
- package/fixtures/S-FactoryBody/consts-then-a-final-return/project/good.ts +3 -0
- package/fixtures/S-FactoryBody/consts-then-a-final-return/project/no-return.ts +3 -0
- package/fixtures/S-FactoryParams/one-plainly-bound-parameter/expect.json +38 -0
- package/fixtures/S-FactoryParams/one-plainly-bound-parameter/project/bad-shapes.ts +4 -0
- package/fixtures/S-FactoryParams/one-plainly-bound-parameter/project/good-shapes.ts +3 -0
- package/fixtures/S-FactoryParams/one-plainly-bound-parameter/project/named.ts +3 -0
- package/fixtures/S-FactoryParams/one-plainly-bound-parameter/project/rest.ts +3 -0
- package/fixtures/S-FactoryParams/one-plainly-bound-parameter/project/two.ts +3 -0
- package/fixtures/S-Module/non-exported-statements-are-invisible/expect.json +17 -0
- package/fixtures/S-Module/non-exported-statements-are-invisible/project/app.ts +13 -0
- package/fixtures/S-Prop/a-computed-key-rejects/expect.json +10 -0
- package/fixtures/S-Prop/a-computed-key-rejects/input.ts +3 -0
- package/fixtures/S-Prop/literal-keys/expect.json +15 -0
- package/fixtures/S-Prop/literal-keys/input.ts +1 -0
- package/fixtures/S-ReExport/named-from-a-sibling/expect.json +18 -0
- package/fixtures/S-ReExport/named-from-a-sibling/project/app.ts +1 -0
- package/fixtures/S-ReExport/named-from-a-sibling/project/config.ts +2 -0
- package/fixtures/S-Shorthand/a-shorthand-property-resolves-as-an-identifier/expect.json +14 -0
- package/fixtures/S-Shorthand/a-shorthand-property-resolves-as-an-identifier/input.ts +4 -0
- package/fixtures/S-TopConst/an-unexported-const-is-a-binding/expect.json +16 -0
- package/fixtures/S-TopConst/an-unexported-const-is-a-binding/project/binding.ts +3 -0
- package/fixtures/S-TopConst/an-unexported-const-is-a-binding/project/invisible.ts +3 -0
- package/fixtures/UNCOVERED.md +1 -28
- package/fixtures/roundtrip/a-resource-and-its-attribute/expect.json +8 -0
- package/fixtures/roundtrip/a-resource-and-its-attribute/value.json +29 -0
- package/fixtures/roundtrip/a-resource-by-args/expect.json +8 -0
- package/fixtures/roundtrip/a-resource-by-args/value.json +14 -0
- package/fixtures/roundtrip/intrinsics-and-a-symbol/expect.json +8 -0
- package/fixtures/roundtrip/intrinsics-and-a-symbol/value.json +36 -0
- package/fixtures/roundtrip/scalars-and-containers/expect.json +8 -0
- package/fixtures/roundtrip/scalars-and-containers/value.json +28 -0
- package/package.json +1 -1
- package/spec/VERSION +1 -1
- package/spec/hosts.md +6 -2
- package/spec/judgments.md +27 -10
- package/spec/rules.md +7 -4
- package/spec/values.md +47 -2
- package/src/adapter.ts +53 -0
- package/src/adapters/chant.ts +361 -10
- package/src/adapters/rust.ts +77 -0
- package/src/chant-agreement.test.ts +68 -23
- package/src/corpus.test.ts +12 -0
- package/src/corpus.ts +76 -34
- package/src/fixture.ts +42 -3
- package/src/host.ts +50 -0
- package/src/runner.test.ts +6 -2
- package/src/runner.ts +107 -5
- package/src/rust-agreement.test.ts +37 -0
package/src/adapters/chant.ts
CHANGED
|
@@ -10,7 +10,8 @@ import { dirname, join } from "node:path";
|
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import * as ts from "typescript";
|
|
12
12
|
import * as chant from "@intentius/chant";
|
|
13
|
-
import type { ConformanceAdapter, ProjectResult, ProjectVerdict } from "../adapter.js";
|
|
13
|
+
import type { ConformanceAdapter, Finding, IsolationMode, ProjectResult, ProjectVerdict, RulePhase, Severity } from "../adapter.js";
|
|
14
|
+
import type { ConformanceHost } from "../host.js";
|
|
14
15
|
|
|
15
16
|
function exportInitializer(sf: ts.SourceFile, name: string): ts.Expression | undefined {
|
|
16
17
|
for (const st of sf.statements) {
|
|
@@ -53,14 +54,197 @@ type ChantVerdict = {
|
|
|
53
54
|
taintedBy?: { from: string; kind: "importer" | "capture" };
|
|
54
55
|
exports?: ReadonlyMap<string, unknown>;
|
|
55
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* Whether the pinned chant takes `FoldProjectOptions.lexiconPackages`
|
|
59
|
+
* (chant#2438).
|
|
60
|
+
*
|
|
61
|
+
* Probed, not inferred from a version string. An older pin ignores an unknown
|
|
62
|
+
* option silently, so every hosted fixture would come back `run` and read as a
|
|
63
|
+
* real disagreement rather than as a missing feature. The probe is the smallest
|
|
64
|
+
* possible instance of the thing itself: one file importing one generated
|
|
65
|
+
* package. Memoized, since every hosted fixture asks.
|
|
66
|
+
*/
|
|
67
|
+
/** chant's post-synthesis engine, which is what runs the host's rules (#101). */
|
|
68
|
+
const runChecks = (
|
|
69
|
+
chant as unknown as {
|
|
70
|
+
runPostSynthChecks?: (
|
|
71
|
+
checks: unknown[],
|
|
72
|
+
buildResult: { outputs: Map<string, string>; entities: Map<string, unknown> },
|
|
73
|
+
) => Array<{ checkId: string; severity: string; message: string; entity?: string }>;
|
|
74
|
+
}
|
|
75
|
+
).runPostSynthChecks;
|
|
76
|
+
|
|
77
|
+
let hostPackageSupport: Promise<boolean> | undefined;
|
|
78
|
+
|
|
79
|
+
function acceptsHostPackages(): Promise<boolean> {
|
|
80
|
+
hostPackageSupport ??= (async () => {
|
|
81
|
+
if (!projectFn) return false;
|
|
82
|
+
const root = mkdtempSync(join(tmpdir(), "tsad-probe-"));
|
|
83
|
+
try {
|
|
84
|
+
const dir = join(root, "node_modules", "@tsad", "probe");
|
|
85
|
+
mkdirSync(dir, { recursive: true });
|
|
86
|
+
writeFileSync(
|
|
87
|
+
join(dir, "package.json"),
|
|
88
|
+
JSON.stringify({ name: "@tsad/probe", version: "0.0.0", type: "module", main: "index.js" }),
|
|
89
|
+
"utf8",
|
|
90
|
+
);
|
|
91
|
+
writeFileSync(join(dir, "index.js"), "export const PROBE = 1;\n", "utf8");
|
|
92
|
+
const file = join(root, "probe.ts");
|
|
93
|
+
writeFileSync(file, 'import { PROBE } from "@tsad/probe";\nexport const p = PROBE;\n', "utf8");
|
|
94
|
+
const verdicts = await projectFn([file], [], { lexiconPackages: ["@tsad/probe"] });
|
|
95
|
+
return verdicts.get(file)?.verdict === "fold";
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
} finally {
|
|
99
|
+
rmSync(root, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
return hostPackageSupport;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Does this chant interpret a composite the HOST registered, under sandbox?
|
|
107
|
+
*
|
|
108
|
+
* chant#2442, fixed in chant-v0.72.1. Before it, `findCompositeDefinition`
|
|
109
|
+
* required `Composite` to be chant's own, so a host's registration form was
|
|
110
|
+
* never recognised; and recognising it was not enough on its own, because
|
|
111
|
+
* chant wrapped the host's members in its own `Composite`, whose member
|
|
112
|
+
* validation asks for chant's `Declarable` and refused an entity carrying a
|
|
113
|
+
* different marker.
|
|
114
|
+
*
|
|
115
|
+
* Both halves are exercised here: the fixture folds only if the form is
|
|
116
|
+
* recognised AND the host's own entity survives, and it runs under `sandbox`
|
|
117
|
+
* so a chant that fell through to invocation reports `run` rather than
|
|
118
|
+
* folding by the wrong route.
|
|
119
|
+
*/
|
|
120
|
+
let hostCompositeSupport: Promise<boolean> | undefined;
|
|
121
|
+
|
|
122
|
+
function interpretsHostComposites(): Promise<boolean> {
|
|
123
|
+
hostCompositeSupport ??= (async () => {
|
|
124
|
+
if (!projectFn) return false;
|
|
125
|
+
const root = mkdtempSync(join(tmpdir(), "tsad-probe-composite-"));
|
|
126
|
+
try {
|
|
127
|
+
const dir = join(root, "node_modules", "@tsad", "probe-composite");
|
|
128
|
+
mkdirSync(dir, { recursive: true });
|
|
129
|
+
writeFileSync(
|
|
130
|
+
join(dir, "package.json"),
|
|
131
|
+
JSON.stringify({ name: "@tsad/probe-composite", version: "0.0.0", type: "module", main: "index.js" }),
|
|
132
|
+
"utf8",
|
|
133
|
+
);
|
|
134
|
+
writeFileSync(
|
|
135
|
+
join(dir, "index.js"),
|
|
136
|
+
[
|
|
137
|
+
"const MARK = Symbol.for('tsad.conformance.declarable');",
|
|
138
|
+
"export class Thing {",
|
|
139
|
+
" constructor(props = {}) {",
|
|
140
|
+
" this.entityType = 'Thing'; this.props = props;",
|
|
141
|
+
" Object.defineProperty(this, MARK, { value: true, enumerable: false });",
|
|
142
|
+
" }",
|
|
143
|
+
"}",
|
|
144
|
+
"export function Composite(factory, name) {",
|
|
145
|
+
" const d = (props) => factory(props); d.compositeName = name; return d;",
|
|
146
|
+
"}",
|
|
147
|
+
].join("\n") + "\n",
|
|
148
|
+
"utf8",
|
|
149
|
+
);
|
|
150
|
+
const defs = join(root, "defs.ts");
|
|
151
|
+
writeFileSync(
|
|
152
|
+
defs,
|
|
153
|
+
'import { Thing, Composite } from "@tsad/probe-composite";\n' +
|
|
154
|
+
'export const C = Composite(({ n }) => ({ t: new Thing({ n }) }), "C");\n',
|
|
155
|
+
"utf8",
|
|
156
|
+
);
|
|
157
|
+
const file = join(root, "probe.ts");
|
|
158
|
+
writeFileSync(file, 'import { C } from "./defs";\nexport const c = C({ n: "x" });\n', "utf8");
|
|
159
|
+
const verdicts = await projectFn([file], [], {
|
|
160
|
+
lexiconPackages: ["@tsad/probe-composite"],
|
|
161
|
+
sandbox: true,
|
|
162
|
+
});
|
|
163
|
+
return verdicts.get(file)?.verdict === "fold";
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
} finally {
|
|
167
|
+
rmSync(root, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
})();
|
|
170
|
+
return hostCompositeSupport;
|
|
171
|
+
}
|
|
172
|
+
|
|
56
173
|
const projectFn = (
|
|
57
174
|
chant as unknown as {
|
|
58
|
-
foldProject?: (
|
|
175
|
+
foldProject?: (
|
|
176
|
+
files: readonly string[],
|
|
177
|
+
intrinsics?: readonly unknown[],
|
|
178
|
+
options?: { lexiconPackages?: readonly string[]; sandbox?: boolean },
|
|
179
|
+
) => Promise<Map<string, ChantVerdict>>;
|
|
59
180
|
}
|
|
60
181
|
).foldProject;
|
|
61
182
|
|
|
183
|
+
/**
|
|
184
|
+
* The key a generated host package reads its values back out of (#2438).
|
|
185
|
+
*
|
|
186
|
+
* A host's values are live JavaScript — classes revival must `new`, functions
|
|
187
|
+
* it must call — so the package written next to a fixture cannot be their
|
|
188
|
+
* source. It reads them off the process instead, which is sound because chant
|
|
189
|
+
* imports it in this same process.
|
|
190
|
+
*/
|
|
191
|
+
const HOST_VALUES = Symbol.for("tsad.conformance.hostValues");
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Write the host's packages into `root/node_modules`, so chant's own module
|
|
195
|
+
* resolution finds them from the fixture sources (#2438).
|
|
196
|
+
*
|
|
197
|
+
* Returns the specifiers written, which is what chant has to be told to follow
|
|
198
|
+
* a bare import into: its allowlist is `@intentius/chant-lexicon-*` unless a
|
|
199
|
+
* caller names a package outright, and `@tsad/shapes` is not that shape.
|
|
200
|
+
*/
|
|
201
|
+
function installHost(root: string, host: ConformanceHost): string[] {
|
|
202
|
+
const globals = (globalThis as Record<symbol, unknown>)[HOST_VALUES] as
|
|
203
|
+
| Map<string, ReadonlyMap<string, unknown>>
|
|
204
|
+
| undefined;
|
|
205
|
+
const registry = globals ?? new Map<string, ReadonlyMap<string, unknown>>();
|
|
206
|
+
(globalThis as Record<symbol, unknown>)[HOST_VALUES] = registry;
|
|
207
|
+
|
|
208
|
+
const specifiers: string[] = [];
|
|
209
|
+
for (const [specifier, values] of host.values) {
|
|
210
|
+
registry.set(specifier, values);
|
|
211
|
+
const dir = join(root, "node_modules", ...specifier.split("/"));
|
|
212
|
+
mkdirSync(dir, { recursive: true });
|
|
213
|
+
writeFileSync(
|
|
214
|
+
join(dir, "package.json"),
|
|
215
|
+
JSON.stringify({ name: specifier, version: "0.0.0", type: "module", main: "index.js" }),
|
|
216
|
+
"utf8",
|
|
217
|
+
);
|
|
218
|
+
// One binding per export, read back from the process. `export const` with a
|
|
219
|
+
// computed initializer is still a static export, so a named import of it
|
|
220
|
+
// resolves exactly as it would from a hand-written module.
|
|
221
|
+
const lines = [
|
|
222
|
+
`const values = globalThis[Symbol.for(${JSON.stringify("tsad.conformance.hostValues")})].get(${JSON.stringify(specifier)});`,
|
|
223
|
+
...[...values.keys()].map((name) => `export const ${name} = values.get(${JSON.stringify(name)});`),
|
|
224
|
+
];
|
|
225
|
+
writeFileSync(join(dir, "index.js"), lines.join("\n") + "\n", "utf8");
|
|
226
|
+
specifiers.push(specifier);
|
|
227
|
+
}
|
|
228
|
+
return specifiers;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The host's registered intrinsics, in chant's own `IntrinsicDef` shape (#2438). */
|
|
232
|
+
function hostIntrinsics(host: ConformanceHost): unknown[] {
|
|
233
|
+
return host.intrinsics.map((i) => ({
|
|
234
|
+
name: i.name,
|
|
235
|
+
isTag: i.isTag,
|
|
236
|
+
...(i.foldsAsCall ? { foldsAsCall: true } : {}),
|
|
237
|
+
...(i.foldsEagerly ? { foldsEagerly: true } : {}),
|
|
238
|
+
...(i.outputKey ? { outputKey: i.outputKey } : {}),
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
|
|
62
242
|
/** chant resolves modules from disk, so a fixture's sources are written out and the paths handed over. */
|
|
63
|
-
async function foldOnDisk(
|
|
243
|
+
async function foldOnDisk(
|
|
244
|
+
files: Map<string, string>,
|
|
245
|
+
host?: ConformanceHost,
|
|
246
|
+
mode?: IsolationMode,
|
|
247
|
+
): Promise<ProjectResult> {
|
|
64
248
|
const root = mkdtempSync(join(tmpdir(), "tsad-conformance-"));
|
|
65
249
|
try {
|
|
66
250
|
const paths: string[] = [];
|
|
@@ -70,7 +254,15 @@ async function foldOnDisk(files: Map<string, string>): Promise<ProjectResult> {
|
|
|
70
254
|
writeFileSync(abs, source, "utf8");
|
|
71
255
|
paths.push(abs);
|
|
72
256
|
}
|
|
73
|
-
const
|
|
257
|
+
const lexiconPackages = host ? installHost(root, host) : [];
|
|
258
|
+
const verdicts = await projectFn!(paths, host ? hostIntrinsics(host) : [], {
|
|
259
|
+
lexiconPackages,
|
|
260
|
+
// tsad#113 — `isolated` means "do not invoke". chant's `--sandbox`
|
|
261
|
+
// (chant#1093) is the setting that refuses to import project code, which
|
|
262
|
+
// is F-Call step 5 firing so step 6 never runs. It leaves interpretation
|
|
263
|
+
// alone, so the interpretable factories still fold.
|
|
264
|
+
...(mode === "isolated" ? { sandbox: true } : {}),
|
|
265
|
+
});
|
|
74
266
|
const out: ProjectResult = { verdicts: {}, tentative: {}, taintedBy: {} };
|
|
75
267
|
const relOf = (abs: string) => abs.slice(root.length + 1).split(/[\\/]/).join("/");
|
|
76
268
|
for (const [abs, v] of verdicts) {
|
|
@@ -88,6 +280,100 @@ async function foldOnDisk(files: Map<string, string>): Promise<ProjectResult> {
|
|
|
88
280
|
}
|
|
89
281
|
}
|
|
90
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The `shapes` host's two rules, as chant post-synthesis checks (#101, chant#2438).
|
|
285
|
+
*
|
|
286
|
+
* A rule is host code and the harness carries only its identifier, so an
|
|
287
|
+
* implementation supplies the code. These are written against chant's own
|
|
288
|
+
* `PostSynthCheck` contract and run through chant's own `runPostSynthChecks`,
|
|
289
|
+
* because that engine is where the rules contract was extracted from. Writing
|
|
290
|
+
* them against the fold output directly would re-implement the reference inside
|
|
291
|
+
* the adapter, and `F-Rule-Equivalence` would then be comparing this file with
|
|
292
|
+
* itself rather than two implementations.
|
|
293
|
+
*
|
|
294
|
+
* chant's engine has one phase where the specification has two, so the phase is
|
|
295
|
+
* expressed in the input each check is given rather than in the engine: the
|
|
296
|
+
* pre-synthesis rule is handed the folded namespace as `entities`, the
|
|
297
|
+
* post-synthesis rule the serialized artifact as `outputs`. The runner asks for
|
|
298
|
+
* each phase separately, so each gets a context built for it.
|
|
299
|
+
*/
|
|
300
|
+
interface ChantCheck {
|
|
301
|
+
id: string;
|
|
302
|
+
description: string;
|
|
303
|
+
check(ctx: { entities: Map<string, unknown>; outputs: Map<string, string> }): Array<{
|
|
304
|
+
checkId: string;
|
|
305
|
+
severity: string;
|
|
306
|
+
message: string;
|
|
307
|
+
entity?: string;
|
|
308
|
+
}>;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const isBucket = (v: unknown): v is { props?: Record<string, unknown> } =>
|
|
312
|
+
typeof v === "object" && v !== null && (v as { entityType?: unknown }).entityType === "Bucket";
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The namespace a rule sees, with callables removed.
|
|
316
|
+
*
|
|
317
|
+
* A callable is never a value (`F-Val-Callable`) and never reaches a serializer
|
|
318
|
+
* (`F-Val-Serializable`), so an exported function is not part of what a rule
|
|
319
|
+
* reads. chant exports a project-local function as a `FoldableFunction` marker
|
|
320
|
+
* carrying the AST it would interpret, so leaving them in also means the
|
|
321
|
+
* post-synthesis artifact cannot be serialized at all.
|
|
322
|
+
*/
|
|
323
|
+
function ruleNamespace(exports: Record<string, unknown>): Map<string, unknown> {
|
|
324
|
+
const isCallable = (chant as unknown as { isFoldableFunction?: (v: unknown) => boolean }).isFoldableFunction;
|
|
325
|
+
return new Map(
|
|
326
|
+
Object.entries(exports).filter(([, v]) => typeof v !== "function" && !(isCallable?.(v) ?? false)),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function shapesChecks(severityOf: (id: string) => Severity): Record<RulePhase, ChantCheck> {
|
|
331
|
+
return {
|
|
332
|
+
pre: {
|
|
333
|
+
id: "SHAPES001",
|
|
334
|
+
description: "every Bucket declares a BucketName",
|
|
335
|
+
check(ctx) {
|
|
336
|
+
const out = [];
|
|
337
|
+
for (const [name, value] of ctx.entities) {
|
|
338
|
+
if (isBucket(value) && !(value.props && "BucketName" in value.props)) {
|
|
339
|
+
out.push({
|
|
340
|
+
checkId: "SHAPES001",
|
|
341
|
+
severity: severityOf("SHAPES001"),
|
|
342
|
+
message: `Bucket ${name} declares no BucketName`,
|
|
343
|
+
entity: name,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
post: {
|
|
351
|
+
id: "SHAPES002",
|
|
352
|
+
description: "the artifact declares at least one Bucket",
|
|
353
|
+
check(ctx) {
|
|
354
|
+
// The artifact, not the namespace: this reads the serialized form, which
|
|
355
|
+
// is what makes it a post-synthesis rule (F-Rule-Phase).
|
|
356
|
+
const declaresBucket = [...ctx.outputs.values()].some((doc) => {
|
|
357
|
+
const parsed = JSON.parse(doc) as Record<string, Record<string, unknown>>;
|
|
358
|
+
return Object.values(parsed).some((exports) => Object.values(exports).some(isBucket));
|
|
359
|
+
});
|
|
360
|
+
return declaresBucket
|
|
361
|
+
? []
|
|
362
|
+
: [
|
|
363
|
+
{
|
|
364
|
+
checkId: "SHAPES002",
|
|
365
|
+
severity: severityOf("SHAPES002"),
|
|
366
|
+
// Nothing to attach it to, so the subject states what is absent
|
|
367
|
+
// (F-Rule-Finding, L11.5).
|
|
368
|
+
message: "the artifact declares no Bucket",
|
|
369
|
+
entity: "missing: Bucket",
|
|
370
|
+
},
|
|
371
|
+
];
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
91
377
|
export const chantAdapter: ConformanceAdapter = {
|
|
92
378
|
// Read from the installed package, never a literal: a hardcoded fallback
|
|
93
379
|
// silently misreports the pin, and this name is what the paper's measurement
|
|
@@ -105,13 +391,78 @@ export const chantAdapter: ConformanceAdapter = {
|
|
|
105
391
|
const { line, character } = sf.getLineAndCharacterOfPosition(v.node.getStart());
|
|
106
392
|
return { accepted: false, rule: v.ruleId, line: line + 1, column: character + 1, message: v.message };
|
|
107
393
|
},
|
|
108
|
-
async foldProject(files, host) {
|
|
394
|
+
async foldProject(files, host, mode) {
|
|
109
395
|
if (!projectFn) return "unavailable";
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
396
|
+
// #2438 — a host used to make a fixture unanswerable here: its entity
|
|
397
|
+
// classes come from a package chant's allowlist could not name, and the
|
|
398
|
+
// entry took an intrinsic registry rather than a host. Both are addressed:
|
|
399
|
+
// the host's packages are written next to the sources and named to chant
|
|
400
|
+
// outright, and its registrations are translated. A chant too old for the
|
|
401
|
+
// `lexiconPackages` option would resolve none of it, so say so rather than
|
|
402
|
+
// report a wrong verdict.
|
|
403
|
+
if (host && !(await acceptsHostPackages())) return "unavailable";
|
|
404
|
+
// tsad#113 — `isolated` is answerable now, and it was not before.
|
|
405
|
+
//
|
|
406
|
+
// The two rules are close but not identical: isolated refuses to INVOKE,
|
|
407
|
+
// so F-Call step 5 fires and step 6 never imports, while chant's
|
|
408
|
+
// `--sandbox` refuses to import project code at all. Wiring them together
|
|
409
|
+
// used to trade one disagreement for another, because chant took the
|
|
410
|
+
// interpretable factories down with the rest: the four negatives agreed
|
|
411
|
+
// and `good.ts` and `named.ts` broke, reference `fold` against chant `run`.
|
|
412
|
+
// That was chant#2442 — a host's `Composite` was not recognised as the
|
|
413
|
+
// registration form, so nothing was interpreted and everything fell to the
|
|
414
|
+
// invocation arm the sandbox refuses.
|
|
415
|
+
//
|
|
416
|
+
// A chant without that fix would report exactly those wrong verdicts, so
|
|
417
|
+
// the probe is a behaviour test rather than a version comparison, the same
|
|
418
|
+
// posture as `acceptsHostPackages` above.
|
|
419
|
+
if (mode === "isolated" && !(await interpretsHostComposites())) return "unavailable";
|
|
420
|
+
return foldOnDisk(files, host, mode);
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
async rules(files, host, phase) {
|
|
424
|
+
if (!projectFn || !runChecks) return "unavailable";
|
|
425
|
+
if (!(await acceptsHostPackages())) return "unavailable";
|
|
426
|
+
|
|
427
|
+
// Every rule the host names must be one this implementation carries, and at
|
|
428
|
+
// the phase the host declared. A host naming a rule chant has no code for
|
|
429
|
+
// is reported unavailable and the fixture is skipped visibly, rather than
|
|
430
|
+
// answered with a silence that would read as "no findings".
|
|
431
|
+
const declared = host.rules ?? [];
|
|
432
|
+
if (declared.length === 0) return "unavailable";
|
|
433
|
+
const checks = shapesChecks((id) => declared.find((r) => r.id === id)?.severity ?? "error");
|
|
434
|
+
if (!declared.every((r) => checks[r.phase]?.id === r.id)) return "unavailable";
|
|
435
|
+
|
|
436
|
+
const folded = await foldOnDisk(files, host);
|
|
437
|
+
const wanted = declared.filter((r) => r.phase === phase);
|
|
438
|
+
if (wanted.length === 0) return [];
|
|
439
|
+
|
|
440
|
+
const out: Finding[] = [];
|
|
441
|
+
for (const rule of wanted) {
|
|
442
|
+
const check = checks[rule.phase];
|
|
443
|
+
// A file that ran has no folded namespace, so no rule sees it
|
|
444
|
+
// (F-Rule-Input). Pre runs per file, so a finding carries the file whose
|
|
445
|
+
// namespace holds its subject; post reads one artifact over the whole
|
|
446
|
+
// build and carries none.
|
|
447
|
+
const folds = Object.entries(folded.verdicts).filter(([, v]) => v.kind === "fold");
|
|
448
|
+
if (phase === "pre") {
|
|
449
|
+
for (const [file, verdict] of folds) {
|
|
450
|
+
const entities = ruleNamespace((verdict as { exports: Record<string, unknown> }).exports);
|
|
451
|
+
for (const d of runChecks([check as never], { outputs: new Map(), entities } as never)) {
|
|
452
|
+
out.push({ rule: d.checkId, severity: d.severity as Severity, subject: d.entity ?? "", message: d.message, file });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const artifact = Object.fromEntries(
|
|
458
|
+
folds.map(([file, v]) => [file, Object.fromEntries(ruleNamespace((v as { exports: Record<string, unknown> }).exports))]),
|
|
459
|
+
);
|
|
460
|
+
const outputs = new Map([["artifact", JSON.stringify(artifact)]]);
|
|
461
|
+
for (const d of runChecks([check as never], { outputs, entities: new Map() } as never)) {
|
|
462
|
+
out.push({ rule: d.checkId, severity: d.severity as Severity, subject: d.entity ?? "", message: d.message });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
115
466
|
},
|
|
116
467
|
foldExport(source, exportName) {
|
|
117
468
|
const sf = parse(source); const init = exportInitializer(sf, exportName);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adapter for `evaluators/rust`, the evaluator with no JavaScript
|
|
3
|
+
* runtime (#86). It is a binary that speaks JSON on stdin and stdout; this
|
|
4
|
+
* adapter spawns it per call and decodes the two things JSON cannot carry,
|
|
5
|
+
* `undefined` and a non-finite number, from the tagged objects the binary
|
|
6
|
+
* writes. Nothing here folds anything.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { ConformanceAdapter, ConformanceHost } from "../index.js";
|
|
12
|
+
|
|
13
|
+
/** The binary, from `TSAD_RUST_EVAL` or the crate's release build; `undefined` when neither exists. */
|
|
14
|
+
export function rustEvaluatorPath(root = process.cwd()): string | undefined {
|
|
15
|
+
const env = process.env.TSAD_RUST_EVAL;
|
|
16
|
+
if (env) return existsSync(env) ? env : undefined;
|
|
17
|
+
const built = join(root, "evaluators", "rust", "target", "release", "tsad-eval");
|
|
18
|
+
return existsSync(built) ? built : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function decode(v: unknown): unknown {
|
|
22
|
+
if (Array.isArray(v)) return v.map(decode);
|
|
23
|
+
if (v && typeof v === "object") {
|
|
24
|
+
const o = v as Record<string, unknown>;
|
|
25
|
+
if (o.$tsad === "undefined") return undefined;
|
|
26
|
+
if (o.$tsad === "number") return Number(o.text);
|
|
27
|
+
if (o.$tsad === "function" || o.$tsad === "host") return o;
|
|
28
|
+
return Object.fromEntries(Object.entries(o).map(([k, e]) => [k, decode(e)]));
|
|
29
|
+
}
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The inverse of decode, for a value the harness sends in (generate's namespace). */
|
|
34
|
+
function encode(v: unknown): unknown {
|
|
35
|
+
if (v === undefined) return { $tsad: "undefined" };
|
|
36
|
+
if (typeof v === "number" && !Number.isFinite(v)) return { $tsad: "number", text: String(v) };
|
|
37
|
+
if (Array.isArray(v)) return v.map(encode);
|
|
38
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, e]) => [k, encode(e)]));
|
|
39
|
+
return v;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hostDescription(h?: ConformanceHost) {
|
|
43
|
+
// F-Profile-DataHost: the host is a description, the registry and the trust set.
|
|
44
|
+
return h ? { intrinsics: h.intrinsics, ownedSpecifierPrefixes: h.ownedSpecifierPrefixes } : {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function rustAdapter(binary: string): ConformanceAdapter {
|
|
48
|
+
const call = (req: Record<string, unknown>): Record<string, unknown> => {
|
|
49
|
+
const out = execFileSync(binary, [], { input: JSON.stringify(req), encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
50
|
+
const r = JSON.parse(out) as Record<string, unknown>;
|
|
51
|
+
if (r.error) throw new Error(`tsad-eval: ${r.error}`);
|
|
52
|
+
return r;
|
|
53
|
+
};
|
|
54
|
+
const version = call({ op: "version" }) as { specVersion: string; name: string };
|
|
55
|
+
return {
|
|
56
|
+
name: version.name,
|
|
57
|
+
specVersion: version.specVersion,
|
|
58
|
+
shape(source, exportName) {
|
|
59
|
+
const r = call({ op: "shape", files: { "input.ts": source }, entry: "input.ts", export: exportName }) as { accepted: boolean; rule?: string; line: number; column: number; message: string };
|
|
60
|
+
return r.accepted ? { accepted: true } : { accepted: false, rule: r.rule, line: r.line, column: r.column, message: r.message };
|
|
61
|
+
},
|
|
62
|
+
foldExport(source, exportName) {
|
|
63
|
+
const r = call({ op: "foldExport", files: { "input.ts": source }, entry: "input.ts", export: exportName }) as { ok: boolean; value?: unknown; rule?: string; line: number; column: number; message: string };
|
|
64
|
+
return r.ok ? { ok: true, value: decode(r.value) } : { ok: false, rule: r.rule, line: r.line, column: r.column, message: r.message };
|
|
65
|
+
},
|
|
66
|
+
foldProject(files, host) {
|
|
67
|
+
const r = call({ op: "foldProject", files: Object.fromEntries(files), host: hostDescription(host) }) as { verdicts: Record<string, { kind: "fold"; exports: Record<string, unknown> } | { kind: "run"; rule?: string; reason: string }> };
|
|
68
|
+
const verdicts: Record<string, { kind: "fold"; exports: Record<string, unknown> } | { kind: "run"; rule?: string; reason: string }> = {};
|
|
69
|
+
for (const [path, v] of Object.entries(r.verdicts)) verdicts[path] = v.kind === "fold" ? { kind: "fold", exports: decode(v.exports) as Record<string, unknown> } : v;
|
|
70
|
+
return { verdicts };
|
|
71
|
+
},
|
|
72
|
+
generate(namespace, host) {
|
|
73
|
+
const r = call({ op: "generate", namespace: encode(namespace), host: hostDescription(host) }) as { source?: string; unavailable?: string };
|
|
74
|
+
return r.source ?? "unavailable";
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -10,13 +10,33 @@ import { referenceAdapter } from "@intentius/tsad-reference";
|
|
|
10
10
|
// (S-ExportDefault, which full keeps under S-Disqualify until chant admits it)
|
|
11
11
|
// is not chant's to answer.
|
|
12
12
|
const all = loadFixtures(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "spec", "fixtures")).filter((f) => f.profiles.includes("full"));
|
|
13
|
-
// chant
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
// chant#2435 held S-CallLocal out of the shape half from spec 1.2 until
|
|
14
|
+
// chant-v0.72.0 admitted a project-local call in its classifier (#2437);
|
|
15
|
+
// nothing is held out of the shape comparison now, and a future hold-out
|
|
16
|
+
// needs a chant issue as its reason, the way this one had.
|
|
17
|
+
//
|
|
18
|
+
// chant#2441 — chant evaluates a helper or intrinsic call inside a folded
|
|
19
|
+
// function body, which F-Div-Depth (L3.16) says the folder must refuse, and
|
|
20
|
+
// produces the envelope the fixture's own note warns about. Unlike every other
|
|
21
|
+
// F-Div row this is not a fallback, so it is a chant bug rather than a
|
|
22
|
+
// tolerated divergence; these three are held out of the whole-build comparison
|
|
23
|
+
// by name until it is fixed, and by name rather than by rule so a new fixture
|
|
24
|
+
// citing F-Div-Depth is compared rather than silently excused.
|
|
25
|
+
const foldsAtDepth = new Set([
|
|
26
|
+
"F-Eval-CallIntrinsic/inside-function-body",
|
|
27
|
+
"F-Eval-CallHelper/inside-function-body",
|
|
28
|
+
"F-Div-Provenance/helper-name-from-project-import",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// chant#2446 — chant has the three counters F-Obs-Counters names, in
|
|
32
|
+
// fold-import.ts's FoldExecutionCounts, and its public entry exports neither
|
|
33
|
+
// the snapshot nor the reset, so the adapter cannot report them through the
|
|
34
|
+
// pinned package. The fixture is skipped there ("counters unavailable") rather
|
|
35
|
+
// than failed, and held out by name until a release exports them.
|
|
36
|
+
const countersNotPublic = new Set(["F-Obs-Counters/a-build-reports-its-counts"]);
|
|
37
|
+
|
|
38
|
+
const heldOut = new Set([...foldsAtDepth, ...countersNotPublic]);
|
|
39
|
+
const fixtures = all.filter((f) => !heldOut.has(f.id));
|
|
20
40
|
|
|
21
41
|
describe("chant cross-check (#11)", () => {
|
|
22
42
|
test("chant passes every fixture through its public fold API", async () => {
|
|
@@ -27,32 +47,57 @@ describe("chant cross-check (#11)", () => {
|
|
|
27
47
|
const dis = await compareAdapters(referenceAdapter, chantAdapter, fixtures);
|
|
28
48
|
expect(dis, dis.join("\n")).toEqual([]);
|
|
29
49
|
});
|
|
30
|
-
test("
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
50
|
+
test("every whole-build fixture reaches chant (#62)", async () => {
|
|
51
|
+
// This used to allow a skip when the fixture named a host, because a host's
|
|
52
|
+
// entity classes came from a package chant's allowlist could not name
|
|
53
|
+
// (chant#2408, chant#2438), and later because chant could not answer in
|
|
54
|
+
// `isolated` at all (chant#2442). Both are fixed and the allowance is gone:
|
|
55
|
+
// every project fixture is answered, so a skip is a failure rather than an
|
|
56
|
+
// excuse. Three fixtures moved from skipped to answered when chant-v0.72.1
|
|
57
|
+
// shipped, and the count is asserted so a silent return to "unavailable"
|
|
58
|
+
// fails here instead of reading as agreement.
|
|
35
59
|
const projects = projectFixtures(fixtures);
|
|
36
60
|
expect(projects.length).toBeGreaterThan(0);
|
|
37
61
|
const reports = await Promise.all(projects.map((f) => runProjectFixture(chantAdapter, f)));
|
|
38
62
|
const skipped = reports.filter((r) => r.skipped).map((r) => r.fixture);
|
|
39
|
-
|
|
40
|
-
const unexpected = skipped.filter((id) => !hosted.has(id));
|
|
41
|
-
expect(unexpected, `skipped without a host to explain it:\n${unexpected.join("\n")}`).toEqual([]);
|
|
42
|
-
// The pin carries the entry, so "unavailable" everywhere would mean the
|
|
43
|
-
// comparison silently stopped happening. Asserted, not assumed.
|
|
44
|
-
expect(skipped.length, "every hostless whole-build fixture must reach chant").toBeLessThan(projects.length);
|
|
63
|
+
expect(skipped, `chant answered none of these:\n${skipped.join("\n")}`).toEqual([]);
|
|
45
64
|
});
|
|
46
65
|
|
|
47
66
|
test("chant and the reference agree on every whole-build fixture chant can answer (#62)", async () => {
|
|
48
67
|
const dis = await compareAdapters(referenceAdapter, chantAdapter, projectFixtures(fixtures));
|
|
49
68
|
expect(dis, dis.join("\n")).toEqual([]);
|
|
50
69
|
});
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
|
|
71
|
+
test("every held-out fixture exists and still disagrees, so neither list outlives its reason", async () => {
|
|
72
|
+
// A hold-out that quietly outlived its cause would be worse than the cause.
|
|
73
|
+
// Run per list rather than over the union, so each empties itself on its
|
|
74
|
+
// own event, so one reason cannot go on excusing another's fixtures.
|
|
75
|
+
// (tsad#110's list emptied when spec 1.6 gave J1 F-Eval-CallHost.)
|
|
76
|
+
for (const [reason, names] of [["chant#2441", foldsAtDepth]] as const) {
|
|
77
|
+
const held = projectFixtures(all).filter((f) => names.has(f.id));
|
|
78
|
+
expect(held.map((f) => f.id).sort(), `${reason}: a held-out fixture no longer exists`).toEqual([...names].sort());
|
|
79
|
+
|
|
80
|
+
// A skip is not agreement. `compareAdapters` reports nothing when one
|
|
81
|
+
// side answers "unavailable", so without this a chant that had stopped
|
|
82
|
+
// answering hosted fixtures at all would read as "these now agree, drop
|
|
83
|
+
// the hold-out" — exactly the false signal this guard exists to prevent,
|
|
84
|
+
// one level up.
|
|
85
|
+
const reports = await Promise.all(held.map((f) => runProjectFixture(chantAdapter, f)));
|
|
86
|
+
const skipped = reports.filter((r) => r.skipped).map((r) => r.fixture);
|
|
87
|
+
expect(skipped, `${reason}: chant answered none of these, so agreement cannot be read from them:\n${skipped.join("\n")}`).toEqual([]);
|
|
88
|
+
|
|
89
|
+
const dis = await compareAdapters(referenceAdapter, chantAdapter, held);
|
|
90
|
+
expect(dis.length, `${reason} looks settled — drop its hold-out`).toBeGreaterThan(0);
|
|
91
|
+
}
|
|
92
|
+
// The other kind of hold-out: chant does not disagree, it cannot answer.
|
|
93
|
+
// The guard is the skip itself; once a release exports the counters and
|
|
94
|
+
// the adapter reports them, this fails and the hold-out retires.
|
|
95
|
+
for (const [reason, names] of [["chant#2446", countersNotPublic]] as const) {
|
|
96
|
+
const held = projectFixtures(all).filter((f) => names.has(f.id));
|
|
97
|
+
expect(held.map((f) => f.id).sort(), `${reason}: a held-out fixture no longer exists`).toEqual([...names].sort());
|
|
98
|
+
const reports = await Promise.all(held.map((f) => runProjectFixture(chantAdapter, f)));
|
|
99
|
+
const notSkipped = reports.filter((r) => r.skipped !== "counters unavailable").map((r) => `${r.fixture}: ${r.skipped ?? (r.pass ? "answered" : r.failures.join("; "))}`);
|
|
100
|
+
expect(notSkipped, `${reason} looks settled — drop its hold-out`).toEqual([]);
|
|
56
101
|
}
|
|
57
102
|
});
|
|
58
103
|
test("chant's shape classifier is available (chant-v0.64.0+, chant#2362) and agrees on every fixture", async () => {
|
package/src/corpus.test.ts
CHANGED
|
@@ -34,6 +34,18 @@ describe.skipIf(!checkout)("the corpus against both implementations (#25)", () =
|
|
|
34
34
|
}
|
|
35
35
|
}, 600_000);
|
|
36
36
|
|
|
37
|
+
test("the data-host column is present and agrees (#86, #116)", () => {
|
|
38
|
+
// A missing evaluator must not read as a clean pass: the column is asserted
|
|
39
|
+
// unless a local run opts out of it by name, and then the report says so.
|
|
40
|
+
if (!summary.dataHost) {
|
|
41
|
+
expect(process.env.TSAD_CORPUS_NO_RUST, "no data-host column: build evaluators/rust (cargo build --release) or set TSAD_CORPUS_NO_RUST=1 to run the two-implementation comparison only").toBeTruthy();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const dis = summary.dataHost.disagreements.map((d) => `${d.entry}/${d.file}: reference ${d.dataHost!.reference}, evaluator ${d.dataHost!.rust} ${d.dataHost!.diff ?? ""}`);
|
|
45
|
+
expect(dis, dis.join("\n")).toEqual([]);
|
|
46
|
+
expect(summary.dataHost.files).toBe(summary.files);
|
|
47
|
+
});
|
|
48
|
+
|
|
37
49
|
test("the corpus is the whole one, not a fragment of it", () => {
|
|
38
50
|
// A checkout whose dependencies are not installed, or a corpus discovery
|
|
39
51
|
// that quietly returned less, would otherwise read as a clean run.
|