@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.
Files changed (130) hide show
  1. package/dist/adapter.d.ts +53 -1
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/fixture.d.ts +28 -1
  4. package/dist/fixture.d.ts.map +1 -1
  5. package/dist/fixture.js +27 -2
  6. package/dist/fixture.js.map +1 -1
  7. package/dist/host.d.ts +13 -0
  8. package/dist/host.d.ts.map +1 -1
  9. package/dist/host.js +33 -0
  10. package/dist/host.js.map +1 -1
  11. package/dist/runner.d.ts +28 -2
  12. package/dist/runner.d.ts.map +1 -1
  13. package/dist/runner.js +129 -3
  14. package/dist/runner.js.map +1 -1
  15. package/fixtures/F-Call/a-host-factory-is-invoked/expect.json +45 -0
  16. package/fixtures/F-Call/a-host-factory-is-invoked/project/app.ts +4 -0
  17. package/fixtures/F-Call/a-host-factory-is-invoked/project/nested.ts +3 -0
  18. package/fixtures/F-Call/a-host-factory-is-invoked/project/plain.ts +3 -0
  19. package/fixtures/F-Declarator/a-const-alias-of-a-call-resolves-at-the-declarator/expect.json +50 -0
  20. package/fixtures/F-Declarator/a-const-alias-of-a-call-resolves-at-the-declarator/project/app.ts +13 -0
  21. package/fixtures/F-Declarator/a-const-alias-of-a-call-resolves-at-the-declarator/project/nested.ts +5 -0
  22. package/fixtures/F-Declarator/a-const-alias-of-a-call-resolves-at-the-declarator/project/store.ts +3 -0
  23. package/fixtures/F-Div-Step/step-reads-the-resolved-composite/expect.json +48 -0
  24. package/fixtures/F-Div-Step/step-reads-the-resolved-composite/project/app.ts +4 -0
  25. package/fixtures/F-Div-Step/step-reads-the-resolved-composite/project/pipeline.ts +3 -0
  26. package/fixtures/F-Host-Closed-vs-Open/package-call-is-closed/expect.json +18 -5
  27. package/fixtures/F-Host-Composite/a-registered-composite-is-interpreted/expect.json +47 -0
  28. package/fixtures/F-Host-Composite/a-registered-composite-is-interpreted/project/app.ts +4 -0
  29. package/fixtures/F-Host-Composite/a-registered-composite-is-interpreted/project/store.ts +6 -0
  30. package/fixtures/F-IsolatedRefusal/a-project-factory-is-refused-not-invoked/expect.json +18 -0
  31. package/fixtures/F-IsolatedRefusal/a-project-factory-is-refused-not-invoked/project/app.ts +3 -0
  32. package/fixtures/F-IsolatedRefusal/a-project-factory-is-refused-not-invoked/project/shapes.ts +5 -0
  33. package/fixtures/F-Obs-Counters/a-build-reports-its-counts/expect.json +43 -0
  34. package/fixtures/F-Obs-Counters/a-build-reports-its-counts/project/app.ts +5 -0
  35. package/fixtures/F-Obs-Counters/a-build-reports-its-counts/project/shapes.ts +3 -0
  36. package/fixtures/F-Rule-Equivalence/same-source-same-findings/expect.json +21 -0
  37. package/fixtures/F-Rule-Equivalence/same-source-same-findings/project/buckets.ts +8 -0
  38. package/fixtures/F-Rule-Equivalence/same-source-same-findings/project/helpers.ts +3 -0
  39. package/fixtures/F-Rule-Input/a-rule-sees-the-folded-namespace/expect.json +21 -0
  40. package/fixtures/F-Rule-Input/a-rule-sees-the-folded-namespace/project/buckets.ts +4 -0
  41. package/fixtures/F-Rule-Phase/a-post-rule-reads-the-artifact/expect.json +21 -0
  42. package/fixtures/F-Rule-Phase/a-post-rule-reads-the-artifact/project/pairs.ts +3 -0
  43. package/fixtures/F-Rule-Pure/findings-are-a-function-of-the-input/expect.json +21 -0
  44. package/fixtures/F-Rule-Pure/findings-are-a-function-of-the-input/project/buckets.ts +4 -0
  45. package/fixtures/F-Rule-Pure/findings-are-a-function-of-the-input/project/clock.ts +1 -0
  46. package/fixtures/F-Rule-Supply/the-host-names-the-rule/expect.json +21 -0
  47. package/fixtures/F-Rule-Supply/the-host-names-the-rule/project/app.ts +4 -0
  48. package/fixtures/F-Val-Domain/every-case-is-a-value/expect.json +49 -0
  49. package/fixtures/F-Val-Domain/every-case-is-a-value/project/app.ts +6 -0
  50. package/fixtures/F-Val-Envelope/envelopes-are-the-output-in-data-host/expect.json +41 -0
  51. package/fixtures/F-Val-Envelope/envelopes-are-the-output-in-data-host/project/app.ts +5 -0
  52. package/fixtures/F-Val-Source/a-form-per-case-in-data-host/expect.json +74 -0
  53. package/fixtures/F-Val-Source/a-form-per-case-in-data-host/project/app.ts +8 -0
  54. package/fixtures/F-Val-Source/a-form-per-case-in-full/expect.json +35 -0
  55. package/fixtures/F-Val-Source/a-form-per-case-in-full/project/app.ts +7 -0
  56. package/fixtures/F-Val-Symbol-Scope/a-symbol-only-inside-an-intrinsic/expect.json +32 -0
  57. package/fixtures/F-Val-Symbol-Scope/a-symbol-only-inside-an-intrinsic/project/interior.ts +3 -0
  58. package/fixtures/F-Val-Symbol-Scope/a-symbol-only-inside-an-intrinsic/project/top.ts +1 -0
  59. package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/expect.json +34 -0
  60. package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/array.ts +1 -0
  61. package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/class.ts +1 -0
  62. package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/default-function.ts +3 -0
  63. package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/let.ts +1 -0
  64. package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/ok.ts +1 -0
  65. package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/rest.ts +1 -0
  66. package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/star.ts +1 -0
  67. package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/uninitialized.ts +1 -0
  68. package/fixtures/S-Disqualify/each-arm-disqualifies-the-file/project/var.ts +1 -0
  69. package/fixtures/S-ExportDestructure/plain-elements/expect.json +16 -0
  70. package/fixtures/S-ExportDestructure/plain-elements/project/app.ts +3 -0
  71. package/fixtures/S-ExportNamed/local-names-with-an-alias/expect.json +19 -0
  72. package/fixtures/S-ExportNamed/local-names-with-an-alias/project/app.ts +4 -0
  73. package/fixtures/S-ExportResource/new-with-props/expect.json +26 -0
  74. package/fixtures/S-ExportResource/new-with-props/project/app.ts +3 -0
  75. package/fixtures/S-ExportSingle/an-expression-initializer/expect.json +21 -0
  76. package/fixtures/S-ExportSingle/an-expression-initializer/input.ts +1 -0
  77. package/fixtures/S-ExportTypeOnly/type-exports-are-invisible/expect.json +19 -0
  78. package/fixtures/S-ExportTypeOnly/type-exports-are-invisible/project/app.ts +12 -0
  79. package/fixtures/S-FactoryBody/consts-then-a-final-return/expect.json +38 -0
  80. package/fixtures/S-FactoryBody/consts-then-a-final-return/project/bad-shapes.ts +7 -0
  81. package/fixtures/S-FactoryBody/consts-then-a-final-return/project/empty.ts +3 -0
  82. package/fixtures/S-FactoryBody/consts-then-a-final-return/project/good-shapes.ts +7 -0
  83. package/fixtures/S-FactoryBody/consts-then-a-final-return/project/good.ts +3 -0
  84. package/fixtures/S-FactoryBody/consts-then-a-final-return/project/no-return.ts +3 -0
  85. package/fixtures/S-FactoryParams/one-plainly-bound-parameter/expect.json +38 -0
  86. package/fixtures/S-FactoryParams/one-plainly-bound-parameter/project/bad-shapes.ts +4 -0
  87. package/fixtures/S-FactoryParams/one-plainly-bound-parameter/project/good-shapes.ts +3 -0
  88. package/fixtures/S-FactoryParams/one-plainly-bound-parameter/project/named.ts +3 -0
  89. package/fixtures/S-FactoryParams/one-plainly-bound-parameter/project/rest.ts +3 -0
  90. package/fixtures/S-FactoryParams/one-plainly-bound-parameter/project/two.ts +3 -0
  91. package/fixtures/S-Module/non-exported-statements-are-invisible/expect.json +17 -0
  92. package/fixtures/S-Module/non-exported-statements-are-invisible/project/app.ts +13 -0
  93. package/fixtures/S-Prop/a-computed-key-rejects/expect.json +10 -0
  94. package/fixtures/S-Prop/a-computed-key-rejects/input.ts +3 -0
  95. package/fixtures/S-Prop/literal-keys/expect.json +15 -0
  96. package/fixtures/S-Prop/literal-keys/input.ts +1 -0
  97. package/fixtures/S-ReExport/named-from-a-sibling/expect.json +18 -0
  98. package/fixtures/S-ReExport/named-from-a-sibling/project/app.ts +1 -0
  99. package/fixtures/S-ReExport/named-from-a-sibling/project/config.ts +2 -0
  100. package/fixtures/S-Shorthand/a-shorthand-property-resolves-as-an-identifier/expect.json +14 -0
  101. package/fixtures/S-Shorthand/a-shorthand-property-resolves-as-an-identifier/input.ts +4 -0
  102. package/fixtures/S-TopConst/an-unexported-const-is-a-binding/expect.json +16 -0
  103. package/fixtures/S-TopConst/an-unexported-const-is-a-binding/project/binding.ts +3 -0
  104. package/fixtures/S-TopConst/an-unexported-const-is-a-binding/project/invisible.ts +3 -0
  105. package/fixtures/UNCOVERED.md +1 -28
  106. package/fixtures/roundtrip/a-resource-and-its-attribute/expect.json +8 -0
  107. package/fixtures/roundtrip/a-resource-and-its-attribute/value.json +29 -0
  108. package/fixtures/roundtrip/a-resource-by-args/expect.json +8 -0
  109. package/fixtures/roundtrip/a-resource-by-args/value.json +14 -0
  110. package/fixtures/roundtrip/intrinsics-and-a-symbol/expect.json +8 -0
  111. package/fixtures/roundtrip/intrinsics-and-a-symbol/value.json +36 -0
  112. package/fixtures/roundtrip/scalars-and-containers/expect.json +8 -0
  113. package/fixtures/roundtrip/scalars-and-containers/value.json +28 -0
  114. package/package.json +1 -1
  115. package/spec/VERSION +1 -1
  116. package/spec/hosts.md +6 -2
  117. package/spec/judgments.md +27 -10
  118. package/spec/rules.md +7 -4
  119. package/spec/values.md +47 -2
  120. package/src/adapter.ts +53 -0
  121. package/src/adapters/chant.ts +361 -10
  122. package/src/adapters/rust.ts +77 -0
  123. package/src/chant-agreement.test.ts +68 -23
  124. package/src/corpus.test.ts +12 -0
  125. package/src/corpus.ts +76 -34
  126. package/src/fixture.ts +42 -3
  127. package/src/host.ts +50 -0
  128. package/src/runner.test.ts +6 -2
  129. package/src/runner.ts +107 -5
  130. 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
- | "host"
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", composite: "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
- const compositeLimited = spread(compositeSeed, sources, reference.taintSource);
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" : compositeLimited.has(file) ? "composite" : undefined;
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, composite: 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} | ${s.limited.composite} |`;
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 | No composite form |",
515
- "|---|---|---|---|---|---|",
542
+ "| Files | Comparable | Agreed | Both fold | No host |",
543
+ "|---|---|---|---|---|",
516
544
  `| ${summary.files} | ${summary.comparable} | ${summary.comparableAgreed} | ${summary.comparableBothFold} |` +
517
- ` ${summary.limited.host} | ${summary.limited.composite} |`,
545
+ ` ${summary.limited.host} |`,
518
546
  "",
519
- "Both limits disarm the reference. chant is given the entry's lexicons and build parameters, the inputs a real build has.",
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 | No composite form |",
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 type Fixture = ExpressionFixture | ProjectFixture;
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.project) {
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
  ]),
@@ -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 with an empty host passes every fixture tagged for it (#78)", async () => {
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
- expect(tagged.every((f) => f.kind === "expression" || !f.host)).toBe(true);
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
+ });