From 82d5016c9efc032216ee4988cb6adfb084647394 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:00:27 -0600 Subject: [PATCH] feat(refimpl): revival, and a host that supplies real values (#61) F-Val-Fate was unimplemented: `Host` carried descriptions of what a host supplies and no values, so a `{__resource}` envelope had no class to become and stayed an envelope in the namespace. `Host` gains `values`, specifier to export name to the real value, and F-Import binds a host-owned specifier's real exports per F-Host-Trust arm 1. `revive.ts` walks a declarator's folded tree and replaces five of the six envelopes: the resource is constructed by the class the folding file imports, honouring F-Val-Arity's props/attributes against spread-args split; the intrinsic is invoked in whichever form the envelope recorded; the helper is invoked; the symbol resolves as a dotted chain. `{__attrRef}` passes through, and is rejected inside a host call's arguments per F-Val-Position. `{__compositeStep}` rejects, since there is no composite factory form here. Two things fell out of getting this right. Revival must not rebuild a structure that holds no envelope. The first version returned a fresh object every time, which made X(g) a copy and F-Memo's guarantee vacuous, exactly as J3 warns. It now returns the original when every child came back identical. F-Capture is stated over X(f), not over what f imported, and the previous implementation recorded the edge at import time. That made every import of a project-local function a capture, since a FoldableFunction is an object, which defeats the parameter helper F-CallLeak exists to protect. Captures are now decided by walking the produced namespace against an owner index, and F-CallLeak's own edge is recorded at the call site. Conformance fixtures name a host: `packages/conformance/src/host.ts` carries one, with two entity classes covering both arities, an intrinsic tag, a helper, and a live object the host owns outright for the leak case. Four fixtures land on it, and F-CallLeak's shows a leaking call and a pure one in the same file. F-NoOwnExecution is asserted directly: a top-level side effect in a folded file never runs, a side effect in a called body is rejected rather than executed, and with no host a resource cannot revive and the file falls back rather than silently keeping an envelope. Coverage 51 to 57 of 126 rules. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RfnRhfdYHLFAZJKHwZhBYc --- packages/conformance/src/adapter.ts | 4 +- packages/conformance/src/fixture.ts | 5 +- packages/conformance/src/host.ts | 89 +++++++++++++ packages/conformance/src/index.ts | 1 + packages/conformance/src/runner.ts | 6 +- packages/reference/CAVEATS.md | 31 +++-- packages/reference/src/adapter.ts | 18 ++- packages/reference/src/fold.ts | 15 ++- packages/reference/src/host.ts | 11 +- .../reference/src/no-own-execution.test.ts | 86 +++++++++++++ packages/reference/src/project.ts | 85 ++++++++++--- packages/reference/src/revive.ts | 120 ++++++++++++++++++ paper/measurements.md | 8 +- .../a-call-returns-a-live-object/expect.json | 8 ++ .../project/caller.ts | 5 + .../project/leaky.ts | 7 + .../project/pure.ts | 5 + .../props-attributes-and-spread/expect.json | 14 ++ .../project/arities.ts | 5 + .../revive-every-envelope/expect.json | 15 +++ .../revive-every-envelope/project/entities.ts | 6 + .../plain-data-is-not-live/expect.json | 9 ++ .../plain-data-is-not-live/project/breaker.ts | 10 ++ .../plain-data-is-not-live/project/quiet.ts | 3 + .../plain-data-is-not-live/project/source.ts | 5 + spec/fixtures/UNCOVERED.md | 7 +- 26 files changed, 528 insertions(+), 50 deletions(-) create mode 100644 packages/conformance/src/host.ts create mode 100644 packages/reference/src/no-own-execution.test.ts create mode 100644 packages/reference/src/revive.ts create mode 100644 spec/fixtures/F-CallLeak/a-call-returns-a-live-object/expect.json create mode 100644 spec/fixtures/F-CallLeak/a-call-returns-a-live-object/project/caller.ts create mode 100644 spec/fixtures/F-CallLeak/a-call-returns-a-live-object/project/leaky.ts create mode 100644 spec/fixtures/F-CallLeak/a-call-returns-a-live-object/project/pure.ts create mode 100644 spec/fixtures/F-Val-Arity/props-attributes-and-spread/expect.json create mode 100644 spec/fixtures/F-Val-Arity/props-attributes-and-spread/project/arities.ts create mode 100644 spec/fixtures/F-Val-Fate/revive-every-envelope/expect.json create mode 100644 spec/fixtures/F-Val-Fate/revive-every-envelope/project/entities.ts create mode 100644 spec/fixtures/F-Val-Live/plain-data-is-not-live/expect.json create mode 100644 spec/fixtures/F-Val-Live/plain-data-is-not-live/project/breaker.ts create mode 100644 spec/fixtures/F-Val-Live/plain-data-is-not-live/project/quiet.ts create mode 100644 spec/fixtures/F-Val-Live/plain-data-is-not-live/project/source.ts diff --git a/packages/conformance/src/adapter.ts b/packages/conformance/src/adapter.ts index dc7fa94..3031acc 100644 --- a/packages/conformance/src/adapter.ts +++ b/packages/conformance/src/adapter.ts @@ -3,6 +3,8 @@ * foreign implementation (chant, or one in another language behind a shim) * can satisfy it without exposing internals: source in, verdict out. */ +import type { ConformanceHost } from "./host"; + export type ShapeResult = | { accepted: true } | { accepted: false; rule?: string; line: number; column: number; message: string } @@ -34,5 +36,5 @@ export interface ConformanceAdapter { * chant's public API is per-file, so its project fixtures are reported * skipped rather than silently passing. */ - foldProject?(files: Map): ProjectResult | "unavailable"; + foldProject?(files: Map, host?: ConformanceHost): ProjectResult | "unavailable"; } diff --git a/packages/conformance/src/fixture.ts b/packages/conformance/src/fixture.ts index 4f48149..adc623e 100644 --- a/packages/conformance/src/fixture.ts +++ b/packages/conformance/src/fixture.ts @@ -22,6 +22,7 @@ * "tentative": { "config.ts": "fold" }, // optional, J2 before J3 disposed * "taintedBy": { "config.ts": "app.ts" }, // optional, the file whose taint reached it * "exports": { "config.ts": { "port": 8080 } }, // optional, for files that finally fold + * "host": "shapes", // optional, a named host from host.ts; required if the sources import one * "note": "why this fixture exists" } * `tentative` and `taintedBy` are what separate "folds because nothing * reached it" from "would have folded, and an edge killed it" — without them @@ -46,6 +47,8 @@ export interface ProjectFixture { tentative?: Record; taintedBy?: Record; exports?: Record>; + /** A named host from host.ts. Required for any fixture whose sources import one. */ + host?: string; note?: string; } export type Fixture = ExpressionFixture | ProjectFixture; @@ -77,7 +80,7 @@ export function loadFixtures(root: string): Fixture[] { const id = `${rule}/${name}`; if (e.project) { out.push({ kind: "project", id, dir: d, rules: e.rules, files: readProject(join(d, "project")), - verdicts: e.verdicts, tentative: e.tentative, taintedBy: e.taintedBy, exports: e.exports, note: e.note }); + verdicts: e.verdicts, tentative: e.tentative, taintedBy: e.taintedBy, exports: e.exports, host: e.host, note: e.note }); } else { out.push({ kind: "expression", id, dir: d, input: readFileSync(join(d, "input.ts"), "utf8"), rules: e.rules, exportName: e.export, shape: e.shape, fold: e.fold, value: e.value, rejectAt: e.rejectAt, note: e.note }); diff --git a/packages/conformance/src/host.ts b/packages/conformance/src/host.ts new file mode 100644 index 0000000..094ec0f --- /dev/null +++ b/packages/conformance/src/host.ts @@ -0,0 +1,89 @@ +/** + * Named hosts a fixture may ask for (#61). A fixture is data — `.ts` sources + * and a JSON expectation — and F-Host-Interface item 1 requires real classes + * that revival can construct, so the classes live here, in code, and a fixture + * names the host it wants. + * + * Neutral by construction: nothing here imports an implementation. An adapter + * translates a `ConformanceHost` into whatever its own host interface is. + */ + +/** An intrinsic registration, F-Host-Registry's shape. */ +export interface HostIntrinsic { + readonly name: string; + readonly isTag: boolean; + readonly foldsAsCall?: boolean; + readonly foldsEagerly?: boolean; + readonly outputKey?: string; +} + +export interface ConformanceHost { + readonly name: string; + /** F-Host-Trust arm 1: the specifiers this host owns. */ + readonly ownedSpecifierPrefixes: readonly string[]; + readonly intrinsics: readonly HostIntrinsic[]; + readonly helpers: readonly { name: string; module: string; note: string }[]; + /** Specifier, then export name, to the real value. Revival calls these (F-Val-Fate). */ + readonly values: ReadonlyMap>; +} + +const DECLARABLE = Symbol.for("tsad.conformance.declarable"); + +/** F-Host-Interface item 1's shape: `new (props, attributes?)`, carrying the markers an entity carries. */ +class Bucket { + readonly entityType = "Bucket"; + readonly lexicon = "shapes"; + readonly props: Record; + readonly attributes: Record; + constructor(props: Record = {}, attributes: Record = {}) { + this.props = props; + this.attributes = attributes; + Object.defineProperty(this, DECLARABLE, { value: true, enumerable: false }); + } +} + +/** Spread-`args` arity, the other arm of F-Val-Arity. */ +class Pair { + readonly entityType = "Pair"; + readonly lexicon = "shapes"; + constructor(readonly left: unknown, readonly right: unknown) { + Object.defineProperty(this, DECLARABLE, { value: true, enumerable: false }); + } +} + +/** A live object the host owns outright, for the F-CallLeak case: no construction involved. */ +const registry = new Map([["one", "1"]]); + +/** An intrinsic in tag form: revival invokes it as `Name(strings, ...values)`. */ +const join = (strings: readonly string[], ...values: unknown[]): string => + strings.reduce((acc, s, i) => acc + s + (i < values.length ? String(values[i]) : ""), ""); + +/** An authoring helper: pure, deterministic, the same at fold time as at run time. */ +const upper = (s: string): string => s.toUpperCase(); + +const SHAPES: ConformanceHost = { + name: "shapes", + ownedSpecifierPrefixes: ["@tsad/shapes"], + intrinsics: [{ name: "join", isTag: true }], + helpers: [{ name: "upper", module: "@tsad/shapes", note: "pure string transform, no environment read" }], + values: new Map([ + [ + "@tsad/shapes", + new Map([ + ["Bucket", Bucket], + ["Pair", Pair], + ["join", join], + ["upper", upper], + ["registry", registry], + ]), + ], + ]), +}; + +export const HOSTS: ReadonlyMap = new Map([[SHAPES.name, SHAPES]]); + +export function requireHost(name: string): ConformanceHost { + const h = HOSTS.get(name); + if (!h) throw new Error(`no such conformance host: ${name}. Known: ${[...HOSTS.keys()].join(", ")}`); + return h; +} diff --git a/packages/conformance/src/index.ts b/packages/conformance/src/index.ts index 806a8a3..f520e91 100644 --- a/packages/conformance/src/index.ts +++ b/packages/conformance/src/index.ts @@ -1,3 +1,4 @@ export * from "./adapter"; export * from "./fixture"; +export * from "./host"; export * from "./runner"; diff --git a/packages/conformance/src/runner.ts b/packages/conformance/src/runner.ts index c35030d..03a8056 100644 --- a/packages/conformance/src/runner.ts +++ b/packages/conformance/src/runner.ts @@ -1,6 +1,7 @@ import type { ConformanceAdapter } from "./adapter"; import type { ExpressionFixture, Fixture, ProjectFixture } from "./fixture"; import { expressionFixtures, projectFixtures } from "./fixture"; +import { requireHost } from "./host"; export interface FixtureReport { fixture: string; adapter: string; pass: boolean; skipped?: string; failures: string[] } @@ -32,7 +33,7 @@ export function runFixtures(adapter: ConformanceAdapter, fixtures: Fixture[]): F export function runProjectFixture(adapter: ConformanceAdapter, f: ProjectFixture): FixtureReport { const base = { fixture: f.id, adapter: adapter.name }; if (!adapter.foldProject) return { ...base, pass: true, skipped: "no project entry", failures: [] }; - const r = adapter.foldProject(f.files); + const r = adapter.foldProject(f.files, f.host ? requireHost(f.host) : undefined); if (r === "unavailable") return { ...base, pass: true, skipped: "project entry unavailable", failures: [] }; const failures: string[] = []; for (const [path, want] of Object.entries(f.verdicts)) { @@ -66,7 +67,8 @@ export function compareAdapters(a: ConformanceAdapter, b: ConformanceAdapter, fi const dis: string[] = []; for (const f of projectFixtures(fixtures)) { if (!a.foldProject || !b.foldProject) continue; - const ra = a.foldProject(f.files), rb = b.foldProject(f.files); + const host = f.host ? requireHost(f.host) : undefined; + const ra = a.foldProject(f.files, host), rb = b.foldProject(f.files, host); if (ra === "unavailable" || rb === "unavailable") continue; for (const path of new Set([...Object.keys(ra.verdicts), ...Object.keys(rb.verdicts)])) { const va = ra.verdicts[path]?.kind ?? "absent", vb = rb.verdicts[path]?.kind ?? "absent"; diff --git a/packages/reference/CAVEATS.md b/packages/reference/CAVEATS.md index 8366d24..ca882f6 100644 --- a/packages/reference/CAVEATS.md +++ b/packages/reference/CAVEATS.md @@ -13,19 +13,24 @@ reference test for import and re-export captures, the entity test for call leaks. Kept here as a pointer, because the code comments cite F-Identity and a reader of an older revision will not find it (#59). -## No revival - -**F-Val-Fate** has a resource envelope revived into a real instance by the -class the declarator's `new` resolves to. Revival needs a host that supplies -real constructors; this package ships `EMPTY_HOST`, so a `{__resource}` -envelope stays an envelope in the namespace. - -This does not change a verdict. F-Import asks only whether a value has -identity, and an envelope is an object either way. It does change two things: -a project fixture cannot assert a revived instance's class, and **F-CallLeak** -is not reachable at all, because no body this package can evaluate produces a -value that F-Val-Live calls live. That is why F-CallLeak is still listed in -`spec/fixtures/UNCOVERED.md` with a reason of its own. +## Revival needs a host, and one envelope has no fate here + +**F-Val-Fate** is implemented (#61): a declarator's value is revived through +the folding file's own imports, so a `{__resource}` becomes a real instance of +the class the host supplies, `{__intrinsic}` and `{__helper}` are invoked, and +`{__symbol}` resolves as a dotted chain. `{__attrRef}` passes through, and is +rejected inside a host call's arguments per **F-Val-Position**. + +Two limits remain. + +`{__compositeStep}` has no fate here. Its revival is "resolve the composite +(J2 F-Call), then read `.step` off the real result", and this package has no +composite factory form, so revival rejects rather than guessing. + +Revival can only construct what a host supplies. With `EMPTY_HOST` an envelope +has no class to become and revival rejects, which is correct rather than +silent: a build that folds a resource and cannot revive it has not folded the +file. Conformance fixtures name the host they need. ## No filesystem, no module resolution algorithm diff --git a/packages/reference/src/adapter.ts b/packages/reference/src/adapter.ts index ffd2748..89e68ad 100644 --- a/packages/reference/src/adapter.ts +++ b/packages/reference/src/adapter.ts @@ -1,7 +1,19 @@ -import type { ConformanceAdapter, ProjectResult } from "@intentius/tsad-conformance"; +import type { ConformanceAdapter, ConformanceHost, ProjectResult } from "@intentius/tsad-conformance"; +import { EMPTY_HOST, type Host } from "./host"; import { shapeOfExport, foldExport } from "./module"; import { foldProject } from "./project"; +/** A named conformance host, in this implementation's own terms. */ +function hostOf(h: ConformanceHost | undefined): Host { + if (!h) return EMPTY_HOST; + return { + intrinsics: h.intrinsics.map((i) => ({ name: i.name, isTag: i.isTag, foldsAsCall: i.foldsAsCall, foldsEagerly: i.foldsEagerly, outputKey: i.outputKey })), + helpers: h.helpers, + ownedSpecifierPrefixes: h.ownedSpecifierPrefixes, + values: h.values, + }; +} + /** The reference reports spec rule identifiers directly: it is written from the spec. */ export const referenceAdapter: ConformanceAdapter = { name: "reference", @@ -13,8 +25,8 @@ export const referenceAdapter: ConformanceAdapter = { return { accepted: false, rule: v.rule, line: line + 1, column: character + 1, message: v.message }; }, foldExport(source, exportName) { return foldExport(source, exportName); }, - foldProject(files) { - const r = foldProject(files); + foldProject(files, host) { + const r = foldProject(files, hostOf(host)); const out: ProjectResult = { verdicts: {}, tentative: {}, taintedBy: {} }; for (const [path, v] of r.verdicts) { out.verdicts[path] = v.kind === "fold" ? { kind: "fold", exports: Object.fromEntries(v.exports) } : { kind: "run", rule: v.rule, reason: v.reason }; diff --git a/packages/reference/src/fold.ts b/packages/reference/src/fold.ts index 48d213f..893cba2 100644 --- a/packages/reference/src/fold.ts +++ b/packages/reference/src/fold.ts @@ -85,11 +85,22 @@ export function carriesLiveObject(v: unknown, seen = new Set()): boolea if (v === null || typeof v !== "object") return typeof v === "function"; if (seen.has(v)) return false; seen.add(v); - const proto = Object.getPrototypeOf(v); - if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) return true; + if (isLiveObject(v)) return true; return Object.values(v).some((inner) => carriesLiveObject(inner, seen)); } +/** + * The non-recursive half of F-Val-Live: this value *is* a live object, rather + * than a plain structure that may hold one. Revival passes these through + * unchanged (L6.1); rebuilding one would destroy the identity J3 preserves. + */ +export function isLiveObject(v: unknown): boolean { + if (typeof v === "function") return true; + if (v === null || typeof v !== "object") return false; + const proto = Object.getPrototypeOf(v); + return proto !== Object.prototype && proto !== Array.prototype && proto !== null; +} + /** F-Val-Envelope: a non-array object carrying one of the six keys. */ const ENVELOPE_KEYS = ["__attrRef", "__intrinsic", "__helper", "__resource", "__compositeStep", "__symbol"] as const; export function isEnvelope(v: unknown): boolean { diff --git a/packages/reference/src/host.ts b/packages/reference/src/host.ts index fd955fb..61f5244 100644 --- a/packages/reference/src/host.ts +++ b/packages/reference/src/host.ts @@ -16,9 +16,18 @@ export function intrinsicTagFolds(def: { isTag?: boolean }): boolean { return de export function intrinsicCallFolds(def: { isTag?: boolean; foldsAsCall?: boolean }): boolean { return def.isTag !== true && def.foldsAsCall === true; } export function intrinsicCallFoldsEagerly(def: { isTag?: boolean; foldsEagerly?: boolean }): boolean { return def.isTag !== true && def.foldsEagerly === true; } +/** + * What a host-owned specifier really exports: specifier, then export name, to + * the live value. F-Host-Interface item 1's entity constructors live here, as + * do the intrinsic and helper functions items 3 and 4 name, because revival + * has to *call* them (F-Val-Fate) and a description cannot be called. + */ +export type HostValues = ReadonlyMap>; + export interface Host { readonly intrinsics: readonly IntrinsicDef[]; readonly helpers: readonly { name: string; module: string; note: string }[]; readonly ownedSpecifierPrefixes: readonly string[]; + readonly values: HostValues; } -export const EMPTY_HOST: Host = { intrinsics: [], helpers: [], ownedSpecifierPrefixes: [] }; +export const EMPTY_HOST: Host = { intrinsics: [], helpers: [], ownedSpecifierPrefixes: [], values: new Map() }; diff --git a/packages/reference/src/no-own-execution.test.ts b/packages/reference/src/no-own-execution.test.ts new file mode 100644 index 0000000..16d55bc --- /dev/null +++ b/packages/reference/src/no-own-execution.test.ts @@ -0,0 +1,86 @@ +/** + * F-NoOwnExecution (J4). The reference parses project source and reduces it; + * it never runs a statement of it. Revival (#61) is the one place real code is + * invoked, and what it invokes is the *host's* constructors and functions, + * never anything the folded file wrote. + * + * Asserted by folding files whose top-level statements would be observable if + * they ran. + */ +import { describe, test, expect } from "vitest"; +import { requireHost } from "@intentius/tsad-conformance"; +import { foldProject } from "./project"; +import type { Host } from "./host"; + +const shapes = requireHost("shapes"); +const host: Host = { + intrinsics: shapes.intrinsics.map((i) => ({ name: i.name, isTag: i.isTag })), + helpers: shapes.helpers, + ownedSpecifierPrefixes: shapes.ownedSpecifierPrefixes, + values: shapes.values, +}; + +declare global { + // eslint-disable-next-line no-var + var __tsadRan: string[] | undefined; +} + +describe("F-NoOwnExecution", () => { + test("a top-level side effect never runs, and the file folds anyway", () => { + globalThis.__tsadRan = []; + const files = new Map([ + [ + "sideEffects.ts", + `import { Bucket } from "@tsad/shapes";\n` + + `globalThis.__tsadRan.push("top level");\n` + + `export const bucket = new Bucket({ name: "b" });\n`, + ], + ]); + const v = foldProject(files, host).verdicts.get("sideEffects.ts"); + // The resource folds and revives into a real instance, which means the host + // constructor ran... + expect(v?.kind).toBe("fold"); + if (v?.kind === "fold") expect((v.exports.get("bucket") as { entityType: string }).entityType).toBe("Bucket"); + // ...and the file's own statement did not. Non-exported statements are + // invisible to the gate precisely because they never execute. + expect(globalThis.__tsadRan).toEqual([]); + }); + + test("a side effect in a called body is rejected, not executed", () => { + globalThis.__tsadRan = []; + const files = new Map([ + ["lib.ts", `export function helper() {\n globalThis.__tsadRan.push("body");\n return 1;\n}\nexport const marker = 1;\n`], + ["caller.ts", `import { helper } from "./lib";\nexport const n = helper();\n`], + ]); + const r = foldProject(files, host); + const v = r.verdicts.get("caller.ts"); + expect(v?.kind).toBe("run"); + // S-FnBody refuses the body rather than running it, and the forward edge + // then takes lib.ts with it. + expect(r.verdicts.get("lib.ts")?.kind).toBe("run"); + expect(globalThis.__tsadRan).toEqual([]); + }); + + test("a host constructor is the only thing revival calls", () => { + const files = new Map([["a.ts", `import { Bucket } from "@tsad/shapes";\nexport const b = new Bucket({ n: 1 });\n`]]); + const v = foldProject(files, host).verdicts.get("a.ts"); + expect(v?.kind).toBe("fold"); + if (v?.kind === "fold") { + const b = v.exports.get("b") as { lexicon: string; props: unknown }; + expect(b.lexicon).toBe("shapes"); + expect(b.props).toEqual({ n: 1 }); + } + }); + + test("with no host, a resource cannot be revived and the file falls back", () => { + // Correct rather than silent: a namespace holding an unrevived envelope is + // not a folded file. F-Val-Fate has no arm that leaves one in place. + const files = new Map([["a.ts", `import { Bucket } from "@tsad/shapes";\nexport const b = new Bucket({ n: 1 });\n`]]); + const v = foldProject(files).verdicts.get("a.ts"); + expect(v?.kind).toBe("run"); + if (v?.kind === "run") { + expect(v.rule).toBe("F-Val-Fate"); + expect(v.reason).toContain("Bucket"); + } + }); +}); diff --git a/packages/reference/src/project.ts b/packages/reference/src/project.ts index 82c9a61..f2a0e7c 100644 --- a/packages/reference/src/project.ts +++ b/packages/reference/src/project.ts @@ -16,16 +16,14 @@ import * as ts from "typescript"; import { EMPTY_HOST, type Host } from "./host"; import { foldExpr, collectConsts, FoldRejection, FoldableFunction, type Scope } from "./fold"; -import { registerHelpers, registerHostSpecifiers } from "./foldable-helpers"; +import { registerHelpers, registerHostSpecifiers, isHostOwnedSpecifier } from "./foldable-helpers"; +import { revive } from "./revive"; import type { FnDecl } from "./fnbody"; export type Verdict = | { kind: "fold"; exports: Map; captures: Set } | { kind: "run"; rule: string; reason: string }; -/** F-Identity's reference test, which F-Import uses: `typeof` object or function, not recursive. */ -const hasIdentity = (v: unknown): boolean => v !== null && (typeof v === "object" || typeof v === "function"); - const parse = (path: string, source: string) => ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true); const isProjectSpecifier = (s: string) => s.startsWith(".") || s.startsWith("/"); @@ -114,6 +112,43 @@ interface Session { readonly stack: string[]; /** Local functions this build created, so F-CallLeak's flag is observable. */ readonly locals: Map; + /** + * Every non-primitive in some folded `X(g)`, to the `g` that produced it. + * F-Capture is stated over `X(f)`, not over what `f` imported, so the edge + * can only be decided once the namespace exists. F-Memo is what makes the + * index meaningful: one object per entity, so the first owner is the owner. + */ + readonly owner: Map; +} + +/** + * Index every non-primitive `f` produced, so a later file's capture of one is + * attributable. "Non-primitive" is F-Identity's reference test, the broad one + * F-Import uses: a plain object counts, and the walk does not ask whether + * anything inside it is live. + */ +function indexOwned(value: unknown, file: string, session: Session, seen = new Set()): void { + if (value === null || typeof value !== "object") return; + if (seen.has(value)) return; + seen.add(value); + if (!session.owner.has(value)) session.owner.set(value, file); + // Recurse through plain structure only: an entity's interior belongs to the + // entity, and reaching into it would attribute its fields to the wrong file. + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) return; + for (const inner of Object.values(value)) indexOwned(inner, file, session, seen); +} + +/** F-Capture, decided over the produced namespace: which other files' objects are in `X(f)`. */ +function capturesIn(value: unknown, self: string, session: Session, out: Set, seen = new Set()): void { + if (value === null || typeof value !== "object") return; + if (seen.has(value)) return; + seen.add(value); + const from = session.owner.get(value); + if (from !== undefined && from !== self) out.add(from); + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) return; + for (const inner of Object.values(value)) capturesIn(inner, self, session, out, seen); } /** Every value binding a non-type-only import clause introduces. */ @@ -166,8 +201,20 @@ function foldFile(path: string, session: Session): Verdict { const spec = st.moduleSpecifier.text; const clause = st.importClause; if (!clause || clause.isTypeOnly) continue; - // A bare specifier is never resolved here: a package is not a member of F. - if (!isProjectSpecifier(spec)) continue; + if (!isProjectSpecifier(spec)) { + // F-Import, bare specifier: a package is never a member of F. A host-owned + // one still binds its REAL exports (F-Host-Trust arm 1), which is what lets + // revival construct anything at all (F-Val-Fate). + const supplied = session.host.values.get(spec); + if (supplied && isHostOwnedSpecifier(spec) && clause.namedBindings && ts.isNamedImports(clause.namedBindings)) { + for (const el of clause.namedBindings.elements) { + if (el.isTypeOnly) continue; + const imported = (el.propertyName ?? el.name).text; + if (supplied.has(imported)) externals.set(el.name.text, supplied.get(imported)); + } + } + continue; + } const target = resolveKey(path, spec, session.files); if (!target) continue; const tv = verdictOf(target, session); @@ -180,7 +227,6 @@ function foldFile(path: string, session: Session): Verdict { // F-Namespace: a synthetic plain object of the target's entries. const ns = Object.fromEntries(tv.exports); externals.set(clause.namedBindings.name.text, ns); - if ([...tv.exports.values()].some(hasIdentity)) captures.add(target); continue; } if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) { @@ -190,7 +236,6 @@ function foldFile(path: string, session: Session): Verdict { if (!tv.exports.has(imported)) continue; const value = tv.exports.get(imported); externals.set(el.name.text, value); - if (hasIdentity(value)) captures.add(target); // F-Capture } } } @@ -199,19 +244,25 @@ function foldFile(path: string, session: Session): Verdict { const scope: Scope = { consts, externals, depth: 0, captures }; const evalHost = { intrinsics: session.host.intrinsics }; const exports = new Map(); + /** F-Val-Fate: what the declarator produced, revived through this file's own imports. */ + const live = (v: unknown, node: ts.Node, what: string) => { + const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart()); + return revive(v, externals, { line: line + 1, column: character + 1, what }); + }; for (const d of scan.declarators) { try { if (d.kind === "resource" || d.kind === "single") { - exports.set(d.name, foldExpr(d.expr, scope, evalHost)); + exports.set(d.name, live(foldExpr(d.expr, scope, evalHost), d.expr, d.name)); } else if (d.kind === "destructure") { - const base = foldExpr(d.expr, scope, evalHost); + const base = live(foldExpr(d.expr, scope, evalHost), d.expr, "a destructured declaration"); if (base === null || typeof base !== "object") { return { kind: "run", rule: "F-Declarator", reason: "destructured source is not an object" }; } for (const el of d.elements) exports.set(el.as, (base as Record)[el.key]); } else if (d.kind === "named-export") { for (const el of d.elements) { - const v = consts.has(el.local) ? foldExpr(consts.get(el.local)!, scope, evalHost) : externals.get(el.local); + const init = consts.get(el.local); + const v = init ? live(foldExpr(init, scope, evalHost), init, el.as) : externals.get(el.local); if (v === undefined && !consts.has(el.local) && !externals.has(el.local)) { return { kind: "run", rule: "F-Reference", reason: `unresolved identifier: ${el.local}` }; } @@ -222,11 +273,8 @@ function foldFile(path: string, session: Session): Verdict { if (!target) return { kind: "run", rule: "F-Import", reason: `cannot resolve re-export from ${d.specifier}` }; const tv = verdictOf(target, session); if (tv.kind !== "fold") return { kind: "run", rule: "F-Import", reason: `re-export source ${target} falls back to run` }; - for (const el of d.elements) { - const v = tv.exports.get(el.imported); - exports.set(el.as, v); - if (hasIdentity(v)) captures.add(target); // a re-export is a capture - } + // A re-export is a capture, and the owner index below records it as one. + for (const el of d.elements) exports.set(el.as, tv.exports.get(el.imported)); } else if (d.kind === "function") { const marker = new FoldableFunction(d.name, d.fn, path, consts, externals); mine.push(marker); @@ -249,6 +297,9 @@ function foldFile(path: string, session: Session): Verdict { throw e; } } + // F-Capture over X(f). F-CallLeak has already put its own edges in `captures`. + for (const v of exports.values()) capturesIn(v, path, session, captures); + for (const v of exports.values()) indexOwned(v, path, session); return { kind: "fold", exports, captures }; } @@ -267,7 +318,7 @@ export interface ProjectResult { export function foldProject(files: ReadonlyMap, host: Host = EMPTY_HOST): ProjectResult { registerHelpers(host.helpers); registerHostSpecifiers(host.ownedSpecifierPrefixes); - const session: Session = { files, host, memo: new Map(), stack: [], locals: new Map() }; + const session: Session = { files, host, memo: new Map(), stack: [], locals: new Map(), owner: new Map() }; const tentative = new Map(); for (const path of files.keys()) tentative.set(path, verdictOf(path, session)); diff --git a/packages/reference/src/revive.ts b/packages/reference/src/revive.ts new file mode 100644 index 0000000..9b5fe87 --- /dev/null +++ b/packages/reference/src/revive.ts @@ -0,0 +1,120 @@ +/** + * F-Val-Fate: revival. A folded tree holds envelopes, values that *denote* + * something not yet constructed; revival walks the tree and replaces each one + * by resolving its name through the folding file's own imports and invoking + * the real constructor or function the host supplies (#61). + * + * Written from `spec/values.md`. Five of the six envelope kinds are here. + * `__compositeStep` is not: its fate is "resolve the composite (J2 F-Call), + * then read `.step` off the real result", and this implementation has no + * composite factory form, so it rejects rather than guessing. See CAVEATS.md. + * + * Revival is J2's step, not J1's. `foldExpr` yields envelopes and that is the + * answer the expression fixtures compare; `F-Declarator` revives what the + * declarator produced. + */ +import { FoldRejection, isEnvelope, isLiveObject } from "./fold"; + +/** Where a revival happened, for the located rejection F-Reason wants. */ +export interface RevivalSite { + readonly line: number; + readonly column: number; + /** The declarator or call this tree came from, for the message. */ + readonly what: string; +} + +const DOTTED = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/; + +type Env = Record; + +/** + * @param bindings the folding file's resolved names, `externals`. A host-owned + * import puts the real value here (F-Import, F-Host-Trust arm 1), which is + * what makes revival possible at all. + * @param inHostArgs true inside an intrinsic's or helper's arguments, where + * F-Val-Position rejects an `{__attrRef}` instead of passing it through. + */ +export function revive( + value: unknown, + bindings: ReadonlyMap, + site: RevivalSite, + inHostArgs = false, +): unknown { + if (value === null || typeof value !== "object") return value; + // F-Val-Live: a live object passes through unchanged, never rebuilt. + if (isLiveObject(value)) return value; + // A structure with no envelope anywhere inside is already final, and is + // returned AS IS rather than rebuilt. F-Memo requires every reference to + // X(g) to be the same object; a rebuilt copy would make F-Capture record an + // edge to a copy and the judgment vacuous. + if (Array.isArray(value)) { + const next = value.map((v) => revive(v, bindings, site, inHostArgs)); + return next.every((v, i) => v === value[i]) ? value : next; + } + if (!isEnvelope(value)) { + const out: Env = {}; + let changed = false; + for (const [k, v] of Object.entries(value)) { + out[k] = revive(v, bindings, site, inHostArgs); + if (out[k] !== v) changed = true; + } + return changed ? out : value; + } + return reviveEnvelope(value as Env, bindings, site, inHostArgs); +} + +function fail(site: RevivalSite, message: string): never { + throw new FoldRejection("F-Val-Fate", site.line, site.column, `${site.what}: ${message}`); +} + +function resolve(name: string, bindings: ReadonlyMap, site: RevivalSite): unknown { + if (!bindings.has(name)) fail(site, `revival cannot resolve ${name}; the folding file does not import it from the host`); + return bindings.get(name); +} + +function callable(name: string, bindings: ReadonlyMap, site: RevivalSite): (...a: unknown[]) => unknown { + const v = resolve(name, bindings, site); + if (typeof v !== "function") fail(site, `${name} resolves to ${typeof v}, which revival cannot invoke`); + return v as (...a: unknown[]) => unknown; +} + +function reviveEnvelope(e: Env, bindings: ReadonlyMap, site: RevivalSite, inHostArgs: boolean): unknown { + // `__attrRef` is the one envelope that survives, except inside host arguments. + if ("__attrRef" in e) { + if (inHostArgs) { + const { entity, attribute } = e.__attrRef as { entity: string; attribute: string }; + fail(site, `an attribute reference (${entity}.${attribute}) inside a host call's arguments is rejected, not revived (F-Val-Position)`); + } + return e; + } + const args = (a: unknown) => (a as unknown[]).map((x) => revive(x, bindings, site, true)); + + if ("__resource" in e) { + const name = e.__resource as string; + const C = callable(name, bindings, site) as unknown as new (...a: unknown[]) => unknown; + // F-Val-Arity: spread `args` when present, otherwise props and optional attributes. + if (Array.isArray(e.args)) return new C(...(e.args as unknown[]).map((x) => revive(x, bindings, site, false))); + const props = revive(e.props, bindings, site, false); + return "attributes" in e ? new C(props, revive(e.attributes, bindings, site, false)) : new C(props); + } + if ("__intrinsic" in e) { + const fn = callable(e.__intrinsic as string, bindings, site); + // The tag form keeps its cooked strings; the call form is a plain call. + if (Array.isArray(e.strings)) return fn(e.strings as unknown as string[], ...args(e.values)); + return fn(...args(e.args)); + } + if ("__helper" in e) return callable(e.__helper as string, bindings, site)(...args(e.args)); + if ("__symbol" in e) { + const text = e.__symbol as string; + if (!DOTTED.test(text)) fail(site, `a symbolic reference revives only through a simple dotted chain, and "${text}" is not one`); + const [root, ...rest] = text.split("."); + let current = resolve(root, bindings, site); + for (const step of rest) { + if (current === null || current === undefined) fail(site, `revival of "${text}" read "${step}" on ${String(current)}`); + current = (current as Env)[step]; + } + return current; + } + // __compositeStep + fail(site, "a composite step envelope needs a composite factory form, which this implementation does not have"); +} diff --git a/paper/measurements.md b/paper/measurements.md index ea94136..82f6ba6 100644 --- a/paper/measurements.md +++ b/paper/measurements.md @@ -52,7 +52,7 @@ The chant side's `false` is also an unsampled invariant: the run fails unless ev | Pin | Fixtures | Rules with a fixture | Shape and fold agreement | |---|---|---|---| -| chant 0.69.1 | 37, of which 8 are whole-build | 51 of 125 | all, on the 29 the pin can answer | +| chant 0.69.1 | 41, of which 12 are whole-build | 57 of 126 | all, on the 29 the pin can answer | One disagreement existed between the reference and chant, on an envelope inside a template span. The spec recorded the recommendation, chant-v0.68.0 implemented it, and a fixture now pins it (chant#2349). @@ -60,7 +60,7 @@ One disagreement existed between the reference and chant, on an envelope inside Two limits on that. The rewrite's author had read chant's implementation closely while writing the specification from it, so this establishes that the specification is complete enough to implement from, not that a reader who had never seen chant would arrive at the same place. And the agreement covers the expression and single-file layers. It does not cover J3, for the reason the next section gives. -**What the whole-build fixtures establish, and against how many implementations.** The reference now implements J2's per-file verdict and J3's fixpoint from `judgments.md` (#21, #22). Eight whole-build fixtures assert the verdicts of an entire build rather than the value of one expression. Four of them fire an edge on purpose: +**What the whole-build fixtures establish, and against how many implementations.** The reference now implements J2's per-file verdict and J3's fixpoint from `judgments.md` (#21, #22), and revival from `values.md` (#61), so a folded declarator yields a real instance of the class the host supplies rather than an envelope. Twelve whole-build fixtures assert the verdicts of an entire build rather than the value of one expression. Four of them fire a taint edge on purpose: | Fixture | What it fires | |---|---| @@ -109,7 +109,7 @@ The corpus is chant's own examples, and chant's documentation says the number is ## Limits - Twelve mixed entries and one adversarial build are a small sample, all from one project. -- Fixture coverage is 51 of 125 rules. The 74 without one are listed with a reason, and the list may only shrink. +- Fixture coverage is 57 of 126 rules. The 69 without one are listed with a reason, and the list may only shrink. - Only the differential puts J3 in front of both implementations at once; the fixtures for it reach one (chant#2408). -- The reference has no host that supplies constructors, so it cannot revive a resource envelope into an instance. `F-CallLeak` therefore has no fixture, and the J3 fixtures reach captures through imports only (`packages/reference/CAVEATS.md`). +- Revival is implemented for five of the six envelopes; `{__compositeStep}` needs a composite factory form the reference does not have (`packages/reference/CAVEATS.md`). - The independent rewrite found two specification gaps. Two is a small sample, and it is the sample a single author working alone can produce. diff --git a/spec/fixtures/F-CallLeak/a-call-returns-a-live-object/expect.json b/spec/fixtures/F-CallLeak/a-call-returns-a-live-object/expect.json new file mode 100644 index 0000000..dc3a8b7 --- /dev/null +++ b/spec/fixtures/F-CallLeak/a-call-returns-a-live-object/expect.json @@ -0,0 +1,8 @@ +{ + "rules": ["F-CallLeak", "F-Val-Live"], + "project": true, + "host": "shapes", + "verdicts": { "leaky.ts": "fold", "pure.ts": "fold", "caller.ts": "fold" }, + "exports": { "caller.ts": { "data": { "doubled": 42 } } }, + "note": "Two project-local calls in one file, one leaking and one not. shared() returns a live object the caller's arguments did not carry in, so caller.ts captures leaky.ts; twice() returns computed plain data, so pure.ts is not captured. Everything folds here, and the difference is only visible in the edges — which is why the taint fixtures are the ones that make it observable." +} diff --git a/spec/fixtures/F-CallLeak/a-call-returns-a-live-object/project/caller.ts b/spec/fixtures/F-CallLeak/a-call-returns-a-live-object/project/caller.ts new file mode 100644 index 0000000..cf14262 --- /dev/null +++ b/spec/fixtures/F-CallLeak/a-call-returns-a-live-object/project/caller.ts @@ -0,0 +1,5 @@ +import { shared } from "./leaky"; +import { twice } from "./pure"; + +export const live = shared(); +export const data = twice(21); diff --git a/spec/fixtures/F-CallLeak/a-call-returns-a-live-object/project/leaky.ts b/spec/fixtures/F-CallLeak/a-call-returns-a-live-object/project/leaky.ts new file mode 100644 index 0000000..6a94281 --- /dev/null +++ b/spec/fixtures/F-CallLeak/a-call-returns-a-live-object/project/leaky.ts @@ -0,0 +1,7 @@ +import { registry } from "@tsad/shapes"; + +export function shared() { + return registry; +} + +export const marker = 1; diff --git a/spec/fixtures/F-CallLeak/a-call-returns-a-live-object/project/pure.ts b/spec/fixtures/F-CallLeak/a-call-returns-a-live-object/project/pure.ts new file mode 100644 index 0000000..047479f --- /dev/null +++ b/spec/fixtures/F-CallLeak/a-call-returns-a-live-object/project/pure.ts @@ -0,0 +1,5 @@ +export function twice(n: number) { + return { doubled: n * 2 }; +} + +export const marker = 2; diff --git a/spec/fixtures/F-Val-Arity/props-attributes-and-spread/expect.json b/spec/fixtures/F-Val-Arity/props-attributes-and-spread/expect.json new file mode 100644 index 0000000..0529738 --- /dev/null +++ b/spec/fixtures/F-Val-Arity/props-attributes-and-spread/expect.json @@ -0,0 +1,14 @@ +{ + "rules": ["F-Val-Arity"], + "project": true, + "host": "shapes", + "verdicts": { "arities.ts": "fold" }, + "exports": { + "arities.ts": { + "withProps": { "entityType": "Bucket", "lexicon": "shapes", "props": { "name": "a" }, "attributes": {} }, + "withAttributes": { "entityType": "Bucket", "lexicon": "shapes", "props": { "name": "b" }, "attributes": { "region": "eu" } }, + "spread": { "left": 1, "right": "two", "entityType": "Pair", "lexicon": "shapes" } + } + }, + "note": "Both arms. A leading object literal is props, a second one is attributes, and anything else is spread positionally. The three forms are distinguished at fold time by the shape of the argument list, so revival has only to honour what the envelope recorded." +} diff --git a/spec/fixtures/F-Val-Arity/props-attributes-and-spread/project/arities.ts b/spec/fixtures/F-Val-Arity/props-attributes-and-spread/project/arities.ts new file mode 100644 index 0000000..49ce2f9 --- /dev/null +++ b/spec/fixtures/F-Val-Arity/props-attributes-and-spread/project/arities.ts @@ -0,0 +1,5 @@ +import { Bucket, Pair } from "@tsad/shapes"; + +export const withProps = new Bucket({ name: "a" }); +export const withAttributes = new Bucket({ name: "b" }, { region: "eu" }); +export const spread = new Pair(1, "two"); diff --git a/spec/fixtures/F-Val-Fate/revive-every-envelope/expect.json b/spec/fixtures/F-Val-Fate/revive-every-envelope/expect.json new file mode 100644 index 0000000..7d5d1c3 --- /dev/null +++ b/spec/fixtures/F-Val-Fate/revive-every-envelope/expect.json @@ -0,0 +1,15 @@ +{ + "rules": ["F-Val-Fate", "F-Val-Position"], + "project": true, + "host": "shapes", + "verdicts": { "entities.ts": "fold" }, + "exports": { + "entities.ts": { + "bucket": { "entityType": "Bucket", "lexicon": "shapes", "props": { "name": "logs" }, "attributes": {} }, + "tagged": "bucket-1", + "shouted": "DONE", + "reference": { "who": { "__attrRef": { "entity": "bucket", "attribute": "name" } } } + } + }, + "note": "Four of the six envelopes and the one that survives. The resource is constructed by the class the file imports, the intrinsic tag and the helper are invoked, and the attribute reference passes through unrevived because nothing in the build has named the instance yet." +} diff --git a/spec/fixtures/F-Val-Fate/revive-every-envelope/project/entities.ts b/spec/fixtures/F-Val-Fate/revive-every-envelope/project/entities.ts new file mode 100644 index 0000000..c96c953 --- /dev/null +++ b/spec/fixtures/F-Val-Fate/revive-every-envelope/project/entities.ts @@ -0,0 +1,6 @@ +import { Bucket, join, upper } from "@tsad/shapes"; + +export const bucket = new Bucket({ name: "logs" }); +export const tagged = join`bucket-${1}`; +export const shouted = upper("done"); +export const reference = { who: bucket.name }; diff --git a/spec/fixtures/F-Val-Live/plain-data-is-not-live/expect.json b/spec/fixtures/F-Val-Live/plain-data-is-not-live/expect.json new file mode 100644 index 0000000..8cdcb93 --- /dev/null +++ b/spec/fixtures/F-Val-Live/plain-data-is-not-live/expect.json @@ -0,0 +1,9 @@ +{ + "rules": ["F-Val-Live", "F-Identity"], + "project": true, + "verdicts": { "source.ts": "run", "breaker.ts": "run", "quiet.ts": "fold" }, + "tentative": { "source.ts": "fold", "breaker.ts": "run", "quiet.ts": "fold" }, + "taintedBy": { "source.ts": "breaker.ts" }, + "exports": { "quiet.ts": { "data": { "n": 2, "nested": { "deeper": [2, 2] } } } }, + "note": "The entity test earning its keep. breaker.ts is in the seed and taints source.ts forward. quiet.ts calls the same function and survives, because nothing the call returned carries a live object: under the reference test every parameter helper would be a taint source and quiet.ts would fall back with it." +} diff --git a/spec/fixtures/F-Val-Live/plain-data-is-not-live/project/breaker.ts b/spec/fixtures/F-Val-Live/plain-data-is-not-live/project/breaker.ts new file mode 100644 index 0000000..d19cd28 --- /dev/null +++ b/spec/fixtures/F-Val-Live/plain-data-is-not-live/project/breaker.ts @@ -0,0 +1,10 @@ +import { plain } from "./source"; + +export function guard(n: number) { + if (n > 0) { + return n; + } + return 0; +} + +export const value = guard(plain(1).n); diff --git a/spec/fixtures/F-Val-Live/plain-data-is-not-live/project/quiet.ts b/spec/fixtures/F-Val-Live/plain-data-is-not-live/project/quiet.ts new file mode 100644 index 0000000..4c66bed --- /dev/null +++ b/spec/fixtures/F-Val-Live/plain-data-is-not-live/project/quiet.ts @@ -0,0 +1,3 @@ +import { plain } from "./source"; + +export const data = plain(2); diff --git a/spec/fixtures/F-Val-Live/plain-data-is-not-live/project/source.ts b/spec/fixtures/F-Val-Live/plain-data-is-not-live/project/source.ts new file mode 100644 index 0000000..5080830 --- /dev/null +++ b/spec/fixtures/F-Val-Live/plain-data-is-not-live/project/source.ts @@ -0,0 +1,5 @@ +export function plain(n: number) { + return { n: n, nested: { deeper: [n, n] } }; +} + +export const marker = 1; diff --git a/spec/fixtures/UNCOVERED.md b/spec/fixtures/UNCOVERED.md index 2e5b40b..ab9ddf7 100644 --- a/spec/fixtures/UNCOVERED.md +++ b/spec/fixtures/UNCOVERED.md @@ -2,9 +2,8 @@ Deliberate, and this list may only shrink (#8). Each entry names the rule and why it is still uncovered. Remove an entry when its fixture lands; the gate fails if an entry is stale. -51 of 125 rules have fixtures. Most of the rules below are uncovered because the corpus is still growing (#24); where a rule needs something the reference implementation does not have, the entry says so. +57 of 126 rules have fixtures. Most of the rules below are uncovered because the corpus is still growing (#24); where a rule needs something the reference implementation does not have, the entry says so. -- `F-CallLeak` — needs a host that supplies a real constructor, so a called body can produce a live object; the reference ships an empty host (packages/reference/CAVEATS.md) - `F-Count` — no fixture yet; corpus is #24's - `F-Depth` — no fixture yet; corpus is #24's - `F-Direction` — no fixture yet; corpus is #24's @@ -45,12 +44,8 @@ Deliberate, and this list may only shrink (#8). Each entry names the rule and wh - `F-Obs-Messages` — no fixture yet; corpus is #24's - `F-Obs-Provenance` — no fixture yet; corpus is #24's - `F-Obs-Report` — no fixture yet; corpus is #24's -- `F-Val-Arity` — no fixture yet; corpus is #24's - `F-Val-Domain` — no fixture yet; corpus is #24's - `F-Val-Envelope` — no fixture yet; corpus is #24's -- `F-Val-Fate` — no fixture yet; corpus is #24's -- `F-Val-Live` — no fixture yet; corpus is #24's -- `F-Val-Position` — no fixture yet; corpus is #24's - `F-Val-Serializable` — no fixture yet; corpus is #24's - `F-Val-Symbol-Scope` — no fixture yet; corpus is #24's - `F-Val-Undefined` — no fixture yet; corpus is #24's