From e304f0197fe2d377ac9fe1fb11be514ba1ddec09 Mon Sep 17 00:00:00 2001 From: dsmcewan Date: Thu, 16 Jul 2026 13:07:37 -0400 Subject: [PATCH 1/3] =?UTF-8?q?Clotho=20Task=202:=20registry.mjs=20?= =?UTF-8?q?=E2=80=94=20closed=20registries,=20identity,=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements v12 Task 2: the single authoritative source of node/edge/status membership and canonical identity. Zero-dependency, Node stdlib only. - NODE_KINDS / EDGE_KINDS / ASSERTION_STATUS / WEAVER_IDS as read-only Set facades over private Sets (add/delete/clear throw; not Object.freeze(new Set)). - canonicalJson: JSON-only, sorted keys, preserved array order; rejects undefined/sparse/non-finite/bigint/symbol/function/cycle/non-plain proto. - deriveNodeId = sha256(canonicalJson({kind, locator})); content-bound. - validateLocator for all 11 kinds with exact fields + content bindings (blob_sha / text_sha256 / entry_hash / summary_sha256; commit = no repo_ref). - validateSourceRef (git:/file:@/ledger:#), validateAssertionStatus (weaver-> deterministic-extraction, human->human-authorized, model:->model-proposal), validateEdgeInput with the full endpoint matrix incl. the four depends-on rows and the discharges matrix; supersedes same-kind + human/model assertor. - deriveRepositoryRef(git) shallow guard (rev-parse --is-shallow-repository == false; single 40-hex root via rev-list --max-parents=0 HEAD). test-registry.mjs (replaces the Task 1 scaffold): full coverage incl. the real-git shallow/full-clone fixture (D18) plus injected-git units. npm test green. Traceability: v12 sha256:bdc93901..., authz-005, Eye impl-authorization #109. Decisions D2/D13/D16/D18. Per-task cadence: separate PR, re-enters TELOS. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X8pTab2QMpfM2KsZmwkeYg --- clotho/registry.mjs | 392 +++++++++++++++++++++++++++++++ clotho/scripts/test-registry.mjs | 291 ++++++++++++++++++++++- 2 files changed, 673 insertions(+), 10 deletions(-) create mode 100644 clotho/registry.mjs diff --git a/clotho/registry.mjs b/clotho/registry.mjs new file mode 100644 index 0000000..8a0325f --- /dev/null +++ b/clotho/registry.mjs @@ -0,0 +1,392 @@ +// registry.mjs — the single authoritative source of Clotho's closed node/edge/ +// status membership, canonical identity, and locator/source/endpoint validation +// (plan v12 Task 2). Zero dependencies: Node stdlib only. +// +// Everything here is deterministic and fail-closed: unknown kinds/statuses, +// malformed locators, mismatched node ids, bad couplings, and invalid endpoint +// pairs throw rather than being silently normalized. + +import { createHash } from "node:crypto"; + +// ---- closed registries (read-only Set facades over private native Sets) ------ +// Not `Object.freeze(new Set(...))`: a frozen Set still mutates via add/delete. +// The facade backs a private Set and makes every mutator throw. + +function readonlySet(members) { + const set = new Set(members); + const deny = (op) => () => { + throw new Error(`read-only set: ${op} is not permitted`); + }; + const facade = { + has: (value) => set.has(value), + keys: () => set.keys(), + values: () => set.values(), + entries: () => set.entries(), + forEach: (fn, thisArg) => set.forEach(fn, thisArg), + add: deny("add"), + delete: deny("delete"), + clear: deny("clear"), + [Symbol.iterator]: () => set[Symbol.iterator]() + }; + Object.defineProperty(facade, "size", { get: () => set.size, enumerable: true }); + return Object.freeze(facade); +} + +export const NODE_KINDS = readonlySet([ + "contract-clause", "code-symbol", "repository-file", "test", "commit", + "concern", "obligation", "check-contract", "run-evidence", "doc-section", "decision" +]); + +export const EDGE_KINDS = readonlySet([ + "depends-on", "introduced-by", "motivated-by", "verified-by", + "documented-in", "evidenced-by", "discharges", "supersedes" +]); + +export const ASSERTION_STATUS = readonlySet([ + "deterministic-extraction", "human-authorized", "model-proposal", + "rejected", "superseded" +]); + +// The five deterministic weaver ids (asserted_by => deterministic-extraction). +export const WEAVER_IDS = readonlySet([ + "clotho-git-weaver", "clotho-code-weaver", "clotho-test-weaver", + "clotho-doc-weaver", "clotho-ledger-weaver" +]); + +// ---- canonical JSON ---------------------------------------------------------- +// Accepts JSON primitives, dense arrays, and plain objects only. Rejects +// undefined, sparse arrays, non-finite numbers, bigint, symbols, functions, +// cycles, and non-plain prototypes. Object keys sorted by JS string code-unit +// order; array order preserved. + +export function canonicalJson(value) { + return encode(value, new Set()); +} + +function encode(value, seen) { + if (value === null) return "null"; + const type = typeof value; + if (type === "boolean") return value ? "true" : "false"; + if (type === "string") return JSON.stringify(value); + if (type === "number") { + if (!Number.isFinite(value)) throw new TypeError("canonicalJson: non-finite number"); + return JSON.stringify(value); + } + if (type === "bigint") throw new TypeError("canonicalJson: bigint is not JSON"); + if (type === "undefined" || type === "symbol" || type === "function") { + throw new TypeError(`canonicalJson: ${type} is not JSON`); + } + if (type !== "object") throw new TypeError(`canonicalJson: unsupported ${type}`); + if (seen.has(value)) throw new TypeError("canonicalJson: cycle"); + seen.add(value); + let out; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + if (!Object.prototype.hasOwnProperty.call(value, i)) { + throw new TypeError("canonicalJson: sparse array"); + } + } + out = "[" + value.map((item) => encode(item, seen)).join(",") + "]"; + } else { + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) { + throw new TypeError("canonicalJson: non-plain object"); + } + const keys = Object.keys(value).sort(); + out = "{" + keys.map((k) => JSON.stringify(k) + ":" + encode(value[k], seen)).join(",") + "}"; + } + seen.delete(value); + return out; +} + +// ---- primitive validators ---------------------------------------------------- + +const HEX40 = /^[0-9a-f]{40}$/; +const HEX64 = /^[0-9a-f]{64}$/; +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const STABLE_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/; +const REPO_REF = /^git-root:[0-9a-f]{40}$/; + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +// Exactly the expected own keys, no missing, no extra, no inherited enumerable. +function requireExactKeys(obj, expected, label) { + if (!isPlainObject(obj)) throw new TypeError(`${label}: expected a plain object`); + const keys = Object.keys(obj); + if (keys.length !== expected.length) { + throw new TypeError(`${label}: expected keys [${[...expected].sort().join(", ")}], got [${[...keys].sort().join(", ")}]`); + } + for (const k of expected) { + if (!Object.prototype.hasOwnProperty.call(obj, k)) { + throw new TypeError(`${label}: missing field '${k}'`); + } + } +} + +function isCanonicalPath(p) { + if (typeof p !== "string" || p.length === 0) return false; + if (p.includes("\0") || p.includes("\\")) return false; + if (p.startsWith("/") || p.endsWith("/")) return false; + for (const seg of p.split("/")) { + if (seg === "" || seg === "." || seg === "..") return false; + } + return true; +} + +function requirePath(p, label) { + if (!isCanonicalPath(p)) throw new TypeError(`${label}: not a canonical POSIX relative path: ${JSON.stringify(p)}`); +} + +function requireHex(value, re, label) { + if (typeof value !== "string" || !re.test(value)) { + throw new TypeError(`${label}: expected lowercase ${re === HEX40 ? "40" : "64"}-hex, got ${JSON.stringify(value)}`); + } +} + +function normalizeHeading(h) { + return h.normalize("NFC").trim().replace(/\s+/g, " "); +} + +function requireHeadingPath(hp, label) { + if (!Array.isArray(hp) || hp.length === 0) throw new TypeError(`${label}: heading_path must be a nonempty array`); + for (const h of hp) { + if (typeof h !== "string" || h.length === 0) throw new TypeError(`${label}: heading must be a nonempty string`); + if (h !== normalizeHeading(h)) throw new TypeError(`${label}: heading is not normalized: ${JSON.stringify(h)}`); + } +} + +function requireRepositoryRef(value, repositoryRef, label) { + if (typeof value !== "string" || !REPO_REF.test(value)) { + throw new TypeError(`${label}: repository_ref must be 'git-root:<40-hex>', got ${JSON.stringify(value)}`); + } + if (repositoryRef !== undefined && value !== repositoryRef) { + throw new TypeError(`${label}: repository_ref ${JSON.stringify(value)} does not match derived ${JSON.stringify(repositoryRef)}`); + } +} + +// ---- locator schemas --------------------------------------------------------- + +const LOCATOR_FIELDS = { + "code-symbol": ["repository_ref", "path", "symbol", "blob_sha"], + "repository-file": ["repository_ref", "path", "blob_sha"], + "test": ["repository_ref", "path", "blob_sha"], + "commit": ["sha"], + "doc-section": ["repository_ref", "path", "heading_path", "text_sha256"], + "contract-clause": ["repository_ref", "path", "heading_path", "text_sha256"], + "decision": ["repository_ref", "path", "heading_path", "text_sha256"], + "concern": ["repository_ref", "ledger_path", "entry_hash"], + "obligation": ["repository_ref", "ledger_path", "entry_hash"], + "check-contract": ["repository_ref", "path", "contract_id", "blob_sha"], + "run-evidence": ["repository_ref", "path", "summary_sha256"] +}; + +export function validateLocator(kind, locator, { repositoryRef } = {}) { + if (!NODE_KINDS.has(kind)) throw new TypeError(`validateLocator: unknown kind ${JSON.stringify(kind)}`); + const fields = LOCATOR_FIELDS[kind]; + requireExactKeys(locator, fields, `locator[${kind}]`); + + if (kind === "commit") { + requireHex(locator.sha, HEX40, "commit.sha"); + return; + } + + requireRepositoryRef(locator.repository_ref, repositoryRef, `${kind}.repository_ref`); + + switch (kind) { + case "code-symbol": + requirePath(locator.path, "code-symbol.path"); + if (typeof locator.symbol !== "string" || !IDENTIFIER.test(locator.symbol)) { + throw new TypeError(`code-symbol.symbol: not a JavaScript identifier: ${JSON.stringify(locator.symbol)}`); + } + requireHex(locator.blob_sha, HEX40, "code-symbol.blob_sha"); + break; + case "repository-file": + case "test": + requirePath(locator.path, `${kind}.path`); + requireHex(locator.blob_sha, HEX40, `${kind}.blob_sha`); + break; + case "doc-section": + case "contract-clause": + case "decision": + requirePath(locator.path, `${kind}.path`); + requireHeadingPath(locator.heading_path, `${kind}.heading_path`); + requireHex(locator.text_sha256, HEX64, `${kind}.text_sha256`); + break; + case "concern": + case "obligation": + requirePath(locator.ledger_path, `${kind}.ledger_path`); + requireHex(locator.entry_hash, HEX64, `${kind}.entry_hash`); + break; + case "check-contract": + requirePath(locator.path, "check-contract.path"); + if (typeof locator.contract_id !== "string" || locator.contract_id.trim() === "") { + throw new TypeError("check-contract.contract_id: must be a nonblank string"); + } + requireHex(locator.blob_sha, HEX40, "check-contract.blob_sha"); + break; + case "run-evidence": + requirePath(locator.path, "run-evidence.path"); + if (!locator.path.startsWith("docs/runs/")) { + throw new TypeError(`run-evidence.path: must be below docs/runs/, got ${JSON.stringify(locator.path)}`); + } + requireHex(locator.summary_sha256, HEX64, "run-evidence.summary_sha256"); + break; + default: + throw new TypeError(`validateLocator: unhandled kind ${kind}`); + } +} + +// ---- node identity ----------------------------------------------------------- + +export function deriveNodeId({ kind, locator }) { + validateLocator(kind, locator); + return createHash("sha256").update(Buffer.from(canonicalJson({ kind, locator }), "utf8")).digest("hex"); +} + +// ---- source references ------------------------------------------------------- +// git:<40-hex> | file:@<40-hex> | ledger:#<64-hex> + +export function validateSourceRef(sourceRef) { + if (typeof sourceRef !== "string" || sourceRef.length === 0) { + throw new TypeError("validateSourceRef: must be a nonempty string"); + } + if (sourceRef.startsWith("git:")) { + requireHex(sourceRef.slice(4), HEX40, "source_ref git"); + return; + } + if (sourceRef.startsWith("file:")) { + const at = sourceRef.lastIndexOf("@"); + if (at < 0) throw new TypeError("source_ref file: missing '@'"); + requirePath(sourceRef.slice(5, at), "source_ref file path"); + requireHex(sourceRef.slice(at + 1), HEX40, "source_ref file blob_sha"); + return; + } + if (sourceRef.startsWith("ledger:")) { + const hash = sourceRef.lastIndexOf("#"); + if (hash < 0) throw new TypeError("source_ref ledger: missing '#'"); + requirePath(sourceRef.slice(7, hash), "source_ref ledger path"); + requireHex(sourceRef.slice(hash + 1), HEX64, "source_ref ledger entry_hash"); + return; + } + throw new TypeError(`validateSourceRef: unknown scheme in ${JSON.stringify(sourceRef)}`); +} + +// ---- assertor / status coupling --------------------------------------------- + +function requireAssertor(assertedBy) { + if (typeof assertedBy !== "string" || assertedBy.length === 0) { + throw new TypeError("asserted_by: must be a nonempty string"); + } + if (assertedBy !== assertedBy.trim()) throw new TypeError("asserted_by: must be trimmed"); + if (assertedBy.length > 128) throw new TypeError("asserted_by: exceeds 128 characters"); + if (!STABLE_ID.test(assertedBy)) throw new TypeError(`asserted_by: not a stable identifier: ${JSON.stringify(assertedBy)}`); +} + +export function validateAssertionStatus(assertedBy, assertionStatus) { + requireAssertor(assertedBy); + if (!ASSERTION_STATUS.has(assertionStatus)) { + throw new TypeError(`validateAssertionStatus: unknown status ${JSON.stringify(assertionStatus)}`); + } + let required; + if (WEAVER_IDS.has(assertedBy)) required = "deterministic-extraction"; + else if (assertedBy === "human") required = "human-authorized"; + else if (assertedBy.startsWith("model:")) required = "model-proposal"; + else throw new TypeError(`validateAssertionStatus: unrecognized assertor ${JSON.stringify(assertedBy)}`); + if (assertionStatus !== required) { + throw new TypeError(`validateAssertionStatus: ${assertedBy} requires ${required}, got ${assertionStatus}`); + } +} + +// ---- endpoint matrix + edge validation -------------------------------------- + +const ENDPOINTS = { + "introduced-by": [["code-symbol", "commit"], ["repository-file", "commit"]], + "depends-on": [ + ["code-symbol", "code-symbol"], ["code-symbol", "repository-file"], + ["repository-file", "code-symbol"], ["repository-file", "repository-file"] + ], + "verified-by": [["code-symbol", "test"], ["repository-file", "test"]], + "documented-in": [ + ["code-symbol", "doc-section"], ["code-symbol", "contract-clause"], + ["repository-file", "doc-section"], ["repository-file", "contract-clause"] + ], + "motivated-by": [["code-symbol", "concern"]], + "evidenced-by": [["code-symbol", "run-evidence"]], + "discharges": [["code-symbol", "obligation"], ["obligation", "contract-clause"]] + // "supersedes" is handled specially: same-kind endpoints, human/model assertor. +}; + +const EDGE_INPUT_FIELDS = ["edge_kind", "from_node", "to_node", "source_ref", "asserted_by", "assertion_status"]; + +function requireNodeDescriptor(node, repositoryRef, label) { + requireExactKeys(node, ["kind", "locator"], label); + validateLocator(node.kind, node.locator, { repositoryRef }); +} + +export function validateEdgeInput(edgeInput, { repositoryRef } = {}) { + requireExactKeys(edgeInput, EDGE_INPUT_FIELDS, "edgeInput"); + const { edge_kind, from_node, to_node, source_ref, asserted_by, assertion_status } = edgeInput; + if (!EDGE_KINDS.has(edge_kind)) throw new TypeError(`validateEdgeInput: unknown edge_kind ${JSON.stringify(edge_kind)}`); + + requireNodeDescriptor(from_node, repositoryRef, "edgeInput.from_node"); + requireNodeDescriptor(to_node, repositoryRef, "edgeInput.to_node"); + + if (edge_kind === "supersedes") { + if (from_node.kind !== to_node.kind) { + throw new TypeError(`supersedes: endpoints must share a kind (${from_node.kind} -> ${to_node.kind})`); + } + if (!(asserted_by === "human" || asserted_by.startsWith("model:"))) { + throw new TypeError("supersedes: asserted_by must be 'human' or begin with 'model:'"); + } + } else { + const allowed = ENDPOINTS[edge_kind]; + const ok = allowed.some(([f, t]) => f === from_node.kind && t === to_node.kind); + if (!ok) { + throw new TypeError(`validateEdgeInput: ${from_node.kind} -> ${to_node.kind} is not a valid ${edge_kind} endpoint`); + } + } + + validateAssertionStatus(asserted_by, assertion_status); + validateSourceRef(source_ref); +} + +// ---- current-document address key ------------------------------------------- + +export function docAddressKey({ path, heading_path }) { + requirePath(path, "docAddressKey.path"); + requireHeadingPath(heading_path, "docAddressKey.heading_path"); + return canonicalJson({ path, heading_path }); +} + +// ---- repository identity ----------------------------------------------------- +// `git` is an injected runner: git(argsArray) -> stdout string. This keeps the +// module free of a git dependency (the no-shell runner lands in weavers/util.mjs +// at Task 4a) and lets Task 2 prove the contract with both injected units and a +// real-git fixture. + +export class ShallowRepositoryError extends Error { + constructor(message = "repository has shallow history; full history is required") { + super(message); + this.name = "ShallowRepositoryError"; + this.code = "CLOTHO_SHALLOW_REPOSITORY"; + } +} + +export function deriveRepositoryRef(git) { + if (typeof git !== "function") throw new TypeError("deriveRepositoryRef: git runner must be a function"); + const shallow = String(git(["rev-parse", "--is-shallow-repository"])).trim(); + if (shallow !== "false") throw new ShallowRepositoryError(); + const roots = String(git(["rev-list", "--max-parents=0", "HEAD"])) + .trim().split(/\r?\n/).filter((line) => line.length > 0); + if (roots.length !== 1) { + throw new Error(`deriveRepositoryRef: expected exactly one root commit, got ${roots.length}`); + } + if (!HEX40.test(roots[0])) { + throw new Error(`deriveRepositoryRef: malformed root commit ${JSON.stringify(roots[0])}`); + } + return `git-root:${roots[0]}`; +} diff --git a/clotho/scripts/test-registry.mjs b/clotho/scripts/test-registry.mjs index 5e3a8f3..3863f34 100644 --- a/clotho/scripts/test-registry.mjs +++ b/clotho/scripts/test-registry.mjs @@ -1,11 +1,282 @@ #!/usr/bin/env node -// test-registry.mjs — Task 1 scaffold test. -// -// This proves ONLY that the package runs end-to-end under the implementation- -// governance loop (check + test harness execute in a fresh Node process). It -// asserts NOTHING about Clotho knowledge-graph functionality — the node/edge -// model, weavers, queries, scanner, and provenance mechanisms begin at Task 2 -// and are validated by their own tests. A green scaffold means the governance -// chain works, not that the design is sound. - -console.log("clotho scaffold OK"); +// test-registry.mjs — Task 2. Real coverage of clotho/registry.mjs: closed +// registries, canonical identity, locator/source/status/endpoint validation, and +// the deriveRepositoryRef shallow/full-clone contract proven against BOTH +// injected git and a real-git fixture (D18). Plain node:assert/strict, no +// framework; runs in a fresh Node process via test-all.mjs. + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + NODE_KINDS, EDGE_KINDS, ASSERTION_STATUS, WEAVER_IDS, + canonicalJson, deriveNodeId, validateLocator, validateSourceRef, + validateAssertionStatus, validateEdgeInput, docAddressKey, + deriveRepositoryRef, ShallowRepositoryError +} from "../registry.mjs"; + +const HEX40A = "0123456789abcdef0123456789abcdef01234567"; +const HEX40B = "fedcba9876543210fedcba9876543210fedcba98"; +const HEX64A = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const HEX64B = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; +const REPO = "git-root:" + HEX40A; + +// ---- 1. closed-set membership + counts -------------------------------------- +{ + assert.equal(NODE_KINDS.size, 11); + assert.equal(EDGE_KINDS.size, 8); + assert.equal(ASSERTION_STATUS.size, 5); + assert.equal(WEAVER_IDS.size, 5); + for (const k of ["contract-clause", "code-symbol", "repository-file", "test", "commit", + "concern", "obligation", "check-contract", "run-evidence", "doc-section", "decision"]) { + assert.ok(NODE_KINDS.has(k), `NODE_KINDS has ${k}`); + } + for (const k of ["depends-on", "introduced-by", "motivated-by", "verified-by", + "documented-in", "evidenced-by", "discharges", "supersedes"]) { + assert.ok(EDGE_KINDS.has(k), `EDGE_KINDS has ${k}`); + } + for (const s of ["deterministic-extraction", "human-authorized", "model-proposal", "rejected", "superseded"]) { + assert.ok(ASSERTION_STATUS.has(s), `ASSERTION_STATUS has ${s}`); + } + assert.ok(!NODE_KINDS.has("unknown-kind")); + assert.ok(!EDGE_KINDS.has("references")); + assert.ok(!ASSERTION_STATUS.has("approved")); + assert.equal([...NODE_KINDS].length, 11); + assert.equal([...NODE_KINDS.keys()].length, 11); + assert.equal([...NODE_KINDS.values()].length, 11); + assert.equal([...NODE_KINDS.entries()].length, 11); + let counted = 0; + NODE_KINDS.forEach(() => { counted++; }); + assert.equal(counted, 11); +} + +// ---- 2. read-only facades: every mutator throws ----------------------------- +for (const [name, set] of [["NODE_KINDS", NODE_KINDS], ["EDGE_KINDS", EDGE_KINDS], ["ASSERTION_STATUS", ASSERTION_STATUS]]) { + assert.throws(() => set.add("x"), /read-only/, `${name}.add throws`); + assert.throws(() => set.delete("code-symbol"), /read-only/, `${name}.delete throws`); + assert.throws(() => set.clear(), /read-only/, `${name}.clear throws`); +} + +// ---- 3. canonicalJson ------------------------------------------------------- +{ + assert.equal(canonicalJson({ b: 1, a: 2 }), canonicalJson({ a: 2, b: 1 })); + assert.equal(canonicalJson({ b: 1, a: 2 }), '{"a":2,"b":1}'); + assert.notEqual(canonicalJson([1, 2]), canonicalJson([2, 1])); + const v = { kind: "x", locator: { path: "a/b", n: [1, 2, 3] } }; + assert.equal(canonicalJson(v), canonicalJson(v)); + assert.throws(() => canonicalJson(undefined), /undefined/); + assert.throws(() => canonicalJson(NaN), /non-finite/); + assert.throws(() => canonicalJson(Infinity), /non-finite/); + assert.throws(() => canonicalJson(10n), /bigint/); + assert.throws(() => canonicalJson(() => 1), /function/); + assert.throws(() => canonicalJson(Symbol("s")), /symbol/); + const sparse = [1]; sparse[2] = 3; + assert.throws(() => canonicalJson(sparse), /sparse/); + const cyclic = {}; cyclic.self = cyclic; + assert.throws(() => canonicalJson(cyclic), /cycle/); + assert.throws(() => canonicalJson(Object.create({ inherited: 1 })), /non-plain/); +} + +// ---- 4. locators: valid instances for every kind ---------------------------- +const LOCATORS = { + "code-symbol": { repository_ref: REPO, path: "clotho/registry.mjs", symbol: "deriveNodeId", blob_sha: HEX40A }, + "repository-file": { repository_ref: REPO, path: "clotho/registry.mjs", blob_sha: HEX40A }, + "test": { repository_ref: REPO, path: "clotho/scripts/test-registry.mjs", blob_sha: HEX40A }, + "commit": { sha: HEX40B }, + "doc-section": { repository_ref: REPO, path: "docs/x.md", heading_path: ["Title", "Section"], text_sha256: HEX64A }, + "contract-clause": { repository_ref: REPO, path: "contracts/x.md", heading_path: ["A"], text_sha256: HEX64A }, + "decision": { repository_ref: REPO, path: "docs/decisions.md", heading_path: ["D1"], text_sha256: HEX64A }, + "concern": { repository_ref: REPO, ledger_path: "docs/runs/x/ledger.jsonl", entry_hash: HEX64A }, + "obligation": { repository_ref: REPO, ledger_path: "docs/runs/x/ledger.jsonl", entry_hash: HEX64B }, + "check-contract": { repository_ref: REPO, path: "contracts/check.md", contract_id: "gate-1", blob_sha: HEX40A }, + "run-evidence": { repository_ref: REPO, path: "docs/runs/clotho-self-weave", summary_sha256: HEX64A } +}; +for (const [kind, loc] of Object.entries(LOCATORS)) { + validateLocator(kind, loc, { repositoryRef: REPO }); +} +assert.throws(() => validateLocator("commit", { sha: HEX40B, repository_ref: REPO }), /expected keys|got/); +assert.throws(() => validateLocator("mystery", {}), /unknown kind/); + +// ---- 5. locator rejections -------------------------------------------------- +{ + assert.throws(() => validateLocator("repository-file", { repository_ref: REPO, path: "a" }, { repositoryRef: REPO }), /missing|expected keys/); + assert.throws(() => validateLocator("repository-file", { repository_ref: REPO, path: "a", blob_sha: HEX40A, woven_at: "x" }, { repositoryRef: REPO }), /expected keys|got/); + const proto = { blob_sha: HEX40A }; + const inherited = Object.assign(Object.create(proto), { repository_ref: REPO, path: "a" }); + assert.throws(() => validateLocator("repository-file", inherited, { repositoryRef: REPO }), /plain object|missing|expected/); + assert.throws(() => validateLocator("repository-file", { repository_ref: "git-root:" + HEX40B, path: "a", blob_sha: HEX40A }, { repositoryRef: REPO }), /does not match/); + assert.throws(() => validateLocator("repository-file", { repository_ref: REPO, path: "a", blob_sha: "abc" }, { repositoryRef: REPO }), /40-hex/); + assert.throws(() => validateLocator("repository-file", { repository_ref: REPO, path: "a", blob_sha: HEX40A.toUpperCase() }, { repositoryRef: REPO }), /40-hex/); + for (const bad of ["../etc", "/abs", "a/", "a\\b", "a/./b", "a//b", ""]) { + assert.throws(() => validateLocator("repository-file", { repository_ref: REPO, path: bad, blob_sha: HEX40A }, { repositoryRef: REPO }), /canonical POSIX/, `path ${JSON.stringify(bad)} rejected`); + } + assert.throws(() => validateLocator("code-symbol", { repository_ref: REPO, path: "a", symbol: "9bad", blob_sha: HEX40A }, { repositoryRef: REPO }), /identifier/); + assert.throws(() => validateLocator("doc-section", { repository_ref: REPO, path: "d.md", heading_path: [" spaced "], text_sha256: HEX64A }, { repositoryRef: REPO }), /normalized/); + assert.throws(() => validateLocator("doc-section", { repository_ref: REPO, path: "d.md", heading_path: [], text_sha256: HEX64A }, { repositoryRef: REPO }), /nonempty array/); + assert.throws(() => validateLocator("run-evidence", { repository_ref: REPO, path: "elsewhere/x", summary_sha256: HEX64A }, { repositoryRef: REPO }), /docs\/runs/); + assert.throws(() => validateLocator("concern", { repository_ref: REPO, ledger_path: "docs/runs/x/l.jsonl", entry_hash: HEX40A }, { repositoryRef: REPO }), /64-hex/); +} + +// ---- 6. deriveNodeId: stability + content-bound distinctness ----------------- +{ + const a = deriveNodeId({ kind: "code-symbol", locator: LOCATORS["code-symbol"] }); + const a2 = deriveNodeId({ kind: "code-symbol", locator: { ...LOCATORS["code-symbol"] } }); + assert.equal(a, a2, "same descriptor -> same id"); + assert.match(a, /^[0-9a-f]{64}$/); + const b = deriveNodeId({ kind: "code-symbol", locator: { ...LOCATORS["code-symbol"], blob_sha: HEX40B } }); + assert.notEqual(a, b, "distinct blob_sha -> distinct node id"); + assert.throws(() => deriveNodeId({ kind: "code-symbol", locator: { path: "a" } }), /expected keys|missing/); +} + +// ---- 7. source refs --------------------------------------------------------- +{ + validateSourceRef("git:" + HEX40A); + validateSourceRef("file:clotho/registry.mjs@" + HEX40A); + validateSourceRef("ledger:docs/runs/x/l.jsonl#" + HEX64A); + assert.throws(() => validateSourceRef("http:" + HEX40A), /unknown scheme/); + assert.throws(() => validateSourceRef("git:" + HEX40A.toUpperCase()), /40-hex/); + assert.throws(() => validateSourceRef("file:clotho/x.mjs"), /missing '@/); + assert.throws(() => validateSourceRef("ledger:docs/runs/x/l.jsonl"), /missing '#/); + assert.throws(() => validateSourceRef("file:/abs@" + HEX40A), /canonical POSIX/); + assert.throws(() => validateSourceRef(""), /nonempty/); +} + +// ---- 8. status/assertor coupling -------------------------------------------- +{ + validateAssertionStatus("clotho-git-weaver", "deterministic-extraction"); + validateAssertionStatus("human", "human-authorized"); + validateAssertionStatus("model:claude-fable-5", "model-proposal"); + assert.throws(() => validateAssertionStatus("clotho-git-weaver", "human-authorized"), /requires deterministic-extraction/); + assert.throws(() => validateAssertionStatus("human", "deterministic-extraction"), /requires human-authorized/); + assert.throws(() => validateAssertionStatus("model:x", "deterministic-extraction"), /requires model-proposal/); + assert.throws(() => validateAssertionStatus("someone-else", "deterministic-extraction"), /unrecognized assertor/); + assert.throws(() => validateAssertionStatus("human", "approved"), /unknown status/); + assert.throws(() => validateAssertionStatus(" human", "human-authorized"), /trimmed/); + assert.throws(() => validateAssertionStatus("a".repeat(129), "deterministic-extraction"), /128/); + assert.throws(() => validateAssertionStatus("bad id", "human-authorized"), /stable identifier/); +} + +// ---- 9. edge endpoints ------------------------------------------------------ +const cs = { kind: "code-symbol", locator: LOCATORS["code-symbol"] }; +const rf = { kind: "repository-file", locator: LOCATORS["repository-file"] }; +const tst = { kind: "test", locator: LOCATORS["test"] }; +const commit = { kind: "commit", locator: LOCATORS["commit"] }; +const docSec = { kind: "doc-section", locator: LOCATORS["doc-section"] }; +const clause = { kind: "contract-clause", locator: LOCATORS["contract-clause"] }; +const concern = { kind: "concern", locator: LOCATORS["concern"] }; +const obligation = { kind: "obligation", locator: LOCATORS["obligation"] }; +const runEv = { kind: "run-evidence", locator: LOCATORS["run-evidence"] }; + +function edge(edge_kind, from, to, asserted_by, assertion_status, source_ref) { + return { edge_kind, from_node: from, to_node: to, asserted_by, assertion_status, source_ref }; +} +const W = "clotho-code-weaver"; +const DET = "deterministic-extraction"; +const SR = "git:" + HEX40A; + +validateEdgeInput(edge("introduced-by", cs, commit, "clotho-git-weaver", DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("introduced-by", rf, commit, "clotho-git-weaver", DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("depends-on", cs, cs, W, DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("depends-on", cs, rf, W, DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("depends-on", rf, cs, W, DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("depends-on", rf, rf, W, DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("verified-by", cs, tst, "clotho-test-weaver", DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("documented-in", cs, docSec, "clotho-doc-weaver", DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("documented-in", rf, clause, "clotho-doc-weaver", DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("motivated-by", cs, concern, "clotho-ledger-weaver", DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("evidenced-by", cs, runEv, "clotho-ledger-weaver", DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("discharges", cs, obligation, "clotho-ledger-weaver", DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("discharges", obligation, clause, "clotho-ledger-weaver", DET, SR), { repositoryRef: REPO }); + +assert.throws(() => validateEdgeInput(edge("depends-on", cs, tst, W, DET, SR), { repositoryRef: REPO }), /valid depends-on endpoint/); +assert.throws(() => validateEdgeInput(edge("discharges", obligation, cs, "clotho-ledger-weaver", DET, SR), { repositoryRef: REPO }), /valid discharges endpoint/); +assert.throws(() => validateEdgeInput(edge("introduced-by", cs, cs, "clotho-git-weaver", DET, SR), { repositoryRef: REPO }), /valid introduced-by endpoint/); +assert.throws(() => validateEdgeInput(edge("motivated-by", rf, concern, W, DET, SR), { repositoryRef: REPO }), /valid motivated-by endpoint/); +assert.throws(() => validateEdgeInput(edge("references", cs, cs, W, DET, SR), { repositoryRef: REPO }), /unknown edge_kind/); +assert.throws(() => validateEdgeInput({ ...edge("depends-on", cs, cs, W, DET, SR), woven_at: "t" }, { repositoryRef: REPO }), /expected keys|got/); +assert.throws(() => validateEdgeInput(edge("depends-on", cs, cs, W, "human-authorized", SR), { repositoryRef: REPO }), /requires deterministic-extraction/); + +// ---- 10. supersedes provenance --------------------------------------------- +{ + const oldRf = { kind: "repository-file", locator: { repository_ref: REPO, path: "old/name.mjs", blob_sha: HEX40A } }; + const newRf = { kind: "repository-file", locator: { repository_ref: REPO, path: "new/name.mjs", blob_sha: HEX40B } }; + validateEdgeInput(edge("supersedes", oldRf, newRf, "human", "human-authorized", SR), { repositoryRef: REPO }); + const csV1 = { kind: "code-symbol", locator: { ...LOCATORS["code-symbol"], blob_sha: HEX40A } }; + const csV2 = { kind: "code-symbol", locator: { ...LOCATORS["code-symbol"], blob_sha: HEX40B } }; + validateEdgeInput(edge("supersedes", csV1, csV2, "model:codex", "model-proposal", SR), { repositoryRef: REPO }); + assert.throws(() => validateEdgeInput(edge("supersedes", oldRf, cs, "human", "human-authorized", SR), { repositoryRef: REPO }), /share a kind/); + assert.throws(() => validateEdgeInput(edge("supersedes", csV1, csV2, "clotho-code-weaver", DET, SR), { repositoryRef: REPO }), /begin with 'model:'/); +} + +// ---- 11. docAddressKey ------------------------------------------------------ +{ + const k = docAddressKey({ path: "docs/x.md", heading_path: ["Title", "Section"] }); + assert.equal(k, canonicalJson({ path: "docs/x.md", heading_path: ["Title", "Section"] })); + assert.equal(docAddressKey({ heading_path: ["A"], path: "d.md" }), docAddressKey({ path: "d.md", heading_path: ["A"] })); + assert.throws(() => docAddressKey({ path: "/abs", heading_path: ["A"] }), /canonical POSIX/); +} + +// ---- 12. deriveRepositoryRef: injected units -------------------------------- +{ + const root = HEX40B; + const happy = (args) => { + if (args.join(" ") === "rev-parse --is-shallow-repository") return "false\n"; + if (args.join(" ") === "rev-list --max-parents=0 HEAD") return root + "\n"; + throw new Error("unexpected git args: " + args.join(" ")); + }; + assert.equal(deriveRepositoryRef(happy), "git-root:" + root); + + const shallow = (args) => args[0] === "rev-parse" ? "true\n" : ""; + assert.throws(() => deriveRepositoryRef(shallow), ShallowRepositoryError); + + const malformedShallow = (args) => args[0] === "rev-parse" ? "yes\n" : ""; + assert.throws(() => deriveRepositoryRef(malformedShallow), ShallowRepositoryError); + + const multiRoot = (args) => args[0] === "rev-parse" ? "false" : `${HEX40A}\n${HEX40B}\n`; + assert.throws(() => deriveRepositoryRef(multiRoot), /exactly one root/); + + const malformedRoot = (args) => args[0] === "rev-parse" ? "false" : "not-a-sha\n"; + assert.throws(() => deriveRepositoryRef(malformedRoot), /malformed root/); + + assert.throws(() => deriveRepositoryRef("not a function"), /must be a function/); +} + +// ---- 13. deriveRepositoryRef: REAL-git shallow/full-clone fixture (D18) ------ +{ + const work = mkdtempSync(path.join(tmpdir(), "clotho-reg-git-")); + try { + const origin = path.join(work, "origin"); + const runIn = (dir) => (args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + execFileSync("git", ["init", "-q", origin], { stdio: "ignore" }); + const git = runIn(origin); + git(["config", "user.email", "fixture@example.com"]); + git(["config", "user.name", "Fixture"]); + git(["config", "commit.gpgsign", "false"]); + writeFileSync(path.join(origin, "a.txt"), "one\n"); + git(["add", "a.txt"]); + git(["commit", "-q", "-m", "first"]); + writeFileSync(path.join(origin, "b.txt"), "two\n"); + git(["add", "b.txt"]); + git(["commit", "-q", "-m", "second"]); + const originRoot = git(["rev-list", "--max-parents=0", "HEAD"]).trim(); + assert.match(originRoot, /^[0-9a-f]{40}$/); + + const originUrl = pathToFileURL(origin).href; + + const shallowDir = path.join(work, "shallow"); + execFileSync("git", ["clone", "-q", "--depth", "1", originUrl, shallowDir], { stdio: "ignore" }); + assert.throws(() => deriveRepositoryRef(runIn(shallowDir)), ShallowRepositoryError, "shallow clone rejected"); + + const fullDir = path.join(work, "full"); + execFileSync("git", ["clone", "-q", originUrl, fullDir], { stdio: "ignore" }); + assert.equal(deriveRepositoryRef(runIn(fullDir)), "git-root:" + originRoot, "full clone resolves origin root"); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +console.log("test-registry: all assertions passed"); From 3e64e528a7f1708702762320f49d62fad046b69b Mon Sep 17 00:00:00 2001 From: dsmcewan Date: Thu, 16 Jul 2026 13:58:56 -0400 Subject: [PATCH 2/3] Clotho Task 2 (revision): address required-seat review blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies The Eye's two rulings + the genuine fixes from the authz-005-constituency review of PR #113: Ruling A — edge inputs carry explicit node ids: validateEdgeInput now accepts {edge_kind, from_node, to_node, from_locator, to_locator, source_ref, asserted_by, assertion_status}. Both locators validated as exact {kind,locator}; both stated ids validated as 64-hex and compared against deriveNodeId(locator); a mismatch is rejected (never silently replaced); the endpoint matrix uses the locator kinds. Ruling B — real-git fixture driven through a PRIVATE, fixture-only, no-shell git allowlist in the test (init/config/add/commit/rev-list/rev-parse/clone shapes only; every other shape rejected). Not exported; not production; the Task 4a weaver-facing wrapper stays out of Task 2. Genuine fixes: forEach passes the facade (not the private Set) as the 3rd arg (+ mutation-boundary regression); WEAVER_IDS and ShallowRepositoryError removed from the public exports; exact outer schemas on deriveNodeId and docAddressKey; nonempty model: suffix required; deriveRepositoryRef accepts only the exact git-output forms (at most one terminal newline; malformed != shallow); per-kind missing repository_ref/content-hash + short/uppercase-hash tests added. npm test green; diff confined to clotho/; zero deps. Expected revision, not a release candidate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X8pTab2QMpfM2KsZmwkeYg --- clotho/registry.mjs | 132 ++++++++++++--------- clotho/scripts/test-registry.mjs | 189 ++++++++++++++++++++++--------- 2 files changed, 217 insertions(+), 104 deletions(-) diff --git a/clotho/registry.mjs b/clotho/registry.mjs index 8a0325f..20e9bf6 100644 --- a/clotho/registry.mjs +++ b/clotho/registry.mjs @@ -10,7 +10,9 @@ import { createHash } from "node:crypto"; // ---- closed registries (read-only Set facades over private native Sets) ------ // Not `Object.freeze(new Set(...))`: a frozen Set still mutates via add/delete. -// The facade backs a private Set and makes every mutator throw. +// The facade backs a private Set and makes every mutator throw. forEach passes +// the FACADE (never the private set) as its third argument, so a callback cannot +// reach the backing set to mutate it. function readonlySet(members) { const set = new Set(members); @@ -22,7 +24,7 @@ function readonlySet(members) { keys: () => set.keys(), values: () => set.values(), entries: () => set.entries(), - forEach: (fn, thisArg) => set.forEach(fn, thisArg), + forEach: (fn, thisArg) => set.forEach((value) => { fn.call(thisArg, value, value, facade); }), add: deny("add"), delete: deny("delete"), clear: deny("clear"), @@ -48,7 +50,8 @@ export const ASSERTION_STATUS = readonlySet([ ]); // The five deterministic weaver ids (asserted_by => deterministic-extraction). -export const WEAVER_IDS = readonlySet([ +// Module-private: not part of the frozen Task 2 public interface. +const WEAVER_IDS = new Set([ "clotho-git-weaver", "clotho-code-weaver", "clotho-test-weaver", "clotho-doc-weaver", "clotho-ledger-weaver" ]); @@ -113,17 +116,16 @@ function isPlainObject(value) { return proto === Object.prototype || proto === null; } -// Exactly the expected own keys, no missing, no extra, no inherited enumerable. +// Exactly the expected own keys — no missing, no extra, no inherited enumerable. +// The own-key count check plus per-field hasOwnProperty rejects both extras and +// any enumerable field inherited from a polluted prototype. function requireExactKeys(obj, expected, label) { if (!isPlainObject(obj)) throw new TypeError(`${label}: expected a plain object`); - const keys = Object.keys(obj); - if (keys.length !== expected.length) { - throw new TypeError(`${label}: expected keys [${[...expected].sort().join(", ")}], got [${[...keys].sort().join(", ")}]`); + for (const k of Object.keys(obj)) { + if (!expected.includes(k)) throw new TypeError(`${label}: unexpected field '${k}'`); } for (const k of expected) { - if (!Object.prototype.hasOwnProperty.call(obj, k)) { - throw new TypeError(`${label}: missing field '${k}'`); - } + if (!Object.prototype.hasOwnProperty.call(obj, k)) throw new TypeError(`${label}: missing field '${k}'`); } } @@ -168,6 +170,10 @@ function requireRepositoryRef(value, repositoryRef, label) { } } +function isModelAssertor(assertedBy) { + return typeof assertedBy === "string" && assertedBy.startsWith("model:") && assertedBy.length > "model:".length; +} + // ---- locator schemas --------------------------------------------------------- const LOCATOR_FIELDS = { @@ -242,9 +248,12 @@ export function validateLocator(kind, locator, { repositoryRef } = {}) { // ---- node identity ----------------------------------------------------------- -export function deriveNodeId({ kind, locator }) { - validateLocator(kind, locator); - return createHash("sha256").update(Buffer.from(canonicalJson({ kind, locator }), "utf8")).digest("hex"); +export function deriveNodeId(descriptor) { + requireExactKeys(descriptor, ["kind", "locator"], "deriveNodeId"); + validateLocator(descriptor.kind, descriptor.locator); + return createHash("sha256") + .update(Buffer.from(canonicalJson({ kind: descriptor.kind, locator: descriptor.locator }), "utf8")) + .digest("hex"); } // ---- source references ------------------------------------------------------- @@ -278,9 +287,7 @@ export function validateSourceRef(sourceRef) { // ---- assertor / status coupling --------------------------------------------- function requireAssertor(assertedBy) { - if (typeof assertedBy !== "string" || assertedBy.length === 0) { - throw new TypeError("asserted_by: must be a nonempty string"); - } + if (typeof assertedBy !== "string" || assertedBy.length === 0) throw new TypeError("asserted_by: must be a nonempty string"); if (assertedBy !== assertedBy.trim()) throw new TypeError("asserted_by: must be trimmed"); if (assertedBy.length > 128) throw new TypeError("asserted_by: exceeds 128 characters"); if (!STABLE_ID.test(assertedBy)) throw new TypeError(`asserted_by: not a stable identifier: ${JSON.stringify(assertedBy)}`); @@ -294,7 +301,7 @@ export function validateAssertionStatus(assertedBy, assertionStatus) { let required; if (WEAVER_IDS.has(assertedBy)) required = "deterministic-extraction"; else if (assertedBy === "human") required = "human-authorized"; - else if (assertedBy.startsWith("model:")) required = "model-proposal"; + else if (isModelAssertor(assertedBy)) required = "model-proposal"; else throw new TypeError(`validateAssertionStatus: unrecognized assertor ${JSON.stringify(assertedBy)}`); if (assertionStatus !== required) { throw new TypeError(`validateAssertionStatus: ${assertedBy} requires ${required}, got ${assertionStatus}`); @@ -317,36 +324,48 @@ const ENDPOINTS = { "motivated-by": [["code-symbol", "concern"]], "evidenced-by": [["code-symbol", "run-evidence"]], "discharges": [["code-symbol", "obligation"], ["obligation", "contract-clause"]] - // "supersedes" is handled specially: same-kind endpoints, human/model assertor. + // "supersedes" handled specially: same-kind endpoints, human/model assertor. }; -const EDGE_INPUT_FIELDS = ["edge_kind", "from_node", "to_node", "source_ref", "asserted_by", "assertion_status"]; - -function requireNodeDescriptor(node, repositoryRef, label) { - requireExactKeys(node, ["kind", "locator"], label); - validateLocator(node.kind, node.locator, { repositoryRef }); -} +// An edgeInput carries the signed-edge payload fields except woven_at: the two +// stated node ids (from_node/to_node), the two locator descriptors +// (from_locator/to_locator), edge_kind, source_ref, and the assertor/status. +const EDGE_INPUT_FIELDS = ["edge_kind", "from_node", "to_node", "from_locator", "to_locator", "source_ref", "asserted_by", "assertion_status"]; export function validateEdgeInput(edgeInput, { repositoryRef } = {}) { requireExactKeys(edgeInput, EDGE_INPUT_FIELDS, "edgeInput"); - const { edge_kind, from_node, to_node, source_ref, asserted_by, assertion_status } = edgeInput; + const { edge_kind, from_node, to_node, from_locator, to_locator, source_ref, asserted_by, assertion_status } = edgeInput; if (!EDGE_KINDS.has(edge_kind)) throw new TypeError(`validateEdgeInput: unknown edge_kind ${JSON.stringify(edge_kind)}`); - requireNodeDescriptor(from_node, repositoryRef, "edgeInput.from_node"); - requireNodeDescriptor(to_node, repositoryRef, "edgeInput.to_node"); - + // 1. locator descriptors validated as exact {kind, locator} + requireExactKeys(from_locator, ["kind", "locator"], "edgeInput.from_locator"); + requireExactKeys(to_locator, ["kind", "locator"], "edgeInput.to_locator"); + validateLocator(from_locator.kind, from_locator.locator, { repositoryRef }); + validateLocator(to_locator.kind, to_locator.locator, { repositoryRef }); + + // 2. stated ids are lowercase 64-hex, and 3-4. must equal the derived ids + // (mismatch is exactly what the validator must detect — never silently + // replaced with the derived value). + requireHex(from_node, HEX64, "edgeInput.from_node"); + requireHex(to_node, HEX64, "edgeInput.to_node"); + const fromDerived = deriveNodeId(from_locator); + const toDerived = deriveNodeId(to_locator); + if (from_node !== fromDerived) throw new TypeError(`edgeInput.from_node ${from_node} does not match derived ${fromDerived}`); + if (to_node !== toDerived) throw new TypeError(`edgeInput.to_node ${to_node} does not match derived ${toDerived}`); + + // 5. endpoint matrix applied using the locator kinds if (edge_kind === "supersedes") { - if (from_node.kind !== to_node.kind) { - throw new TypeError(`supersedes: endpoints must share a kind (${from_node.kind} -> ${to_node.kind})`); + if (from_locator.kind !== to_locator.kind) { + throw new TypeError(`supersedes: endpoints must share a kind (${from_locator.kind} -> ${to_locator.kind})`); } - if (!(asserted_by === "human" || asserted_by.startsWith("model:"))) { - throw new TypeError("supersedes: asserted_by must be 'human' or begin with 'model:'"); + if (!(asserted_by === "human" || isModelAssertor(asserted_by))) { + throw new TypeError("supersedes: asserted_by must be 'human' or 'model:'"); } } else { const allowed = ENDPOINTS[edge_kind]; - const ok = allowed.some(([f, t]) => f === from_node.kind && t === to_node.kind); + const ok = allowed.some(([f, t]) => f === from_locator.kind && t === to_locator.kind); if (!ok) { - throw new TypeError(`validateEdgeInput: ${from_node.kind} -> ${to_node.kind} is not a valid ${edge_kind} endpoint`); + throw new TypeError(`validateEdgeInput: ${from_locator.kind} -> ${to_locator.kind} is not a valid ${edge_kind} endpoint`); } } @@ -356,19 +375,24 @@ export function validateEdgeInput(edgeInput, { repositoryRef } = {}) { // ---- current-document address key ------------------------------------------- -export function docAddressKey({ path, heading_path }) { - requirePath(path, "docAddressKey.path"); - requireHeadingPath(heading_path, "docAddressKey.heading_path"); - return canonicalJson({ path, heading_path }); +export function docAddressKey(descriptor) { + requireExactKeys(descriptor, ["path", "heading_path"], "docAddressKey"); + requirePath(descriptor.path, "docAddressKey.path"); + requireHeadingPath(descriptor.heading_path, "docAddressKey.heading_path"); + return canonicalJson({ path: descriptor.path, heading_path: descriptor.heading_path }); } // ---- repository identity ----------------------------------------------------- // `git` is an injected runner: git(argsArray) -> stdout string. This keeps the -// module free of a git dependency (the no-shell runner lands in weavers/util.mjs -// at Task 4a) and lets Task 2 prove the contract with both injected units and a -// real-git fixture. +// module free of a git dependency (the no-shell weaver-facing runner lands at +// Task 4a) and lets Task 2 prove the contract with both injected units and a +// real-git fixture (whose own test-only allowlist lives in the test). +// +// Output is parsed strictly: only the exact expected forms are accepted, with at +// most one terminal line ending. No trimming, blank-line filtering, or extra +// whitespace — malformed output is fatal and distinct from genuine shallowness. -export class ShallowRepositoryError extends Error { +class ShallowRepositoryError extends Error { constructor(message = "repository has shallow history; full history is required") { super(message); this.name = "ShallowRepositoryError"; @@ -376,17 +400,21 @@ export class ShallowRepositoryError extends Error { } } +function stripTerminalNewline(s) { + if (s.endsWith("\r\n")) return s.slice(0, -2); + if (s.endsWith("\n")) return s.slice(0, -1); + return s; +} + export function deriveRepositoryRef(git) { if (typeof git !== "function") throw new TypeError("deriveRepositoryRef: git runner must be a function"); - const shallow = String(git(["rev-parse", "--is-shallow-repository"])).trim(); - if (shallow !== "false") throw new ShallowRepositoryError(); - const roots = String(git(["rev-list", "--max-parents=0", "HEAD"])) - .trim().split(/\r?\n/).filter((line) => line.length > 0); - if (roots.length !== 1) { - throw new Error(`deriveRepositoryRef: expected exactly one root commit, got ${roots.length}`); - } - if (!HEX40.test(roots[0])) { - throw new Error(`deriveRepositoryRef: malformed root commit ${JSON.stringify(roots[0])}`); + const shallow = stripTerminalNewline(String(git(["rev-parse", "--is-shallow-repository"]))); + if (shallow === "true") throw new ShallowRepositoryError(); + if (shallow !== "false") { + throw new Error(`deriveRepositoryRef: malformed is-shallow-repository output ${JSON.stringify(shallow)}`); } - return `git-root:${roots[0]}`; + const root = stripTerminalNewline(String(git(["rev-list", "--max-parents=0", "HEAD"]))); + if (root.includes("\n")) throw new Error("deriveRepositoryRef: expected exactly one root commit"); + if (!HEX40.test(root)) throw new Error(`deriveRepositoryRef: malformed root commit ${JSON.stringify(root)}`); + return `git-root:${root}`; } diff --git a/clotho/scripts/test-registry.mjs b/clotho/scripts/test-registry.mjs index 3863f34..ca06b20 100644 --- a/clotho/scripts/test-registry.mjs +++ b/clotho/scripts/test-registry.mjs @@ -1,9 +1,11 @@ #!/usr/bin/env node // test-registry.mjs — Task 2. Real coverage of clotho/registry.mjs: closed -// registries, canonical identity, locator/source/status/endpoint validation, and -// the deriveRepositoryRef shallow/full-clone contract proven against BOTH -// injected git and a real-git fixture (D18). Plain node:assert/strict, no -// framework; runs in a fresh Node process via test-all.mjs. +// registries (incl. the forEach mutation-boundary), canonical identity, per-kind +// locator/source/status/endpoint validation, explicit edge id-vs-locator checks, +// and the deriveRepositoryRef shallow/full-clone contract proven against BOTH +// injected git and a real-git fixture driven through a PRIVATE, fixture-only, +// no-shell git allowlist (D18). Plain node:assert/strict; runs in a fresh Node +// process via test-all.mjs. import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; @@ -13,10 +15,9 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { - NODE_KINDS, EDGE_KINDS, ASSERTION_STATUS, WEAVER_IDS, + NODE_KINDS, EDGE_KINDS, ASSERTION_STATUS, canonicalJson, deriveNodeId, validateLocator, validateSourceRef, - validateAssertionStatus, validateEdgeInput, docAddressKey, - deriveRepositoryRef, ShallowRepositoryError + validateAssertionStatus, validateEdgeInput, docAddressKey, deriveRepositoryRef } from "../registry.mjs"; const HEX40A = "0123456789abcdef0123456789abcdef01234567"; @@ -24,13 +25,13 @@ const HEX40B = "fedcba9876543210fedcba9876543210fedcba98"; const HEX64A = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const HEX64B = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; const REPO = "git-root:" + HEX40A; +const isShallow = (e) => e && e.code === "CLOTHO_SHALLOW_REPOSITORY"; // ---- 1. closed-set membership + counts -------------------------------------- { assert.equal(NODE_KINDS.size, 11); assert.equal(EDGE_KINDS.size, 8); assert.equal(ASSERTION_STATUS.size, 5); - assert.equal(WEAVER_IDS.size, 5); for (const k of ["contract-clause", "code-symbol", "repository-file", "test", "commit", "concern", "obligation", "check-contract", "run-evidence", "doc-section", "decision"]) { assert.ok(NODE_KINDS.has(k), `NODE_KINDS has ${k}`); @@ -54,11 +55,17 @@ const REPO = "git-root:" + HEX40A; assert.equal(counted, 11); } -// ---- 2. read-only facades: every mutator throws ----------------------------- +// ---- 2. read-only facades: mutators throw, forEach can't reach the backing set for (const [name, set] of [["NODE_KINDS", NODE_KINDS], ["EDGE_KINDS", EDGE_KINDS], ["ASSERTION_STATUS", ASSERTION_STATUS]]) { assert.throws(() => set.add("x"), /read-only/, `${name}.add throws`); assert.throws(() => set.delete("code-symbol"), /read-only/, `${name}.delete throws`); assert.throws(() => set.clear(), /read-only/, `${name}.clear throws`); + // forEach's third argument is the read-only facade, NOT the private Set: + // a callback cannot mutate the backing registry through it. + let third; + set.forEach((v, v2, s) => { third = s; assert.equal(v, v2); }); + assert.equal(third, set, `${name}.forEach third arg is the facade`); + assert.throws(() => third.add("x"), /read-only/, `${name}.forEach arg is not mutable`); } // ---- 3. canonicalJson ------------------------------------------------------- @@ -98,30 +105,60 @@ const LOCATORS = { for (const [kind, loc] of Object.entries(LOCATORS)) { validateLocator(kind, loc, { repositoryRef: REPO }); } -assert.throws(() => validateLocator("commit", { sha: HEX40B, repository_ref: REPO }), /expected keys|got/); +assert.throws(() => validateLocator("commit", { sha: HEX40B, repository_ref: REPO }), /unexpected field|expected keys/); assert.throws(() => validateLocator("mystery", {}), /unknown kind/); -// ---- 5. locator rejections -------------------------------------------------- +// ---- 5. locator rejections: per repository-scoped kind ---------------------- +// Every repository-scoped kind must reject a missing repository_ref and a +// missing/short/uppercase content hash (D13 content-binding). +const CONTENT_FIELD = { + "code-symbol": ["blob_sha", "40-hex"], + "repository-file": ["blob_sha", "40-hex"], + "test": ["blob_sha", "40-hex"], + "doc-section": ["text_sha256", "64-hex"], + "contract-clause": ["text_sha256", "64-hex"], + "decision": ["text_sha256", "64-hex"], + "concern": ["entry_hash", "64-hex"], + "obligation": ["entry_hash", "64-hex"], + "check-contract": ["blob_sha", "40-hex"], + "run-evidence": ["summary_sha256", "64-hex"] +}; +for (const [kind, base] of Object.entries(LOCATORS)) { + if (kind === "commit") continue; + const [field, label] = CONTENT_FIELD[kind]; + // missing repository_ref + const noRepo = { ...base }; delete noRepo.repository_ref; + assert.throws(() => validateLocator(kind, noRepo, { repositoryRef: REPO }), /missing field 'repository_ref'|expected keys/, `${kind} missing repository_ref`); + // missing content hash + const noHash = { ...base }; delete noHash[field]; + assert.throws(() => validateLocator(kind, noHash, { repositoryRef: REPO }), new RegExp(`missing field '${field}'|expected keys`), `${kind} missing ${field}`); + // short + uppercase content hash + assert.throws(() => validateLocator(kind, { ...base, [field]: "abc" }, { repositoryRef: REPO }), new RegExp(label), `${kind} short ${field}`); + assert.throws(() => validateLocator(kind, { ...base, [field]: base[field].toUpperCase() }, { repositoryRef: REPO }), new RegExp(label), `${kind} uppercase ${field}`); +} { - assert.throws(() => validateLocator("repository-file", { repository_ref: REPO, path: "a" }, { repositoryRef: REPO }), /missing|expected keys/); - assert.throws(() => validateLocator("repository-file", { repository_ref: REPO, path: "a", blob_sha: HEX40A, woven_at: "x" }, { repositoryRef: REPO }), /expected keys|got/); + // extra / caller-owned field + assert.throws(() => validateLocator("repository-file", { repository_ref: REPO, path: "a", blob_sha: HEX40A, woven_at: "x" }, { repositoryRef: REPO }), /unexpected field/); + // inherited enumerable field is not counted as own -> missing 'blob_sha' const proto = { blob_sha: HEX40A }; const inherited = Object.assign(Object.create(proto), { repository_ref: REPO, path: "a" }); assert.throws(() => validateLocator("repository-file", inherited, { repositoryRef: REPO }), /plain object|missing|expected/); + // wrong repository_ref assert.throws(() => validateLocator("repository-file", { repository_ref: "git-root:" + HEX40B, path: "a", blob_sha: HEX40A }, { repositoryRef: REPO }), /does not match/); - assert.throws(() => validateLocator("repository-file", { repository_ref: REPO, path: "a", blob_sha: "abc" }, { repositoryRef: REPO }), /40-hex/); - assert.throws(() => validateLocator("repository-file", { repository_ref: REPO, path: "a", blob_sha: HEX40A.toUpperCase() }, { repositoryRef: REPO }), /40-hex/); + // path rejections for (const bad of ["../etc", "/abs", "a/", "a\\b", "a/./b", "a//b", ""]) { assert.throws(() => validateLocator("repository-file", { repository_ref: REPO, path: bad, blob_sha: HEX40A }, { repositoryRef: REPO }), /canonical POSIX/, `path ${JSON.stringify(bad)} rejected`); } + // non-identifier symbol assert.throws(() => validateLocator("code-symbol", { repository_ref: REPO, path: "a", symbol: "9bad", blob_sha: HEX40A }, { repositoryRef: REPO }), /identifier/); + // un-normalized / empty heading assert.throws(() => validateLocator("doc-section", { repository_ref: REPO, path: "d.md", heading_path: [" spaced "], text_sha256: HEX64A }, { repositoryRef: REPO }), /normalized/); assert.throws(() => validateLocator("doc-section", { repository_ref: REPO, path: "d.md", heading_path: [], text_sha256: HEX64A }, { repositoryRef: REPO }), /nonempty array/); + // run-evidence outside docs/runs/ assert.throws(() => validateLocator("run-evidence", { repository_ref: REPO, path: "elsewhere/x", summary_sha256: HEX64A }, { repositoryRef: REPO }), /docs\/runs/); - assert.throws(() => validateLocator("concern", { repository_ref: REPO, ledger_path: "docs/runs/x/l.jsonl", entry_hash: HEX40A }, { repositoryRef: REPO }), /64-hex/); } -// ---- 6. deriveNodeId: stability + content-bound distinctness ----------------- +// ---- 6. deriveNodeId: stability, content-bound distinctness, exact outer schema { const a = deriveNodeId({ kind: "code-symbol", locator: LOCATORS["code-symbol"] }); const a2 = deriveNodeId({ kind: "code-symbol", locator: { ...LOCATORS["code-symbol"] } }); @@ -129,7 +166,9 @@ assert.throws(() => validateLocator("mystery", {}), /unknown kind/); assert.match(a, /^[0-9a-f]{64}$/); const b = deriveNodeId({ kind: "code-symbol", locator: { ...LOCATORS["code-symbol"], blob_sha: HEX40B } }); assert.notEqual(a, b, "distinct blob_sha -> distinct node id"); - assert.throws(() => deriveNodeId({ kind: "code-symbol", locator: { path: "a" } }), /expected keys|missing/); + assert.throws(() => deriveNodeId({ kind: "code-symbol", locator: { path: "a" } }), /unexpected field|missing|expected/); + // exact outer schema: extra field on the descriptor is rejected + assert.throws(() => deriveNodeId({ kind: "commit", locator: LOCATORS["commit"], extra: 1 }), /unexpected field/); } // ---- 7. source refs --------------------------------------------------------- @@ -142,6 +181,7 @@ assert.throws(() => validateLocator("mystery", {}), /unknown kind/); assert.throws(() => validateSourceRef("file:clotho/x.mjs"), /missing '@/); assert.throws(() => validateSourceRef("ledger:docs/runs/x/l.jsonl"), /missing '#/); assert.throws(() => validateSourceRef("file:/abs@" + HEX40A), /canonical POSIX/); + assert.throws(() => validateSourceRef("ledger:docs/runs/x/l.jsonl#" + HEX40A), /64-hex/); assert.throws(() => validateSourceRef(""), /nonempty/); } @@ -154,13 +194,15 @@ assert.throws(() => validateLocator("mystery", {}), /unknown kind/); assert.throws(() => validateAssertionStatus("human", "deterministic-extraction"), /requires human-authorized/); assert.throws(() => validateAssertionStatus("model:x", "deterministic-extraction"), /requires model-proposal/); assert.throws(() => validateAssertionStatus("someone-else", "deterministic-extraction"), /unrecognized assertor/); + // degenerate 'model:' with no seat is rejected + assert.throws(() => validateAssertionStatus("model:", "model-proposal"), /unrecognized assertor/); assert.throws(() => validateAssertionStatus("human", "approved"), /unknown status/); assert.throws(() => validateAssertionStatus(" human", "human-authorized"), /trimmed/); assert.throws(() => validateAssertionStatus("a".repeat(129), "deterministic-extraction"), /128/); assert.throws(() => validateAssertionStatus("bad id", "human-authorized"), /stable identifier/); } -// ---- 9. edge endpoints ------------------------------------------------------ +// ---- 9. edges: explicit id + locator, endpoint matrix ----------------------- const cs = { kind: "code-symbol", locator: LOCATORS["code-symbol"] }; const rf = { kind: "repository-file", locator: LOCATORS["repository-file"] }; const tst = { kind: "test", locator: LOCATORS["test"] }; @@ -171,8 +213,15 @@ const concern = { kind: "concern", locator: LOCATORS["concern"] }; const obligation = { kind: "obligation", locator: LOCATORS["obligation"] }; const runEv = { kind: "run-evidence", locator: LOCATORS["run-evidence"] }; -function edge(edge_kind, from, to, asserted_by, assertion_status, source_ref) { - return { edge_kind, from_node: from, to_node: to, asserted_by, assertion_status, source_ref }; +function edge(edge_kind, fromLoc, toLoc, asserted_by, assertion_status, source_ref, overrides = {}) { + return { + edge_kind, + from_node: overrides.from_node ?? deriveNodeId(fromLoc), + to_node: overrides.to_node ?? deriveNodeId(toLoc), + from_locator: fromLoc, + to_locator: toLoc, + source_ref, asserted_by, assertion_status + }; } const W = "clotho-code-weaver"; const DET = "deterministic-extraction"; @@ -192,15 +241,24 @@ validateEdgeInput(edge("evidenced-by", cs, runEv, "clotho-ledger-weaver", DET, S validateEdgeInput(edge("discharges", cs, obligation, "clotho-ledger-weaver", DET, SR), { repositoryRef: REPO }); validateEdgeInput(edge("discharges", obligation, clause, "clotho-ledger-weaver", DET, SR), { repositoryRef: REPO }); +// explicit id-vs-locator: a mismatched stated node id is rejected (not replaced) +assert.throws(() => validateEdgeInput(edge("depends-on", cs, cs, W, DET, SR, { from_node: HEX64B }), { repositoryRef: REPO }), /from_node .* does not match derived/); +assert.throws(() => validateEdgeInput(edge("depends-on", cs, cs, W, DET, SR, { to_node: HEX64B }), { repositoryRef: REPO }), /to_node .* does not match derived/); +// non-64-hex stated id +assert.throws(() => validateEdgeInput(edge("depends-on", cs, cs, W, DET, SR, { from_node: "abc" }), { repositoryRef: REPO }), /64-hex/); + +// forbidden endpoints assert.throws(() => validateEdgeInput(edge("depends-on", cs, tst, W, DET, SR), { repositoryRef: REPO }), /valid depends-on endpoint/); assert.throws(() => validateEdgeInput(edge("discharges", obligation, cs, "clotho-ledger-weaver", DET, SR), { repositoryRef: REPO }), /valid discharges endpoint/); assert.throws(() => validateEdgeInput(edge("introduced-by", cs, cs, "clotho-git-weaver", DET, SR), { repositoryRef: REPO }), /valid introduced-by endpoint/); assert.throws(() => validateEdgeInput(edge("motivated-by", rf, concern, W, DET, SR), { repositoryRef: REPO }), /valid motivated-by endpoint/); +// unknown edge kind + extra caller-owned field assert.throws(() => validateEdgeInput(edge("references", cs, cs, W, DET, SR), { repositoryRef: REPO }), /unknown edge_kind/); -assert.throws(() => validateEdgeInput({ ...edge("depends-on", cs, cs, W, DET, SR), woven_at: "t" }, { repositoryRef: REPO }), /expected keys|got/); +assert.throws(() => validateEdgeInput({ ...edge("depends-on", cs, cs, W, DET, SR), woven_at: "t" }, { repositoryRef: REPO }), /unexpected field/); +// coupling enforced inside edge validation assert.throws(() => validateEdgeInput(edge("depends-on", cs, cs, W, "human-authorized", SR), { repositoryRef: REPO }), /requires deterministic-extraction/); -// ---- 10. supersedes provenance --------------------------------------------- +// ---- 10. supersedes provenance ---------------------------------------------- { const oldRf = { kind: "repository-file", locator: { repository_ref: REPO, path: "old/name.mjs", blob_sha: HEX40A } }; const newRf = { kind: "repository-file", locator: { repository_ref: REPO, path: "new/name.mjs", blob_sha: HEX40B } }; @@ -209,7 +267,7 @@ assert.throws(() => validateEdgeInput(edge("depends-on", cs, cs, W, "human-autho const csV2 = { kind: "code-symbol", locator: { ...LOCATORS["code-symbol"], blob_sha: HEX40B } }; validateEdgeInput(edge("supersedes", csV1, csV2, "model:codex", "model-proposal", SR), { repositoryRef: REPO }); assert.throws(() => validateEdgeInput(edge("supersedes", oldRf, cs, "human", "human-authorized", SR), { repositoryRef: REPO }), /share a kind/); - assert.throws(() => validateEdgeInput(edge("supersedes", csV1, csV2, "clotho-code-weaver", DET, SR), { repositoryRef: REPO }), /begin with 'model:'/); + assert.throws(() => validateEdgeInput(edge("supersedes", csV1, csV2, "clotho-code-weaver", DET, SR), { repositoryRef: REPO }), /'model:'/); } // ---- 11. docAddressKey ------------------------------------------------------ @@ -218,6 +276,8 @@ assert.throws(() => validateEdgeInput(edge("depends-on", cs, cs, W, "human-autho assert.equal(k, canonicalJson({ path: "docs/x.md", heading_path: ["Title", "Section"] })); assert.equal(docAddressKey({ heading_path: ["A"], path: "d.md" }), docAddressKey({ path: "d.md", heading_path: ["A"] })); assert.throws(() => docAddressKey({ path: "/abs", heading_path: ["A"] }), /canonical POSIX/); + // exact outer schema: extra field rejected + assert.throws(() => docAddressKey({ path: "d.md", heading_path: ["A"], extra: 1 }), /unexpected field/); } // ---- 12. deriveRepositoryRef: injected units -------------------------------- @@ -229,51 +289,76 @@ assert.throws(() => validateEdgeInput(edge("depends-on", cs, cs, W, "human-autho throw new Error("unexpected git args: " + args.join(" ")); }; assert.equal(deriveRepositoryRef(happy), "git-root:" + root); + // also accepts no terminal newline + assert.equal(deriveRepositoryRef((a) => a[0] === "rev-parse" ? "false" : root), "git-root:" + root); - const shallow = (args) => args[0] === "rev-parse" ? "true\n" : ""; - assert.throws(() => deriveRepositoryRef(shallow), ShallowRepositoryError); - - const malformedShallow = (args) => args[0] === "rev-parse" ? "yes\n" : ""; - assert.throws(() => deriveRepositoryRef(malformedShallow), ShallowRepositoryError); - - const multiRoot = (args) => args[0] === "rev-parse" ? "false" : `${HEX40A}\n${HEX40B}\n`; - assert.throws(() => deriveRepositoryRef(multiRoot), /exactly one root/); - - const malformedRoot = (args) => args[0] === "rev-parse" ? "false" : "not-a-sha\n"; - assert.throws(() => deriveRepositoryRef(malformedRoot), /malformed root/); - + assert.throws(() => deriveRepositoryRef((a) => a[0] === "rev-parse" ? "true\n" : ""), isShallow, "shallow rejected"); + // malformed is-shallow output is DISTINCT from shallowness + assert.throws(() => deriveRepositoryRef((a) => a[0] === "rev-parse" ? "yes\n" : ""), (e) => !isShallow(e) && /malformed is-shallow/.test(e.message)); + assert.throws(() => deriveRepositoryRef((a) => a[0] === "rev-parse" ? "false " : ""), /malformed is-shallow/, "trailing space is malformed, not trimmed"); + assert.throws(() => deriveRepositoryRef((a) => a[0] === "rev-parse" ? "false" : `${HEX40A}\n${HEX40B}\n`), /exactly one root/); + assert.throws(() => deriveRepositoryRef((a) => a[0] === "rev-parse" ? "false" : "not-a-sha\n"), /malformed root/); assert.throws(() => deriveRepositoryRef("not a function"), /must be a function/); } -// ---- 13. deriveRepositoryRef: REAL-git shallow/full-clone fixture (D18) ------ +// ---- 13. deriveRepositoryRef: REAL git via a PRIVATE fixture-only allowlist -- +// The wrapper the weavers use lands at Task 4a. For this fixture only, a private +// no-shell git runner permits exactly the command shapes needed to init an +// origin, build commits, and clone (shallow + full); every other shape throws. { + const permitted = (args) => { + const c = args[0]; + if (c === "init") return args.length === 3 && args[1] === "-q"; + if (c === "config") return args.length === 3; + if (c === "add") return args.length === 2; + if (c === "commit") return args.length === 4 && args[1] === "-q" && args[2] === "-m"; + if (c === "rev-list") return args.length === 3 && args[1] === "--max-parents=0" && args[2] === "HEAD"; + if (c === "rev-parse") return args.length === 2 && args[1] === "--is-shallow-repository"; + if (c === "clone") { + if (args[1] !== "-q") return false; + if (args.length === 4) return true; // clone -q + return args.length === 6 && args[2] === "--depth" && args[3] === "1"; // clone -q --depth 1 + } + return false; + }; + const fixtureGit = (cwd) => (args) => { + if (!Array.isArray(args) || !permitted(args)) { + throw new Error("fixture git: command not allowlisted: " + JSON.stringify(args)); + } + return execFileSync("git", args, { cwd, shell: false, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + }; + + // the allowlist itself is enforced + assert.throws(() => fixtureGit(tmpdir())(["status"]), /not allowlisted/); + assert.throws(() => fixtureGit(tmpdir())(["rev-parse", "HEAD"]), /not allowlisted/); + const work = mkdtempSync(path.join(tmpdir(), "clotho-reg-git-")); try { const origin = path.join(work, "origin"); - const runIn = (dir) => (args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); - execFileSync("git", ["init", "-q", origin], { stdio: "ignore" }); - const git = runIn(origin); - git(["config", "user.email", "fixture@example.com"]); - git(["config", "user.name", "Fixture"]); - git(["config", "commit.gpgsign", "false"]); + const top = fixtureGit(work); + top(["init", "-q", origin]); + const gorigin = fixtureGit(origin); + gorigin(["config", "user.email", "fixture@example.com"]); + gorigin(["config", "user.name", "Fixture"]); + gorigin(["config", "commit.gpgsign", "false"]); writeFileSync(path.join(origin, "a.txt"), "one\n"); - git(["add", "a.txt"]); - git(["commit", "-q", "-m", "first"]); + gorigin(["add", "a.txt"]); + gorigin(["commit", "-q", "-m", "first"]); writeFileSync(path.join(origin, "b.txt"), "two\n"); - git(["add", "b.txt"]); - git(["commit", "-q", "-m", "second"]); - const originRoot = git(["rev-list", "--max-parents=0", "HEAD"]).trim(); + gorigin(["add", "b.txt"]); + gorigin(["commit", "-q", "-m", "second"]); + const originRoot = gorigin(["rev-list", "--max-parents=0", "HEAD"]).trim(); assert.match(originRoot, /^[0-9a-f]{40}$/); const originUrl = pathToFileURL(origin).href; const shallowDir = path.join(work, "shallow"); - execFileSync("git", ["clone", "-q", "--depth", "1", originUrl, shallowDir], { stdio: "ignore" }); - assert.throws(() => deriveRepositoryRef(runIn(shallowDir)), ShallowRepositoryError, "shallow clone rejected"); + top(["clone", "-q", "--depth", "1", originUrl, shallowDir]); + assert.throws(() => deriveRepositoryRef(fixtureGit(shallowDir)), isShallow, "shallow file:// --depth 1 clone rejected"); const fullDir = path.join(work, "full"); - execFileSync("git", ["clone", "-q", originUrl, fullDir], { stdio: "ignore" }); - assert.equal(deriveRepositoryRef(runIn(fullDir)), "git-root:" + originRoot, "full clone resolves origin root"); + top(["clone", "-q", originUrl, fullDir]); + assert.equal(deriveRepositoryRef(fixtureGit(fullDir)), "git-root:" + originRoot, "full clone resolves origin root"); } finally { rmSync(work, { recursive: true, force: true }); } From 952087094e86fc92162a750bd74742b35ff73c8c Mon Sep 17 00:00:00 2001 From: dsmcewan Date: Thu, 16 Jul 2026 14:11:49 -0400 Subject: [PATCH 3/3] Clotho Task 2 (revision 2): exact-object validation + strict git output Addresses the sole remaining required-seat (codex) blockers on PR #113: - requireExactKeys is now truly exact: rejects own symbol keys, own non-enumerable "required" fields (which would validate but be omitted by canonicalJson -> node-id collision, D13), and enumerable fields inherited via Object.prototype pollution. Regressions added, incl. proof such locators cannot mint a node id. - deriveRepositoryRef no longer String()-coerces the injected runner's output: non-string outputs are malformed (never accepted); the stable shallow error is kept only for the exact "true" form. Injected non-string tests added. - Tests completed: the three missing allowed endpoint rows (verified-by repository-file->test; documented-in code-symbol->contract-clause and repository-file->doc-section), a NUL-in-path rejection, and a table-driven assertion over every assertor x every status in the closed set. npm test green; diff confined to clotho/; zero deps. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X8pTab2QMpfM2KsZmwkeYg --- clotho/registry.mjs | 30 ++++++++++++++++++++--- clotho/scripts/test-registry.mjs | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/clotho/registry.mjs b/clotho/registry.mjs index 20e9bf6..8ef3c67 100644 --- a/clotho/registry.mjs +++ b/clotho/registry.mjs @@ -121,11 +121,28 @@ function isPlainObject(value) { // any enumerable field inherited from a polluted prototype. function requireExactKeys(obj, expected, label) { if (!isPlainObject(obj)) throw new TypeError(`${label}: expected a plain object`); - for (const k of Object.keys(obj)) { + if (Object.getOwnPropertySymbols(obj).length > 0) { + throw new TypeError(`${label}: symbol-keyed fields are not permitted`); + } + // No own field (enumerable OR not) outside the expected set. + for (const k of Object.getOwnPropertyNames(obj)) { if (!expected.includes(k)) throw new TypeError(`${label}: unexpected field '${k}'`); } + // Every expected field must be present as an OWN, ENUMERABLE property — so it + // is exactly the set canonicalJson (own enumerable keys) will encode. A + // non-enumerable "required" field would validate but be omitted from the node + // id, letting distinct content bindings collide (D13). for (const k of expected) { - if (!Object.prototype.hasOwnProperty.call(obj, k)) throw new TypeError(`${label}: missing field '${k}'`); + const desc = Object.getOwnPropertyDescriptor(obj, k); + if (!desc) throw new TypeError(`${label}: missing field '${k}'`); + if (!desc.enumerable) throw new TypeError(`${label}: field '${k}' must be own-enumerable`); + } + // Reject any enumerable property inherited through the prototype chain + // (e.g. Object.prototype pollution). + for (const k in obj) { + if (!Object.prototype.hasOwnProperty.call(obj, k)) { + throw new TypeError(`${label}: inherited enumerable field '${k}' is not permitted`); + } } } @@ -406,14 +423,19 @@ function stripTerminalNewline(s) { return s; } +function requireGitString(value, label) { + if (typeof value !== "string") throw new Error(`deriveRepositoryRef: non-string ${label} output`); + return value; +} + export function deriveRepositoryRef(git) { if (typeof git !== "function") throw new TypeError("deriveRepositoryRef: git runner must be a function"); - const shallow = stripTerminalNewline(String(git(["rev-parse", "--is-shallow-repository"]))); + const shallow = stripTerminalNewline(requireGitString(git(["rev-parse", "--is-shallow-repository"]), "is-shallow-repository")); if (shallow === "true") throw new ShallowRepositoryError(); if (shallow !== "false") { throw new Error(`deriveRepositoryRef: malformed is-shallow-repository output ${JSON.stringify(shallow)}`); } - const root = stripTerminalNewline(String(git(["rev-list", "--max-parents=0", "HEAD"]))); + const root = stripTerminalNewline(requireGitString(git(["rev-list", "--max-parents=0", "HEAD"]), "rev-list")); if (root.includes("\n")) throw new Error("deriveRepositoryRef: expected exactly one root commit"); if (!HEX40.test(root)) throw new Error(`deriveRepositoryRef: malformed root commit ${JSON.stringify(root)}`); return `git-root:${root}`; diff --git a/clotho/scripts/test-registry.mjs b/clotho/scripts/test-registry.mjs index ca06b20..f2a3a64 100644 --- a/clotho/scripts/test-registry.mjs +++ b/clotho/scripts/test-registry.mjs @@ -158,6 +158,29 @@ for (const [kind, base] of Object.entries(LOCATORS)) { assert.throws(() => validateLocator("run-evidence", { repository_ref: REPO, path: "elsewhere/x", summary_sha256: HEX64A }, { repositoryRef: REPO }), /docs\/runs/); } +// ---- 5b. requireExactKeys is truly exact: pollution / non-enumerable / symbol +{ + const base = { repository_ref: REPO, path: "a", blob_sha: HEX40A }; + // inherited enumerable field via Object.prototype pollution is rejected + Object.defineProperty(Object.prototype, "__evil__", { value: 1, enumerable: true, configurable: true }); + try { + assert.throws(() => validateLocator("repository-file", { ...base }, { repositoryRef: REPO }), /inherited enumerable/); + } finally { + delete Object.prototype.__evil__; + } + // a non-enumerable required field cannot pass, and therefore cannot mint a + // colliding node id (canonicalJson would omit it — D13 content binding). + const hidden = { repository_ref: REPO, path: "a" }; + Object.defineProperty(hidden, "blob_sha", { value: HEX40A, enumerable: false }); + assert.throws(() => validateLocator("repository-file", hidden, { repositoryRef: REPO }), /own-enumerable/); + assert.throws(() => deriveNodeId({ kind: "repository-file", locator: hidden }), /own-enumerable/); + // symbol-keyed field rejected + assert.throws(() => validateLocator("repository-file", { ...base, [Symbol("x")]: 1 }, { repositoryRef: REPO }), /symbol/); + // NUL byte in a path is rejected (constructed at runtime; no literal NUL in source) + const nulPath = "a" + String.fromCharCode(0) + "b"; + assert.throws(() => validateLocator("repository-file", { repository_ref: REPO, path: nulPath, blob_sha: HEX40A }, { repositoryRef: REPO }), /canonical POSIX/); +} + // ---- 6. deriveNodeId: stability, content-bound distinctness, exact outer schema { const a = deriveNodeId({ kind: "code-symbol", locator: LOCATORS["code-symbol"] }); @@ -200,6 +223,19 @@ for (const [kind, base] of Object.entries(LOCATORS)) { assert.throws(() => validateAssertionStatus(" human", "human-authorized"), /trimmed/); assert.throws(() => validateAssertionStatus("a".repeat(129), "deterministic-extraction"), /128/); assert.throws(() => validateAssertionStatus("bad id", "human-authorized"), /stable identifier/); + // table-driven: every assertor category x every status in the closed set + const couplings = [ + { by: "clotho-git-weaver", ok: "deterministic-extraction" }, + { by: "human", ok: "human-authorized" }, + { by: "model:codex", ok: "model-proposal" } + ]; + const allStatuses = ["deterministic-extraction", "human-authorized", "model-proposal", "rejected", "superseded"]; + for (const { by, ok } of couplings) { + for (const st of allStatuses) { + if (st === ok) validateAssertionStatus(by, st); + else assert.throws(() => validateAssertionStatus(by, st), /requires/, `${by} x ${st} must be rejected`); + } + } } // ---- 9. edges: explicit id + locator, endpoint matrix ----------------------- @@ -234,7 +270,10 @@ validateEdgeInput(edge("depends-on", cs, rf, W, DET, SR), { repositoryRef: REPO validateEdgeInput(edge("depends-on", rf, cs, W, DET, SR), { repositoryRef: REPO }); validateEdgeInput(edge("depends-on", rf, rf, W, DET, SR), { repositoryRef: REPO }); validateEdgeInput(edge("verified-by", cs, tst, "clotho-test-weaver", DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("verified-by", rf, tst, "clotho-test-weaver", DET, SR), { repositoryRef: REPO }); validateEdgeInput(edge("documented-in", cs, docSec, "clotho-doc-weaver", DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("documented-in", cs, clause, "clotho-doc-weaver", DET, SR), { repositoryRef: REPO }); +validateEdgeInput(edge("documented-in", rf, docSec, "clotho-doc-weaver", DET, SR), { repositoryRef: REPO }); validateEdgeInput(edge("documented-in", rf, clause, "clotho-doc-weaver", DET, SR), { repositoryRef: REPO }); validateEdgeInput(edge("motivated-by", cs, concern, "clotho-ledger-weaver", DET, SR), { repositoryRef: REPO }); validateEdgeInput(edge("evidenced-by", cs, runEv, "clotho-ledger-weaver", DET, SR), { repositoryRef: REPO }); @@ -298,6 +337,9 @@ assert.throws(() => validateEdgeInput(edge("depends-on", cs, cs, W, "human-autho assert.throws(() => deriveRepositoryRef((a) => a[0] === "rev-parse" ? "false " : ""), /malformed is-shallow/, "trailing space is malformed, not trimmed"); assert.throws(() => deriveRepositoryRef((a) => a[0] === "rev-parse" ? "false" : `${HEX40A}\n${HEX40B}\n`), /exactly one root/); assert.throws(() => deriveRepositoryRef((a) => a[0] === "rev-parse" ? "false" : "not-a-sha\n"), /malformed root/); + // non-string runner outputs are malformed, never coerced + assert.throws(() => deriveRepositoryRef((a) => a[0] === "rev-parse" ? false : ""), /non-string is-shallow/); + assert.throws(() => deriveRepositoryRef((a) => a[0] === "rev-parse" ? "false" : {}), /non-string rev-list/); assert.throws(() => deriveRepositoryRef("not a function"), /must be a function/); }