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: 2 additions & 2 deletions docs/content/spec/conformance/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ The file's own header states the count, {{< figure "rulesWithFixture" >}} of

`paper/measurements.md` sorts the rest by what each waits on. Five need a
form the reference implementation lacks, the composite factory and isolation
mode. The six of `rules.md` need the harness half of the rules contract
(#101), and the remainder are waiting for the corpus to grow (#24).
mode, and five are properties no adapter can observe, such as execution
counters and provenance.

A few entries are worth reading as a group, because they say something about
where the limits of a conformance suite are rather than where the gaps in the
Expand Down
2 changes: 1 addition & 1 deletion docs/content/what-it-enables/semantic-linting.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ chant's lexicons carry semantic lint rules for each target, running over folded

## Where the rule lives

Evaluability, "is this file data", is already specified as the `S-*` classifier and `F-Div-*`. The contract for semantic rules over values, `F-Rule-*`, is [issue #79](https://github.com/INTENTIUS/typescript-as-data/issues/79). It fixes the input a rule sees and its purity, along with its located findings and the two phases. It is stated here as a forward reference rather than a claim.
Evaluability, "is this file data", is already specified as the `S-*` classifier and `F-Div-*`. The contract for semantic rules over values, `F-Rule-*`, is [`rules.md`](/typescript-as-data/spec/normative/rules/) since spec `1.4`. It fixes the input a rule sees and its purity, along with its findings and the two phases, and each of its six rules has a fixture whose expectation is a set of findings as data.
32 changes: 32 additions & 0 deletions packages/conformance/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ export interface ProjectResult {
taintedBy?: Record<string, string>;
}

/** F-Rule-Finding's closed severity set. */
export type Severity = "error" | "warning" | "info";
/** F-Rule-Phase: named by the input the rule reads, the folded namespace or the artifact. */
export type RulePhase = "pre" | "post";
/**
* A finding, as data (F-Rule-Finding). `rule`, `severity` and `subject` are
* compared; `message` is not (F-Obs-Messages); `at` only when the fixture
* gives one and the implementation reports one (F-Obs-Provenance is
* optional). `file` names the file whose namespace holds the subject for a
* pre-synthesis finding; a post-synthesis finding concerns the artifact and
* carries none.
*/
export interface Finding {
rule: string;
severity: Severity;
subject: string;
message: string;
file?: string;
at?: { line: number; column: number };
}

export interface ConformanceAdapter {
readonly name: string;
/**
Expand All @@ -49,4 +70,15 @@ export interface ConformanceAdapter {
files: Map<string, string>,
host?: ConformanceHost,
): ProjectResult | "unavailable" | Promise<ProjectResult | "unavailable">;
/**
* The findings of the host's rules of one phase over the build (#101).
* A rule is host code, so the harness carries only its identifier and its
* findings; an implementation that cannot run a rule the host names
* reports "unavailable" and the fixture is skipped visibly.
*/
rules?(
files: Map<string, string>,
host: ConformanceHost,
phase: RulePhase,
): Finding[] | "unavailable" | Promise<Finding[] | "unavailable">;
}
11 changes: 10 additions & 1 deletion packages/conformance/src/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,17 @@
* "exports": { "config.ts": { "port": 8080 } }, // optional, for files that finally fold
* "rejectRule": { "app.ts": "F-Eval-CallLocal" }, // optional, the rule a run verdict must name; checked when the adapter reports one
* "host": "shapes", // optional, a named host from host.ts; required if the sources import one
* "findings": { "bucket.ts": [ { "rule": "SHAPES001", "subject": "bad", "severity": "error" } ], // optional (#101): the host's rules' findings, keyed by the
* "artifact": [ { "rule": "SHAPES002", "subject": "missing: Bucket", "severity": "warning" } ] }, // file whose namespace holds the subject (pre) or "artifact" (post); "at" optional
* "profiles": ["full"], // optional; see profilesOf for the default
* "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
* a project fixture cannot tell F-Seed from F-Taint.
*
* `findings` is what an F-Rule-* fixture asserts (#101): the findings of the
* named host's rules, as data. Matched on rule, subject and severity; the
* message is non-normative, and a location only when both sides give one.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
Expand All @@ -41,6 +47,7 @@ export interface ExpressionFixture {
shape: "accept" | "reject"; fold: "fold" | "run";
value?: unknown; rejectAt?: { line: number; column: number }; note?: string;
}
export interface ExpectedFinding { rule: string; subject: string; severity: "error" | "warning" | "info"; at?: { line: number; column: number } }
export interface ProjectFixture {
kind: "project";
profiles: Profile[];
Expand All @@ -55,6 +62,8 @@ export interface ProjectFixture {
rejectRule?: Record<string, string>;
/** A named host from host.ts. Required for any fixture whose sources import one. */
host?: string;
/** The host's rules' findings, keyed by file (pre-synthesis) or "artifact" (post-synthesis). */
findings?: Record<string, ExpectedFinding[]>;
note?: string;
}
export type Fixture = ExpressionFixture | ProjectFixture;
Expand Down Expand Up @@ -102,7 +111,7 @@ export function loadFixtures(root: string): Fixture[] {
const id = `${rule}/${name}`;
if (e.project) {
const fx: ProjectFixture = { kind: "project", id, dir: d, rules: e.rules, files: readProject(join(d, "project")), profiles: [],
verdicts: e.verdicts, tentative: e.tentative, taintedBy: e.taintedBy, exports: e.exports, rejectRule: e.rejectRule, host: e.host, note: e.note };
verdicts: e.verdicts, tentative: e.tentative, taintedBy: e.taintedBy, exports: e.exports, rejectRule: e.rejectRule, host: e.host, findings: e.findings, note: e.note };
fx.profiles = profilesOf(fx, e.profiles);
out.push(fx);
} else {
Expand Down
22 changes: 22 additions & 0 deletions packages/conformance/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ export interface HostIntrinsic {
readonly outputKey?: string;
}

/**
* A rule the host supplies (F-Rule-Supply), by identifier. The rule's code is
* the implementation's; what the harness carries is the id, the phase, the
* rule's own severity (L11.6) and what it checks, in words.
*/
export interface HostRule {
readonly id: string;
readonly phase: "pre" | "post";
readonly severity: "error" | "warning" | "info";
readonly description: string;
}

export interface ConformanceHost {
readonly name: string;
/** F-Host-Trust arm 1: the specifiers this host owns. */
Expand All @@ -25,6 +37,8 @@ export interface ConformanceHost {
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>>;
/** F-Host-Interface item 7: the rules this host supplies, if any. */
readonly rules?: readonly HostRule[];
}

const DECLARABLE = Symbol.for("tsad.conformance.declarable");
Expand Down Expand Up @@ -79,6 +93,14 @@ const SHAPES: ConformanceHost = {
{ name: "count", isTag: false, foldsEagerly: true },
],
helpers: [{ name: "upper", module: "@tsad/shapes", note: "pure string transform, no environment read" }],
// Two rules, one per phase (F-Rule-Phase). The pre-synthesis one reads
// the folded namespace and names the export it concerns; the
// post-synthesis one reads the artifact and, finding nothing to attach
// to, reports the missing-resource form (F-Rule-Finding, L11.5).
rules: [
{ id: "SHAPES001", phase: "pre", severity: "error", description: "every Bucket declares a BucketName; the subject is the export that does not" },
{ id: "SHAPES002", phase: "post", severity: "warning", description: "the artifact declares at least one Bucket; otherwise the subject is `missing: Bucket`" },
],
values: new Map([
[
"@tsad/shapes",
Expand Down
8 changes: 6 additions & 2 deletions packages/conformance/src/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ describe("conformance runner (#7) over the reference implementation (#10)", () =
const failed = reports.filter((r) => !r.pass).map((r) => `${r.fixture}: ${r.failures.join("; ")}`);
expect(failed, failed.join("\n")).toEqual([]);
});
test("the data-host profile: the reference with an empty host passes every fixture tagged for it (#78)", async () => {
test("the data-host profile: the reference passes every fixture tagged for it, a named host reduced to its description (#78)", async () => {
const tagged = fixtures.filter((f) => f.profiles.includes("data-host"));
expect(tagged.length).toBeGreaterThan(40);
expect(tagged.every((f) => f.kind === "expression" || !f.host)).toBe(true);
// A data-host fixture may name a host for its intrinsic registry and trust
// set (F-Profile-DataHost, F-Host-Interface items 3 and 5); the adapter is
// what drops the classes, helpers and values, and these fixtures are what
// would fail if it did not.
expect(tagged.some((f) => f.kind === "project" && f.host)).toBe(true);
const failed = (await runFixtures(referenceDataHostAdapter, tagged)).filter((r) => !r.pass).map((r) => `${r.fixture}: ${r.failures.join("; ")}`);
expect(failed, failed.join("\n")).toEqual([]);
});
Expand Down
57 changes: 56 additions & 1 deletion packages/conformance/src/runner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ConformanceAdapter } from "./adapter.js";
import type { ConformanceAdapter, Finding, RulePhase } from "./adapter.js";
import type { ExpressionFixture, Fixture, ProjectFixture } from "./fixture.js";
import { expressionFixtures, projectFixtures } from "./fixture.js";
import { requireHost } from "./host.js";
Expand Down Expand Up @@ -83,9 +83,55 @@ export async function runProjectFixture(adapter: ConformanceAdapter, f: ProjectF
}
}
}
if (f.findings) {
const got = await collectFindings(adapter, f);
if (got === "unavailable") return { ...base, pass: failures.length === 0, skipped: "rules unavailable", failures };
failures.push(...compareFindings(f.findings, got));
}
return { ...base, pass: failures.length === 0, failures };
}

/** The key a finding is filed under: the file whose namespace holds the subject, or the artifact. */
const findingKey = (x: Finding, phase: RulePhase): string => (phase === "post" ? "artifact" : x.file ?? "artifact");
const findingSig = (x: { rule: string; subject: string; severity: string }): string => `${x.rule} ${x.severity} ${x.subject}`;

/**
* Every finding of both phases (#101). Each phase is run twice and the two
* runs must agree: F-Rule-Pure says a rule is a function of its input, and
* a rule that reads a clock or the environment fails here before its
* findings are compared with anything.
*/
export async function collectFindings(adapter: ConformanceAdapter, f: ProjectFixture): Promise<Map<string, Finding[]> | "unavailable"> {
if (!adapter.rules || !f.host) return "unavailable";
const host = requireHost(f.host);
const out = new Map<string, Finding[]>();
for (const phase of ["pre", "post"] as const) {
const first = await adapter.rules(f.files, host, phase);
if (first === "unavailable") return "unavailable";
const second = await adapter.rules(f.files, host, phase);
if (second === "unavailable") return "unavailable";
const a = first.map((x) => `${findingKey(x, phase)}: ${findingSig(x)}`).sort(), b = second.map((x) => `${findingKey(x, phase)}: ${findingSig(x)}`).sort();
if (a.join("\n") !== b.join("\n")) throw new Error(`${f.id}: ${phase}-synthesis findings differ between two runs of the same input (F-Rule-Pure)\n${a.join("\n")}\n---\n${b.join("\n")}`);
for (const x of first) { const k = findingKey(x, phase); out.set(k, [...(out.get(k) ?? []), x]); }
}
return out;
}

/** Findings matched on rule, subject and severity; `at` only when both sides carry one. */
export function compareFindings(want: Record<string, { rule: string; subject: string; severity: string; at?: { line: number; column: number } }[]>, got: Map<string, Finding[]>): string[] {
const failures: string[] = [];
for (const key of new Set([...Object.keys(want), ...got.keys()])) {
const w = want[key] ?? [], g = got.get(key) ?? [];
for (const x of w) {
const hit = g.find((y) => findingSig(y) === findingSig(x));
if (!hit) { failures.push(`${key}: expected finding ${findingSig(x)}, not reported`); continue; }
if (x.at && hit.at && (hit.at.line !== x.at.line || hit.at.column !== x.at.column)) failures.push(`${key}: ${findingSig(x)} at ${hit.at.line}:${hit.at.column}, expected ${x.at.line}:${x.at.column}`);
}
for (const y of g) if (!w.some((x) => findingSig(x) === findingSig(y))) failures.push(`${key}: finding ${findingSig(y)} reported but not expected (${y.message})`);
}
return failures;
}

/** #11 — two implementations must agree on every fixture, independently of what the fixture expects. */
export async function compareAdapters(a: ConformanceAdapter, b: ConformanceAdapter, fixtures: Fixture[]): Promise<string[]> {
const dis: string[] = [];
Expand All @@ -107,6 +153,15 @@ export async function compareAdapters(a: ConformanceAdapter, b: ConformanceAdapt
dis.push(`${f.id} ${path}: tainted by — ${a.name} ${ra.taintedBy[path] ?? "nothing"}, ${b.name} ${rb.taintedBy[path] ?? "nothing"}`);
}
}
// F-Rule-Equivalence, across implementations: the same host rules over
// the same source report the same findings, whichever path each took.
if (f.findings) {
const [fa, fb] = await Promise.all([collectFindings(a, f), collectFindings(b, f)]);
if (fa !== "unavailable" && fb !== "unavailable") {
const sig = (m: Map<string, Finding[]>) => [...m.entries()].flatMap(([k, xs]) => xs.map((x) => `${k}: ${findingSig(x)}`)).sort().join("\n");
if (sig(fa) !== sig(fb)) dis.push(`${f.id}: findings — ${a.name}\n${sig(fa)}\n${b.name}\n${sig(fb)}`);
}
}
}
for (const f of expressionFixtures(fixtures)) {
const ra = a.foldExport(f.input, f.exportName), rb = b.foldExport(f.input, f.exportName);
Expand Down
4 changes: 2 additions & 2 deletions packages/reference/CAVEATS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ chant as F-Import's text says; it now does both (#96). A project-local
function is excluded at the import, since it is a callable rather than a
value and F-CallLeak decides its edge at the call.

## No rules, and no provenance
## Two rules, and no provenance

`rules.md` (spec `1.4`) specifies the contract a semantic rule runs under. This package runs none: it has no rule hook, and no value provenance, so a finding it produced could name no source line. Both wait on the harness half (#101).
`rules.md` (spec `1.4`) specifies the contract a semantic rule runs under. This package carries the two rules the `shapes` host names, `SHAPES001` over the folded namespace and `SHAPES002` over its JSON serialization (`rules.ts`, #101), and nothing else: a host that names a rule this file does not carry gets `"unavailable"`. A file that runs has no folded namespace here, so no rule sees it; chant answers that case by executing the file. There is no value provenance, so a finding names an export and never a source line, which `F-Rule-Finding` allows.

## No filesystem, no module resolution algorithm

Expand Down
20 changes: 17 additions & 3 deletions packages/reference/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,25 @@ import type { ConformanceAdapter, ConformanceHost, ProjectResult } from "@intent
import { EMPTY_HOST, type Host, type Profile } from "./host.js";
import { shapeOfExport, foldExport } from "./module.js";
import { foldProject } from "./project.js";
import { canRun, runRules } from "./rules.js";

/** A named conformance host, in this implementation's own terms. */
/**
* A named conformance host, in this implementation's own terms. In
* `data-host` the host is a description and not code (F-Profile-DataHost,
* F-Host-Interface): the intrinsic registry and the trust set are kept, and
* the helpers, the classes and the live values, items 1, 2, 4 and 6, are
* absent.
*/
function hostOf(h: ConformanceHost | undefined, profile: Profile): Host {
if (!h) return { ...EMPTY_HOST, profile };
const data = profile === "data-host";
return {
profile,
intrinsics: h.intrinsics.map((i) => ({ name: i.name, isTag: i.isTag, foldsAsCall: i.foldsAsCall, foldsEagerly: i.foldsEagerly, outputKey: i.outputKey })),
helpers: h.helpers,
helpers: data ? [] : h.helpers,
ownedSpecifierPrefixes: h.ownedSpecifierPrefixes,
values: h.values,
values: data ? new Map() : h.values,
rules: h.rules,
};
}

Expand Down Expand Up @@ -40,6 +49,11 @@ function adapterFor(profile: Profile): ConformanceAdapter {
for (const [path, from] of r.taintSource) out.taintedBy![path] = from;
return out;
},
rules(files, host, phase) {
const h = hostOf(host, profile);
if (!canRun(h)) return "unavailable";
return runRules(foldProject(files, h), h, phase);
},
};
}

Expand Down
2 changes: 2 additions & 0 deletions packages/reference/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,7 @@ export interface Host {
readonly helpers: readonly { name: string; module: string; note: string }[];
readonly ownedSpecifierPrefixes: readonly string[];
readonly values: HostValues;
/** F-Host-Interface item 7: the rules the host supplies, by id. The code for each is this implementation's (rules.ts). */
readonly rules?: readonly { id: string; phase: "pre" | "post"; severity: "error" | "warning" | "info" }[];
}
export const EMPTY_HOST: Host = { profile: "full", intrinsics: [], helpers: [], ownedSpecifierPrefixes: [], values: new Map() };
1 change: 1 addition & 0 deletions packages/reference/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from "./foldable-helpers.js";
export * from "./module.js";
export * from "./project.js";
export { referenceAdapter, referenceDataHostAdapter } from "./adapter.js";
export { runRules, RULES, namespaceOf, artifactOf, type Finding } from "./rules.js";
7 changes: 6 additions & 1 deletion packages/reference/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,13 @@ 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<string, unknown>();
/** F-Val-Fate: what the declarator produced, revived through this file's own imports. */
/**
* F-Val-Fate: what the declarator produced, revived through this file's own
* imports. In `data-host` revival is serialization (F-Profile-DataHost): no
* constructor and no function is invoked, and the envelope is the output.
*/
const live = (v: unknown, node: ts.Node, what: string) => {
if (session.host.profile === "data-host") return v;
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
return revive(v, externals, { line: line + 1, column: character + 1, what });
};
Expand Down
Loading