mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-25 06:24:27 +08:00
Profiles deny surfaces by manifest id: fences, generalized teachings, and the determinism invariant
- A fences array denies manifest-identified surfaces reached by the compiled library graph: SC4008 names the id and surface with the profile's teaching as an attributed note; unreached fences leave emission byte-identical - Fence evaluation walks the dead-stripped module IR — the same ground as the deterministic attestation, making the full-fences invariant structural - Teachings generalize to any refusal code or manifest id; unknown or unpoliceable fence selectors refuse loudly rather than sit inert - Teaching and remediation strings take the ratified byte constraints and the 512-byte cap everywhere
This commit is contained in:
@@ -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 name>' 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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>' 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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Compi
|
||||
let entryInfo: Map<string, EntryExportInfo>;
|
||||
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<Compi
|
||||
// this path to serve anything.
|
||||
(s.detail ?? "its static compilation was refused").replace("; the island serves the package", ""),
|
||||
fe.npmImportSites.get(s.package) ?? { file: entryPath, start: 0, end: 0 },
|
||||
profileTeaching(profile, "SC4020"),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -986,19 +987,26 @@ export async function compileLibrary(opts: CompileLibraryOptions): Promise<Compi
|
||||
}
|
||||
const mod = lowered.module!;
|
||||
|
||||
const fail = (diagnostics: ScrDiagnostic[]): CompileLibraryResult => ({ 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
|
||||
|
||||
@@ -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<string, SurfaceManifestEntry>;
|
||||
ids: readonly string[];
|
||||
/** node-builtin member id → libCall fn spellings (platform twins
|
||||
* included: a fence covers the surface on every target). */
|
||||
builtinFns: Map<string, readonly string[]>;
|
||||
/** node-builtin module-root id → the union of its members' spellings. */
|
||||
builtinRoots: Map<string, readonly string[]>;
|
||||
/** node-builtin constant ids: reads fold to literals at compile time. */
|
||||
foldedIds: Set<string>;
|
||||
}
|
||||
|
||||
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<string, readonly string[]>();
|
||||
const builtinRoots = new Map<string, readonly string[]>();
|
||||
const foldedIds = new Set<string>();
|
||||
for (const [mod, members] of Object.entries(BUILTIN_MODULE_FNS)) {
|
||||
const root = new Set<string>();
|
||||
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<string, readonly string[] | undefined> = {
|
||||
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<string, SrcLoc>;
|
||||
strMethods: Map<string, SrcLoc>;
|
||||
arrMethods: Map<string, SrcLoc>;
|
||||
mapMethods: Map<string, SrcLoc>;
|
||||
setMethods: Map<string, SrcLoc>;
|
||||
}
|
||||
|
||||
/** 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<string, SrcLoc>, 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<string, unknown>)[key], here);
|
||||
};
|
||||
visit(mod, entryLoc);
|
||||
return reached;
|
||||
}
|
||||
|
||||
function detectorHit(reached: ReachedSurfaces, det: SurfaceDetector): SrcLoc | null {
|
||||
const scan = (facts: Map<string, SrcLoc>, 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<Record<string, string>>;
|
||||
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<string>();
|
||||
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}` };
|
||||
});
|
||||
}
|
||||
@@ -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<Record<string, string>>;
|
||||
/** 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<string, string> = {};
|
||||
const remediations: Record<string, string> = {};
|
||||
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<string, string>): 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<string, unknown>)) {
|
||||
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<string, unknown>)["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<string, unknown>;
|
||||
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<string>(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<string>(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<string>(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;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
): 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<number> { 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<string, unknown> = {},
|
||||
): 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
@@ -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." }
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user