@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/corpus.ts
CHANGED
|
@@ -31,6 +31,9 @@ import { foldProject as chantFoldProject } from "@intentius/chant";
|
|
|
31
31
|
import { findInfraFiles } from "@intentius/chant/discovery/files";
|
|
32
32
|
import { FOLDABLE_AUTHORING_HELPERS, isChantOwnedSpecifier } from "@intentius/chant/fold/foldable-helpers";
|
|
33
33
|
import { foldProject as referenceFoldProject, type Host } from "@intentius/tsad-reference";
|
|
34
|
+
import type { ConformanceAdapter } from "./adapter.js";
|
|
35
|
+
import type { ConformanceHost } from "./host.js";
|
|
36
|
+
import { rustAdapter, rustEvaluatorPath } from "./adapters/rust.js";
|
|
34
37
|
|
|
35
38
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
36
39
|
|
|
@@ -125,6 +128,15 @@ export async function discoverCorpus(checkout: ChantCheckout): Promise<CorpusEnt
|
|
|
125
128
|
* A specifier whose module will not load is reported, not skipped silently:
|
|
126
129
|
* every file that imports it is host-limited, and the count has to show it.
|
|
127
130
|
*/
|
|
131
|
+
/**
|
|
132
|
+
* The third column (#86): the evaluator with no JavaScript runtime, judged in
|
|
133
|
+
* `data-host` against the reference judged in the same profile with the same
|
|
134
|
+
* host description. Present when the binary is built; the column is omitted
|
|
135
|
+
* from the report otherwise, never silently empty.
|
|
136
|
+
*/
|
|
137
|
+
const rust: ConformanceAdapter | undefined = (() => { const p = rustEvaluatorPath(REPO_ROOT); return p ? rustAdapter(p) : undefined; })();
|
|
138
|
+
export const dataHostEvaluator = rust ? { name: rust.name, specVersion: rust.specVersion } : undefined;
|
|
139
|
+
|
|
128
140
|
export interface CorpusHost extends Host {
|
|
129
141
|
/** Host-owned specifiers whose module could not be loaded. */
|
|
130
142
|
readonly unloadable: ReadonlySet<string>;
|
|
@@ -174,9 +186,7 @@ export type Side = "fold" | "run";
|
|
|
174
186
|
*/
|
|
175
187
|
export type Limit =
|
|
176
188
|
/** The reference has no bindings for a package it cannot load (#20 is not finished). */
|
|
177
|
-
|
|
178
|
-
/** The reference implements no composite factory form; see `packages/reference/CAVEATS.md`. */
|
|
179
|
-
| "composite";
|
|
189
|
+
"host";
|
|
180
190
|
|
|
181
191
|
/**
|
|
182
192
|
* Both limits disarm the reference, and a limit can only make its own side
|
|
@@ -184,7 +194,15 @@ export type Limit =
|
|
|
184
194
|
* build parameters chant's entry could not be given; chant-v0.71.0 takes both
|
|
185
195
|
* (chant#2422) and they are gone (#96).
|
|
186
196
|
*/
|
|
187
|
-
export const LIMIT_SIDE: Readonly<Record<Limit, "reference">> = { host: "reference"
|
|
197
|
+
export const LIMIT_SIDE: Readonly<Record<Limit, "reference">> = { host: "reference" };
|
|
198
|
+
|
|
199
|
+
/** The data-host column of one file: the reference and the Rust evaluator, both in `data-host`. */
|
|
200
|
+
export interface DataHostComparison {
|
|
201
|
+
reference: Side;
|
|
202
|
+
rust: Side;
|
|
203
|
+
agreed: boolean;
|
|
204
|
+
diff?: string;
|
|
205
|
+
}
|
|
188
206
|
|
|
189
207
|
export interface FileComparison {
|
|
190
208
|
readonly file: string;
|
|
@@ -192,6 +210,8 @@ export interface FileComparison {
|
|
|
192
210
|
readonly reference: Side;
|
|
193
211
|
/** The first limit that applies, for the comparable-set accounting. */
|
|
194
212
|
readonly limit?: Limit;
|
|
213
|
+
/** The data-host column, when the evaluator with no JavaScript runtime was present (#86). */
|
|
214
|
+
readonly dataHost?: DataHostComparison;
|
|
195
215
|
/** Both folded, and their export namespaces were compared as data. */
|
|
196
216
|
readonly values?: "equal" | "differ" | "not-data";
|
|
197
217
|
/** Where the two encodings first differ, with a little context either side, so a difference is a diff and not a verdict. */
|
|
@@ -252,24 +272,6 @@ function hostBoundNames(source: string, path: string, host: CorpusHost): Set<str
|
|
|
252
272
|
* message wording is explicitly non-normative, and a classifier that reads it
|
|
253
273
|
* would silently stop classifying the day the wording changed.
|
|
254
274
|
*/
|
|
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
275
|
/** The reference's own resolution: a relative specifier against the project's key set. */
|
|
274
276
|
function resolveKey(from: string, spec: string, keys: ReadonlySet<string>): string | undefined {
|
|
275
277
|
const base = from.includes("/") ? from.slice(0, from.lastIndexOf("/") + 1) : "";
|
|
@@ -402,27 +404,44 @@ export async function runCorpusEntry(checkout: ChantCheckout, entry: CorpusEntry
|
|
|
402
404
|
const reference = referenceFoldProject(sources, host);
|
|
403
405
|
|
|
404
406
|
const hostSeed = new Set<string>();
|
|
405
|
-
const compositeSeed = new Set<string>();
|
|
406
407
|
for (const [key, source] of sources) {
|
|
407
408
|
const { bare } = specifiersOf(source, key);
|
|
408
409
|
if (bare.some((s) => !isChantOwnedSpecifier(s) || host.unloadable.has(s))) hostSeed.add(key);
|
|
409
|
-
if (usesCompositeFactory(source, key, host)) compositeSeed.add(key);
|
|
410
410
|
}
|
|
411
411
|
const hostLimited = spread(hostSeed, sources, reference.taintSource);
|
|
412
|
-
|
|
412
|
+
|
|
413
|
+
// The data-host column: the same host as a description, no code in it.
|
|
414
|
+
const description: Host = { profile: "data-host", intrinsics: host.intrinsics, helpers: [], ownedSpecifierPrefixes: host.ownedSpecifierPrefixes, values: new Map() };
|
|
415
|
+
const conformanceHost: ConformanceHost = { name: "chant", ownedSpecifierPrefixes: host.ownedSpecifierPrefixes, intrinsics: host.intrinsics, helpers: [], values: new Map() };
|
|
416
|
+
const dataHostReference = rust ? referenceFoldProject(sources, description) : undefined;
|
|
417
|
+
const dataHostRust = rust?.foldProject ? await rust.foldProject(new Map(sources), conformanceHost) : undefined;
|
|
413
418
|
|
|
414
419
|
const comparisons: FileComparison[] = paths.map((abs) => {
|
|
415
420
|
const file = keyOf(abs);
|
|
421
|
+
let dataHost: DataHostComparison | undefined;
|
|
422
|
+
if (dataHostReference && dataHostRust && dataHostRust !== "unavailable") {
|
|
423
|
+
const dr = dataHostReference.verdicts.get(file), xr = dataHostRust.verdicts[file];
|
|
424
|
+
const referenceSide: Side = dr?.kind === "fold" ? "fold" : "run", rustSide: Side = xr?.kind === "fold" ? "fold" : "run";
|
|
425
|
+
let agreed = referenceSide === rustSide, diff: string | undefined;
|
|
426
|
+
if (agreed && dr?.kind === "fold" && xr?.kind === "fold") {
|
|
427
|
+
const c = compareData(Object.fromEntries(dr.exports), xr.exports);
|
|
428
|
+
if (c.values === "differ") { agreed = false; diff = c.valuesDiff; }
|
|
429
|
+
} else if (!agreed) {
|
|
430
|
+
diff = dr?.kind === "run" ? `reference ${dr.rule}: ${dr.reason}` : xr?.kind === "run" ? `rust ${xr.rule ?? ""}: ${xr.reason}` : undefined;
|
|
431
|
+
}
|
|
432
|
+
dataHost = { reference: referenceSide, rust: rustSide, agreed, diff };
|
|
433
|
+
}
|
|
416
434
|
const cv = chant.get(abs);
|
|
417
435
|
const rv = reference.verdicts.get(file);
|
|
418
436
|
const chantSide: Side = cv?.verdict === "fold" ? "fold" : "run";
|
|
419
437
|
const referenceSide: Side = rv?.kind === "fold" ? "fold" : "run";
|
|
420
|
-
const limit: Limit | undefined = hostLimited.has(file) ? "host" :
|
|
438
|
+
const limit: Limit | undefined = hostLimited.has(file) ? "host" : undefined;
|
|
421
439
|
const base: FileComparison = {
|
|
422
440
|
file,
|
|
423
441
|
chant: chantSide,
|
|
424
442
|
reference: referenceSide,
|
|
425
443
|
limit,
|
|
444
|
+
dataHost,
|
|
426
445
|
referenceRule: rv?.kind === "run" ? rv.rule : undefined,
|
|
427
446
|
referenceReason: rv?.kind === "run" ? rv.reason : undefined,
|
|
428
447
|
chantReason:
|
|
@@ -461,16 +480,24 @@ export interface CorpusSummary {
|
|
|
461
480
|
* reaches and chant does not has no benign explanation wherever it appears.
|
|
462
481
|
*/
|
|
463
482
|
readonly referenceMorePermissive: readonly Disagreement[];
|
|
483
|
+
/** The data-host column, when the evaluator with no JavaScript runtime was present. */
|
|
484
|
+
readonly dataHost?: { readonly files: number; readonly agreed: number; readonly bothFold: number; readonly disagreements: readonly Disagreement[] };
|
|
464
485
|
}
|
|
465
486
|
|
|
466
487
|
export function summarize(reports: readonly EntryReport[]): CorpusSummary {
|
|
467
488
|
let files = 0, comparable = 0, comparableAgreed = 0, comparableBothFold = 0, comparableValuesNotData = 0;
|
|
468
|
-
const limited: Record<Limit, number> = { host: 0
|
|
489
|
+
const limited: Record<Limit, number> = { host: 0 };
|
|
469
490
|
const disagreements: Disagreement[] = [];
|
|
470
491
|
const referenceMorePermissive: Disagreement[] = [];
|
|
492
|
+
let dhFiles = 0, dhAgreed = 0, dhBothFold = 0;
|
|
493
|
+
const dhDisagreements: Disagreement[] = [];
|
|
471
494
|
for (const report of reports) {
|
|
472
495
|
files += report.files;
|
|
473
496
|
for (const c of report.comparisons) {
|
|
497
|
+
if (c.dataHost) {
|
|
498
|
+
dhFiles++;
|
|
499
|
+
if (c.dataHost.agreed) { dhAgreed++; if (c.dataHost.reference === "fold") dhBothFold++; } else dhDisagreements.push({ ...c, entry: report.name });
|
|
500
|
+
}
|
|
474
501
|
if (c.reference === "fold" && c.chant === "run") referenceMorePermissive.push({ ...c, entry: report.name });
|
|
475
502
|
if (c.limit) { limited[c.limit]++; continue; }
|
|
476
503
|
comparable++;
|
|
@@ -483,6 +510,7 @@ export function summarize(reports: readonly EntryReport[]): CorpusSummary {
|
|
|
483
510
|
return {
|
|
484
511
|
entries: reports.length, files, comparable, comparableAgreed, comparableBothFold,
|
|
485
512
|
comparableValuesNotData, limited, disagreements, referenceMorePermissive,
|
|
513
|
+
dataHost: dhFiles ? { files: dhFiles, agreed: dhAgreed, bothFold: dhBothFold, disagreements: dhDisagreements } : undefined,
|
|
486
514
|
};
|
|
487
515
|
}
|
|
488
516
|
|
|
@@ -495,7 +523,7 @@ export function renderCorpusReport(
|
|
|
495
523
|
): string {
|
|
496
524
|
const rows = reports.map((r) => {
|
|
497
525
|
const s = summarize([r]);
|
|
498
|
-
return `| \`${r.name}\` | ${r.files} | ${s.comparable} | ${s.comparableAgreed} | ${s.limited.host}
|
|
526
|
+
return `| \`${r.name}\` | ${r.files} | ${s.comparable} | ${s.comparableAgreed} | ${s.limited.host} |`;
|
|
499
527
|
});
|
|
500
528
|
const line = (d: Disagreement) =>
|
|
501
529
|
`- \`${d.entry}/${d.file}\`: chant ${d.chant}, reference ${d.reference}${d.values ? `, values ${d.values}` : ""}${d.valuesDiff ? ` (${d.valuesDiff})` : ""}` +
|
|
@@ -511,12 +539,12 @@ export function renderCorpusReport(
|
|
|
511
539
|
"",
|
|
512
540
|
"## Totals",
|
|
513
541
|
"",
|
|
514
|
-
"| Files | Comparable | Agreed | Both fold | No host |
|
|
515
|
-
"
|
|
542
|
+
"| Files | Comparable | Agreed | Both fold | No host |",
|
|
543
|
+
"|---|---|---|---|---|",
|
|
516
544
|
`| ${summary.files} | ${summary.comparable} | ${summary.comparableAgreed} | ${summary.comparableBothFold} |` +
|
|
517
|
-
` ${summary.limited.host}
|
|
545
|
+
` ${summary.limited.host} |`,
|
|
518
546
|
"",
|
|
519
|
-
"
|
|
547
|
+
"The one limit disarms the reference. chant is given the entry's lexicons and build parameters, the inputs a real build has.",
|
|
520
548
|
"",
|
|
521
549
|
`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
550
|
"",
|
|
@@ -530,10 +558,24 @@ export function renderCorpusReport(
|
|
|
530
558
|
? "None."
|
|
531
559
|
: summary.referenceMorePermissive.map(line).join("\n"),
|
|
532
560
|
"",
|
|
561
|
+
...(summary.dataHost && dataHostEvaluator ? [
|
|
562
|
+
"## The data-host column",
|
|
563
|
+
"",
|
|
564
|
+
`- evaluator: \`${dataHostEvaluator.name}\`, declaring spec \`${dataHostEvaluator.specVersion}\`, no JavaScript runtime`,
|
|
565
|
+
"",
|
|
566
|
+
"| Files | Agreed | Both fold |",
|
|
567
|
+
"|---|---|---|",
|
|
568
|
+
`| ${summary.dataHost.files} | ${summary.dataHost.agreed} | ${summary.dataHost.bothFold} |`,
|
|
569
|
+
"",
|
|
570
|
+
"The reference and the evaluator are both judged in `data-host`, on the same host description and no code. A file that folds on both sides has the same namespace on both, envelopes included.",
|
|
571
|
+
"",
|
|
572
|
+
summary.dataHost.disagreements.length === 0 ? "No disagreements." : summary.dataHost.disagreements.map((d) => `- \`${d.entry}/${d.file}\`: reference ${d.dataHost!.reference}, evaluator ${d.dataHost!.rust}${d.dataHost!.diff ? ` (${d.dataHost!.diff})` : ""}`).join("\n"),
|
|
573
|
+
"",
|
|
574
|
+
] : []),
|
|
533
575
|
"## Per entry",
|
|
534
576
|
"",
|
|
535
|
-
"| Entry | Files | Comparable | Agreed | No host |
|
|
536
|
-
"
|
|
577
|
+
"| Entry | Files | Comparable | Agreed | No host |",
|
|
578
|
+
"|---|---|---|---|---|",
|
|
537
579
|
...rows,
|
|
538
580
|
"",
|
|
539
581
|
].join("\n");
|
package/src/fixture.ts
CHANGED
|
@@ -24,14 +24,31 @@
|
|
|
24
24
|
* "exports": { "config.ts": { "port": 8080 } }, // optional, for files that finally fold
|
|
25
25
|
* "rejectRule": { "app.ts": "F-Eval-CallLocal" }, // optional, the rule a run verdict must name; checked when the adapter reports one
|
|
26
26
|
* "host": "shapes", // optional, a named host from host.ts; required if the sources import one
|
|
27
|
+
* "mode": "isolated", // optional, J2's ι; "open" by default. An adapter that cannot honour it reports "unavailable"
|
|
28
|
+
* "findings": { "bucket.ts": [ { "rule": "SHAPES001", "subject": "bad", "severity": "error" } ], // optional (#101): the host's rules' findings, keyed by the
|
|
29
|
+
* "artifact": [ { "rule": "SHAPES002", "subject": "missing: Bucket", "severity": "warning" } ] }, // file whose namespace holds the subject (pre) or "artifact" (post); "at" optional
|
|
30
|
+
* "counters": { "projectFactoryInvocations": 0 }, // optional (F-Obs-Counters): the build's counters; the keys given are compared, all three must be reported
|
|
27
31
|
* "profiles": ["full"], // optional; see profilesOf for the default
|
|
28
32
|
* "note": "why this fixture exists" }
|
|
29
33
|
* `tentative` and `taintedBy` are what separate "folds because nothing
|
|
30
34
|
* reached it" from "would have folded, and an edge killed it" — without them
|
|
31
35
|
* a project fixture cannot tell F-Seed from F-Taint.
|
|
36
|
+
*
|
|
37
|
+
* `findings` is what an F-Rule-* fixture asserts (#101): the findings of the
|
|
38
|
+
* named host's rules, as data. Matched on rule, subject and severity; the
|
|
39
|
+
* message is non-normative, and a location only when both sides give one.
|
|
40
|
+
*
|
|
41
|
+
* A *roundtrip* fixture (#80) has no source at all. Its input is a namespace
|
|
42
|
+
* as data and the implementation's generator writes the source:
|
|
43
|
+
* value.json — { "<export>": <value> }, envelopes as F-Val-Domain writes them, "$undefined" as in expect.json
|
|
44
|
+
* expect.json — { "rules": ["F-Val-Source"], "roundtrip": true, "host": "shapes", "profiles": ["data-host"], "note": "…" }
|
|
45
|
+
* The fold of the generated source must equal the input. Judged in
|
|
46
|
+
* `data-host` unless the fixture says otherwise, since in `full` the fold of
|
|
47
|
+
* a resource's form is a live instance and not the envelope (F-Val-Fate).
|
|
32
48
|
*/
|
|
33
49
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
34
50
|
import { join, relative } from "node:path";
|
|
51
|
+
import type { ExecutionCounters } from "./adapter.js";
|
|
35
52
|
|
|
36
53
|
export interface ExpressionFixture {
|
|
37
54
|
kind: "expression";
|
|
@@ -41,6 +58,7 @@ export interface ExpressionFixture {
|
|
|
41
58
|
shape: "accept" | "reject"; fold: "fold" | "run";
|
|
42
59
|
value?: unknown; rejectAt?: { line: number; column: number }; note?: string;
|
|
43
60
|
}
|
|
61
|
+
export interface ExpectedFinding { rule: string; subject: string; severity: "error" | "warning" | "info"; at?: { line: number; column: number } }
|
|
44
62
|
export interface ProjectFixture {
|
|
45
63
|
kind: "project";
|
|
46
64
|
profiles: Profile[];
|
|
@@ -55,9 +73,23 @@ export interface ProjectFixture {
|
|
|
55
73
|
rejectRule?: Record<string, string>;
|
|
56
74
|
/** A named host from host.ts. Required for any fixture whose sources import one. */
|
|
57
75
|
host?: string;
|
|
76
|
+
/** J2's ι, `open` unless the fixture says `isolated` (F-IsolatedRefusal). */
|
|
77
|
+
mode?: "open" | "isolated";
|
|
78
|
+
/** The host's rules' findings, keyed by file (pre-synthesis) or "artifact" (post-synthesis). */
|
|
79
|
+
findings?: Record<string, ExpectedFinding[]>;
|
|
80
|
+
/** F-Obs-Counters: the values the build's counters must show. An adapter reporting none is skipped, not failed. */
|
|
81
|
+
counters?: Partial<ExecutionCounters>;
|
|
58
82
|
note?: string;
|
|
59
83
|
}
|
|
60
|
-
export
|
|
84
|
+
export interface RoundtripFixture {
|
|
85
|
+
kind: "roundtrip";
|
|
86
|
+
profiles: Profile[];
|
|
87
|
+
id: string; dir: string; rules: string[];
|
|
88
|
+
value: Record<string, unknown>;
|
|
89
|
+
host?: string;
|
|
90
|
+
note?: string;
|
|
91
|
+
}
|
|
92
|
+
export type Fixture = ExpressionFixture | ProjectFixture | RoundtripFixture;
|
|
61
93
|
|
|
62
94
|
export type Profile = "full" | "data-host";
|
|
63
95
|
|
|
@@ -69,6 +101,7 @@ export type Profile = "full" | "data-host";
|
|
|
69
101
|
*/
|
|
70
102
|
export function profilesOf(f: Fixture, explicit?: string[]): Profile[] {
|
|
71
103
|
if (explicit) return explicit as Profile[];
|
|
104
|
+
if (f.kind === "roundtrip") return ["data-host"];
|
|
72
105
|
const sources = f.kind === "expression" ? [f.input] : [...f.files.values()];
|
|
73
106
|
const usesNew = sources.some((s) => /\bnew\s+[A-Za-z_$]/.test(s));
|
|
74
107
|
if (f.kind === "project" && (f.host || f.tentative || f.taintedBy)) return ["full"];
|
|
@@ -78,6 +111,7 @@ export function profilesOf(f: Fixture, explicit?: string[]): Profile[] {
|
|
|
78
111
|
/** Kept as the name the expression-only callers import; `kind` narrows. */
|
|
79
112
|
export const expressionFixtures = (all: Fixture[]): ExpressionFixture[] => all.filter((f): f is ExpressionFixture => f.kind === "expression");
|
|
80
113
|
export const projectFixtures = (all: Fixture[]): ProjectFixture[] => all.filter((f): f is ProjectFixture => f.kind === "project");
|
|
114
|
+
export const roundtripFixtures = (all: Fixture[]): RoundtripFixture[] => all.filter((f): f is RoundtripFixture => f.kind === "roundtrip");
|
|
81
115
|
|
|
82
116
|
function readProject(dir: string): Map<string, string> {
|
|
83
117
|
const files = new Map<string, string>();
|
|
@@ -100,9 +134,14 @@ export function loadFixtures(root: string): Fixture[] {
|
|
|
100
134
|
const d = join(rd, name); if (!statSync(d).isDirectory()) continue;
|
|
101
135
|
const e = JSON.parse(readFileSync(join(d, "expect.json"), "utf8"));
|
|
102
136
|
const id = `${rule}/${name}`;
|
|
103
|
-
if (e.
|
|
137
|
+
if (e.roundtrip) {
|
|
138
|
+
const fx: RoundtripFixture = { kind: "roundtrip", id, dir: d, rules: e.rules, profiles: [], host: e.host, note: e.note,
|
|
139
|
+
value: JSON.parse(readFileSync(join(d, "value.json"), "utf8")) };
|
|
140
|
+
fx.profiles = profilesOf(fx, e.profiles);
|
|
141
|
+
out.push(fx);
|
|
142
|
+
} else if (e.project) {
|
|
104
143
|
const fx: ProjectFixture = { kind: "project", id, dir: d, rules: e.rules, files: readProject(join(d, "project")), profiles: [],
|
|
105
|
-
verdicts: e.verdicts, tentative: e.tentative, taintedBy: e.taintedBy, exports: e.exports, rejectRule: e.rejectRule, host: e.host, note: e.note };
|
|
144
|
+
verdicts: e.verdicts, tentative: e.tentative, taintedBy: e.taintedBy, exports: e.exports, rejectRule: e.rejectRule, host: e.host, mode: e.mode, findings: e.findings, counters: e.counters, note: e.note };
|
|
106
145
|
fx.profiles = profilesOf(fx, e.profiles);
|
|
107
146
|
out.push(fx);
|
|
108
147
|
} else {
|
package/src/host.ts
CHANGED
|
@@ -17,6 +17,18 @@ export interface HostIntrinsic {
|
|
|
17
17
|
readonly outputKey?: string;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* A rule the host supplies (F-Rule-Supply), by identifier. The rule's code is
|
|
22
|
+
* the implementation's; what the harness carries is the id, the phase, the
|
|
23
|
+
* rule's own severity (L11.6) and what it checks, in words.
|
|
24
|
+
*/
|
|
25
|
+
export interface HostRule {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly phase: "pre" | "post";
|
|
28
|
+
readonly severity: "error" | "warning" | "info";
|
|
29
|
+
readonly description: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
export interface ConformanceHost {
|
|
21
33
|
readonly name: string;
|
|
22
34
|
/** F-Host-Trust arm 1: the specifiers this host owns. */
|
|
@@ -25,6 +37,8 @@ export interface ConformanceHost {
|
|
|
25
37
|
readonly helpers: readonly { name: string; module: string; note: string }[];
|
|
26
38
|
/** Specifier, then export name, to the real value. Revival calls these (F-Val-Fate). */
|
|
27
39
|
readonly values: ReadonlyMap<string, ReadonlyMap<string, unknown>>;
|
|
40
|
+
/** F-Host-Interface item 7: the rules this host supplies, if any. */
|
|
41
|
+
readonly rules?: readonly HostRule[];
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
const DECLARABLE = Symbol.for("tsad.conformance.declarable");
|
|
@@ -51,6 +65,30 @@ class Pair {
|
|
|
51
65
|
}
|
|
52
66
|
}
|
|
53
67
|
|
|
68
|
+
/**
|
|
69
|
+
* F-Host-Interface item 6: the registration form. What it returns is what a
|
|
70
|
+
* *running* file would call; a folding one never invokes it, because
|
|
71
|
+
* F-Host-Composite makes the registered factory interpretable instead. The
|
|
72
|
+
* instance keeps its members enumerable and carries a marker on the
|
|
73
|
+
* prototype, so it is live (F-Val-Live) and spreads to its members.
|
|
74
|
+
*/
|
|
75
|
+
const COMPOSITE = Symbol.for("tsad.conformance.composite");
|
|
76
|
+
class CompositeInstance {
|
|
77
|
+
readonly [COMPOSITE]: true = true;
|
|
78
|
+
}
|
|
79
|
+
const Composite = <P>(factory: (props: P) => Record<string, unknown>, name = "anonymous") => {
|
|
80
|
+
const definition = (props: P) => Object.assign(new CompositeInstance(), factory(props));
|
|
81
|
+
return Object.assign(definition, { compositeName: name });
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** A host-published composite (L7.1: never interpreted, invoked at a declarator or read through a const, F-Eval-CompositeConst). */
|
|
85
|
+
const Stack = Composite((props: { left: unknown; right: unknown }) => ({ pair: new Pair(props.left, props.right) }), "Stack");
|
|
86
|
+
|
|
87
|
+
/** A host-published factory (F-Call step 6): invoked with the resolved arguments, it returns an entity. */
|
|
88
|
+
const makePair = (left: unknown, right: unknown): Pair => new Pair(left, right);
|
|
89
|
+
/** A host export that is a function but returns plain data: step 7 refuses its result. */
|
|
90
|
+
const describe = (name: string): { described: string } => ({ described: name });
|
|
91
|
+
|
|
54
92
|
/** A live object the host owns outright, for the F-CallLeak case: no construction involved. */
|
|
55
93
|
const registry = new Map<string, string>([["one", "1"]]);
|
|
56
94
|
|
|
@@ -79,6 +117,14 @@ const SHAPES: ConformanceHost = {
|
|
|
79
117
|
{ name: "count", isTag: false, foldsEagerly: true },
|
|
80
118
|
],
|
|
81
119
|
helpers: [{ name: "upper", module: "@tsad/shapes", note: "pure string transform, no environment read" }],
|
|
120
|
+
// Two rules, one per phase (F-Rule-Phase). The pre-synthesis one reads
|
|
121
|
+
// the folded namespace and names the export it concerns; the
|
|
122
|
+
// post-synthesis one reads the artifact and, finding nothing to attach
|
|
123
|
+
// to, reports the missing-resource form (F-Rule-Finding, L11.5).
|
|
124
|
+
rules: [
|
|
125
|
+
{ id: "SHAPES001", phase: "pre", severity: "error", description: "every Bucket declares a BucketName; the subject is the export that does not" },
|
|
126
|
+
{ id: "SHAPES002", phase: "post", severity: "warning", description: "the artifact declares at least one Bucket; otherwise the subject is `missing: Bucket`" },
|
|
127
|
+
],
|
|
82
128
|
values: new Map([
|
|
83
129
|
[
|
|
84
130
|
"@tsad/shapes",
|
|
@@ -91,6 +137,10 @@ const SHAPES: ConformanceHost = {
|
|
|
91
137
|
["count", count],
|
|
92
138
|
["SIZES", SIZES],
|
|
93
139
|
["registry", registry],
|
|
140
|
+
["Composite", Composite],
|
|
141
|
+
["makePair", makePair],
|
|
142
|
+
["Stack", Stack],
|
|
143
|
+
["describe", describe],
|
|
94
144
|
]),
|
|
95
145
|
],
|
|
96
146
|
]),
|
package/src/runner.test.ts
CHANGED
|
@@ -14,10 +14,14 @@ describe("conformance runner (#7) over the reference implementation (#10)", () =
|
|
|
14
14
|
const failed = reports.filter((r) => !r.pass).map((r) => `${r.fixture}: ${r.failures.join("; ")}`);
|
|
15
15
|
expect(failed, failed.join("\n")).toEqual([]);
|
|
16
16
|
});
|
|
17
|
-
test("the data-host profile: the reference
|
|
17
|
+
test("the data-host profile: the reference passes every fixture tagged for it, a named host reduced to its description (#78)", async () => {
|
|
18
18
|
const tagged = fixtures.filter((f) => f.profiles.includes("data-host"));
|
|
19
19
|
expect(tagged.length).toBeGreaterThan(40);
|
|
20
|
-
|
|
20
|
+
// A data-host fixture may name a host for its intrinsic registry and trust
|
|
21
|
+
// set (F-Profile-DataHost, F-Host-Interface items 3 and 5); the adapter is
|
|
22
|
+
// what drops the classes, helpers and values, and these fixtures are what
|
|
23
|
+
// would fail if it did not.
|
|
24
|
+
expect(tagged.some((f) => f.kind === "project" && f.host)).toBe(true);
|
|
21
25
|
const failed = (await runFixtures(referenceDataHostAdapter, tagged)).filter((r) => !r.pass).map((r) => `${r.fixture}: ${r.failures.join("; ")}`);
|
|
22
26
|
expect(failed, failed.join("\n")).toEqual([]);
|
|
23
27
|
});
|
package/src/runner.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { ConformanceAdapter } from "./adapter.js";
|
|
2
|
-
import type { ExpressionFixture, Fixture, ProjectFixture } from "./fixture.js";
|
|
3
|
-
import { expressionFixtures, projectFixtures } from "./fixture.js";
|
|
1
|
+
import type { ConformanceAdapter, Finding, RulePhase, ExecutionCounters } from "./adapter.js";
|
|
2
|
+
import type { ExpressionFixture, Fixture, ProjectFixture, RoundtripFixture } from "./fixture.js";
|
|
3
|
+
import { expressionFixtures, projectFixtures, roundtripFixtures } from "./fixture.js";
|
|
4
4
|
import { requireHost } from "./host.js";
|
|
5
5
|
|
|
6
6
|
export interface FixtureReport { fixture: string; adapter: string; pass: boolean; skipped?: string; failures: string[] }
|
|
@@ -45,14 +45,49 @@ export async function runFixtures(adapter: ConformanceAdapter, fixtures: Fixture
|
|
|
45
45
|
return [
|
|
46
46
|
...expressionFixtures(fixtures).map((f) => runFixture(adapter, f)),
|
|
47
47
|
...(await Promise.all(projectFixtures(fixtures).map((f) => runProjectFixture(adapter, f)))),
|
|
48
|
+
...(await Promise.all(roundtripFixtures(fixtures).map((f) => runRoundtripFixture(adapter, f)))),
|
|
48
49
|
];
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* #80 — the round trip. The input is data; the implementation's generator
|
|
54
|
+
* writes source for it and the fold of that source must be the input. The
|
|
55
|
+
* "$undefined" sentinel in value.json is decoded before generation so the
|
|
56
|
+
* generator sees the value the domain has.
|
|
57
|
+
*/
|
|
58
|
+
export async function runRoundtripFixture(adapter: ConformanceAdapter, f: RoundtripFixture): Promise<FixtureReport> {
|
|
59
|
+
const base = { fixture: f.id, adapter: adapter.name };
|
|
60
|
+
if (!adapter.generate || !adapter.foldProject) return { ...base, pass: true, skipped: "no generator", failures: [] };
|
|
61
|
+
const host = f.host ? requireHost(f.host) : undefined;
|
|
62
|
+
const source = adapter.generate(decodeValue(f.value) as Record<string, unknown>, host);
|
|
63
|
+
if (source === "unavailable") return { ...base, pass: true, skipped: "generator unavailable for this value", failures: [] };
|
|
64
|
+
const r = await adapter.foldProject(new Map([["generated.ts", source]]), host);
|
|
65
|
+
if (r === "unavailable") return { ...base, pass: true, skipped: "project entry unavailable", failures: [] };
|
|
66
|
+
const failures: string[] = [];
|
|
67
|
+
const v = r.verdicts["generated.ts"];
|
|
68
|
+
if (v?.kind !== "fold") failures.push(`generated source does not fold${v?.kind === "run" ? ` (${v.reason})` : ""}:\n${source}`);
|
|
69
|
+
else {
|
|
70
|
+
for (const name of new Set([...Object.keys(f.value), ...Object.keys(v.exports)])) {
|
|
71
|
+
if (!(name in f.value)) { failures.push(`export ${name} generated but not in the input`); continue; }
|
|
72
|
+
if (encodeValue(v.exports[name]) !== encodeValue(f.value[name])) failures.push(`export ${name} = ${encodeValue(v.exports[name])} ≠ input ${encodeValue(f.value[name])}\n${source}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { ...base, pass: failures.length === 0, failures };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The inverse of encodeValue's sentinel: "$undefined" in fixture data is the domain's undefined. */
|
|
79
|
+
export function decodeValue(v: unknown): unknown {
|
|
80
|
+
if (v === "$undefined") return undefined;
|
|
81
|
+
if (v === null || typeof v !== "object") return v;
|
|
82
|
+
if (Array.isArray(v)) return v.map(decodeValue);
|
|
83
|
+
return Object.fromEntries(Object.entries(v).map(([k, e]) => [k, decodeValue(e)]));
|
|
84
|
+
}
|
|
85
|
+
|
|
51
86
|
/** #24 — a whole-build fixture. J3's edges are invisible in any single file, so this is the only shape that can test them. */
|
|
52
87
|
export async function runProjectFixture(adapter: ConformanceAdapter, f: ProjectFixture): Promise<FixtureReport> {
|
|
53
88
|
const base = { fixture: f.id, adapter: adapter.name };
|
|
54
89
|
if (!adapter.foldProject) return { ...base, pass: true, skipped: "no project entry", failures: [] };
|
|
55
|
-
const r = await adapter.foldProject(f.files, f.host ? requireHost(f.host) : undefined);
|
|
90
|
+
const r = await adapter.foldProject(f.files, f.host ? requireHost(f.host) : undefined, f.mode);
|
|
56
91
|
if (r === "unavailable") return { ...base, pass: true, skipped: "project entry unavailable", failures: [] };
|
|
57
92
|
const failures: string[] = [];
|
|
58
93
|
for (const [path, want] of Object.entries(f.verdicts)) {
|
|
@@ -83,16 +118,74 @@ export async function runProjectFixture(adapter: ConformanceAdapter, f: ProjectF
|
|
|
83
118
|
}
|
|
84
119
|
}
|
|
85
120
|
}
|
|
121
|
+
if (f.findings) {
|
|
122
|
+
const got = await collectFindings(adapter, f);
|
|
123
|
+
if (got === "unavailable") return { ...base, pass: failures.length === 0, skipped: "rules unavailable", failures };
|
|
124
|
+
failures.push(...compareFindings(f.findings, got));
|
|
125
|
+
}
|
|
126
|
+
if (f.counters) {
|
|
127
|
+
// F-Obs-Counters: the shape is normative, so all three must be present as non-negative integers; the fixture then pins the values it names.
|
|
128
|
+
if (!r.counters) return { ...base, pass: failures.length === 0, skipped: "counters unavailable", failures };
|
|
129
|
+
for (const k of ["factoryInvocations", "projectFactoryInvocations", "factoryInterpretations"] as const) {
|
|
130
|
+
const v = r.counters[k];
|
|
131
|
+
if (!Number.isInteger(v) || v < 0) failures.push(`counters.${k}: ${String(v)} is not a non-negative integer (F-Obs-Counters)`);
|
|
132
|
+
}
|
|
133
|
+
for (const [k, want] of Object.entries(f.counters)) {
|
|
134
|
+
const got = r.counters[k as keyof ExecutionCounters];
|
|
135
|
+
if (got !== want) failures.push(`counters.${k}: ${got} ≠ expected ${want}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
86
138
|
return { ...base, pass: failures.length === 0, failures };
|
|
87
139
|
}
|
|
88
140
|
|
|
141
|
+
/** The key a finding is filed under: the file whose namespace holds the subject, or the artifact. */
|
|
142
|
+
const findingKey = (x: Finding, phase: RulePhase): string => (phase === "post" ? "artifact" : x.file ?? "artifact");
|
|
143
|
+
const findingSig = (x: { rule: string; subject: string; severity: string }): string => `${x.rule} ${x.severity} ${x.subject}`;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Every finding of both phases (#101). Each phase is run twice and the two
|
|
147
|
+
* runs must agree: F-Rule-Pure says a rule is a function of its input, and
|
|
148
|
+
* a rule that reads a clock or the environment fails here before its
|
|
149
|
+
* findings are compared with anything.
|
|
150
|
+
*/
|
|
151
|
+
export async function collectFindings(adapter: ConformanceAdapter, f: ProjectFixture): Promise<Map<string, Finding[]> | "unavailable"> {
|
|
152
|
+
if (!adapter.rules || !f.host) return "unavailable";
|
|
153
|
+
const host = requireHost(f.host);
|
|
154
|
+
const out = new Map<string, Finding[]>();
|
|
155
|
+
for (const phase of ["pre", "post"] as const) {
|
|
156
|
+
const first = await adapter.rules(f.files, host, phase);
|
|
157
|
+
if (first === "unavailable") return "unavailable";
|
|
158
|
+
const second = await adapter.rules(f.files, host, phase);
|
|
159
|
+
if (second === "unavailable") return "unavailable";
|
|
160
|
+
const a = first.map((x) => `${findingKey(x, phase)}: ${findingSig(x)}`).sort(), b = second.map((x) => `${findingKey(x, phase)}: ${findingSig(x)}`).sort();
|
|
161
|
+
if (a.join("\n") !== b.join("\n")) throw new Error(`${f.id}: ${phase}-synthesis findings differ between two runs of the same input (F-Rule-Pure)\n${a.join("\n")}\n---\n${b.join("\n")}`);
|
|
162
|
+
for (const x of first) { const k = findingKey(x, phase); out.set(k, [...(out.get(k) ?? []), x]); }
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Findings matched on rule, subject and severity; `at` only when both sides carry one. */
|
|
168
|
+
export function compareFindings(want: Record<string, { rule: string; subject: string; severity: string; at?: { line: number; column: number } }[]>, got: Map<string, Finding[]>): string[] {
|
|
169
|
+
const failures: string[] = [];
|
|
170
|
+
for (const key of new Set([...Object.keys(want), ...got.keys()])) {
|
|
171
|
+
const w = want[key] ?? [], g = got.get(key) ?? [];
|
|
172
|
+
for (const x of w) {
|
|
173
|
+
const hit = g.find((y) => findingSig(y) === findingSig(x));
|
|
174
|
+
if (!hit) { failures.push(`${key}: expected finding ${findingSig(x)}, not reported`); continue; }
|
|
175
|
+
if (x.at && hit.at && (hit.at.line !== x.at.line || hit.at.column !== x.at.column)) failures.push(`${key}: ${findingSig(x)} at ${hit.at.line}:${hit.at.column}, expected ${x.at.line}:${x.at.column}`);
|
|
176
|
+
}
|
|
177
|
+
for (const y of g) if (!w.some((x) => findingSig(x) === findingSig(y))) failures.push(`${key}: finding ${findingSig(y)} reported but not expected (${y.message})`);
|
|
178
|
+
}
|
|
179
|
+
return failures;
|
|
180
|
+
}
|
|
181
|
+
|
|
89
182
|
/** #11 — two implementations must agree on every fixture, independently of what the fixture expects. */
|
|
90
183
|
export async function compareAdapters(a: ConformanceAdapter, b: ConformanceAdapter, fixtures: Fixture[]): Promise<string[]> {
|
|
91
184
|
const dis: string[] = [];
|
|
92
185
|
for (const f of projectFixtures(fixtures)) {
|
|
93
186
|
if (!a.foldProject || !b.foldProject) continue;
|
|
94
187
|
const host = f.host ? requireHost(f.host) : undefined;
|
|
95
|
-
const [ra, rb] = await Promise.all([a.foldProject(f.files, host), b.foldProject(f.files, host)]);
|
|
188
|
+
const [ra, rb] = await Promise.all([a.foldProject(f.files, host, f.mode), b.foldProject(f.files, host, f.mode)]);
|
|
96
189
|
if (ra === "unavailable" || rb === "unavailable") continue;
|
|
97
190
|
for (const path of new Set([...Object.keys(ra.verdicts), ...Object.keys(rb.verdicts)])) {
|
|
98
191
|
const va = ra.verdicts[path]?.kind ?? "absent", vb = rb.verdicts[path]?.kind ?? "absent";
|
|
@@ -107,6 +200,15 @@ export async function compareAdapters(a: ConformanceAdapter, b: ConformanceAdapt
|
|
|
107
200
|
dis.push(`${f.id} ${path}: tainted by — ${a.name} ${ra.taintedBy[path] ?? "nothing"}, ${b.name} ${rb.taintedBy[path] ?? "nothing"}`);
|
|
108
201
|
}
|
|
109
202
|
}
|
|
203
|
+
// F-Rule-Equivalence, across implementations: the same host rules over
|
|
204
|
+
// the same source report the same findings, whichever path each took.
|
|
205
|
+
if (f.findings) {
|
|
206
|
+
const [fa, fb] = await Promise.all([collectFindings(a, f), collectFindings(b, f)]);
|
|
207
|
+
if (fa !== "unavailable" && fb !== "unavailable") {
|
|
208
|
+
const sig = (m: Map<string, Finding[]>) => [...m.entries()].flatMap(([k, xs]) => xs.map((x) => `${k}: ${findingSig(x)}`)).sort().join("\n");
|
|
209
|
+
if (sig(fa) !== sig(fb)) dis.push(`${f.id}: findings — ${a.name}\n${sig(fa)}\n${b.name}\n${sig(fb)}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
110
212
|
}
|
|
111
213
|
for (const f of expressionFixtures(fixtures)) {
|
|
112
214
|
const ra = a.foldExport(f.input, f.exportName), rb = b.foldExport(f.input, f.exportName);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #86 — the evaluator with no JavaScript runtime, held to the data-host
|
|
3
|
+
* profile's fixtures and to agreement with the reference judged in the same
|
|
4
|
+
* profile. Skips, visibly, when the binary is not built: `cargo build
|
|
5
|
+
* --release` in evaluators/rust, or TSAD_RUST_EVAL naming it.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect } from "vitest";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { loadFixtures, runFixtures, compareAdapters } from "./index";
|
|
10
|
+
import { rustAdapter, rustEvaluatorPath } from "./adapters/rust";
|
|
11
|
+
import { referenceDataHostAdapter } from "../../reference/src/index";
|
|
12
|
+
|
|
13
|
+
const root = join(__dirname, "..", "..", "..");
|
|
14
|
+
const binary = rustEvaluatorPath(root);
|
|
15
|
+
const suite = binary ? describe : describe.skip;
|
|
16
|
+
if (!binary) console.warn("[rust] skipped: evaluators/rust is not built (cargo build --release) and TSAD_RUST_EVAL is unset");
|
|
17
|
+
|
|
18
|
+
suite("the Rust evaluator (#86) in the data-host profile", () => {
|
|
19
|
+
// The callback is collected even when the suite is skipped, so the adapter is made only when the binary exists.
|
|
20
|
+
const rust = binary ? rustAdapter(binary) : (undefined as unknown as ReturnType<typeof rustAdapter>);
|
|
21
|
+
const fixtures = loadFixtures(join(root, "spec", "fixtures")).filter((f) => f.profiles.includes("data-host"));
|
|
22
|
+
test("declares the specification version the fixtures are at", () => {
|
|
23
|
+
expect(rust.specVersion).toBe(referenceDataHostAdapter.specVersion);
|
|
24
|
+
});
|
|
25
|
+
test("passes every fixture tagged for the profile", async () => {
|
|
26
|
+
const reports = await runFixtures(rust, fixtures);
|
|
27
|
+
const failed = reports.filter((r) => !r.pass).map((r) => `${r.fixture}: ${r.failures.join("; ")}`);
|
|
28
|
+
expect(failed, failed.join("\n")).toEqual([]);
|
|
29
|
+
// Nothing is skipped: the evaluator generates as well as folds (F-Val-Source), so the round trip runs here too.
|
|
30
|
+
const skipped = reports.filter((r) => r.skipped).map((r) => r.fixture);
|
|
31
|
+
expect(skipped, `skipped: ${skipped.join(", ")}`).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
test("agrees with the reference on every fixture, independently of what the fixture expects", async () => {
|
|
34
|
+
const dis = await compareAdapters(referenceDataHostAdapter, rust, fixtures);
|
|
35
|
+
expect(dis, dis.join("\n")).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
});
|