Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/conformance/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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<string, string>): ProjectResult | "unavailable";
foldProject?(files: Map<string, string>, host?: ConformanceHost): ProjectResult | "unavailable";
}
5 changes: 4 additions & 1 deletion packages/conformance/src/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,6 +47,8 @@ export interface ProjectFixture {
tentative?: Record<string, "fold" | "run">;
taintedBy?: Record<string, string>;
exports?: Record<string, Record<string, unknown>>;
/** A named host from host.ts. Required for any fixture whose sources import one. */
host?: string;
note?: string;
}
export type Fixture = ExpressionFixture | ProjectFixture;
Expand Down Expand Up @@ -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 });
Expand Down
89 changes: 89 additions & 0 deletions packages/conformance/src/host.ts
Original file line number Diff line number Diff line change
@@ -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<string, ReadonlyMap<string, unknown>>;
}

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<string, unknown>;
readonly attributes: Record<string, unknown>;
constructor(props: Record<string, unknown> = {}, attributes: Record<string, unknown> = {}) {
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<string, string>([["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<string, unknown>([
["Bucket", Bucket],
["Pair", Pair],
["join", join],
["upper", upper],
["registry", registry],
]),
],
]),
};

export const HOSTS: ReadonlyMap<string, ConformanceHost> = 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;
}
1 change: 1 addition & 0 deletions packages/conformance/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./adapter";
export * from "./fixture";
export * from "./host";
export * from "./runner";
6 changes: 4 additions & 2 deletions packages/conformance/src/runner.ts
Original file line number Diff line number Diff line change
@@ -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[] }

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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";
Expand Down
31 changes: 18 additions & 13 deletions packages/reference/CAVEATS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 15 additions & 3 deletions packages/reference/src/adapter.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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 };
Expand Down
15 changes: 13 additions & 2 deletions packages/reference/src/fold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,22 @@ export function carriesLiveObject(v: unknown, seen = new Set<unknown>()): 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 {
Expand Down
11 changes: 10 additions & 1 deletion packages/reference/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ReadonlyMap<string, unknown>>;

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() };
86 changes: 86 additions & 0 deletions packages/reference/src/no-own-execution.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
Loading