diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index c21c8757d..7bcae7f18 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -29,8 +29,9 @@ * LIB_RUNTIME_TRAP_CODES below), and npm packages a library * graph imports that fail the npm-static eligibility bar * (SC4020 — static-or-refuse; eligible packages compile - * statically instead); SC4008 reserved for ask-5 - * determinism denials + * statically instead), and the ask-5 determinism fence + * denial (SC4008 — a profile-fenced manifest surface reached + * by the compiled library module graph) * SC9xxx internal compiler errors (still source-anchored) */ import type { SrcLoc } from "../ir/nodes.js"; @@ -46,6 +47,12 @@ export interface ScrDiagnostic { loc: SrcLoc; milestone?: Milestone; hint?: string; + /** Embedder-authored guidance riding a refusal (the ask-5 teaching + * surface): rendered as its own visibly-attributed trailing line, so the + * tool's message and hint stay the tool's and the profile's text is + * recognizably the profile's. Only library-mode refusals carry one — + * the text always arrives prefixed `from the '' profile:`. */ + note?: string; } interface UnsupportedEntry { @@ -650,9 +657,12 @@ export function comptimeFailedDiag(detail: string, loc: SrcLoc): ScrDiagnostic { /* ── SC4xxx: library-mode/profile refusals ───────────────────────────────── * The library-emission mode's own code block (design §5.4 + the ratified * SC4xxx range): neither preflight nor type rules nor backend coverage — - * each refusal names the profile entry involved and the fix, and SC4004/ - * SC4005 additionally carry the profile's own teaching text when the - * profile supplies one (the ratification session's rider). */ + * each refusal names the profile entry involved and the fix. Profile + * teaching text no longer threads through individual factories: EVERY + * library-mode refusal passes through one decoration pass + * (library/fence-eval.ts's decorateLibraryRefusals — the SC4004/SC4005 + * rider generalized to any refusal code or manifest id), which attaches + * the profile's text as the diagnostic's attributed `note`. */ /** SC4001 — the profile file itself is malformed: parse errors, unknown * fields at meaning-changing positions, prefix violations, duplicate @@ -698,39 +708,63 @@ export function libUnmappableSignatureDiag( } /** SC4004 — a mapped export is async or a generator: a synchronous ABI - * entry cannot be either. Carries the profile's teaching when supplied. */ + * entry cannot be either. The profile's teaching arrives via the shared + * decoration pass (the "async" shared key or an SC4004 key). */ export function libAsyncExportDiag( exportName: string, kind: "async" | "generator", loc: SrcLoc, - teaching?: string, ): ScrDiagnostic { return { code: "SC4004", message: `library export '${exportName}' is ${kind === "async" ? "an async function" : "a generator"} — a profile entry is a synchronous ABI symbol`, loc, - hint: - "expose a synchronous facade function and map that instead" + - (teaching !== undefined ? ` — ${teaching}` : ""), + hint: "expose a synchronous facade function and map that instead", }; } /** SC4005 — the library module graph reaches event-loop or ambient-process * surface (async functions, generators, timers, sockets, signals, child * processes, ...): async_free is a v1 REQUIREMENT, derived from the module - * graph, never observed at runtime. Carries the profile's teaching. */ -export function libAsyncSurfaceDiag(surface: string, loc: SrcLoc, teaching?: string): ScrDiagnostic { + * graph, never observed at runtime. */ +export function libAsyncSurfaceDiag(surface: string, loc: SrcLoc): ScrDiagnostic { return { code: "SC4005", message: `library mode requires an async_free module graph, and this graph reaches ${surface}`, loc, hint: "v1 library artifacts link no event loop, install no signal handlers, and create no threads — remove the surface from " + - "everything the entry module reaches" + - (teaching !== undefined ? ` — ${teaching}` : ""), + "everything the entry module reaches", }; } +/** SC4008 — the ask-5 determinism denial: the profile fences a surface by + * its surface-manifest id, and the compiled library module graph REACHES + * it. Minted only for manifest entries the static tier compiles (a fenced + * surface the tier refuses anyway keeps its own code, with the fence's + * teaching riding that refusal as the note); anchored at the reaching + * construct. `surfaceCode` is the diagnostic code the manifest entry + * carries, when it carries one. */ +export function libFenceDiag( + profileName: string, + surfaceId: string, + surfaceName: string, + loc: SrcLoc, + surfaceCode?: string, + teaching?: string, +): ScrDiagnostic { + const diag: ScrDiagnostic = { + code: "SC4008", + message: `the '${profileName}' profile fences '${surfaceId}' (${surfaceName}${surfaceCode !== undefined ? `, ${surfaceCode}` : ""}), and this library graph reaches it`, + loc, + hint: + "the profile denies this surface at compile time (a determinism fence over the surface manifest's ids) — " + + "remove the use from everything the entry module reaches, or lift the fence", + }; + if (teaching !== undefined) diag.note = `from the '${profileName}' profile: ${teaching}`; + return diag; +} + /** SC4006 — island/dynamic machinery on the library path: --dynamic, * --npm-static as a FLAG, `any`-typed island surface. Bare npm imports * themselves are not this code: an eligible package compiles statically @@ -859,16 +893,14 @@ export const LIB_RUNTIME_TRAP_CODES = [ * had to refuse. Library mode is STATIC-OR-REFUSE by construction: the * island/dynamic tier the executable lane falls back to does not exist on * this path (SC4006's ground), so the refusal names the package, the - * specific bar it missed, and the remedy. Carries the profile's teaching - * for this code when supplied. */ -export function libNpmIneligibleDiag(pkg: string, reason: string, loc: SrcLoc, teaching?: string): ScrDiagnostic { + * specific bar it missed, and the remedy. */ +export function libNpmIneligibleDiag(pkg: string, reason: string, loc: SrcLoc): ScrDiagnostic { return { code: "SC4020", message: `library mode compiles npm packages statically or not at all, and '${pkg}' cannot compile statically: ${reason}`, loc, hint: - "library artifacts have no island/dynamic tier to fall back to — vendor the code you need from the package as project modules, or drop the dependency" + - (teaching !== undefined ? ` — ${teaching}` : ""), + "library artifacts have no island/dynamic tier to fall back to — vendor the code you need from the package as project modules, or drop the dependency", }; } diff --git a/packages/compiler/src/diagnostics/render.ts b/packages/compiler/src/diagnostics/render.ts index f1497f27b..772618049 100644 --- a/packages/compiler/src/diagnostics/render.ts +++ b/packages/compiler/src/diagnostics/render.ts @@ -1,5 +1,6 @@ /* Terminal rendering: file:line:col, a hand-rolled code frame with ±1 line - * of context and a ^~~~ underline sized to the span, optional hint line. + * of context and a ^~~~ underline sized to the span, optional hint line, + * and the optional attributed profile-note line after it. */ import type { ScrDiagnostic } from "./diagnostic.js"; @@ -71,6 +72,13 @@ export function renderDiagnostic( if (diag.hint) { out.push("", ` ${c(CYAN, "hint:")} ${diag.hint}`); } + if (diag.note) { + // Embedder-authored teaching text: its own attributed trailing line + // (the text itself begins "from the '' profile:"), so the + // tool's message and hint stay visibly the tool's. + if (!diag.hint) out.push(""); + out.push(` ${c(CYAN, "note:")} ${diag.note}`); + } return out.join("\n"); } diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index b9fa859e9..ccbc7f064 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -5,6 +5,7 @@ import { emitModule } from "./backend/emission/emitter.js"; import { emitLlvmModule, LlvmUnsupportedError } from "./backend/llvm/emitter.js"; import { checkerPanicDiag, libAsyncExportDiag, libAsyncSurfaceDiag, libExportUnresolvedDiag, libGenericExportDiag, libNpmIneligibleDiag, libUnmappableSignatureDiag, iceDiag, isCheckerPanic, LIB_INBOUND_BYTES_TRAP_CODE, LIB_RUNTIME_TRAP_CODES, type ScrDiagnostic } from "./diagnostics/diagnostic.js"; import { loadLibraryProfile, profileRemediation, profileTeaching, type LibraryProfile } from "./library/profile.js"; +import { decorateLibraryRefusals, evaluateLibraryFences } from "./library/fence-eval.js"; import { assembleTrapTeaching } from "./library/trap-teaching.js"; import { buildSidecar, @@ -805,9 +806,7 @@ function resolveLibrarySection( continue; } if (info.async || info.generator) { - diagnostics.push( - libAsyncExportDiag(e.export, info.async ? "async" : "generator", info.loc, profileTeaching(profile, "SC4004")), - ); + diagnostics.push(libAsyncExportDiag(e.export, info.async ? "async" : "generator", info.loc)); continue; } const fn = fnByName.get(e.export); @@ -934,9 +933,12 @@ export async function compileLibrary(opts: CompileLibraryOptions): Promise; let contractFacts: ContractFacts | null; try { + // Every library refusal leaves through the ask-5 teaching decoration: + // profile text attaches by code, manifest id, or fence coverage as the + // attributed note (the SC4004/SC4005 rider generalized). const fail = (diagnostics: ScrDiagnostic[]): CompileLibraryResult => ({ ok: false, - diagnostics, + diagnostics: decorateLibraryRefusals(diagnostics, profile), sourceTexts: fe.sourceTexts(), }); // The npm verdicts FIRST: whatever the shared frontend would have @@ -957,7 +959,6 @@ export async function compileLibrary(opts: CompileLibraryOptions): Promise ({ ok: false, diagnostics, sourceTexts }); + const fail = (diagnostics: ScrDiagnostic[]): CompileLibraryResult => ({ + ok: false, + diagnostics: decorateLibraryRefusals(diagnostics, profile), + sourceTexts, + }); // Export resolution first (SC4002/SC4003/SC4004/SC4007 anchor at the // mapped declaration — a mapped async export reports as SC4004, not the - // graph-wide gate), then the async_free requirement (ratified, SC4005): - // both refused before anything is emitted, so the narrowed library link set - // below is structural fact. + // graph-wide gate), then the async_free requirement (ratified, SC4005), + // then the profile's determinism fences (ask 5, SC4008) over the same + // compiled graph the attestation scan reads: all refused before anything + // is emitted, so the narrowed library link set below is structural fact. const resolved = resolveLibrarySection(profile, entryInfo, mod, entryPath); if ("diagnostics" in resolved) return fail(resolved.diagnostics); const asyncSurface = moduleLibAsyncSurface(mod); if (asyncSurface !== null) { - return fail([libAsyncSurfaceDiag(asyncSurface.surface, asyncSurface.loc, profileTeaching(profile, "SC4005"))]); + return fail([libAsyncSurfaceDiag(asyncSurface.surface, asyncSurface.loc)]); } + const fenced = evaluateLibraryFences(mod, profile); + if (fenced.length > 0) return fail(fenced); mod.lib = resolved.lib; // The ask-2 contract sidecar rides the same invocation. Identity first diff --git a/packages/compiler/src/library/fence-eval.ts b/packages/compiler/src/library/fence-eval.ts new file mode 100644 index 000000000..2ac87336d --- /dev/null +++ b/packages/compiler/src/library/fence-eval.ts @@ -0,0 +1,463 @@ +/* Ask-5 determinism fences + the generalized teaching decoration. + * + * The profile's `determinism.fences` array denies surfaces BY SURFACE- + * MANIFEST ID (coverage/surface-manifest.ts — the compiler's one stable + * surface taxonomy; no new vocabulary). Resolution happens at profile + * load, against the manifest generated from this compiler's own tables: + * + * - an `id` names exactly one manifest entry; a `prefix` fences every + * entry it string-prefixes. An id or prefix matching NOTHING refuses + * loudly at load (ratified): fences pin to a compiler release's + * manifest, and a silently-inert fence is a footgun, so there is no + * forward-compatible acceptance of unknown selectors. + * - a fenced entry the static tier refuses anyway (status not "static") + * needs no new machinery: the surface's own refusal fires, and the + * fence's teaching rides it as the attributed note (spec §1: "changes + * only the message"). + * - a fenced STATIC entry is policed against the COMPILED module graph: + * the emit pass lowers only reachability-discovered bodies, so the + * module IR *is* the reached graph — the same ground the sidecar's + * `deterministic` attestation stands on (ir/nodes.ts's + * moduleLibNondeterministicSurface). Fence evaluation reuses that + * posture with one generic walk collecting the reached surface facts + * (libCall spellings, str/arr/map/set intrinsic methods); each fenced + * entry then answers by table-derived detector. Unreached fences cost + * exactly this one walk — and no walk at all when no fence declares a + * detector-backed surface. + * - a fenced static entry with NO honest detector refuses at load, + * never accepted-and-ignored. Two families are named specifically: + * compile-time-folded constants (os.EOL, path.sep — the artifact + * performs no runtime read a fence could deny) and desugared surfaces + * (array map/filter/forEach and friends lower to plain loops with no + * distinct trace). + * + * Detectors derive MECHANICALLY from the same lowering tables the + * manifest projects from (frontend/lowering/surfaces.ts), so a table row + * added later grows the fenceable surface without hand maintenance. Two + * aliased-lowering facts are inherited from the tables and deliberate: + * string trimLeft/trimRight share trimStart/trimEnd's IR method, and + * querystring decode/encode share parse/stringify's libCall — fencing one + * spelling fences the operation. + * + * decorateLibraryRefusals is the generalized teaching rider (spec §2): + * profile text attaches to ANY library-mode refusal by diagnostic code + * (the ratified SC4004/SC4005 rider's keys, "async" shared-key fallback + * included), by manifest id (matched against the refusal's code plus the + * entry's surface name in the message; diagnostic-fence entries match on + * code alone — the entry IS the code family), or by a fence entry + * covering the refused surface. The text renders as the diagnostic's + * `note`, always prefixed with the profile's name — the tool's message + * stays the tool's. */ +import { generateSurfaceManifest, type SurfaceEntryKind, type SurfaceManifestEntry } from "../coverage/surface-manifest.js"; +import { libFenceDiag, type ScrDiagnostic } from "../diagnostics/diagnostic.js"; +import { + BUILTIN_MODULE_CONSTS, + BUILTIN_MODULE_FNS, + STATIC_MATH_FNS, + STR_METHODS, +} from "../frontend/lowering/surfaces.js"; +import type { IrModule, SrcLoc } from "../ir/nodes.js"; +import { compilerReleaseVersion } from "./sidecar.js"; + +/** One raw fence declaration as the profile spelled it (validated for + * shape by the loader; resolved here against the manifest). */ +export interface LibraryFenceDecl { + /** JSON path for exact-path refusals: `determinism.fences[2]`. */ + path: string; + id?: string; + prefix?: string; + teaching?: string; + remediation?: string; +} + +/** The IR facts that witness one surface's reach in the compiled graph. + * A surface is reached exactly when any listed fact appears. */ +interface SurfaceDetector { + libFns?: readonly string[]; + strMethods?: readonly string[]; + arrMethods?: readonly string[]; + mapMethods?: readonly string[]; + setMethods?: readonly string[]; +} + +/** One manifest entry a fence covers, resolved and load-validated. */ +export interface FencedSurface { + id: string; + name: string; + kind: SurfaceEntryKind; + /** The diagnostic code the manifest entry carries (every non-static + * entry has one) — the code whose refusal the fence teaching rides. */ + code?: string; + /** Present exactly when the entry is static (it compiles, so the fence + * must deny it): the reach witness. Absent = the surface already + * refuses with `code` and the fence changes only the message. */ + detector?: SurfaceDetector; +} + +export interface ResolvedLibraryFence { + /** The declared selector, for messages: `id 'stdlib.math.random'` or + * `prefix 'node-builtin.fs.'`. */ + declared: string; + teaching?: string; + remediation?: string; + surfaces: readonly FencedSurface[]; +} + +/* ── the taxonomy: this release's manifest, indexed once ──────────────── */ + +interface FenceTaxonomy { + byId: Map; + ids: readonly string[]; + /** node-builtin member id → libCall fn spellings (platform twins + * included: a fence covers the surface on every target). */ + builtinFns: Map; + /** node-builtin module-root id → the union of its members' spellings. */ + builtinRoots: Map; + /** node-builtin constant ids: reads fold to literals at compile time. */ + foldedIds: Set; +} + +let taxonomy: FenceTaxonomy | null = null; + +function builtinRootId(mod: string): string { + return `node-builtin.${mod.split("/").join(".")}`; +} + +function fenceTaxonomy(): FenceTaxonomy { + if (taxonomy !== null) return taxonomy; + const manifest = generateSurfaceManifest(compilerReleaseVersion()); + const byId = new Map(manifest.entries.map((e) => [e.id, e])); + const builtinFns = new Map(); + const builtinRoots = new Map(); + const foldedIds = new Set(); + for (const [mod, members] of Object.entries(BUILTIN_MODULE_FNS)) { + const root = new Set(); + for (const [member, entry] of Object.entries(members!)) { + if (entry === undefined) continue; + const fns = new Set([entry.fn]); + // The bare-path and url tables rebind per TARGET platform + // (builtinModuleFnsOf): a fence on the bare id covers both flavors. + if (mod === "path") { + const w = BUILTIN_MODULE_FNS["path/win32"]?.[member]; + if (w !== undefined) fns.add(w.fn); + } + if (mod === "url" && member === "pathToFileURL") fns.add("url.pathToFileURLWin32"); + builtinFns.set(`${builtinRootId(mod)}.${member}`, [...fns]); + for (const f of fns) root.add(f); + } + if (root.size > 0) builtinRoots.set(builtinRootId(mod), [...root]); + } + for (const [mod, members] of Object.entries(BUILTIN_MODULE_CONSTS)) { + for (const member of Object.keys(members!)) foldedIds.add(`${builtinRootId(mod)}.${member}`); + } + taxonomy = { byId, ids: manifest.entries.map((e) => e.id), builtinFns, builtinRoots, foldedIds }; + return taxonomy; +} + +/* ── classification: what policing a static entry means ───────────────── */ + +/** Array methods with a distinct IR trace (arrIntrinsic methods). `push` + * includes the spread spelling — `a.push(...xs)` lowers to pushSpread and + * is the same surface. The rest of ARRAY_METHODS desugars to plain loops. */ +const ARR_DETECTABLE: Record = { + push: ["push", "pushSpread"], + pop: ["pop"], + indexOf: ["indexOf"], + includes: ["includes"], + join: ["join"], + slice: ["slice"], +}; + +const MAP_DETECTABLE = new Set(["get", "set", "has", "delete", "clear"]); +const SET_DETECTABLE = new Set(["add", "has", "delete", "clear"]); + +type SurfaceClass = + | { kind: "detector"; detector: SurfaceDetector } + | { kind: "refusal" } + | { kind: "unfenceable"; why: string }; + +const DESUGARED_WHY = + "it desugars at compile time into plain loops over iteration primitives, so the compiled graph carries no distinct trace to police"; + +function classifySurface(entry: SurfaceManifestEntry, tax: FenceTaxonomy): SurfaceClass { + // Non-static entries already refuse with their own code wherever they + // are reached — the fence's teaching rides that refusal. + if (entry.status !== "static") return { kind: "refusal" }; + if (tax.foldedIds.has(entry.id)) { + return { + kind: "unfenceable", + why: "reads of it fold to a per-binary constant at compile time — the artifact performs no runtime read a fence could deny", + }; + } + const builtinFns = tax.builtinFns.get(entry.id); + if (builtinFns !== undefined) return { kind: "detector", detector: { libFns: builtinFns } }; + const rootFns = tax.builtinRoots.get(entry.id); + if (rootFns !== undefined) return { kind: "detector", detector: { libFns: rootFns } }; + const [, family, member] = entry.id.split("."); + if (entry.id.startsWith("stdlib.") && family !== undefined && member !== undefined) { + if (family === "math") { + const fn = Object.hasOwn(STATIC_MATH_FNS, member) ? STATIC_MATH_FNS[member] : undefined; + if (fn !== undefined) return { kind: "detector", detector: { libFns: [fn.fn] } }; + } + if (family === "string") { + const m = Object.hasOwn(STR_METHODS, member) ? STR_METHODS[member] : undefined; + if (m !== undefined) return { kind: "detector", detector: { strMethods: [m.method] } }; + } + if (family === "array") { + const methods = Object.hasOwn(ARR_DETECTABLE, member) ? ARR_DETECTABLE[member] : undefined; + if (methods !== undefined) return { kind: "detector", detector: { arrMethods: methods } }; + return { kind: "unfenceable", why: DESUGARED_WHY }; + } + if (family === "map") { + if (MAP_DETECTABLE.has(member)) return { kind: "detector", detector: { mapMethods: [member] } }; + return { kind: "unfenceable", why: DESUGARED_WHY }; + } + if (family === "set") { + if (SET_DETECTABLE.has(member)) return { kind: "detector", detector: { setMethods: [member] } }; + return { kind: "unfenceable", why: DESUGARED_WHY }; + } + } + if (entry.id.startsWith("syntax.")) { + return { + kind: "unfenceable", + why: "it lowers to the same IR as its expanded spelling, so the compiled graph carries no distinct trace to police", + }; + } + if (entry.id.startsWith("node-builtin.")) { + return { + kind: "unfenceable", + why: "its lowered surface leaves no tabled per-call trace at this compiler release — fence the member entries the manifest projects instead", + }; + } + return { kind: "unfenceable", why: "no fence detector exists for it at this compiler release" }; +} + +/* ── load-time resolution ──────────────────────────────────────────────── */ + +/** Resolve the profile's fence declarations against this compiler + * release's surface manifest. Every failure returns the exact-path detail + * the loader wraps as SC4001. Selector strictness is RATIFIED: an id or + * prefix matching nothing refuses; a covered static entry no detector can + * police refuses (loudly, never silently inert). Compile-time-folded + * constants refuse as `id` fences (the message says why) and are exempt + * under a `prefix` — the family sweep stays usable, and the fold means + * there is genuinely nothing at runtime to deny. */ +export function resolveLibraryFences( + decls: readonly LibraryFenceDecl[], +): { ok: true; fences: ResolvedLibraryFence[] } | { ok: false; detail: string } { + const tax = fenceTaxonomy(); + const fences: ResolvedLibraryFence[] = []; + for (const d of decls) { + const surfaces: FencedSurface[] = []; + const keep = (entry: SurfaceManifestEntry, cls: SurfaceClass): void => { + surfaces.push({ + id: entry.id, + name: entry.name, + kind: entry.kind, + ...(entry.code !== undefined ? { code: entry.code } : {}), + ...(cls.kind === "detector" ? { detector: cls.detector } : {}), + }); + }; + if (d.id !== undefined) { + const entry = tax.byId.get(d.id); + if (entry === undefined) { + return { + ok: false, + detail: + `'${d.path}.id' names no entry in this compiler release's surface manifest ('${d.id}') — ` + + `fences pin to the release's manifest ids (the package ships surface-manifest.json), and an unknown id is refused, never accepted as inert`, + }; + } + const cls = classifySurface(entry, tax); + if (cls.kind === "unfenceable") { + return { ok: false, detail: `'${d.path}.id' names '${d.id}', which cannot be fenced: ${cls.why}` }; + } + keep(entry, cls); + } else { + const covered = tax.ids.filter((id) => id.startsWith(d.prefix!)); + if (covered.length === 0) { + return { + ok: false, + detail: + `'${d.path}.prefix' matches no entry in this compiler release's surface manifest ('${d.prefix}') — ` + + `fences pin to the release's manifest ids (the package ships surface-manifest.json), and an unknown prefix is refused, never accepted as inert`, + }; + } + for (const id of covered) { + const entry = tax.byId.get(id)!; + const cls = classifySurface(entry, tax); + if (cls.kind === "unfenceable") { + if (tax.foldedIds.has(id)) continue; // the fold IS the exemption — see above + return { ok: false, detail: `'${d.path}.prefix' ('${d.prefix}') covers '${id}', which cannot be fenced: ${cls.why}` }; + } + keep(entry, cls); + } + if (surfaces.length === 0) { + return { + ok: false, + detail: `'${d.path}.prefix' ('${d.prefix}') covers only compile-time-folded constants — there is nothing at runtime for the fence to deny`, + }; + } + } + fences.push({ + declared: d.id !== undefined ? `id '${d.id}'` : `prefix '${d.prefix}'`, + ...(d.teaching !== undefined ? { teaching: d.teaching } : {}), + ...(d.remediation !== undefined ? { remediation: d.remediation } : {}), + surfaces, + }); + } + return { ok: true, fences }; +} + +/* ── compile-time evaluation over the reached graph ────────────────────── */ + +interface ReachedSurfaces { + libFns: Map; + strMethods: Map; + arrMethods: Map; + mapMethods: Map; + setMethods: Map; +} + +/** One generic walk over the compiled module (the moduleLibAsyncSurface + * shape: nearest enclosing `loc` tracked so refusals anchor at the + * reaching construct), collecting every reached surface fact the fence + * detectors speak. First location per fact wins. */ +function collectReachedSurfaces(mod: IrModule): ReachedSurfaces { + const reached: ReachedSurfaces = { + libFns: new Map(), + strMethods: new Map(), + arrMethods: new Map(), + mapMethods: new Map(), + setMethods: new Map(), + }; + const note = (map: Map, key: string, loc: SrcLoc): void => { + if (!map.has(key)) map.set(key, loc); + }; + const entryLoc: SrcLoc = { file: mod.sourceFile, start: 0, end: 0 }; + const visit = (v: unknown, loc: SrcLoc): void => { + if (v === null || typeof v !== "object") return; + if (Array.isArray(v)) { + for (const item of v) visit(item, loc); + return; + } + const node = v as { kind?: unknown; fn?: unknown; method?: unknown; loc?: SrcLoc }; + const here = node.loc ?? loc; + if (typeof node.kind === "string") { + if (node.kind === "libCall" && typeof node.fn === "string") note(reached.libFns, node.fn, here); + else if (typeof node.method === "string") { + if (node.kind === "strIntrinsic") note(reached.strMethods, node.method, here); + else if (node.kind === "arrIntrinsic") note(reached.arrMethods, node.method, here); + else if (node.kind === "mapIntrinsic") note(reached.mapMethods, node.method, here); + else if (node.kind === "setIntrinsic") note(reached.setMethods, node.method, here); + } + } + for (const key of Object.keys(v)) visit((v as Record)[key], here); + }; + visit(mod, entryLoc); + return reached; +} + +function detectorHit(reached: ReachedSurfaces, det: SurfaceDetector): SrcLoc | null { + const scan = (facts: Map, keys: readonly string[] | undefined): SrcLoc | null => { + for (const k of keys ?? []) { + const loc = facts.get(k); + if (loc !== undefined) return loc; + } + return null; + }; + return ( + scan(reached.libFns, det.libFns) ?? + scan(reached.strMethods, det.strMethods) ?? + scan(reached.arrMethods, det.arrMethods) ?? + scan(reached.mapMethods, det.mapMethods) ?? + scan(reached.setMethods, det.setMethods) + ); +} + +/** The structural slice of LibraryProfile the fence machinery reads — + * kept structural so this module never value-imports the loader. */ +export interface FenceProfileView { + name: string; + teachings: Readonly>; + fences: readonly ResolvedLibraryFence[]; +} + +/** Evaluate the profile's fences against the COMPILED module graph: every + * fenced static surface the graph reaches refuses as SC4008, anchored at + * the reaching construct, the fence's teaching attached as the attributed + * note. Unreached fences add nothing; a profile whose fences carry no + * detector-backed surface skips the walk entirely. */ +export function evaluateLibraryFences(mod: IrModule, profile: FenceProfileView): ScrDiagnostic[] { + if (!profile.fences.some((f) => f.surfaces.some((s) => s.detector !== undefined))) return []; + const reached = collectReachedSurfaces(mod); + const diags: ScrDiagnostic[] = []; + const seen = new Set(); + for (const fence of profile.fences) { + for (const s of fence.surfaces) { + if (s.detector === undefined || seen.has(s.id)) continue; + const loc = detectorHit(reached, s.detector); + if (loc === null) continue; + seen.add(s.id); + diags.push(libFenceDiag(profile.name, s.id, s.name, loc, s.code, fence.teaching)); + } + } + return diags; +} + +/* ── the generalized teaching rider ────────────────────────────────────── */ + +function surfaceMatchesDiag( + surface: { code?: string; name: string; kind: SurfaceEntryKind }, + diag: ScrDiagnostic, +): boolean { + if (surface.code === undefined || surface.code !== diag.code) return false; + // A diagnostic-fence entry IS the code family; member entries must also + // appear by name in the refusal's message (SC2020/SC2012 refusals spell + // the surface exactly as the manifest names it). + return surface.kind === "diagnostic-fence" || diag.message.includes(surface.name); +} + +function teachingForRefusal(profile: FenceProfileView, diag: ScrDiagnostic): string | undefined { + // 1. Code-keyed teachings — the ratified SC4004/SC4005 rider's keys, + // "async" shared-key fallback included (profile.ts's profileTeaching + // rule, restated here to keep the import graph acyclic). + const direct = + profile.teachings[diag.code] ?? + (diag.code === "SC4004" || diag.code === "SC4005" ? profile.teachings["async"] : undefined); + if (direct !== undefined) return direct; + // 2. Manifest-id-keyed teachings (dotted keys — embedder refusal-code + // tokens never carry dots; an id key resolves against the taxonomy and + // attaches where that surface's refusal fired). + for (const [key, text] of Object.entries(profile.teachings)) { + if (!key.includes(".")) continue; + const entry = fenceTaxonomy().byId.get(key); + if (entry !== undefined && surfaceMatchesDiag(entry, diag)) return text; + } + // 3. A fence covering the refused surface: the teaching rides the + // surface's own refusal (the non-static half of fence coverage). + for (const fence of profile.fences) { + if (fence.teaching === undefined) continue; + for (const s of fence.surfaces) { + if (surfaceMatchesDiag(s, diag)) return fence.teaching; + } + } + return undefined; +} + +/** Attach the profile's teaching text to every library-mode refusal it + * names — by code, by manifest id, or through a fence — as the visibly + * attributed `note`. Diagnostics already carrying a note (SC4008 threads + * its fence's teaching at mint time) pass through unchanged. */ +export function decorateLibraryRefusals( + diagnostics: ScrDiagnostic[], + profile: FenceProfileView, +): ScrDiagnostic[] { + if (Object.keys(profile.teachings).length === 0 && profile.fences.length === 0) return diagnostics; + return diagnostics.map((d) => { + if (d.note !== undefined) return d; + const teaching = teachingForRefusal(profile, d); + if (teaching === undefined) return d; + return { ...d, note: `from the '${profile.name}' profile: ${teaching}` }; + }); +} diff --git a/packages/compiler/src/library/profile.ts b/packages/compiler/src/library/profile.ts index 5ced72f0f..267ae92a5 100644 --- a/packages/compiler/src/library/profile.ts +++ b/packages/compiler/src/library/profile.ts @@ -26,13 +26,24 @@ * "sidecar": { ... }, // ask-2 contract sidecar * // (see below); absent = * // no sidecar is emitted - * "determinism": { ... } // ask-5 surface, reserved; - * // only `teachings` and - * // `remediations` are read - * // today (the SC4004/SC4005 - * // teaching rider + the + * "determinism": { ... } // the ask-5 surface: + * // `teachings`/`remediations` + * // maps (the SC4004/SC4005 + * // rider generalized + the * // structured trap-teaching * // encoding's text sources) + * // and the `fences` array — + * // deny-by-manifest-id + * // entries, each exactly one + * // of id/prefix plus optional + * // teaching/remediation + * // (fence-eval.ts resolves + * // them against this + * // release's surface + * // manifest at load); + * // everything else under + * // `determinism` stays + * // reserved and ignored * } * * The `sidecar` section (ask 2): when present, the same invocation that @@ -77,6 +88,7 @@ import { readFileSync } from "node:fs"; import { dirname, isAbsolute, resolve } from "node:path"; import { libProfileDiag, type ScrDiagnostic } from "../diagnostics/diagnostic.js"; +import { resolveLibraryFences, type LibraryFenceDecl, type ResolvedLibraryFence } from "./fence-eval.js"; /** Marshalling classes legal in PARAMETER position (v1). */ export const LIB_PARAM_CLASSES = ["f64", "bool", "string", "bytes", "u8", "u32", "i32"] as const; @@ -165,6 +177,11 @@ export interface LibraryProfile { * field is absent from the assembled bytes when the profile supplies * none. Same reserved-byte validation as `teachings`. */ remediations: Readonly>; + /** The ask-5 determinism fences, resolved at load against this compiler + * release's surface manifest (fence-eval.ts): unknown ids/prefixes and + * unpoliceable static entries refused SC4001, never accepted as inert. + * Empty when the profile declares none. */ + fences: readonly ResolvedLibraryFence[]; } const C_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -373,17 +390,33 @@ export function loadLibraryProfile( exportNames.add(e.export); }); - // Ask-5 surface: only the teachings and remediations riders are read - // today; everything else under `determinism` is reserved and ignored. - // Both maps feed the structured trap-teaching encoding, which reserves - // 0x01 (the version marker) and 0x1F (the field separator): a key or - // value carrying either would corrupt the assembled sink message, so - // both are refused at load. Keys are diagnostic codes validated ONLY - // as tokens free of the reserved bytes — embedder code spaces belong + // Ask-5 surface: the teachings/remediations riders and the fences + // array are read; everything else under `determinism` is reserved and + // ignored. Teaching/remediation TEXT refuses every control byte below + // 0x20 except newline (the ratified constraint: 0x01 and 0x1F are the + // structured trap encoding's marker and separator — either inside + // profile-authored text would corrupt the assembled sink message; the + // remaining control bytes have no honest place in teaching prose) and + // caps at 512 UTF-8 bytes per string. Keys are diagnostic codes + // validated ONLY as control-free tokens — embedder code spaces belong // to the embedder (SC is the compiler registry's); membership is // never checked here. const teachings: Record = {}; const remediations: Record = {}; + let fences: ResolvedLibraryFence[] = []; + const RESERVED_TEXT = /[\u0000-\u0009\u000b-\u001f]/; + const RESERVED_KEY = /[\u0000-\u001f]/; + const checkRiderText = (v: string, path: string): void => { + if (RESERVED_TEXT.test(v)) { + throw new ProfileError( + `'${path}' contains a reserved byte — control bytes below 0x20 are refused (0x01 and 0x1F are the structured trap encoding's marker and separator); teaching and remediation text must be plain UTF-8, newlines only`, + ); + } + const bytes = new TextEncoder().encode(v).length; + if (bytes > 512) { + throw new ProfileError(`'${path}' is ${bytes} bytes — teaching and remediation strings cap at 512 bytes`); + } + }; const det = p["determinism"]; if (det !== undefined && det !== null && typeof det === "object" && !Array.isArray(det)) { const readRider = (rider: "teachings" | "remediations", into: Record): void => { @@ -391,21 +424,58 @@ export function loadLibraryProfile( if (t === undefined || t === null || typeof t !== "object" || Array.isArray(t)) return; for (const [k, v] of Object.entries(t as Record)) { if (typeof v !== "string") continue; - if (/[\u0001\u001f]/.test(k)) { + if (RESERVED_KEY.test(k)) { throw new ProfileError( `'determinism.${rider}' key ${JSON.stringify(k)} contains a reserved byte — 0x01 and 0x1F are the structured trap encoding's marker and separator; a code must be a plain token`, ); } - if (/[\u0001\u001f]/.test(v)) { - throw new ProfileError( - `'determinism.${rider}.${k}' contains a reserved byte — 0x01 and 0x1F are the structured trap encoding's marker and separator; teaching and remediation text must be plain UTF-8`, - ); - } + checkRiderText(v, `determinism.${rider}.${k}`); into[k] = v; } }; readRider("teachings", teachings); readRider("remediations", remediations); + + // The fences array (spec §1): each entry exactly one of id/prefix + // plus optional teaching/remediation, unknown fields refused (a typo + // here silently disarms a denial), and the selector resolved against + // this release's surface manifest — unknown ids/prefixes refuse + // loudly (ratified: fence profiles pin per compiler release). + const fencesRaw = (det as Record)["fences"]; + if (fencesRaw !== undefined && fencesRaw !== null) { + if (!Array.isArray(fencesRaw)) throw new ProfileError("'determinism.fences' must be an array"); + const decls: LibraryFenceDecl[] = fencesRaw.map((f, i) => { + const path = `determinism.fences[${i}]`; + if (f === null || typeof f !== "object" || Array.isArray(f)) { + throw new ProfileError(`'${path}' must be an object`); + } + rejectUnknownKeys(f, path, ["id", "prefix", "teaching", "remediation"]); + const ff = f as Record; + if ((ff["id"] === undefined) === (ff["prefix"] === undefined)) { + throw new ProfileError(`'${path}' must carry exactly one of 'id' or 'prefix'`); + } + const decl: LibraryFenceDecl = { path }; + if (ff["id"] !== undefined) { + const id = req(ff["id"], `${path}.id`, "string"); + if (id === "") throw new ProfileError(`'${path}.id' must be a non-empty manifest entry id`); + decl.id = id; + } else { + const prefix = req(ff["prefix"], `${path}.prefix`, "string"); + if (prefix === "") throw new ProfileError(`'${path}.prefix' must be a non-empty manifest id prefix`); + decl.prefix = prefix; + } + for (const rider of ["teaching", "remediation"] as const) { + if (ff[rider] === undefined) continue; + const text = req(ff[rider], `${path}.${rider}`, "string"); + checkRiderText(text, `${path}.${rider}`); + decl[rider] = text; + } + return decl; + }); + const resolved = resolveLibraryFences(decls); + if (!resolved.ok) throw new ProfileError(resolved.detail); + fences = resolved.fences; + } } const entry = isAbsolute(entryRel) ? entryRel : resolve(dirname(profilePath), entryRel); @@ -426,6 +496,7 @@ export function loadLibraryProfile( profileBytes: bytes, teachings, remediations, + fences, }, }; } catch (e) { @@ -443,7 +514,17 @@ export function profileTeaching(profile: LibraryProfile, code: string): string | /** The profile's remediation text for one code — the structured * trap-teaching message's optional fourth field. No shared-key fallback: - * a remediation names one code's fix. */ + * a remediation names one code's fix. A fence entry's `remediation` feeds + * the same lookup through the codes its covered manifest entries carry + * (spec §3: "a remediation in a fence entry or a `remediations` map" — + * one data source for the field that already exists); an explicit map key + * wins over a fence's, and the first declared fence wins among fences. */ export function profileRemediation(profile: LibraryProfile, code: string): string | undefined { - return profile.remediations[code]; + const direct = profile.remediations[code]; + if (direct !== undefined) return direct; + for (const fence of profile.fences) { + if (fence.remediation === undefined) continue; + if (fence.surfaces.some((s) => s.code === code)) return fence.remediation; + } + return undefined; } diff --git a/tests/harness/library-contract.test.ts b/tests/harness/library-contract.test.ts index c48d4bc51..20296d687 100644 --- a/tests/harness/library-contract.test.ts +++ b/tests/harness/library-contract.test.ts @@ -19,6 +19,8 @@ * the clock-touching fixture attests deterministic: false * - SC4009: unprojectable designations refuse with the declaration * named, never guessed around + * - ask-5 §4: the full-fence worked-example profile compiles a clean + * program and the computed attestation agrees — deterministic: true */ import { createHash } from "node:crypto"; import { execFileSync, spawnSync } from "node:child_process"; @@ -311,6 +313,19 @@ identity stable: 1 expect(doc.integer_slots).toEqual([]); }); + test("ask-5 §4 invariant: the full-fence profile compiles and attests deterministic: true", async () => { + // The worked-example determinism profile (real manifest ids) over a + // clean program: every fence loads, none is reached, compilation + // succeeds, and the sidecar's COMPUTED attestation agrees with the + // fences' construction — deterministic: true. A program that compiles + // under full fences yet attests false is a bug in one of the two + // scans (spec §4), and this pin is what recognizes it. + const { doc } = await buildContract("fences-attest", emission); + expect(validateSidecar(doc)).toEqual([]); + expect(doc.deterministic).toBe(true); + expect(doc.async_free).toBe(true); + }); + test("ask-3 §2 adversarial order shapes + §3 spread-union composition", async () => { const { doc } = await buildContract("contract-order", emission); expect(validateSidecar(doc)).toEqual([]); diff --git a/tests/harness/library-mode.test.ts b/tests/harness/library-mode.test.ts index 1ac2488e6..b5b17d140 100644 --- a/tests/harness/library-mode.test.ts +++ b/tests/harness/library-mode.test.ts @@ -57,6 +57,15 @@ * refuses SC4020 naming the failed bar — never * a generic SC1010/SC2013 — and builtins keep * the SC4005 async_free story + * K14 determinism-fences the ask-5 deny-by-manifest-id surface: a + * reached fenced static surface refuses SC4008 + * (id + name + attributed teaching note, both + * selector forms), unreached fences emit + * byte-identical code to a fence-free build, + * the generalized teachings map decorates any + * refusal by code or manifest id, and fencing + * an already-refusing surface only adds the + * note (its own code survives) */ import { execFileSync, spawnSync } from "node:child_process"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; @@ -524,7 +533,8 @@ let refusalCounter = 0; async function refusal( source: string, profilePatch: Record, -): Promise<{ code: string; message: string; hint?: string }[]> { + emission: Emission = "c", +): Promise<{ code: string; message: string; hint?: string; note?: string; file: string }[]> { const outDir = join(cacheDir, `refusal-${refusalCounter++}`); mkdirSync(outDir, { recursive: true }); const entry = join(outDir, "lib.ts"); @@ -533,7 +543,7 @@ async function refusal( profile_format: 1, name: "refusal-fixture", entry, - emission: "c", + emission, abi: { prefix: "kx_", init_symbol: "kx_init", @@ -549,7 +559,13 @@ async function refusal( const result = await compileLibrary({ profilePath, outDir }); expect(result.ok).toBe(false); if (result.ok) throw new Error("unreachable"); - return result.diagnostics.map((d) => ({ code: d.code, message: d.message, ...(d.hint !== undefined ? { hint: d.hint } : {}) })); + return result.diagnostics.map((d) => ({ + code: d.code, + message: d.message, + ...(d.hint !== undefined ? { hint: d.hint } : {}), + ...(d.note !== undefined ? { note: d.note } : {}), + file: d.loc.file, + })); } describe("K9: library-mode refusals", () => { @@ -580,24 +596,26 @@ describe("K9: library-mode refusals", () => { expect(diags[0]!.message).toContain("2 parameter(s)"); }); - test("SC4004: a mapped async export, with the profile teaching", async () => { + test("SC4004: a mapped async export, with the profile teaching as the attributed note", async () => { const diags = await refusal(`export async function tick(): Promise { return 1; }\n`, { exports: [{ export: "tick", symbol: "kx_tick", params: [], returns: "f64" }], determinism: { teachings: { async: "use the host scheduler entry instead" } }, }); expect(diags[0]!.code).toBe("SC4004"); expect(diags[0]!.hint).toContain("synchronous facade"); - expect(diags[0]!.hint).toContain("use the host scheduler entry instead"); + // The teaching arrives as its own visibly-attributed note — the + // tool's hint stays the tool's. + expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: use the host scheduler entry instead"); }); - test("SC4005: a timer anywhere in the graph, teaching verbatim", async () => { + test("SC4005: a timer anywhere in the graph, teaching as the attributed note", async () => { const diags = await refusal(`setTimeout(() => {}, 1);\nexport function f(): number { return 1; }\n`, { exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }], determinism: { teachings: { SC4005: "schedule through the embedder frame loop" } }, }); expect(diags[0]!.code).toBe("SC4005"); expect(diags[0]!.message).toContain("timers"); - expect(diags[0]!.hint).toContain("schedule through the embedder frame loop"); + expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: schedule through the embedder frame loop"); }); test("SC4007: a generic export", async () => { @@ -620,7 +638,7 @@ async function npmRefusal( entryFile: string, exports: unknown[], profilePatch: Record = {}, -): Promise<{ code: string; message: string; hint?: string; file: string }[]> { +): Promise<{ code: string; message: string; hint?: string; note?: string; file: string }[]> { const outDir = join(cacheDir, `npm-refusal-${npmRefusalCounter++}`); mkdirSync(outDir, { recursive: true }); const profile = { @@ -647,6 +665,7 @@ async function npmRefusal( code: d.code, message: d.message, ...(d.hint !== undefined ? { hint: d.hint } : {}), + ...(d.note !== undefined ? { note: d.note } : {}), file: d.loc.file, })); } @@ -683,14 +702,14 @@ describe("K13: library-mode npm refusals (SC4020, static-or-refuse)", () => { expect(diags[0]!.message).toContain("no own .d.ts"); }); - test("the profile teaching rides the SC4020 hint", async () => { + test("the profile teaching rides SC4020 as the attributed note", async () => { const diags = await npmRefusal( "lib-markers.ts", [{ export: "f", symbol: "kx_f", params: [], returns: "string" }], { determinism: { teachings: { SC4020: "request the vendored copy from the embedder SDK" } } }, ); expect(diags[0]!.code).toBe("SC4020"); - expect(diags[0]!.hint).toContain("request the vendored copy from the embedder SDK"); + expect(diags[0]!.note).toBe("from the 'npm-refusal-fixture' profile: request the vendored copy from the embedder SDK"); }); test("a builtin pulling the event loop keeps the SC4005 story, not a new fence", async () => { @@ -702,3 +721,140 @@ describe("K13: library-mode npm refusals (SC4020, static-or-refuse)", () => { expect(diags[0]!.message).toContain("timers"); }); }); + +/* ── K14: the ask-5 determinism fences (spec §1–§2) ─────────────────────── + * The profile's `determinism.fences` deny surfaces by surface-manifest id + * at compile time. Reached STATIC surfaces refuse SC4008 with the fence's + * teaching as the attributed note; fenced surfaces the static tier + * refuses anyway keep their own code with the teaching riding it; and the + * generalized `teachings` map attaches text to any refusal by code or + * manifest id. Both emissions run the refusal cases too — the refusal is + * pre-emission, but the profile pins the emission and conformance asserts + * the answer is emission-invariant. */ + +const WORKED_FENCES = [ + { id: "stdlib.math.random", teaching: "randomness is an effect: request it from the host and receive it as a Msg." }, + { prefix: "node-builtin.fs.", teaching: "files are effects: declare reads and writes as commands the host executes." }, + { prefix: "node-builtin.net.", teaching: "network is an effect: declare requests as commands; responses arrive as Msgs." }, + { prefix: "node-builtin.os.", teaching: "machine identity is an effect: ask the host." }, + { prefix: "node-builtin.crypto.", teaching: "randomness and digests come from the host." }, +]; + +describe.each(EMISSIONS)("K14: determinism fences, %s emission", (emission) => { + test("a reached id-fenced surface refuses SC4008 with code, id, and attributed teaching", async () => { + const diags = await refusal( + `export function roll(): number { return Math.random(); }\n`, + { + exports: [{ export: "roll", symbol: "kx_roll", params: [], returns: "f64" }], + determinism: { fences: WORKED_FENCES }, + }, + emission, + ); + expect(diags.map((d) => d.code)).toEqual(["SC4008"]); + expect(diags[0]!.message).toContain("'stdlib.math.random'"); + expect(diags[0]!.message).toContain("Math.random"); + expect(diags[0]!.note).toBe( + "from the 'refusal-fixture' profile: randomness is an effect: request it from the host and receive it as a Msg.", + ); + // Anchored at the reaching construct, not the profile file. + expect(diags[0]!.file.endsWith("lib.ts")).toBe(true); + }); + + test("a reached prefix-fenced surface refuses SC4008 naming the covered member id", async () => { + const diags = await refusal( + `import { readFileSync } from "node:fs";\nexport function f(): number { return readFileSync("x", "utf8").length; }\n`, + { + exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }], + determinism: { fences: WORKED_FENCES }, + }, + emission, + ); + expect(diags.map((d) => d.code)).toEqual(["SC4008"]); + expect(diags[0]!.message).toContain("'node-builtin.fs.readFileSync'"); + expect(diags[0]!.note).toContain("files are effects"); + }); + + test("unreached fences compile at zero cost: the emitted code is byte-identical", async () => { + // ONE entry file feeds both builds (emitted C/LLVM embeds the entry's + // path in provenance comments; the fence's cost is what's measured, + // not the build directory's spelling). + const entryDir = join(cacheDir, `fence-zerocost-${emission}`); + mkdirSync(entryDir, { recursive: true }); + const entry = join(entryDir, "lib.ts"); + writeFileSync(entry, `export function f(): number { return 41 + 1; }\n`); + const emitted: string[] = []; + for (const fenced of [false, true]) { + const outDir = join(cacheDir, `fence-zerocost-${emission}-${fenced ? "fenced" : "plain"}`); + mkdirSync(outDir, { recursive: true }); + const profilePath = join(outDir, "profile.json"); + writeFileSync( + profilePath, + JSON.stringify({ + profile_format: 1, + name: "fence-zerocost", + entry, + emission, + abi: { + prefix: "kx_", + init_symbol: "kx_init", + sink_register_symbol: "kx_set_panic_sink", + collect_symbol: null, + result_reset_symbol: null, + }, + exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }], + ...(fenced ? { determinism: { fences: WORKED_FENCES } } : {}), + }), + ); + const result = await compileLibrary({ profilePath, outDir }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + emitted.push(readFileSync(result.cPath, "utf8")); + } + expect(emitted[1]).toBe(emitted[0]); + }); + + test("a code-keyed teachings entry rides a non-fence refusal as the attributed note", async () => { + const diags = await refusal( + `export function f(): number { return "abc".normalize().length; }\n`, + { + exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }], + determinism: { teachings: { SC2020: "host strings arrive normalized; never renormalize in a core" } }, + }, + emission, + ); + expect(diags[0]!.code).toBe("SC2020"); + expect(diags[0]!.note).toBe( + "from the 'refusal-fixture' profile: host strings arrive normalized; never renormalize in a core", + ); + }); + + test("a manifest-id-keyed teachings entry attaches to that surface's own refusal", async () => { + const diags = await refusal( + `export function f(): number { return Math.sin(1); }\n`, + { + exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }], + determinism: { teachings: { "stdlib.math.sin": "trig runs in the host; request it as an effect" } }, + }, + emission, + ); + // The surface's own code, not a fence code: the id key attaches text + // to the refusal that already fires. + expect(diags[0]!.code).toBe("SC2012"); + expect(diags[0]!.message).toContain("Math.sin"); + expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: trig runs in the host; request it as an effect"); + }); + + test("fencing a surface the static tier refuses anyway changes only the message", async () => { + const diags = await refusal( + `export function f(): number { return Math.sin(1); }\n`, + { + exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }], + determinism: { fences: [{ id: "stdlib.math.sin", teaching: "trig is host math" }] }, + }, + emission, + ); + // The existing refusal's code survives — the fence never re-codes a + // surface that already refuses; its teaching rides as the note. + expect(diags[0]!.code).toBe("SC2012"); + expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: trig is host math"); + }); +}); diff --git a/tests/harness/library-profile.test.ts b/tests/harness/library-profile.test.ts index 2eb749858..dcd3e1da0 100644 --- a/tests/harness/library-profile.test.ts +++ b/tests/harness/library-profile.test.ts @@ -102,7 +102,9 @@ describe("library profile validation", () => { test("teaching and remediation text reject the encoding's reserved bytes", () => { // 0x01 is the structured message's version marker, 0x1F its field // separator: either byte inside profile-authored text would corrupt - // the assembled sink message, so the load refuses (SC4001). + // the assembled sink message, so the load refuses (SC4001). The full + // ratified constraint bans every control byte below 0x20 except + // newline in TEXT (and every control byte, newline included, in keys). expectSc4001( { ...good, determinism: { teachings: { SC4012: "bad \u0001 marker" } } }, "determinism.teachings.SC4012", @@ -115,6 +117,28 @@ describe("library profile validation", () => { { ...good, determinism: { teachings: { "NS\u001f12": "text is fine" } } }, "reserved byte", ); + expectSc4001( + { ...good, determinism: { teachings: { SC4012: "tabs\tare control bytes" } } }, + "determinism.teachings.SC4012", + ); + const withNewline = loadLibraryProfile( + writeProfile({ ...good, determinism: { teachings: { SC4012: "line one\nline two" } } }), + ); + expect(withNewline.ok).toBe(true); + }); + + test("teaching and remediation text cap at 512 bytes, refuse over", () => { + const atCap = loadLibraryProfile( + writeProfile({ ...good, determinism: { teachings: { SC4012: "x".repeat(512) } } }), + ); + expect(atCap.ok).toBe(true); + expectSc4001({ ...good, determinism: { teachings: { SC4012: "x".repeat(513) } } }, "512 bytes"); + expectSc4001( + { ...good, determinism: { remediations: { SC4012: "x".repeat(513) } } }, + "determinism.remediations.SC4012", + ); + // The cap counts UTF-8 BYTES, not code points. + expectSc4001({ ...good, determinism: { teachings: { SC4012: "\u00e9".repeat(300) } } }, "512"); }); test("parse error", () => expectSc4001("{ not json", "not valid JSON")); @@ -231,3 +255,157 @@ describe("library profile sidecar section", () => { test("an absolute sidecar path refuses", () => expectSc4001({ ...good, sidecar: { ...goodSidecar, path: "/tmp/contract.json" } }, "sidecar.path")); }); + + +/* ── the ask-5 fences array ──────────────────────────────────────────────── + * Selector strictness is RATIFIED: fence profiles pin per compiler + * release, so an id or prefix matching nothing in this release's surface + * manifest refuses SC4001 at load — there is no forward-compatible + * acceptance of unknown selectors, and no silently-inert fence. Static + * surfaces no detector can police refuse the same way, with the reason + * named (folded constants, desugared methods). */ + +describe("library profile fences", () => { + test("well-formed fences resolve: id and prefix selectors, covered surfaces, detectors", () => { + const r = loadLibraryProfile( + writeProfile({ + ...good, + determinism: { + fences: [ + { id: "stdlib.math.random", teaching: "randomness is an effect", remediation: "ask the host" }, + { prefix: "node-builtin.fs.", teaching: "files are effects" }, + { id: "stdlib.math.sin", teaching: "trig is host math", remediation: "request it as an effect" }, + ], + }, + }), + ); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.profile.fences).toHaveLength(3); + // The id fence covers exactly its entry; math.random is static, so it + // carries a detector (the compile-time denial's reach witness). + expect(r.profile.fences[0]!.declared).toBe("id 'stdlib.math.random'"); + expect(r.profile.fences[0]!.surfaces).toHaveLength(1); + expect(r.profile.fences[0]!.surfaces[0]!.id).toBe("stdlib.math.random"); + expect(r.profile.fences[0]!.surfaces[0]!.detector).toBeDefined(); + // The prefix fence covers the whole fs member family. + const fsIds = r.profile.fences[1]!.surfaces.map((s) => s.id); + expect(fsIds).toContain("node-builtin.fs.readFileSync"); + expect(fsIds).toContain("node-builtin.fs.promises.readFile"); + // A fenced dynamic-only surface carries its own refusal code and no + // detector: the teaching rides the refusal that already fires. + const sin = r.profile.fences[2]!.surfaces[0]!; + expect(sin.code).toBe("SC2012"); + expect(sin.detector).toBeUndefined(); + }); + + test("a fence remediation feeds the trap-remediation lookup through covered codes", () => { + const r = loadLibraryProfile( + writeProfile({ + ...good, + determinism: { + remediations: { SC2012: "the explicit map key wins" }, + fences: [ + { id: "stdlib.math.sin", remediation: "request it as an effect" }, + { id: "node-builtin.crypto.createHash", remediation: "digests come from the host" }, + ], + }, + }), + ); + expect(r.ok).toBe(true); + if (!r.ok) return; + // Explicit remediations keys win over fence-supplied text. + expect(profileRemediation(r.profile, "SC2012")).toBe("the explicit map key wins"); + // A fence's remediation answers for its covered entries' codes + // (crypto.createHash is SC2020) — spec §3's "in a fence entry or a + // remediations map", one lookup either way. + expect(profileRemediation(r.profile, "SC2020")).toBe("digests come from the host"); + expect(profileRemediation(r.profile, "SC4014")).toBeUndefined(); + }); + + test("an id matching nothing in this release's manifest refuses (ratified strictness)", () => { + expectSc4001( + { ...good, determinism: { fences: [{ id: "stdlib.date.now" }] } }, + "determinism.fences[0].id", + ); + expectSc4001( + { ...good, determinism: { fences: [{ id: "stdlib.date.now" }] } }, + "names no entry", + ); + }); + + test("a prefix matching nothing refuses — the spec's illustrative spelling included", () => { + // The worked example's 'node.fs.' is illustrative prose; the real + // taxonomy spells 'node-builtin.fs.', and the strict refusal catches + // the drift instead of shipping an inert fence. + expectSc4001( + { ...good, determinism: { fences: [{ prefix: "node.fs." }] } }, + "determinism.fences[0].prefix", + ); + }); + + test("exactly one of id/prefix per fence", () => { + expectSc4001( + { ...good, determinism: { fences: [{ id: "stdlib.math.random", prefix: "node-builtin.fs." }] } }, + "exactly one of 'id' or 'prefix'", + ); + expectSc4001( + { ...good, determinism: { fences: [{ teaching: "no selector" }] } }, + "exactly one of 'id' or 'prefix'", + ); + }); + + test("unknown fields inside a fence entry refuse (a typo silently disarms a denial)", () => { + expectSc4001( + { ...good, determinism: { fences: [{ id: "stdlib.math.random", teachng: "typo" }] } }, + "unknown field 'determinism.fences[0].teachng'", + ); + }); + + test("fence teaching/remediation strings share the reserved-byte and 512-byte rules", () => { + expectSc4001( + { ...good, determinism: { fences: [{ id: "stdlib.math.random", teaching: "bad \u0001 marker" }] } }, + "determinism.fences[0].teaching", + ); + expectSc4001( + { ...good, determinism: { fences: [{ id: "stdlib.math.random", remediation: "x".repeat(513) }] } }, + "determinism.fences[0].remediation", + ); + }); + + test("a compile-time-folded constant cannot be id-fenced; a prefix exempts it", () => { + // os.EOL folds to a per-binary literal: nothing at runtime reads it, + // so an id fence naming it would be a lie — refused with the reason. + expectSc4001( + { ...good, determinism: { fences: [{ id: "node-builtin.os.EOL" }] } }, + "fold", + ); + // The family sweep stays usable: the prefix covers the os members and + // exempts the folded constant. + const r = loadLibraryProfile( + writeProfile({ ...good, determinism: { fences: [{ prefix: "node-builtin.os." }] } }), + ); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.profile.fences[0]!.surfaces.map((s) => s.id)).not.toContain("node-builtin.os.EOL"); + expect(r.profile.fences[0]!.surfaces.map((s) => s.id)).toContain("node-builtin.os.homedir"); + }); + + test("a desugared surface no detector can police refuses, id and prefix alike", () => { + expectSc4001( + { ...good, determinism: { fences: [{ id: "stdlib.array.map" }] } }, + "desugars", + ); + expectSc4001( + { ...good, determinism: { fences: [{ prefix: "stdlib.array." }] } }, + "cannot be fenced", + ); + }); + + test("no fences array means no fences", () => { + const r = loadLibraryProfile(writeProfile(good)); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.profile.fences).toEqual([]); + }); +}); diff --git a/tests/library-mode/fences-attest/lib.ts b/tests/library-mode/fences-attest/lib.ts new file mode 100644 index 000000000..a074b099d --- /dev/null +++ b/tests/library-mode/fences-attest/lib.ts @@ -0,0 +1,37 @@ +// The ask-5 §4 invariant fixture: the worked-example determinism profile +// (translated into this release's real manifest ids) over a program that +// COMPILES under it. Every fence is present and unreached, and the +// sidecar's computed attestation must come out `deterministic: true` — +// with fences covering the ambient-nondeterminism surfaces, determinism +// holds by construction for whatever compiles, so a program that compiles +// under full fences yet attests false is a bug in one of the two scans, +// never ambiguity. + +export interface Model { + total: number; + label: string; +} + +export type Msg = { kind: "add"; value: number } | { kind: "label_set"; value: string }; + +export function init(): Model { + return { total: 0, label: "start" }; +} + +export function update(m: Model, msg: Msg): Model { + switch (msg.kind) { + case "add": + return { total: m.total + msg.value, label: m.label }; + default: + return { total: m.total, label: msg.value }; + } +} + +let state: Model = init(); + +export function boot(delta: number): number { + state = update(state, { kind: "add", value: Math.floor(delta) }); + return state.total; +} + +console.log("fences-attest ready"); diff --git a/tests/library-mode/fences-attest/profile.json b/tests/library-mode/fences-attest/profile.json new file mode 100644 index 000000000..e747bd5fb --- /dev/null +++ b/tests/library-mode/fences-attest/profile.json @@ -0,0 +1,37 @@ +{ + "profile_format": 1, + "name": "conformance-fences-attest", + "entry": "lib.ts", + "emission": "llvm", + "abi": { + "prefix": "kf_", + "init_symbol": "kf_init", + "sink_register_symbol": "kf_set_panic_sink", + "collect_symbol": null, + "result_reset_symbol": null + }, + "exports": [ + { "export": "boot", "symbol": "kf_boot", "params": ["f64"], "returns": "f64" } + ], + "sidecar": { + "wire_version": 1, + "abi_version": 1, + "snapshot_format": 1, + "build_id_symbol": "kf_build_id", + "abi_version_symbol": "kf_abi_version", + "model": "Model", + "msg": "Msg" + }, + "determinism": { + "teachings": { + "SC4005": "async is unavailable in this compilation mode; the host executes effects — declare a command instead." + }, + "fences": [ + { "id": "stdlib.math.random", "teaching": "randomness is an effect: request it from the host and receive it as a Msg." }, + { "prefix": "node-builtin.fs.", "teaching": "files are effects: declare reads and writes as commands the host executes." }, + { "prefix": "node-builtin.net.", "teaching": "network is an effect: declare requests as commands; responses arrive as Msgs." }, + { "prefix": "node-builtin.os.", "teaching": "machine identity is an effect: ask the host." }, + { "prefix": "node-builtin.crypto.", "teaching": "randomness and digests come from the host." } + ] + } +}