@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.
- package/LICENSE +21 -0
- package/README.md +350 -0
- package/extensions/nodd-agents.test.ts +129 -0
- package/extensions/nodd-agents.ts +185 -0
- package/extensions/nodd-allow.test.ts +75 -0
- package/extensions/nodd-allow.ts +76 -0
- package/extensions/nodd-enforcement.test.ts +676 -0
- package/extensions/nodd-gates.test.ts +108 -0
- package/extensions/nodd-gates.ts +121 -0
- package/extensions/nodd-kernel.test.ts +114 -0
- package/extensions/nodd-kernel.ts +593 -0
- package/extensions/nodd-models.test.ts +174 -0
- package/extensions/nodd-models.ts +253 -0
- package/extensions/nodd-promote.test.ts +150 -0
- package/extensions/nodd-promote.ts +96 -0
- package/extensions/nodd-prompt.test.ts +87 -0
- package/extensions/nodd-tools.test.ts +211 -0
- package/package.json +44 -0
- package/src/bash-classifier.test.ts +114 -0
- package/src/bash-classifier.ts +69 -0
- package/src/change-acceptance.test.ts +175 -0
- package/src/change-acceptance.ts +98 -0
- package/src/config.test.ts +61 -0
- package/src/config.ts +103 -0
- package/src/delivery.test.ts +156 -0
- package/src/delivery.ts +151 -0
- package/src/feature-doc.test.ts +120 -0
- package/src/feature-doc.ts +292 -0
- package/src/gates/authorize.test.ts +62 -0
- package/src/gates/authorize.ts +32 -0
- package/src/gates/classify.test.ts +54 -0
- package/src/gates/classify.ts +45 -0
- package/src/gates/delegate.test.ts +127 -0
- package/src/gates/delegate.ts +85 -0
- package/src/gates/evidence.test.ts +281 -0
- package/src/gates/evidence.ts +209 -0
- package/src/gates/policy.test.ts +77 -0
- package/src/gates/policy.ts +90 -0
- package/src/gates/promotion.test.ts +133 -0
- package/src/gates/promotion.ts +81 -0
- package/src/gates/registry.ts +21 -0
- package/src/gates/request.ts +41 -0
- package/src/gates/track.test.ts +80 -0
- package/src/gates/track.ts +58 -0
- package/src/io.test.ts +81 -0
- package/src/io.ts +94 -0
- package/src/ledger.test.ts +122 -0
- package/src/ledger.ts +133 -0
- package/src/manifest.test.ts +53 -0
- package/src/manifest.ts +61 -0
- package/src/models/assign.test.ts +125 -0
- package/src/models/assign.ts +138 -0
- package/src/models/picker.test.ts +141 -0
- package/src/models/picker.ts +98 -0
- package/src/models/profiles.test.ts +186 -0
- package/src/models/profiles.ts +162 -0
- package/src/models/slots.ts +48 -0
- package/src/observations.test.ts +61 -0
- package/src/observations.ts +51 -0
- package/src/odd-prose.test.ts +125 -0
- package/src/odd-prose.ts +198 -0
- package/src/outcome.test.ts +75 -0
- package/src/outcome.ts +63 -0
- package/src/promote.test.ts +129 -0
- package/src/promote.ts +64 -0
- package/src/prompt.test.ts +193 -0
- package/src/prompt.ts +136 -0
- package/src/review-candidate.test.ts +118 -0
- package/src/review-candidate.ts +81 -0
- package/src/state.test.ts +153 -0
- package/src/state.ts +163 -0
- package/test/package-invariants.test.ts +66 -0
- package/test/parity-matrix.test.ts +272 -0
- package/test/readme-contract.test.ts +182 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { emptyCommitted, fold } from "./state.ts";
|
|
5
|
+
import { observation } from "./observations.ts";
|
|
6
|
+
import { emptyDoc, type FeatureDoc, type Task } from "./feature-doc.ts";
|
|
7
|
+
import { acceptRewrite, declarationAfter, reopenTask } from "./change-acceptance.ts";
|
|
8
|
+
|
|
9
|
+
function docWith(tasks: Task[]): FeatureDoc {
|
|
10
|
+
const doc = emptyDoc({ slug: "demo", title: "Demo" });
|
|
11
|
+
doc.tasks = tasks;
|
|
12
|
+
doc.progress = "- started";
|
|
13
|
+
return doc;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const checked = (id: string, title: string): Task => ({
|
|
17
|
+
id,
|
|
18
|
+
title,
|
|
19
|
+
checked: true,
|
|
20
|
+
evidence: { command: "npm test", outcome: "success" },
|
|
21
|
+
candidate: "1a2b3c4",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const open = (id: string, title: string): Task => ({ id, title, checked: false });
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Reason required (`routing.go:97`)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
test("reopening a checked task without a reason is refused, naming the missing reason", () => {
|
|
30
|
+
const result = reopenTask(docWith([checked("T1", "First")]), "T1", "");
|
|
31
|
+
assert.equal(result.ok, false);
|
|
32
|
+
assert.ok(result.ok === false && /reason/i.test(result.problem), result.ok === false ? result.problem : "");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("reopening with a reason succeeds and records it under Progress", () => {
|
|
36
|
+
const result = reopenTask(docWith([checked("T1", "First")]), "T1", "the check was run against stale code");
|
|
37
|
+
assert.equal(result.ok, true);
|
|
38
|
+
if (result.ok !== true) return;
|
|
39
|
+
assert.equal(result.doc.tasks[0].checked, false, "the task is reopened");
|
|
40
|
+
assert.match(result.doc.progress, /T1 reopened: the check was run against stale code/);
|
|
41
|
+
assert.match(result.doc.progress, /- started/, "the existing progress log is preserved");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("reopening drops the previous evidence: it no longer describes the current code", () => {
|
|
45
|
+
const result = reopenTask(docWith([checked("T1", "First")]), "T1", "requirements changed");
|
|
46
|
+
assert.equal(result.ok, true);
|
|
47
|
+
if (result.ok !== true) return;
|
|
48
|
+
assert.ok(!("evidence" in result.doc.tasks[0]), "a reopened task carries no stale evidence");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("reopening a task that is not checked, or does not exist, is refused", () => {
|
|
52
|
+
const notChecked = reopenTask(docWith([open("T1", "First")]), "T1", "a reason");
|
|
53
|
+
assert.equal(notChecked.ok, false);
|
|
54
|
+
const missing = reopenTask(docWith([checked("T1", "First")]), "T9", "a reason");
|
|
55
|
+
assert.equal(missing.ok, false);
|
|
56
|
+
assert.ok(missing.ok === false && missing.problem.includes("T9"));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Preserve valid completed and unrelated work (`routing.go:97`)
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
test("a rewrite dropping an unrelated completed task is refused, naming the lost task", () => {
|
|
63
|
+
const before = docWith([checked("T1", "First"), checked("T2", "Unrelated but done"), open("T3", "Third")]);
|
|
64
|
+
const after = docWith([checked("T1", "First"), open("T3", "Third")]);
|
|
65
|
+
|
|
66
|
+
const result = acceptRewrite(before, after);
|
|
67
|
+
assert.equal(result.ok, false);
|
|
68
|
+
assert.ok(result.ok === false && result.problem.includes("T2"), "the refusal names the task it would have lost");
|
|
69
|
+
assert.ok(result.ok === false && result.problem.includes("Unrelated but done"));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a rewrite that adds tasks and keeps every completed one is accepted", () => {
|
|
73
|
+
const before = docWith([checked("T1", "First"), open("T2", "Second")]);
|
|
74
|
+
const after = docWith([checked("T1", "First"), open("T2", "Second"), open("T3", "New from findings")]);
|
|
75
|
+
|
|
76
|
+
const result = acceptRewrite(before, after);
|
|
77
|
+
assert.equal(result.ok, true);
|
|
78
|
+
assert.equal(result.ok === true && result.doc.tasks.length, 3);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a rewrite may drop an open task: only completed work is protected", () => {
|
|
82
|
+
const before = docWith([checked("T1", "First"), open("T2", "Never started")]);
|
|
83
|
+
const after = docWith([checked("T1", "First")]);
|
|
84
|
+
assert.equal(acceptRewrite(before, after).ok, true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a rewrite silently unchecking a completed task is refused", () => {
|
|
88
|
+
const before = docWith([checked("T1", "First")]);
|
|
89
|
+
const after = docWith([open("T1", "First")]);
|
|
90
|
+
|
|
91
|
+
const result = acceptRewrite(before, after);
|
|
92
|
+
assert.equal(result.ok, false);
|
|
93
|
+
assert.ok(result.ok === false && /reopenTask|reason/i.test(result.problem));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Findings never expand scope on their own (`routing.go:97`)
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
test("an observed subagent result never mutates the declared intent or route", () => {
|
|
100
|
+
let committed = fold(emptyCommitted(), observation({
|
|
101
|
+
toolCallId: "d1",
|
|
102
|
+
toolName: "nodd_declare",
|
|
103
|
+
input: { intent: "read-only", route: "inline", slug: "demo" },
|
|
104
|
+
isError: false,
|
|
105
|
+
resultText: "",
|
|
106
|
+
at: "2026-09-19T10:00:00.000Z",
|
|
107
|
+
}));
|
|
108
|
+
const declared = declarationAfter(committed);
|
|
109
|
+
|
|
110
|
+
committed = fold(committed, observation({
|
|
111
|
+
toolCallId: "s1",
|
|
112
|
+
toolName: "subagent",
|
|
113
|
+
input: { agent: "explorer", prompt: "map the code" },
|
|
114
|
+
isError: false,
|
|
115
|
+
resultText: "Findings: this needs a tracked route and writes to 6 files. Intent must become change.",
|
|
116
|
+
at: "2026-09-19T10:05:00.000Z",
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
assert.deepEqual(declarationAfter(committed), declared, "findings alone authorize nothing");
|
|
120
|
+
assert.deepEqual(declared, {
|
|
121
|
+
intent: "read-only", route: "inline", slug: "demo", runner: null, tdd: "off", files: [],
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("only nodd_declare changes the declaration", () => {
|
|
126
|
+
let committed = fold(emptyCommitted(), observation({
|
|
127
|
+
toolCallId: "d1",
|
|
128
|
+
toolName: "nodd_declare",
|
|
129
|
+
input: { intent: "read-only", route: "inline", slug: "demo" },
|
|
130
|
+
isError: false, resultText: "", at: "2026-09-19T10:00:00.000Z",
|
|
131
|
+
}));
|
|
132
|
+
committed = fold(committed, observation({
|
|
133
|
+
toolCallId: "d2",
|
|
134
|
+
toolName: "nodd_declare",
|
|
135
|
+
input: { intent: "change", route: "tracked", slug: "demo" },
|
|
136
|
+
isError: false, resultText: "", at: "2026-09-19T10:06:00.000Z",
|
|
137
|
+
}));
|
|
138
|
+
assert.deepEqual(declarationAfter(committed), {
|
|
139
|
+
intent: "change", route: "tracked", slug: "demo", runner: null, tdd: "off", files: [],
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Checkboxes grant nothing (`routing.go:97`)
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
/** Comments explain the rules; only executable lines can break them. */
|
|
147
|
+
function code(url: URL): string {
|
|
148
|
+
return readFileSync(url, "utf8")
|
|
149
|
+
.split("\n")
|
|
150
|
+
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
|
|
151
|
+
.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
test("no code path derives approval or evidence from a checkbox state", () => {
|
|
155
|
+
// This module reads `checked` to protect completed work, which is its job.
|
|
156
|
+
// What it may never do is turn that bit into approval or into evidence.
|
|
157
|
+
assert.ok(!/approv/i.test(code(new URL("./change-acceptance.ts", import.meta.url))), "approval is never derived here");
|
|
158
|
+
|
|
159
|
+
// The evidence gate keys on ledger records and committed observations only.
|
|
160
|
+
const evidence = code(new URL("./gates/evidence.ts", import.meta.url));
|
|
161
|
+
assert.ok(!/\bchecked\b/.test(evidence), "gate-evidence must not read checkbox state");
|
|
162
|
+
assert.ok(/commandResults/.test(evidence), "gate-evidence keys on observed command results");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a checkoff is not a receipt: acceptance never reads evidence off a checkbox", () => {
|
|
166
|
+
// Two docs identical except that one claims a success it never observed. The
|
|
167
|
+
// rewrite rules treat them the same, because `[x]` carries no authority.
|
|
168
|
+
const forged = docWith([{
|
|
169
|
+
id: "T1", title: "First", checked: true,
|
|
170
|
+
evidence: { command: "npm test", outcome: "success" }, candidate: "deadbee",
|
|
171
|
+
}]);
|
|
172
|
+
const result = acceptRewrite(forged, docWith([open("T1", "First")]));
|
|
173
|
+
assert.equal(result.ok, false, "the claim is preserved as a claim, never promoted to a receipt");
|
|
174
|
+
assert.ok(result.ok === false && /reason/i.test(result.problem));
|
|
175
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// What happens to work already done when the plan changes.
|
|
2
|
+
//
|
|
3
|
+
// `routing.go:97`: accepted changes preserve valid completed and unrelated work;
|
|
4
|
+
// add new or reopen invalidated tasks **with a reason**; findings alone never
|
|
5
|
+
// authorize scope expansion; business scope changes still need the user;
|
|
6
|
+
// checkboxes grant no approval and no receipt.
|
|
7
|
+
//
|
|
8
|
+
// Three of those are mechanical and live here:
|
|
9
|
+
//
|
|
10
|
+
// - **Preserve.** A rewrite that would lose a completed task is refused,
|
|
11
|
+
// naming it. Losing finished work silently is the worst possible outcome of
|
|
12
|
+
// a "helpful" replan, and it is invisible in a diff of a generated file.
|
|
13
|
+
// - **Reason required.** Reopening a `[x]` needs a reason, which lands in
|
|
14
|
+
// `## Progress`. An unexplained reopen is indistinguishable from erasing an
|
|
15
|
+
// inconvenient result.
|
|
16
|
+
// - **Findings authorize nothing.** The declaration comes from `nodd_declare`
|
|
17
|
+
// observations and from nothing else, so a `subagent` result that says "this
|
|
18
|
+
// really needs a tracked route" cannot make it one. That is the observable
|
|
19
|
+
// half of "findings alone never authorize scope expansion".
|
|
20
|
+
//
|
|
21
|
+
// Whether a change is a *business*-scope change stays prose: the mechanism is
|
|
22
|
+
// only that intent and route move exclusively through an explicit declaration.
|
|
23
|
+
|
|
24
|
+
import type { Committed, Declaration } from "./state.ts";
|
|
25
|
+
import type { FeatureDoc, Task } from "./feature-doc.ts";
|
|
26
|
+
|
|
27
|
+
export type Acceptance =
|
|
28
|
+
| { ok: true; doc: FeatureDoc }
|
|
29
|
+
| { ok: false; problem: string };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The declared intent and route, from observed `nodd_declare` calls only.
|
|
33
|
+
* The reducer already enforces this; the function exists so the rule has a name
|
|
34
|
+
* and a test, rather than being an emergent property nobody is watching.
|
|
35
|
+
*/
|
|
36
|
+
export function declarationAfter(committed: Committed): Declaration | null {
|
|
37
|
+
return committed.declaration;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function describe(task: Task): string {
|
|
41
|
+
return `${task.id} (${task.title})`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Reopen a checked task. A reason is required and is recorded under Progress. */
|
|
45
|
+
export function reopenTask(doc: FeatureDoc, id: string, reason: string): Acceptance {
|
|
46
|
+
const index = doc.tasks.findIndex((task) => task.id === id);
|
|
47
|
+
if (index < 0) return { ok: false, problem: `${id} is not in the feature document` };
|
|
48
|
+
|
|
49
|
+
const task = doc.tasks[index];
|
|
50
|
+
if (!task.checked) return { ok: false, problem: `${id} is not checked, so there is nothing to reopen` };
|
|
51
|
+
|
|
52
|
+
if (reason.trim() === "") {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
problem: `reopening ${describe(task)} requires a reason: an unexplained reopen is indistinguishable from erasing a result`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tasks = [...doc.tasks];
|
|
60
|
+
// The old evidence described the old code. Keeping it on a reopened task would
|
|
61
|
+
// let a stale success get re-checked without anything being observed again.
|
|
62
|
+
tasks[index] = { id: task.id, title: task.title, checked: false };
|
|
63
|
+
|
|
64
|
+
const progress = [doc.progress.trim(), `- ${id} reopened: ${reason.trim()}`]
|
|
65
|
+
.filter((part) => part !== "")
|
|
66
|
+
.join("\n");
|
|
67
|
+
|
|
68
|
+
return { ok: true, doc: { ...doc, tasks, progress } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Accept a proposed replacement document. Completed tasks must survive it, and
|
|
73
|
+
* they must survive it still completed — silently unchecking one is a reopen
|
|
74
|
+
* wearing a rewrite's clothes, and reopens need a reason.
|
|
75
|
+
*/
|
|
76
|
+
export function acceptRewrite(before: FeatureDoc, after: FeatureDoc): Acceptance {
|
|
77
|
+
const proposed = new Map(after.tasks.map((task) => [task.id, task]));
|
|
78
|
+
|
|
79
|
+
for (const task of before.tasks) {
|
|
80
|
+
if (!task.checked) continue;
|
|
81
|
+
|
|
82
|
+
const replacement = proposed.get(task.id);
|
|
83
|
+
if (!replacement) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
problem: `this rewrite would drop the completed task ${describe(task)}; valid completed work is preserved`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (!replacement.checked) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
problem: `this rewrite would uncheck the completed task ${describe(task)}; use reopenTask with a reason instead`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { ok: true, doc: after };
|
|
98
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { DEFAULT_CONFIG, mergeConfig, noddConfigPath, parseConfig } from "./config.ts";
|
|
8
|
+
|
|
9
|
+
test("the config path is ~/.pi/nodd.json and nothing else", () => {
|
|
10
|
+
assert.equal(noddConfigPath(), join(homedir(), ".pi", "nodd.json"));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("absent, empty and malformed input all degrade to defaults", () => {
|
|
14
|
+
for (const input of [undefined, "", " ", "{{{", "[]", "null"]) {
|
|
15
|
+
const parsed = parseConfig(input);
|
|
16
|
+
assert.deepEqual(parsed.config, DEFAULT_CONFIG, `input ${JSON.stringify(input)}`);
|
|
17
|
+
}
|
|
18
|
+
assert.deepEqual(parseConfig(undefined).defects, []);
|
|
19
|
+
assert.equal(parseConfig("{{{").defects.length, 1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("a partial config keeps its own values and fills the rest", () => {
|
|
23
|
+
const { config, defects } = parseConfig(JSON.stringify({ models: { implement: "a/b" } }));
|
|
24
|
+
assert.deepEqual(defects, []);
|
|
25
|
+
assert.equal(config.models.implement, "a/b");
|
|
26
|
+
assert.deepEqual(config.profiles, {});
|
|
27
|
+
assert.equal(config.activeProfile, null);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("merge preserves an unknown key byte-for-byte", () => {
|
|
31
|
+
const existing = { keepMe: { deep: [1, "two", { three: true }] }, models: { implement: "a/b" } };
|
|
32
|
+
const merged = mergeConfig(existing, { models: { implement: "c/d" } });
|
|
33
|
+
assert.deepEqual(merged.keepMe, existing.keepMe);
|
|
34
|
+
assert.equal(JSON.stringify(merged.keepMe), JSON.stringify(existing.keepMe));
|
|
35
|
+
assert.deepEqual(merged.models, { implement: "c/d" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("merge writes only the named section", () => {
|
|
39
|
+
const merged = mergeConfig({ models: { implement: "a/b" } }, { gates: { track: { enabled: false } } });
|
|
40
|
+
assert.deepEqual(merged.models, { implement: "a/b" });
|
|
41
|
+
assert.deepEqual(merged.gates, { track: { enabled: false } });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("no NODD source file mentions zero.json", () => {
|
|
45
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
46
|
+
const walk = (dir: string, out: string[] = []): string[] => {
|
|
47
|
+
if (!existsSync(dir)) return out;
|
|
48
|
+
for (const entry of readdirSync(dir)) {
|
|
49
|
+
const full = join(dir, entry);
|
|
50
|
+
if (statSync(full).isDirectory()) walk(full, out);
|
|
51
|
+
else out.push(full);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
};
|
|
55
|
+
// Assembled at runtime so this assertion does not match its own source.
|
|
56
|
+
const forbidden = `zero${"."}json`;
|
|
57
|
+
for (const file of [...walk(join(root, "src")), ...walk(join(root, "extensions"))]) {
|
|
58
|
+
if (file.endsWith("config.test.ts")) continue;
|
|
59
|
+
assert.ok(!readFileSync(file, "utf8").includes(forbidden), `${file} mentions forge's config file`);
|
|
60
|
+
}
|
|
61
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// NODD's configuration lives in `~/.pi/nodd.json` and nowhere else. Forge's own
|
|
2
|
+
// config file is never read and never written: the two products share a home
|
|
3
|
+
// directory, not a schema. A test asserts no NODD source file even names it.
|
|
4
|
+
//
|
|
5
|
+
// This module is pure: it parses text and merges objects. Reading and writing
|
|
6
|
+
// the file is the extensions' job.
|
|
7
|
+
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import type { ConfigurableSlot } from "./manifest.ts";
|
|
11
|
+
|
|
12
|
+
export type GateFlag = { enabled: boolean };
|
|
13
|
+
|
|
14
|
+
export type NoddConfig = {
|
|
15
|
+
/** slot -> `provider/model` (or a bare model id pi can resolve). */
|
|
16
|
+
models: Partial<Record<ConfigurableSlot, string>>;
|
|
17
|
+
/** profile name -> a full slot assignment set. */
|
|
18
|
+
profiles: Record<string, Partial<Record<ConfigurableSlot, string>>>;
|
|
19
|
+
activeProfile: string | null;
|
|
20
|
+
/** gate id -> flag. Absent means "nobody chose"; the default applies. */
|
|
21
|
+
gates: Record<string, GateFlag>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_CONFIG: NoddConfig = Object.freeze({
|
|
25
|
+
models: {},
|
|
26
|
+
profiles: {},
|
|
27
|
+
activeProfile: null,
|
|
28
|
+
gates: {},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function noddConfigPath(home: string = homedir()): string {
|
|
32
|
+
return join(home, ".pi", "nodd.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function asObject(value: unknown): Record<string, unknown> {
|
|
36
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
37
|
+
? (value as Record<string, unknown>)
|
|
38
|
+
: {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function asStringMap(value: unknown): Record<string, string> {
|
|
42
|
+
const out: Record<string, string> = {};
|
|
43
|
+
for (const [key, raw] of Object.entries(asObject(value))) {
|
|
44
|
+
if (typeof raw === "string") out[key] = raw;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ParsedConfig = { config: NoddConfig; defects: string[] };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Tolerant read. An absent file, an empty file and `{{{` all produce the
|
|
53
|
+
* documented defaults — a broken config must never take a session down. A parse
|
|
54
|
+
* failure is reported as a defect so the user can see it; missing sections are
|
|
55
|
+
* not defects, they are just unset.
|
|
56
|
+
*/
|
|
57
|
+
export function parseConfig(text: string | undefined): ParsedConfig {
|
|
58
|
+
if (text === undefined || text.trim() === "") {
|
|
59
|
+
return { config: { ...DEFAULT_CONFIG }, defects: [] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let raw: unknown;
|
|
63
|
+
try {
|
|
64
|
+
raw = JSON.parse(text);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
67
|
+
return { config: { ...DEFAULT_CONFIG }, defects: [`nodd.json is not valid JSON: ${message}`] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const root = asObject(raw);
|
|
71
|
+
const profiles: NoddConfig["profiles"] = {};
|
|
72
|
+
for (const [name, value] of Object.entries(asObject(root.profiles))) {
|
|
73
|
+
profiles[name] = asStringMap(value) as NoddConfig["profiles"][string];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const gates: NoddConfig["gates"] = {};
|
|
77
|
+
for (const [id, value] of Object.entries(asObject(root.gates))) {
|
|
78
|
+
const enabled = asObject(value).enabled;
|
|
79
|
+
if (typeof enabled === "boolean") gates[id] = { enabled };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
config: {
|
|
84
|
+
models: asStringMap(root.models) as NoddConfig["models"],
|
|
85
|
+
profiles,
|
|
86
|
+
activeProfile: typeof root.activeProfile === "string" ? root.activeProfile : null,
|
|
87
|
+
gates,
|
|
88
|
+
},
|
|
89
|
+
defects: [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Merge a patch into the raw parsed JSON, section by section. Unknown top-level
|
|
95
|
+
* keys survive untouched: NODD is a guest in a file the user owns, and losing a
|
|
96
|
+
* key nobody asked us to manage would be a silent data loss.
|
|
97
|
+
*/
|
|
98
|
+
export function mergeConfig(
|
|
99
|
+
existing: Record<string, unknown>,
|
|
100
|
+
patch: Record<string, unknown>,
|
|
101
|
+
): Record<string, unknown> {
|
|
102
|
+
return { ...existing, ...patch };
|
|
103
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { emptyDoc, parseFeatureDoc, renderFeatureDoc } from "./feature-doc.ts";
|
|
7
|
+
import {
|
|
8
|
+
GENERATED_PATHS,
|
|
9
|
+
countAuthoredLines,
|
|
10
|
+
crossedForecast,
|
|
11
|
+
defaultDelivery,
|
|
12
|
+
isChain,
|
|
13
|
+
isStrategy,
|
|
14
|
+
type Delivery,
|
|
15
|
+
} from "./delivery.ts";
|
|
16
|
+
|
|
17
|
+
const full: Delivery = {
|
|
18
|
+
strategy: "auto-chain",
|
|
19
|
+
chain: "feature-branch-chain",
|
|
20
|
+
forecast: 400,
|
|
21
|
+
running: 612,
|
|
22
|
+
boundaries: [
|
|
23
|
+
{ base: "origin/main", head: "1a2b3c4" },
|
|
24
|
+
{ base: "1a2b3c4", head: "5d6e7f8" },
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
test("the Delivery section round-trips with all five fields", () => {
|
|
29
|
+
const doc = emptyDoc({ slug: "demo", title: "Demo" });
|
|
30
|
+
doc.delivery = full;
|
|
31
|
+
|
|
32
|
+
const parsed = parseFeatureDoc(renderFeatureDoc(doc));
|
|
33
|
+
assert.deepEqual(parsed.defects, []);
|
|
34
|
+
assert.deepEqual(parsed.doc.delivery, full);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("an unknown running count round-trips as unknown, never as a number", () => {
|
|
38
|
+
const doc = emptyDoc({ slug: "demo", title: "Demo" });
|
|
39
|
+
doc.delivery = { ...defaultDelivery(), running: "unknown" };
|
|
40
|
+
const parsed = parseFeatureDoc(renderFeatureDoc(doc));
|
|
41
|
+
assert.equal(parsed.doc.delivery.running, "unknown");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the default strategy is ask-on-risk and both choices are cached in the doc", () => {
|
|
45
|
+
const delivery = defaultDelivery();
|
|
46
|
+
assert.equal(delivery.strategy, "ask-on-risk");
|
|
47
|
+
assert.equal(delivery.chain, "stacked-to-main");
|
|
48
|
+
assert.deepEqual(delivery.boundaries, []);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("invalid strategy and chain values are rejected", () => {
|
|
52
|
+
for (const good of ["ask-on-risk", "auto-chain", "single-pr", "exception-ok"]) {
|
|
53
|
+
assert.equal(isStrategy(good), true, good);
|
|
54
|
+
}
|
|
55
|
+
for (const bad of ["yolo", "", "ASK-ON-RISK", "ask on risk"]) {
|
|
56
|
+
assert.equal(isStrategy(bad), false, bad);
|
|
57
|
+
}
|
|
58
|
+
assert.equal(isChain("stacked-to-main"), true);
|
|
59
|
+
assert.equal(isChain("feature-branch-chain"), true);
|
|
60
|
+
assert.equal(isChain("octopus"), false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// The running count is measured from observed git output, never estimated.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
test("a normal diffstat counts additions plus deletions", () => {
|
|
67
|
+
const output = [
|
|
68
|
+
" src/a.ts | 10 ++++++++--",
|
|
69
|
+
" src/b.ts | 4 ++++",
|
|
70
|
+
" 2 files changed, 12 insertions(+), 2 deletions(-)",
|
|
71
|
+
].join("\n");
|
|
72
|
+
assert.equal(countAuthoredLines(output), 14);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("generated paths are excluded from the count", () => {
|
|
76
|
+
const output = [
|
|
77
|
+
" src/a.ts | 10 ++++++++--",
|
|
78
|
+
" package-lock.json | 900 +++++++++++++++++",
|
|
79
|
+
" dist/bundle.js | 500 +++++++++++",
|
|
80
|
+
" 3 files changed, 1408 insertions(+), 2 deletions(-)",
|
|
81
|
+
].join("\n");
|
|
82
|
+
// Only src/a.ts survives the exclusion list: 8 added + 2 removed.
|
|
83
|
+
assert.equal(countAuthoredLines(output), 10);
|
|
84
|
+
assert.ok(GENERATED_PATHS.length > 0, "the exclusion list is documented, not implicit");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("unparseable output leaves the count unknown, never guessed", () => {
|
|
88
|
+
assert.equal(countAuthoredLines("fatal: not a git repository"), "unknown");
|
|
89
|
+
assert.equal(countAuthoredLines(""), "unknown");
|
|
90
|
+
assert.equal(countAuthoredLines("everything is fine"), "unknown");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a diffstat of only generated files counts zero authored lines, not unknown", () => {
|
|
94
|
+
const output = [
|
|
95
|
+
" package-lock.json | 900 +++++++++++++++++",
|
|
96
|
+
" 1 file changed, 900 insertions(+)",
|
|
97
|
+
].join("\n");
|
|
98
|
+
assert.equal(countAuthoredLines(output), 0, "parsed and measured as zero authored: that is a measurement");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// The crossing NEVER blocks. `routing.go:95` calls the figure a planning
|
|
103
|
+
// heuristic; turning it into a gate would violate the clause it comes from.
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
test("crossing the forecast is reported for the prompt and is not a decision", () => {
|
|
106
|
+
assert.equal(crossedForecast({ ...full, running: 612, forecast: 400 }), true);
|
|
107
|
+
assert.equal(crossedForecast({ ...full, running: 120, forecast: 400 }), false);
|
|
108
|
+
assert.equal(crossedForecast({ ...full, running: "unknown" }), false, "unknown never claims a crossing");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a write at 600 running lines is allowed: no gate consults a line count", () => {
|
|
112
|
+
const root = dirname(fileURLToPath(import.meta.url));
|
|
113
|
+
const gateFiles = readdirSync(join(root, "gates")).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"));
|
|
114
|
+
assert.ok(gateFiles.length >= 6, "the scan must actually cover the gates");
|
|
115
|
+
|
|
116
|
+
for (const file of gateFiles) {
|
|
117
|
+
const source = readFileSync(join(root, "gates", file), "utf8");
|
|
118
|
+
assert.ok(!/from "\.\.\/delivery\.ts"/.test(source), `${file} must not import the delivery module`);
|
|
119
|
+
// The structural guarantee: no gate can reach a count, a size or a
|
|
120
|
+
// forecast, so the heuristic cannot become a block by accident.
|
|
121
|
+
assert.ok(
|
|
122
|
+
!/countAuthoredLines|crossedForecast|\.running\b|\bforecast\b|lineCount|byteSize/.test(source),
|
|
123
|
+
`${file} must not read a line count`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Execution is out of scope: `/zero-branch` and `/zero-pr` already do it.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
test("no NODD code path pushes, opens a PR or merges", () => {
|
|
132
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
133
|
+
const files: string[] = [];
|
|
134
|
+
const walk = (dir: string) => {
|
|
135
|
+
if (!existsSync(dir)) return;
|
|
136
|
+
for (const entry of readdirSync(dir)) {
|
|
137
|
+
const fullPath = join(dir, entry);
|
|
138
|
+
if (statSync(fullPath).isDirectory()) walk(fullPath);
|
|
139
|
+
else if (fullPath.endsWith(".ts")) files.push(fullPath);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
walk(join(root, "src"));
|
|
143
|
+
walk(join(root, "extensions"));
|
|
144
|
+
|
|
145
|
+
const forbidden = [/git\s+push/, /gh\s+pr\s+create/, /git\s+merge/];
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
// Comments may name what NODD refuses to do; executable lines may not.
|
|
148
|
+
const code = readFileSync(file, "utf8")
|
|
149
|
+
.split("\n")
|
|
150
|
+
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
|
|
151
|
+
.join("\n");
|
|
152
|
+
for (const pattern of forbidden) {
|
|
153
|
+
assert.ok(!pattern.test(code), `${file} must not invoke ${pattern}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|