@gonrocca/nodd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +350 -0
  3. package/extensions/nodd-agents.test.ts +129 -0
  4. package/extensions/nodd-agents.ts +185 -0
  5. package/extensions/nodd-allow.test.ts +75 -0
  6. package/extensions/nodd-allow.ts +76 -0
  7. package/extensions/nodd-enforcement.test.ts +676 -0
  8. package/extensions/nodd-gates.test.ts +108 -0
  9. package/extensions/nodd-gates.ts +121 -0
  10. package/extensions/nodd-kernel.test.ts +114 -0
  11. package/extensions/nodd-kernel.ts +593 -0
  12. package/extensions/nodd-models.test.ts +174 -0
  13. package/extensions/nodd-models.ts +253 -0
  14. package/extensions/nodd-promote.test.ts +150 -0
  15. package/extensions/nodd-promote.ts +96 -0
  16. package/extensions/nodd-prompt.test.ts +87 -0
  17. package/extensions/nodd-tools.test.ts +211 -0
  18. package/package.json +44 -0
  19. package/src/bash-classifier.test.ts +114 -0
  20. package/src/bash-classifier.ts +69 -0
  21. package/src/change-acceptance.test.ts +175 -0
  22. package/src/change-acceptance.ts +98 -0
  23. package/src/config.test.ts +61 -0
  24. package/src/config.ts +103 -0
  25. package/src/delivery.test.ts +156 -0
  26. package/src/delivery.ts +151 -0
  27. package/src/feature-doc.test.ts +120 -0
  28. package/src/feature-doc.ts +292 -0
  29. package/src/gates/authorize.test.ts +62 -0
  30. package/src/gates/authorize.ts +32 -0
  31. package/src/gates/classify.test.ts +54 -0
  32. package/src/gates/classify.ts +45 -0
  33. package/src/gates/delegate.test.ts +127 -0
  34. package/src/gates/delegate.ts +85 -0
  35. package/src/gates/evidence.test.ts +281 -0
  36. package/src/gates/evidence.ts +209 -0
  37. package/src/gates/policy.test.ts +77 -0
  38. package/src/gates/policy.ts +90 -0
  39. package/src/gates/promotion.test.ts +133 -0
  40. package/src/gates/promotion.ts +81 -0
  41. package/src/gates/registry.ts +21 -0
  42. package/src/gates/request.ts +41 -0
  43. package/src/gates/track.test.ts +80 -0
  44. package/src/gates/track.ts +58 -0
  45. package/src/io.test.ts +81 -0
  46. package/src/io.ts +94 -0
  47. package/src/ledger.test.ts +122 -0
  48. package/src/ledger.ts +133 -0
  49. package/src/manifest.test.ts +53 -0
  50. package/src/manifest.ts +61 -0
  51. package/src/models/assign.test.ts +125 -0
  52. package/src/models/assign.ts +138 -0
  53. package/src/models/picker.test.ts +141 -0
  54. package/src/models/picker.ts +98 -0
  55. package/src/models/profiles.test.ts +186 -0
  56. package/src/models/profiles.ts +162 -0
  57. package/src/models/slots.ts +48 -0
  58. package/src/observations.test.ts +61 -0
  59. package/src/observations.ts +51 -0
  60. package/src/odd-prose.test.ts +125 -0
  61. package/src/odd-prose.ts +198 -0
  62. package/src/outcome.test.ts +75 -0
  63. package/src/outcome.ts +63 -0
  64. package/src/promote.test.ts +129 -0
  65. package/src/promote.ts +64 -0
  66. package/src/prompt.test.ts +193 -0
  67. package/src/prompt.ts +136 -0
  68. package/src/review-candidate.test.ts +118 -0
  69. package/src/review-candidate.ts +81 -0
  70. package/src/state.test.ts +153 -0
  71. package/src/state.ts +163 -0
  72. package/test/package-invariants.test.ts +66 -0
  73. package/test/parity-matrix.test.ts +272 -0
  74. package/test/readme-contract.test.ts +182 -0
