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