/** * Structural invariants, as pure functions over source text. * * These were inside `tools/sideeffects-test.mjs`, mixed with reading files and asserting. That * worked while the only way to exercise them was to break the real repository, which is a * method with two problems: it leaves the working tree in a broken state if anything throws, and * it cannot be run in CI without risking exactly that. The external ruling asked for the split * in these words: "以 (c) 作为正式架构" -- a pure core taking sources, and an adapter that reads * them from disk. * * So this file does no I/O. It takes `SourceFile[]` and returns `Violation[]`, and that is what * makes a negative test possible: mutate a string in memory, hand it over, and assert the right * invariant fires and names the right file. * * Each violation carries the invariant that produced it. A negative test that only asserted * "something failed" would pass for the wrong reason the moment two checks overlap, and the * whole point of these tests is to know *which* guard noticed. * * It lives at the repository root rather than under `tools/` so it is type-checked with the rest: * `tsconfig.json` includes `*.ts`, and a checker written in untyped JavaScript would be the same * defect one level up. */ export interface SourceFile { /** Repository-relative, using forward slashes. */ path: string; text: string; } export interface Violation { /** Which invariant noticed. Stable, so a test can assert the specific one. */ invariant: string; file: string; detail: string; } /** An operation and the call sites it permits. Derived from the side-effect registry. */ export interface PrimitiveOwner { operation: string; primitives: readonly string[]; allowedIn: readonly string[]; } const escapeForRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); /** * Remove definitions so a declaration is not counted as a call site. * * A method whose name matches its own primitive would otherwise be reported as its own caller, * and a checker that cries wolf on the definition is one people learn to ignore. */ export function stripDeclarations(text: string, name: string): string { const escaped = escapeForRegex(name); const declaration = new RegExp( "(?:^|\\n)\\s*(?:private |public |protected |static |async |readonly )*(?:function )?" + escaped + "\\s*\\([^)]*\\)\\s*(?::[^\\n{]*)?\\{", "g", ); return text.replace(declaration, "\n/* declaration */"); } /** * Lines marked as payloads rather than calls. * * A negative test has to contain the text of the violation it injects, which means the checker * sees its own test data as code. Observed: the baseline reported `primitive-ownership` and * `capability-required` against the negative test file itself, because the mutation strings sit * there as literals. * * The alternatives were worse. Excluding the file by name would blind the scan to a real * violation in it; reconstructing the payloads by string concatenation would hide what the test * is actually injecting, and the point of a negative test is to be legible about what it breaks. * So the payload is labelled, the same way the one deliberate boundary omission is: an * unlabelled occurrence is still an offence. */ export const PAYLOAD_MARKER = "mutation-payload:"; /** Whether a source line is a declared test payload rather than a call site. */ export function isPayloadLine(line: string): boolean { return line.includes(PAYLOAD_MARKER); } /** Every call to `name` in `text`, as `{ line, text }` for the caller to inspect. */ export function findCalls(text: string, name: string): Array<{ line: number; text: string }> { const stripped = stripDeclarations(text, name); const pattern = new RegExp("\\b" + escapeForRegex(name) + "\\s*\\(", "g"); const out: Array<{ line: number; text: string }> = []; let match; while ((match = pattern.exec(stripped)) !== null) { const lineStart = stripped.lastIndexOf("\n", match.index) + 1; const lineEnd = stripped.indexOf("\n", match.index); const lineText = stripped.slice(lineStart, lineEnd === -1 ? undefined : lineEnd); if (isPayloadLine(lineText)) continue; out.push({ line: stripped.slice(0, match.index).split("\n").length, text: lineText, }); } return out; } /** * A primitive may only be called from files its operation declared. * * This is the check that would have caught `rotate-conversation -> ChatGptPage.open`, which * bypassed the creation gates entirely: a new path to the same side effect, added somewhere the * registry never mentioned. */ /** Whether a path is covered by the declared entries, where an entry may be a directory. */ export function isAllowed(path: string, allowedIn: readonly string[]): boolean { return allowedIn.some((entry) => path === entry || path.startsWith(entry + "/")); } export function scanPrimitiveOwnership(files: readonly SourceFile[], owners: readonly PrimitiveOwner[]): Violation[] { const violations: Violation[] = []; for (const owner of owners) { for (const primitive of owner.primitives) { for (const file of files) { const calls = findCalls(file.text, primitive); if (calls.length === 0) continue; // A directory entry means every file under it. Without this, `allowedIn: ["tools"]` // silently permits nothing, because the paths compared are `tools/x.mjs` -- and the // earlier version of this scan never noticed, because it only read root-level files. if (isAllowed(file.path, owner.allowedIn)) continue; violations.push({ invariant: "primitive-ownership", file: file.path, detail: `${owner.operation} / ${primitive} 出现在未声明的文件(允许:${owner.allowedIn.join(", ") || "无"}),` + `共 ${calls.length} 处,首个在第 ${calls[0].line} 行`, }); } } } return violations; } /** * Every call to a capability-guarded primitive must hand over a capability. * * The parameter is required, so a callsite that forgets it does not compile -- but the extension * is loaded by jiti, which does not type check, and a new callsite in a tool would never be seen * by `tsc` at all. */ export function scanCapabilityCallsites(files: readonly SourceFile[], callName = "page.ask"): Violation[] { const violations: Violation[] = []; const accepted = /\(\s*(await sendCapability\(\)|sendDecision\.capability|capability\b)/; for (const file of files) { for (const call of findCalls(file.text, callName)) { // The declaration itself is already stripped; a match without an argument list is not a // callsite either. if (!call.text.includes(callName + "(")) continue; const from = call.text.indexOf(callName + "("); const tail = call.text.slice(from); if (accepted.test(tail)) continue; violations.push({ invariant: "capability-required", file: file.path, detail: `${callName} 调用点没有传 capability:第 ${call.line} 行 ${call.text.trim().slice(0, 80)}`, }); } } return violations; } /** * Every call that issues authority must hand over a human-intent proof. * * The confused-deputy boundary. A normal capability must not be able to sign authority for * itself, so the proof is a required parameter -- and this is what notices a callsite added * without one. * * `boundary-check:` is the one permitted exception: a test that asserts omission is refused. It * has to be written in the source, so an accidental omission has no marker and still fails. */ export function scanHumanIntentCallsites(files: readonly SourceFile[], callName = "issueIntent"): Violation[] { const violations: Violation[] = []; const accepted = /\(\s*(proof|FIXTURE\(\)|mintHumanIntent)/; for (const file of files) { const stripped = stripDeclarations(file.text, callName); for (const call of findCalls(stripped, callName)) { const from = call.text.indexOf(callName + "("); const tail = call.text.slice(from); if (accepted.test(tail)) continue; // Look back a few lines for the explicit marker. const lines = stripped.split("\n"); const window = lines.slice(Math.max(0, call.line - 5), call.line).join("\n"); if (window.includes("boundary-check:")) continue; violations.push({ invariant: "human-intent-required", file: file.path, detail: `${callName} 调用点没有人类意图证明:第 ${call.line} 行 ${call.text.trim().slice(0, 80)}`, }); } } return violations; } /** All structural invariants, for a caller that wants the lot. */ export function scanAll( files: readonly SourceFile[], owners: readonly PrimitiveOwner[], options: { capabilityCall?: string; authorityCall?: string } = {}, ): Violation[] { return [ ...scanPrimitiveOwnership(files, owners), ...scanCapabilityCallsites(files, options.capabilityCall ?? "page.ask"), ...scanHumanIntentCallsites(files, options.authorityCall ?? "issueIntent"), ]; }