@@ -0,0 +1,153 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { observation, type Observation } from "./observations.ts";
7
+ import { emptyCommitted, fold, foldAll } from "./state.ts";
8
+
9
+ let seq = 0;
10
+ function obs(fields: Partial<Observation> & { toolName: string }): Observation {
11
+ return observation({
12
+ toolCallId: fields.toolCallId ?? `call_${++seq}`,
13
+ toolName: fields.toolName,
14
+ input: fields.input ?? {},
15
+ isError: fields.isError ?? false,
16
+ resultText: fields.resultText ?? "",
17
+ at: fields.at ?? "2026-09-19T10:00:00.000Z",
18
+ });
19
+ }
20
+
21
+ test("a fixed sequence folds into an exact snapshot", () => {
22
+ seq = 0;
23
+ const events: Observation[] = [
24
+ obs({ toolName: "read", input: { path: "a.ts" } }),
25
+ obs({ toolName: "read", input: { path: "b.ts" } }),
26
+ obs({ toolName: "read", input: { path: "a.ts" } }),
27
+ obs({ toolName: "grep", input: { pattern: "x" } }),
28
+ obs({ toolName: "ls", input: { path: "." } }),
29
+ obs({ toolName: "write", input: { path: "c.ts" } }),
30
+ obs({ toolName: "edit", input: { path: "c.ts" } }),
31
+ obs({ toolName: "edit", input: { path: "d.ts" } }),
32
+ obs({ toolName: "bash", input: { command: "npm test" }, resultText: "ok" }),
33
+ obs({ toolName: "bash", input: { command: "echo hi > f.txt" } }),
34
+ obs({ toolName: "subagent", input: { agent: "nodd-explore" } }),
35
+ obs({ toolName: "nodd_declare", input: { intent: "change", route: "tracked", slug: "demo" } }),
36
+ ];
37
+
38
+ const committed = foldAll(emptyCommitted(), events);
39
+ assert.deepEqual([...committed.filesRead], ["a.ts", "b.ts"]);
40
+ assert.deepEqual([...committed.filesWritten.keys()], ["c.ts", "d.ts"]);
41
+ assert.equal(committed.delegations, 1);
42
+ assert.equal(committed.toolCalls, 12);
43
+ assert.deepEqual(committed.declaration, {
44
+ intent: "change", route: "tracked", slug: "demo", runner: null, tdd: "off", files: [],
45
+ });
46
+ // Bash records are stored raw. Whether a command mutates is the classifier's
47
+ // judgement (T014) and is derived at gate time, so the ledger never
48
+ // pre-collapses an observation into a verdict.
49
+ assert.deepEqual(committed.commandResults.map((r) => r.command), ["npm test", "echo hi > f.txt"]);
50
+ assert.deepEqual(committed.commandResults[0], {
51
+ toolCallId: "call_9", command: "npm test", isError: false, resultText: "ok",
52
+ at: "2026-09-19T10:00:00.000Z", seq: 9,
53
+ });
54
+ });
55
+
56
+ test("distinct-path counting: one file read six times is one file", () => {
57
+ const events = Array.from({ length: 6 }, () => obs({ toolName: "read", input: { path: "same.ts" } }));
58
+ const committed = foldAll(emptyCommitted(), events);
59
+ assert.equal(committed.filesRead.size, 1);
60
+ assert.equal(committed.toolCalls, 6);
61
+ });
62
+
63
+ test("replay is idempotent by toolCallId", () => {
64
+ const events = [
65
+ obs({ toolCallId: "r1", toolName: "read", input: { path: "a.ts" } }),
66
+ obs({ toolCallId: "w1", toolName: "write", input: { path: "b.ts" } }),
67
+ obs({ toolCallId: "b1", toolName: "bash", input: { command: "rm -rf x" } }),
68
+ ];
69
+ const once = foldAll(emptyCommitted(), events);
70
+ const twice = foldAll(emptyCommitted(), [...events, ...events]);
71
+ assert.deepEqual(twice, once);
72
+ });
73
+
74
+ // Without this, "the evidence ran after the edit" is not computable at all: a
75
+ // Set of paths has no ordering against a command result, which is how a green
76
+ // run predating the edit certified the edit in round 1.
77
+ test("a written file records when it was written, so evidence can be ordered against it", () => {
78
+ const committed = foldAll(emptyCommitted(), [
79
+ obs({ toolName: "write", input: { path: "c.ts" }, at: "2026-09-19T10:00:00.000Z" }),
80
+ obs({ toolName: "edit", input: { path: "d.ts" }, at: "2026-09-19T11:00:00.000Z" }),
81
+ ]);
82
+ assert.deepEqual(committed.filesWritten.get("c.ts"), { at: "2026-09-19T10:00:00.000Z", seq: 1 });
83
+ assert.deepEqual(committed.filesWritten.get("d.ts"), { at: "2026-09-19T11:00:00.000Z", seq: 2 });
84
+ });
85
+
86
+ // Wall clocks are not fine-grained enough to order two tool results that land in
87
+ // the same millisecond, and in a real session a write and the run that follows
88
+ // it routinely do. Observation order is what the kernel actually knows.
89
+ test("observation order, not the clock, decides what came after what", () => {
90
+ const sameInstant = "2026-09-19T10:00:00.000Z";
91
+ const committed = foldAll(emptyCommitted(), [
92
+ obs({ toolName: "bash", input: { command: "npm test" }, at: sameInstant }),
93
+ obs({ toolName: "edit", input: { path: "c.ts" }, at: sameInstant }),
94
+ ]);
95
+ assert.equal(committed.commandResults[0].at, committed.filesWritten.get("c.ts")!.at, "the clock cannot tell them apart");
96
+ assert.ok(
97
+ committed.filesWritten.get("c.ts")!.seq > committed.commandResults[0].seq,
98
+ "but the kernel saw the edit second, and that is what ordering must use",
99
+ );
100
+ });
101
+
102
+ test("rewriting a file moves its timestamp forward: the latest edit is the one evidence must postdate", () => {
103
+ const committed = foldAll(emptyCommitted(), [
104
+ obs({ toolName: "write", input: { path: "c.ts" }, at: "2026-09-19T10:00:00.000Z" }),
105
+ obs({ toolName: "edit", input: { path: "c.ts" }, at: "2026-09-19T12:00:00.000Z" }),
106
+ ]);
107
+ assert.equal(committed.filesWritten.size, 1, "distinct paths, still");
108
+ assert.equal(committed.filesWritten.get("c.ts")!.at, "2026-09-19T12:00:00.000Z");
109
+ });
110
+
111
+ test("a declaration records its runner, its TDD mode and the files it promised to touch", () => {
112
+ const committed = foldAll(emptyCommitted(), [obs({
113
+ toolName: "nodd_declare",
114
+ input: { intent: "change", route: "tracked", slug: "demo", runner: "npm test", tdd: "strict", files: ["a.ts", "b.ts", "a.ts"] },
115
+ })]);
116
+ assert.equal(committed.declaration?.runner, "npm test");
117
+ assert.equal(committed.declaration?.tdd, "strict");
118
+ assert.deepEqual(committed.declaration?.files, ["a.ts", "b.ts"], "distinct paths, as gate-promotion counts them");
119
+ });
120
+
121
+ test("a declaration without a runner records none, and never invents one", () => {
122
+ const committed = foldAll(emptyCommitted(), [obs({
123
+ toolName: "nodd_declare", input: { intent: "change", route: "tracked", slug: "demo" },
124
+ })]);
125
+ assert.equal(committed.declaration?.runner, null);
126
+ assert.equal(committed.declaration?.tdd, "off");
127
+ assert.deepEqual(committed.declaration?.files, []);
128
+ });
129
+
130
+ test("folding is total: an unknown tool advances only the call count", () => {
131
+ const before = emptyCommitted();
132
+ const after = fold(before, obs({ toolName: "some_other_extension_tool", input: { weird: true } }));
133
+ assert.equal(after.toolCalls, 1);
134
+ assert.equal(after.filesRead.size, 0);
135
+ assert.equal(after.filesWritten.size, 0);
136
+ assert.equal(after.commandResults.length, 0);
137
+ });
138
+
139
+ test("a later declaration replaces the earlier one", () => {
140
+ const committed = foldAll(emptyCommitted(), [
141
+ obs({ toolName: "nodd_declare", input: { intent: "read-only", route: "inline", slug: "a" } }),
142
+ obs({ toolName: "nodd_declare", input: { intent: "change", route: "tracked", slug: "b" } }),
143
+ ]);
144
+ assert.deepEqual(committed.declaration, {
145
+ intent: "change", route: "tracked", slug: "b", runner: null, tdd: "off", files: [],
146
+ });
147
+ });
148
+
149
+ test("the reducer imports neither node:fs nor pi", () => {
150
+ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "state.ts"), "utf8");
151
+ assert.ok(!src.includes("node:fs"), "state.ts must not touch the filesystem");
152
+ assert.ok(!src.includes("@earendil-works/"), "state.ts must not import pi");
153
+ });
package/src/state.ts ADDED
@@ -0,0 +1,163 @@
1
+ // The read-only kernel: `Observation[]` folded into committed state.
2
+ //
3
+ // Total, deterministic and idempotent by `toolCallId` — replaying a session on
4
+ // reload must land on the same state, so a re-delivered event may never
5
+ // double-count a file or a tool call. Nothing here reads the filesystem and
6
+ // nothing imports pi: every rule downstream of this reducer is testable without
7
+ // a runtime.
8
+ //
9
+ // Bash commands are recorded raw. Whether one mutates the filesystem is the
10
+ // classifier's judgement (`src/bash-classifier.ts`) and is derived when a gate
11
+ // asks, never baked into the record.
12
+
13
+ import type { Observation, PendingCall } from "./observations.ts";
14
+ import type { Intent, Route } from "./feature-doc.ts";
15
+
16
+ export type CommandResult = {
17
+ toolCallId: string;
18
+ command: string;
19
+ isError: boolean;
20
+ resultText: string;
21
+ at: string;
22
+ /**
23
+ * Position in this session's observation order. `at` is a wall clock and two
24
+ * tool results can share a millisecond, so "did the run come after the edit?"
25
+ * is answered by the order the kernel observed them in — which it knows
26
+ * exactly — rather than by a timestamp's resolution. A rule that is only sound
27
+ * when the clock happens to tick between two events is not a mechanism.
28
+ */
29
+ seq: number;
30
+ };
31
+
32
+ /** When a file was written, and where that write sits in observation order. */
33
+ export type WriteRecord = { at: string; seq: number };
34
+
35
+ export type Declaration = {
36
+ intent: Intent;
37
+ route: Route;
38
+ slug: string;
39
+ /**
40
+ * The verification command this feature is checked with, as declared. Evidence
41
+ * must come from it: without this, any exit-0 string the model chose certifies
42
+ * any task, and `echo 'tests pass'` is a valid receipt.
43
+ */
44
+ runner: string | null;
45
+ tdd: "strict" | "off";
46
+ /** Distinct files the declaration promised to touch. `gate-promotion` reads it. */
47
+ files: string[];
48
+ };
49
+
50
+ export type Committed = {
51
+ seen: Set<string>;
52
+ filesRead: Set<string>;
53
+ /**
54
+ * path -> the most recent write to it. A `Set` made "the evidence ran after
55
+ * the edit" structurally uncomputable, which is how a green run predating an
56
+ * edit certified that edit.
57
+ */
58
+ filesWritten: Map<string, WriteRecord>;
59
+ commandResults: CommandResult[];
60
+ delegations: number;
61
+ toolCalls: number;
62
+ declaration: Declaration | null;
63
+ };
64
+
65
+ /**
66
+ * The whole session state. `committed` is advanced only by `tool_result`;
67
+ * `pending` holds calls preflighted in the current assistant batch. pi does not
68
+ * guarantee a `tool_call` handler sees its siblings' results
69
+ * (`extensions.md:757-758`), so the two halves are different types on purpose:
70
+ * evidence readers take `Committed` and therefore cannot reach a pending call.
71
+ */
72
+ export type NoddState = {
73
+ committed: Committed;
74
+ pending: Map<string, PendingCall>;
75
+ };
76
+
77
+ export function emptyState(): NoddState {
78
+ return { committed: emptyCommitted(), pending: new Map() };
79
+ }
80
+
81
+ export function emptyCommitted(): Committed {
82
+ return {
83
+ seen: new Set(),
84
+ filesRead: new Set(),
85
+ filesWritten: new Map(),
86
+ commandResults: [],
87
+ delegations: 0,
88
+ toolCalls: 0,
89
+ declaration: null,
90
+ };
91
+ }
92
+
93
+ const READ_TOOLS = new Set(["read"]);
94
+ const WRITE_TOOLS = new Set(["write", "edit"]);
95
+
96
+ function str(value: unknown): string | null {
97
+ return typeof value === "string" && value !== "" ? value : null;
98
+ }
99
+
100
+ /** Distinct declared paths. Counted the same way written files are. */
101
+ function declaredFiles(value: unknown): string[] {
102
+ if (!Array.isArray(value)) return [];
103
+ const paths = value.filter((entry): entry is string => typeof entry === "string" && entry !== "");
104
+ return [...new Set(paths)];
105
+ }
106
+
107
+ export function fold(committed: Committed, obs: Observation): Committed {
108
+ if (committed.seen.has(obs.toolCallId)) return committed;
109
+
110
+ const next: Committed = {
111
+ seen: new Set(committed.seen).add(obs.toolCallId),
112
+ filesRead: new Set(committed.filesRead),
113
+ filesWritten: new Map(committed.filesWritten),
114
+ commandResults: [...committed.commandResults],
115
+ delegations: committed.delegations,
116
+ toolCalls: committed.toolCalls + 1,
117
+ declaration: committed.declaration,
118
+ };
119
+
120
+ const path = str(obs.input.path);
121
+ if (READ_TOOLS.has(obs.toolName) && path) next.filesRead.add(path);
122
+ if (WRITE_TOOLS.has(obs.toolName) && path) next.filesWritten.set(path, { at: obs.at, seq: next.toolCalls });
123
+
124
+ if (obs.toolName === "bash") {
125
+ const command = str(obs.input.command);
126
+ if (command) {
127
+ next.commandResults.push({
128
+ toolCallId: obs.toolCallId,
129
+ command,
130
+ isError: obs.isError,
131
+ resultText: obs.resultText,
132
+ at: obs.at,
133
+ seq: next.toolCalls,
134
+ });
135
+ }
136
+ }
137
+
138
+ if (obs.toolName === "subagent") next.delegations += 1;
139
+
140
+ if (obs.toolName === "nodd_declare") {
141
+ const intent = str(obs.input.intent);
142
+ const route = str(obs.input.route);
143
+ const slug = str(obs.input.slug);
144
+ if (intent && route && slug) {
145
+ next.declaration = {
146
+ intent: intent as Intent,
147
+ route: route as Route,
148
+ slug,
149
+ runner: str(obs.input.runner),
150
+ // Anything but the literal `strict` is off. A mode NODD cannot read is
151
+ // not a mode it gets to assume.
152
+ tdd: obs.input.tdd === "strict" ? "strict" : "off",
153
+ files: declaredFiles(obs.input.files),
154
+ };
155
+ }
156
+ }
157
+
158
+ return next;
159
+ }
160
+
161
+ export function foldAll(committed: Committed, observations: Observation[]): Committed {
162
+ return observations.reduce(fold, committed);
163
+ }
@@ -0,0 +1,66 @@
1
+ // T001 — the package manifest is a contract, so it is asserted like one.
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
10
+
11
+ test("package.json is an ESM pi package", () => {
12
+ assert.equal(pkg.type, "module");
13
+ assert.ok(pkg.keywords.includes("pi-package"), "keywords must carry pi-package");
14
+ assert.equal(pkg.scripts.test, "node --test --experimental-strip-types");
15
+ });
16
+
17
+ test("pi.extensions is non-empty and every listed path exists", () => {
18
+ assert.ok(Array.isArray(pkg.pi?.extensions), "pi.extensions must be an array");
19
+ assert.ok(pkg.pi.extensions.length > 0, "pi.extensions must not be empty");
20
+ for (const rel of pkg.pi.extensions) {
21
+ assert.ok(existsSync(join(root, rel)), `missing extension entrypoint: ${rel}`);
22
+ }
23
+ });
24
+
25
+ test("pi is a peer dependency only", () => {
26
+ assert.ok(pkg.peerDependencies?.["@earendil-works/pi-coding-agent"], "pi must be a peerDependency");
27
+ assert.equal(pkg.dependencies, undefined, "the package ships no runtime dependencies");
28
+ });
29
+
30
+ // The scan covers every code and manifest file NODD ships. Prose files are
31
+ // excluded on purpose: README.md must be free to *explain* the rule, and a test
32
+ // that forbade naming the package would make the honest documentation
33
+ // impossible to write.
34
+ const SCANNED_DIRS = ["src", "extensions", "test", "spike"];
35
+
36
+ function codeFiles(): string[] {
37
+ const out: string[] = [join(root, "package.json")];
38
+ const walk = (dir: string) => {
39
+ if (!existsSync(dir)) return;
40
+ for (const entry of readdirSync(dir)) {
41
+ const full = join(dir, entry);
42
+ if (statSync(full).isDirectory()) walk(full);
43
+ else out.push(full);
44
+ }
45
+ };
46
+ for (const dir of SCANNED_DIRS) walk(join(root, dir));
47
+ return out;
48
+ }
49
+
50
+ test("no code file names the TUI package, not even as a type import", () => {
51
+ const forbidden = ["pi", "tui"].join("-");
52
+ for (const file of codeFiles()) {
53
+ const text = readFileSync(file, "utf8");
54
+ assert.ok(!text.includes(`@earendil-works/${forbidden}`), `${file} references the TUI package`);
55
+ }
56
+ });
57
+
58
+ test("no code file imports pi outside extensions/", () => {
59
+ // Assembled at runtime so this assertion does not match its own source.
60
+ const piSpecifier = `@earendil-works/${["pi", "coding", "agent"].join("-")}`;
61
+ for (const file of codeFiles()) {
62
+ if (file.startsWith(join(root, "extensions"))) continue;
63
+ const text = readFileSync(file, "utf8");
64
+ assert.ok(!text.includes(`from "${piSpecifier}"`), `${file} imports pi outside extensions/`);
65
+ }
66
+ });
@@ -0,0 +1,272 @@
1
+ /**
2
+ * The ODD parity matrix, checked against the code.
3
+ *
4
+ * `REQ: odd-parity-matrix` is the contract for what NODD inherits from ODD, and
5
+ * round 1 "verified" it with `assert.ok(README.includes("(M)"))` — which passes
6
+ * on any document containing that substring and proved nothing. Three rows were
7
+ * marked **(M)** with no mechanism behind them, which is the precise sin NODD
8
+ * accuses ODD of: claiming more than the machinery delivers.
9
+ *
10
+ * The rule enforced here: a row may call itself mechanized only if its
11
+ * justification names something that exists in this repository — a `REQ:` with
12
+ * acceptance criteria, a registered gate id, a source file, a tool, or a
13
+ * command. A (P) row must give a reason. Nothing may be unclassified.
14
+ */
15
+ import { test } from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { readFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { GATE_IDS } from "../src/gates/registry.ts";
21
+ import { emptyDoc, parseFeatureDoc, renderFeatureDoc } from "../src/feature-doc.ts";
22
+
23
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
24
+ const requirements = readFileSync(join(root, ".sdd", "nodd", "requirements.md"), "utf8");
25
+
26
+ type Row = { n: number; line: string; clause: string; klass: string; how: string };
27
+
28
+ /** Every table row in the matrix section, which starts at the parity heading. */
29
+ function matrixRows(): Row[] {
30
+ const start = requirements.indexOf("# ODD parity matrix");
31
+ assert.ok(start > 0, "the matrix section must exist");
32
+
33
+ const rows: Row[] = [];
34
+ for (const raw of requirements.slice(start).split("\n")) {
35
+ if (!raw.startsWith("|")) continue;
36
+ // An escaped pipe inside a cell (route unions like `inline|tracked`) is not a
37
+ // column separator. Splitting naively dropped those rows silently, which is
38
+ // exactly how a matrix row could go unchecked.
39
+ const cells = raw
40
+ .replace(/\\\|/g, "\u0000")
41
+ .split("|")
42
+ .slice(1, -1)
43
+ .map((cell) => cell.trim().replace(/\u0000/g, "|"));
44
+ if (cells.length !== 5) continue;
45
+ const n = Number(cells[0]);
46
+ if (!Number.isInteger(n)) continue;
47
+ rows.push({ n, line: cells[1], clause: cells[2], klass: cells[3], how: cells[4] });
48
+ }
49
+ return rows;
50
+ }
51
+
52
+ const rows = matrixRows();
53
+
54
+ test("the matrix is a complete, contiguous, uniquely numbered table", () => {
55
+ assert.ok(rows.length >= 50, `expected the full ODD surface, parsed ${rows.length} rows`);
56
+ const numbers = rows.map((row) => row.n);
57
+ assert.deepEqual(numbers, [...new Set(numbers)], "a duplicated row number hides a clause");
58
+ assert.deepEqual(
59
+ numbers,
60
+ Array.from({ length: rows.length }, (_, i) => i + 1),
61
+ "row numbers must be contiguous from 1: a gap is an unclassified clause",
62
+ );
63
+ });
64
+
65
+ test("every row is classified, and every row cites the ODD line it came from", () => {
66
+ for (const row of rows) {
67
+ assert.match(row.klass, /\*\*(M|P|F)\*\*/, `row ${row.n} is unclassified: ${row.klass}`);
68
+ // One row may answer several ODD lines, or a range of them; each reference
69
+ // still has to be an actual citation.
70
+ for (const ref of row.line.split(",")) {
71
+ assert.match(ref.trim(), /^`:\d+(-\d+)?`$/, `row ${row.n} must cite routing.go lines, got ${row.line}`);
72
+ }
73
+ assert.ok(row.clause.length > 10, `row ${row.n} has no clause text`);
74
+ }
75
+ });
76
+
77
+ /**
78
+ * What counts as naming a mechanism. Each entry is a pattern plus a check that
79
+ * whatever it captured actually exists, so a row cannot satisfy this test by
80
+ * inventing a plausible-looking requirement name or a file that was never
81
+ * written.
82
+ */
83
+ const MECHANISMS: Array<{ label: string; pattern: RegExp; exists: (name: string) => boolean }> = [
84
+ {
85
+ label: "a REQ with acceptance criteria",
86
+ pattern: /`REQ: ([a-z-]+)`/g,
87
+ exists: (name) => requirements.includes(`## REQ: ${name}`),
88
+ },
89
+ {
90
+ label: "a registered gate id",
91
+ pattern: /`gate-([a-z-]+)`/g,
92
+ exists: (name) => (GATE_IDS as readonly string[]).includes(name),
93
+ },
94
+ {
95
+ label: "a source file",
96
+ pattern: /`((?:src|extensions|test)\/[\w./-]+\.ts)`/g,
97
+ exists: (name) => fileExists(name),
98
+ },
99
+ {
100
+ label: "a named exported function",
101
+ pattern: /`([a-z][a-zA-Z]+)\(\)`/g,
102
+ exists: (name) =>
103
+ ["src/feature-doc.ts", "src/gates/evidence.ts", "src/ledger.ts", "extensions/nodd-kernel.ts"]
104
+ .some((file) => sourceOf(file).includes(`function ${name}`)),
105
+ },
106
+ {
107
+ label: "a nodd tool",
108
+ pattern: /`(nodd_[a-z]+)/g,
109
+ exists: (name) => sourceOf("extensions/nodd-kernel.ts").includes(`"${name}"`),
110
+ },
111
+ {
112
+ label: "a slash command",
113
+ pattern: /`(\/nodd-[a-z]+)/g,
114
+ exists: (name) => fileExists(`extensions/${name.slice(1)}.ts`),
115
+ },
116
+ ];
117
+
118
+ const cache = new Map<string, string>();
119
+ function sourceOf(rel: string): string {
120
+ if (!cache.has(rel)) {
121
+ try {
122
+ cache.set(rel, readFileSync(join(root, rel), "utf8"));
123
+ } catch {
124
+ cache.set(rel, "");
125
+ }
126
+ }
127
+ return cache.get(rel)!;
128
+ }
129
+ function fileExists(rel: string): boolean {
130
+ return sourceOf(rel) !== "";
131
+ }
132
+
133
+ /** The mechanisms a row's justification names, and the ones it names falsely. */
134
+ function citations(how: string): { found: string[]; missing: string[] } {
135
+ const found: string[] = [];
136
+ const missing: string[] = [];
137
+ for (const { pattern, exists } of MECHANISMS) {
138
+ for (const match of how.matchAll(pattern)) {
139
+ (exists(match[1]) ? found : missing).push(match[1]);
140
+ }
141
+ }
142
+ return { found, missing };
143
+ }
144
+
145
+ test("every (M) row names a mechanism that exists in this repository", () => {
146
+ // The claim under test: "mechanized" means there is machinery, not that the
147
+ // row's author intended some. Rows 13, 45 and 46 failed this in round 1.
148
+ const unbacked: string[] = [];
149
+ for (const row of rows) {
150
+ if (!row.klass.includes("**M**")) continue;
151
+ const { found, missing } = citations(row.how);
152
+ assert.deepEqual(missing, [], `row ${row.n} cites something that does not exist: ${missing.join(", ")}`);
153
+ if (found.length === 0) unbacked.push(`row ${row.n} (${row.line}): ${row.how.slice(0, 80)}`);
154
+ }
155
+ assert.deepEqual(
156
+ unbacked,
157
+ [],
158
+ `these rows claim (M) without naming any mechanism:\n${unbacked.join("\n")}`,
159
+ );
160
+ });
161
+
162
+ test("every (P) row gives a reason the clause is not mechanizable", () => {
163
+ // `REQ: odd-parity-matrix`: "we did not get to it" is not a reason.
164
+ for (const row of rows) {
165
+ if (!row.klass.includes("**P**")) continue;
166
+ assert.ok(row.how.length > 40, `row ${row.n} must say why, not just that: ${row.how}`);
167
+ assert.match(
168
+ row.how,
169
+ /judge?ment|judgement|not observable|natural language|prose|cannot|wording|taste|intent|proportional/i,
170
+ `row ${row.n} gives no reason the clause resists mechanization: ${row.how}`,
171
+ );
172
+ assert.ok(
173
+ !/did not get|todo|later|future|not yet implemented/i.test(row.how),
174
+ `row ${row.n} defers instead of giving a reason: ${row.how}`,
175
+ );
176
+ }
177
+ });
178
+
179
+ test("every (F) row points at the out-of-scope requirement that justifies it", () => {
180
+ for (const row of rows) {
181
+ if (!row.klass.includes("**F**")) continue;
182
+ assert.ok(
183
+ row.how.includes("odd-parity-out-of-scope") || row.how.length > 40,
184
+ `row ${row.n} must justify being out of scope: ${row.how}`,
185
+ );
186
+ }
187
+ });
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // The three rows the round-1 verdict caught, pinned individually so a
191
+ // regression names the specific claim that broke.
192
+ // ---------------------------------------------------------------------------
193
+
194
+ function row(n: number): Row {
195
+ const found = rows.find((r) => r.n === n);
196
+ assert.ok(found, `row ${n} must exist`);
197
+ return found!;
198
+ }
199
+
200
+ test("row 13: the Outcome section is derived, so a pending check cannot be dropped", () => {
201
+ const doc = sourceOf("src/feature-doc.ts");
202
+ assert.ok(doc.includes("export function renderOutcome"), "the renderer must exist");
203
+ assert.ok(
204
+ /"## Outcome",\s*\n\s*"",\s*\n\s*\.\.\.renderOutcome\(doc\)/.test(doc),
205
+ "renderFeatureDoc must derive ## Outcome on every save, not accept a caller's text",
206
+ );
207
+ // `TaskEvidence.outcome` is legitimate: it holds one observed result. What must
208
+ // not exist is a doc-level outcome string, which is what used to let a caller
209
+ // write the closing report by hand and omit a failed check.
210
+ assert.ok(
211
+ !/^\s*outcome: string;/m.test(doc.slice(doc.indexOf("export type FeatureDoc"))),
212
+ "a doc-level free-text outcome field would let a caller omit a failed check",
213
+ );
214
+ assert.ok(
215
+ !/doc\.outcome/.test(sourceOf("extensions/nodd-kernel.ts")),
216
+ "and no caller may supply one",
217
+ );
218
+ assert.match(row(13).how, /renderOutcome|derived|task list/i, "the row must name the real mechanism");
219
+ });
220
+
221
+ // `includes` over a whole source file is H2's shape: renaming the fields while
222
+ // leaving the words in a comment kept this green. The row claims the values are
223
+ // recorded and survive, so the test round-trips them through the real renderer
224
+ // and parser instead of reading the source for words.
225
+ test("row 45: TDD mode, source and runner are recorded and reach the gate", () => {
226
+ const doc = emptyDoc({ slug: "row45", title: "Row 45" });
227
+ doc.verification = { runner: "npm test", tdd: "strict", source: "nodd_declare", files: ["a.ts"] };
228
+
229
+ const parsed = parseFeatureDoc(renderFeatureDoc(doc));
230
+ assert.deepEqual(parsed.defects, [], "the rendered doc must parse cleanly");
231
+ assert.deepEqual(
232
+ parsed.doc.verification,
233
+ { runner: "npm test", tdd: "strict", source: "nodd_declare", files: ["a.ts"] },
234
+ "mode, source, runner and files must survive the round trip",
235
+ );
236
+ assert.ok(
237
+ sourceOf("extensions/nodd-kernel.ts").includes("tdd:"),
238
+ "the kernel must pass the recorded mode to the evidence gate, or the row's RED clause is unreachable",
239
+ );
240
+ });
241
+
242
+ test("row 12: no NODD source issues a push, a PR or a merge", () => {
243
+ // A negative guarantee needs a mechanism too, and for "NODD never does X" the
244
+ // mechanism is a scan that fails when someone adds X.
245
+ const files = [
246
+ "src/gates/evidence.ts", "src/gates/track.ts", "src/gates/delegate.ts",
247
+ "src/gates/promotion.ts", "src/gates/authorize.ts", "src/gates/classify.ts",
248
+ "src/delivery.ts", "src/io.ts", "src/ledger.ts", "src/feature-doc.ts",
249
+ "extensions/nodd-kernel.ts", "extensions/nodd-promote.ts",
250
+ ];
251
+ for (const file of files) {
252
+ const executable = sourceOf(file)
253
+ .split("\n")
254
+ .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
255
+ .join("\n");
256
+ assert.ok(
257
+ !/git\s+push|gh\s+pr|git\s+merge|pulls?\/create/.test(executable),
258
+ `${file} issues a push, PR or merge, which row 12 promises NODD leaves to the user`,
259
+ );
260
+ }
261
+ });
262
+
263
+ test("row 46: evidence is keyed to the declared runner and the write, never to a checkbox", () => {
264
+ const evidence = sourceOf("src/gates/evidence.ts");
265
+ assert.ok(evidence.includes("isDeclaredRunner"), "the runner binding is the mechanism");
266
+ assert.ok(evidence.includes("lastWriteSeq"), "and the write ordering is the other half");
267
+ assert.match(
268
+ row(46).how,
269
+ /runner|declared|write/i,
270
+ "the row must name what the evidence is actually keyed to, not merely deny the checkbox",
271
+ );
272
+ });