diff --git a/clotho/registry.mjs b/clotho/registry.mjs new file mode 100644 index 0000000..8ef3c67 --- /dev/null +++ b/clotho/registry.mjs @@ -0,0 +1,442 @@ +// 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. 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); + 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((value) => { fn.call(thisArg, value, value, facade); }), + 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). +// 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" +]); + +// ---- 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. +// 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`); + 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) { + 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`); + } + } +} + +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)}`); + } +} + +function isModelAssertor(assertedBy) { + return typeof assertedBy === "string" && assertedBy.startsWith("model:") && assertedBy.length > "model:".length; +} + +// ---- 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(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 ------------------------------------------------------- +// 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 (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}`); + } +} + +// ---- 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" handled specially: same-kind endpoints, human/model assertor. +}; + +// 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, 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)}`); + + // 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_locator.kind !== to_locator.kind) { + throw new TypeError(`supersedes: endpoints must share a kind (${from_locator.kind} -> ${to_locator.kind})`); + } + 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_locator.kind && t === to_locator.kind); + if (!ok) { + throw new TypeError(`validateEdgeInput: ${from_locator.kind} -> ${to_locator.kind} is not a valid ${edge_kind} endpoint`); + } + } + + validateAssertionStatus(asserted_by, assertion_status); + validateSourceRef(source_ref); +} + +// ---- current-document address key ------------------------------------------- + +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 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. + +class ShallowRepositoryError extends Error { + constructor(message = "repository has shallow history; full history is required") { + super(message); + this.name = "ShallowRepositoryError"; + this.code = "CLOTHO_SHALLOW_REPOSITORY"; + } +} + +function stripTerminalNewline(s) { + if (s.endsWith("\r\n")) return s.slice(0, -2); + if (s.endsWith("\n")) return s.slice(0, -1); + 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(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(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 5e3a8f3..f2a3a64 100644 --- a/clotho/scripts/test-registry.mjs +++ b/clotho/scripts/test-registry.mjs @@ -1,11 +1,409 @@ #!/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 (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"; +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, + canonicalJson, deriveNodeId, validateLocator, validateSourceRef, + validateAssertionStatus, validateEdgeInput, docAddressKey, deriveRepositoryRef +} from "../registry.mjs"; + +const HEX40A = "0123456789abcdef0123456789abcdef01234567"; +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); + 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: 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 ------------------------------------------------------- +{ + 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 }), /unexpected field|expected keys/); +assert.throws(() => validateLocator("mystery", {}), /unknown kind/); + +// ---- 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}`); +} +{ + // 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/); + // 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/); +} + +// ---- 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"] }); + 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" } }), /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 --------------------------------------------------------- +{ + 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("ledger:docs/runs/x/l.jsonl#" + HEX40A), /64-hex/); + 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/); + // 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/); + // 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 ----------------------- +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, 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"; +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("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 }); +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 }), /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 ---------------------------------------------- +{ + 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 }), /'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/); + // exact outer schema: extra field rejected + assert.throws(() => docAddressKey({ path: "d.md", heading_path: ["A"], extra: 1 }), /unexpected field/); +} + +// ---- 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); + // also accepts no terminal newline + assert.equal(deriveRepositoryRef((a) => a[0] === "rev-parse" ? "false" : root), "git-root:" + 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/); + // 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/); +} + +// ---- 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 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"); + gorigin(["add", "a.txt"]); + gorigin(["commit", "-q", "-m", "first"]); + writeFileSync(path.join(origin, "b.txt"), "two\n"); + 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"); + 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"); + 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 }); + } +} + +console.log("test-registry: all assertions passed");