doctrina-cli 0.14.0 → 0.15.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/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/analyze.js +56 -0
- package/src/commands/context.js +32 -5
- package/src/commands/contract.js +59 -19
- package/src/commands/coverage.js +110 -8
- package/src/commands/doctor.js +55 -5
- package/src/commands/next.js +24 -1
- package/src/commands/skill.js +186 -3
- package/src/commands/triage.js +248 -0
- package/src/commands/validate.js +91 -4
- package/src/commands/verify.js +114 -3
- package/src/commands/work.js +37 -3
- package/src/index.js +2 -1
- package/src/lib/commands.js +16 -4
- package/src/lib/pipeline.js +161 -0
- package/src/lib/runtime.js +683 -0
- package/templates/AGENTS.md.template +3 -3
- package/templates/contract.md.template +74 -3
- package/templates/spec.md.template +22 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> CLI for the Doctrina framework. The package is published as
|
|
4
4
|
> `doctrina-cli`; the executable it installs is `doctrina`.
|
|
5
5
|
|
|
6
|
-
**Status:** v0.
|
|
6
|
+
**Status:** v0.15.0 — current release. Zero runtime dependencies
|
|
7
7
|
(Node.js standard library only).
|
|
8
8
|
|
|
9
9
|
## Install
|
package/package.json
CHANGED
package/src/commands/analyze.js
CHANGED
|
@@ -4,6 +4,7 @@ import process from "node:process";
|
|
|
4
4
|
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
5
5
|
import { c } from "../lib/colors.js";
|
|
6
6
|
import { isUntouchedScaffold } from "./change.js";
|
|
7
|
+
import { collectBudgets } from "../lib/runtime.js";
|
|
7
8
|
|
|
8
9
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
9
10
|
// adding a command never requires editing the entrypoint — the gap that
|
|
@@ -167,9 +168,64 @@ export function collectAnalysis(projectRoot, changeDir) {
|
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
// BUDGET DISCIPLINE (change 0029). A declared ceiling is a contract, not
|
|
172
|
+
// a preference — and the tempting fix for "the output blew the limit" is
|
|
173
|
+
// to raise the limit. That buys headroom by truncating what mattered
|
|
174
|
+
// instead of sending less, and it is the discussion that comes back every
|
|
175
|
+
// quarter because nothing ever recorded that it had been settled. Raising
|
|
176
|
+
// an INPUT ceiling is a normal trade-off; raising an OUTPUT ceiling to
|
|
177
|
+
// resolve an overflow is the move this refuses.
|
|
178
|
+
for (const r of checkBudgetRaises(projectRoot, changeDir)) results.push(r);
|
|
179
|
+
|
|
180
|
+
return results;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Compare the ceilings a change's proposal/deltas propose against the ones
|
|
184
|
+
// the contracts declare today. Text-scanned: a change states a new value in
|
|
185
|
+
// prose long before any code moves, which is exactly when it should be
|
|
186
|
+
// argued about.
|
|
187
|
+
function checkBudgetRaises(projectRoot, changeDir) {
|
|
188
|
+
const results = [];
|
|
189
|
+
let declared;
|
|
190
|
+
try {
|
|
191
|
+
declared = collectBudgets(projectRoot);
|
|
192
|
+
} catch {
|
|
193
|
+
return results;
|
|
194
|
+
}
|
|
195
|
+
if (declared.size === 0) return results;
|
|
196
|
+
|
|
197
|
+
const texts = [];
|
|
198
|
+
for (const f of walk(changeDir)) {
|
|
199
|
+
if (f.endsWith(".md")) texts.push({ rel: relPath(projectRoot, f), text: read(f) });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const [name, budget] of declared) {
|
|
203
|
+
if (budget.direction !== "output") continue;
|
|
204
|
+
for (const { rel, text } of texts) {
|
|
205
|
+
// "<limit> ... <number>" on one line: the shape a proposal uses when
|
|
206
|
+
// it restates a ceiling ("raise ai-summary to 2000").
|
|
207
|
+
const re = new RegExp(`${escapeRe(name)}[^\\n]{0,60}?(\\d{2,})`, "gi");
|
|
208
|
+
for (const m of text.matchAll(re)) {
|
|
209
|
+
const proposed = Number.parseInt(m[1], 10);
|
|
210
|
+
if (!Number.isFinite(proposed) || proposed <= budget.value) continue;
|
|
211
|
+
results.push(fail(
|
|
212
|
+
`${rel} raises the OUTPUT ceiling "${name}" from ${budget.value} to ${proposed} ` +
|
|
213
|
+
`(declared in ${budget.contract}) — raising an output budget buys headroom by ` +
|
|
214
|
+
`truncating what mattered. Send less, or supersede the budget deliberately in the contract`,
|
|
215
|
+
));
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
170
220
|
return results;
|
|
171
221
|
}
|
|
172
222
|
|
|
223
|
+
// A limit name comes from a contract table cell, so it is user text and
|
|
224
|
+
// must not be spliced into a pattern unescaped.
|
|
225
|
+
function escapeRe(s) {
|
|
226
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
227
|
+
}
|
|
228
|
+
|
|
173
229
|
function pass(msg) {
|
|
174
230
|
return { kind: "pass", line: c.green("✓ ") + msg };
|
|
175
231
|
}
|
package/src/commands/context.js
CHANGED
|
@@ -235,18 +235,35 @@ export async function run(positional, cmdFlags) {
|
|
|
235
235
|
// 6. Skills are on-demand: list name + description + trigger, never the
|
|
236
236
|
// body. The `when:` line is what lets an agent fire the right skill
|
|
237
237
|
// without loading any of them.
|
|
238
|
+
// RANKED by the query (change 0029). Alphabetical order is the wrong
|
|
239
|
+
// order for a list whose whole job is "fire the right one": on a
|
|
240
|
+
// diagnostic task the agent would read four specs and still not learn
|
|
241
|
+
// that CI injects an empty string, because the skill that says so sat
|
|
242
|
+
// at the bottom under `z`. The TRIGGER is what a task matches against,
|
|
243
|
+
// so it is weighted above the description — that is what `when:` is
|
|
244
|
+
// for. A skill with no query to rank on keeps alphabetical order.
|
|
238
245
|
const onDemand = [];
|
|
239
246
|
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
240
247
|
for (const f of walk(skillsDir)) {
|
|
241
248
|
if (!f.endsWith(".md")) continue;
|
|
242
249
|
const text = read(f);
|
|
250
|
+
const name = parseFrontmatter(text, "name") ?? path.basename(f, ".md");
|
|
251
|
+
const description = parseFrontmatter(text, "description") ?? "<missing description>";
|
|
252
|
+
const when = parseFrontmatter(text, "when") ?? null;
|
|
243
253
|
onDemand.push({
|
|
244
254
|
rel: relPath(projectRoot, f),
|
|
245
|
-
name
|
|
246
|
-
description
|
|
247
|
-
when
|
|
255
|
+
name,
|
|
256
|
+
description,
|
|
257
|
+
when,
|
|
258
|
+
// The trigger is the emphasis field, so a term matching `when:`
|
|
259
|
+
// outranks the same term buried in the body.
|
|
260
|
+
rank: relevance(`${when ?? ""} ${description} ${name}`, terms, `${when ?? ""} ${name}`),
|
|
248
261
|
});
|
|
249
262
|
}
|
|
263
|
+
if (terms.length > 0) {
|
|
264
|
+
onDemand.sort((a, b) => compareRank(b.rank, a.rank) || a.name.localeCompare(b.name));
|
|
265
|
+
}
|
|
266
|
+
const skillsMatched = onDemand.filter((s) => s.rank[1] > 0).length;
|
|
250
267
|
|
|
251
268
|
// Fit the pack to the budget. This mutates items in place (degrading) and
|
|
252
269
|
// returns what it had to give up, so the report can name it.
|
|
@@ -278,11 +295,21 @@ export async function run(positional, cmdFlags) {
|
|
|
278
295
|
reportBudget(totalTokens, budget, fit, (msg) => console.log(msg));
|
|
279
296
|
if (onDemand.length > 0) {
|
|
280
297
|
console.log("");
|
|
281
|
-
|
|
298
|
+
const heading = skillsMatched > 0
|
|
299
|
+
? c.bold("On-demand skills") + c.gray(` — ${skillsMatched} match this task; READ THESE FIRST:`)
|
|
300
|
+
: c.bold("On-demand skills") + c.gray(" (load the body only when the trigger fires):");
|
|
301
|
+
console.log(heading);
|
|
282
302
|
for (const s of onDemand) {
|
|
283
|
-
|
|
303
|
+
// A skill whose trigger the task actually matches is marked, so the
|
|
304
|
+
// list stops being a uniform inventory the reader has to scan.
|
|
305
|
+
const hit = s.rank[1] > 0 ? c.green("→ ") : " ";
|
|
306
|
+
console.log(` ${hit}${c.cyan(s.name.padEnd(26))} ${s.description}`);
|
|
284
307
|
if (s.when) console.log(` ${" ".repeat(26)} ${c.gray(`when: ${s.when}`)}`);
|
|
285
308
|
}
|
|
309
|
+
if (skillsMatched > 0) {
|
|
310
|
+
console.log("");
|
|
311
|
+
console.log(c.gray(" A matching skill is procedural memory for THIS task — read it before the specs above."));
|
|
312
|
+
}
|
|
286
313
|
}
|
|
287
314
|
}
|
|
288
315
|
|
package/src/commands/contract.js
CHANGED
|
@@ -12,6 +12,10 @@ import { flagBool } from "../lib/args.js";
|
|
|
12
12
|
import { c } from "../lib/colors.js";
|
|
13
13
|
import { suggest } from "../lib/suggest.js";
|
|
14
14
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
15
|
+
import {
|
|
16
|
+
parseTable, parseRuntimeDeclaration, checkWiring, checkEmptySemantics,
|
|
17
|
+
checkEnums, checkSelectors,
|
|
18
|
+
} from "../lib/runtime.js";
|
|
15
19
|
|
|
16
20
|
// Contracts are the first-class home for the integration/runtime surface
|
|
17
21
|
// no single capability owns: the port map, the env/dependency contract,
|
|
@@ -82,6 +86,7 @@ function contractNew(args, flags) {
|
|
|
82
86
|
console.log(c.green("indexed") + ` contract "${name}"`);
|
|
83
87
|
console.log("");
|
|
84
88
|
console.log("Fill in the Ports / Environment / Interfaces tables, then run " + c.cyan("doctrina contract check") + ".");
|
|
89
|
+
console.log(c.gray("Declaring Wiring and Selectors rows is what makes the RUNTIME half checkable — without them, check can only verify the prose."));
|
|
85
90
|
return 0;
|
|
86
91
|
}
|
|
87
92
|
|
|
@@ -186,6 +191,35 @@ function contractCheck(args, _flags) {
|
|
|
186
191
|
errors += 1;
|
|
187
192
|
}
|
|
188
193
|
}
|
|
194
|
+
|
|
195
|
+
// 4. The RUNTIME half: a declaration is only worth what binds it to the
|
|
196
|
+
// running system. These checks read the contract's Wiring, Selectors
|
|
197
|
+
// and Values declarations and hold the implementation to them — the
|
|
198
|
+
// "I set the secret in GitHub and nothing happened" class, the
|
|
199
|
+
// default that an empty value never triggers, the enum nothing
|
|
200
|
+
// validates, and the selector that matches zero cases and exits 0.
|
|
201
|
+
// Every section is optional, so a contract written before they
|
|
202
|
+
// existed checks exactly as it did before.
|
|
203
|
+
const decl = parseRuntimeDeclaration(text);
|
|
204
|
+
for (const f of [
|
|
205
|
+
...checkWiring(projectRoot, decl),
|
|
206
|
+
...checkEmptySemantics(projectRoot, decl),
|
|
207
|
+
...checkEnums(projectRoot, decl),
|
|
208
|
+
...checkSelectors(projectRoot, decl),
|
|
209
|
+
]) {
|
|
210
|
+
const mark = f.level === "error" ? c.red(" ✗ ") : c.yellow(" ! ");
|
|
211
|
+
console.log(mark + f.message + c.gray(` [${f.code}]`));
|
|
212
|
+
console.log(` ${c.gray(`fix: ${f.remedy}`)}`);
|
|
213
|
+
if (f.level === "error") errors += 1;
|
|
214
|
+
else warnings += 1;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (decl.wiring.length + decl.selectors.length === 0) {
|
|
218
|
+
// Silence here is not proof of correctness — it is proof that nothing
|
|
219
|
+
// was declared. Saying so is what stops a green check from being read
|
|
220
|
+
// as "the wiring is verified".
|
|
221
|
+
console.log(c.gray(" · no Wiring or Selectors declared — the runtime surface is unchecked"));
|
|
222
|
+
}
|
|
189
223
|
}
|
|
190
224
|
|
|
191
225
|
console.log("");
|
|
@@ -198,21 +232,6 @@ function contractCheck(args, _flags) {
|
|
|
198
232
|
return errors === 0 ? 0 : 1;
|
|
199
233
|
}
|
|
200
234
|
|
|
201
|
-
// Parse a GitHub-flavoured Markdown table into { headers, rows }.
|
|
202
|
-
function parseTable(text) {
|
|
203
|
-
const lines = text.split(/\r?\n/).filter((l) => /^\s*\|/.test(l));
|
|
204
|
-
if (lines.length < 2) return null;
|
|
205
|
-
const cells = (line) => line.trim().replace(/^\||\|$/g, "").split("|").map((cl) => cl.trim());
|
|
206
|
-
const headers = cells(lines[0]);
|
|
207
|
-
const rows = [];
|
|
208
|
-
for (let i = 1; i < lines.length; i++) {
|
|
209
|
-
const row = cells(lines[i]);
|
|
210
|
-
if (row.every((cl) => /^:?-+:?$/.test(cl) || cl === "")) continue; // separator row
|
|
211
|
-
rows.push(row);
|
|
212
|
-
}
|
|
213
|
-
return { headers, rows };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
235
|
function colIndex(headers, name) {
|
|
217
236
|
const i = headers.findIndex((h) => h.toLowerCase() === name.toLowerCase());
|
|
218
237
|
return i < 0 ? 0 : i;
|
|
@@ -240,8 +259,29 @@ the port map, the environment contract, and API/WS/event interfaces.
|
|
|
240
259
|
Subcommands:
|
|
241
260
|
new <name> Scaffold .doctrina/contracts/<name>.md and index it
|
|
242
261
|
list One line per contract: id, status, last updated
|
|
243
|
-
check [<name>] Verify the mechanically checkable parts
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
262
|
+
check [<name>] Verify the mechanically checkable parts.
|
|
263
|
+
|
|
264
|
+
Structure: no two services share a port, every
|
|
265
|
+
declared env var exists in .env.example, and every
|
|
266
|
+
referenced spec exists.
|
|
267
|
+
|
|
268
|
+
Runtime — the declaration held to the implementation:
|
|
269
|
+
RT01/02 a variable declared with origin vars or
|
|
270
|
+
secrets must be exported by an env: block
|
|
271
|
+
in the workflow the Wiring row names, from
|
|
272
|
+
the origin and name it declares.
|
|
273
|
+
RT03 its consumer must not give it a default
|
|
274
|
+
that only applies when ABSENT: CI injects
|
|
275
|
+
the empty string, so the default never
|
|
276
|
+
fires (textual lint).
|
|
277
|
+
RT04 a declared Values enum must hold in
|
|
278
|
+
.env.example, and the consumer should
|
|
279
|
+
validate it.
|
|
280
|
+
RT05 every declared selector must match at
|
|
281
|
+
least one target, or a run dispatched on
|
|
282
|
+
it executes nothing and still exits 0.
|
|
283
|
+
|
|
284
|
+
No CI system or test runner is parsed natively: each
|
|
285
|
+
check reads the glob, pattern and origin the contract
|
|
286
|
+
declares. Exits 1 on errors, 0 on warnings only.
|
|
247
287
|
`;
|
package/src/commands/coverage.js
CHANGED
|
@@ -71,22 +71,25 @@ export async function run(_positional, flags) {
|
|
|
71
71
|
let totalCovered = 0;
|
|
72
72
|
let totalDangling = 0;
|
|
73
73
|
let totalConditional = 0;
|
|
74
|
+
let totalUnguarded = 0;
|
|
74
75
|
let totalDeferred = 0;
|
|
75
76
|
for (const rep of reports) {
|
|
76
77
|
totalCriteria += rep.rows.length;
|
|
77
78
|
totalCovered += rep.rows.filter((r) => r.kind === "covered").length;
|
|
78
79
|
totalDangling += rep.rows.filter((r) => r.kind === "dangling").length;
|
|
79
80
|
totalConditional += rep.rows.filter((r) => r.kind === "conditional").length;
|
|
81
|
+
totalUnguarded += rep.rows.filter((r) => r.kind === "unguarded").length;
|
|
80
82
|
totalDeferred += rep.rows.filter((r) => r.kind === "deferred").length;
|
|
81
83
|
}
|
|
82
84
|
const jsonPct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
83
85
|
// Deferred criteria are visible but never gate: declared debt ≠ hidden debt.
|
|
84
|
-
const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
|
|
86
|
+
const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
|
|
87
|
+
&& totalConditional === 0 && totalUnguarded === 0;
|
|
85
88
|
|
|
86
89
|
if (json) {
|
|
87
90
|
emitJson("coverage", {
|
|
88
91
|
specs: reports.map((rep) => ({ capability: rep.cap, deferred: rep.deferred, criteria: rep.rows })),
|
|
89
|
-
summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, deferred: totalDeferred, pct: jsonPct },
|
|
92
|
+
summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, unguarded: totalUnguarded, deferred: totalDeferred, pct: jsonPct },
|
|
90
93
|
});
|
|
91
94
|
return jsonClean ? 0 : strict ? 1 : 0;
|
|
92
95
|
}
|
|
@@ -98,10 +101,12 @@ export async function run(_positional, flags) {
|
|
|
98
101
|
const covered = rep.rows.filter((r) => r.kind === "covered").length;
|
|
99
102
|
const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
|
|
100
103
|
const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
|
|
104
|
+
const unguarded = rep.rows.filter((r) => r.kind === "unguarded").length;
|
|
101
105
|
const deferredN = rep.rows.filter((r) => r.kind === "deferred").length;
|
|
102
106
|
|
|
103
107
|
const notes = [];
|
|
104
108
|
if (conditional > 0) notes.push(c.yellow(`${conditional} conditional`));
|
|
109
|
+
if (unguarded > 0) notes.push(c.yellow(`${unguarded} unguarded`));
|
|
105
110
|
if (dangling > 0) notes.push(c.yellow(`${dangling} dangling`));
|
|
106
111
|
if (deferredN > 0) notes.push(c.gray(`${deferredN} deferred`));
|
|
107
112
|
const note = notes.length > 0 ? ` (${notes.join(", ")})` : "";
|
|
@@ -114,6 +119,8 @@ export async function run(_positional, flags) {
|
|
|
114
119
|
console.log(` ${c.red("✗")} #${r.n} no evidence linked — cite the file/test that proves it in backticks`);
|
|
115
120
|
} else if (r.kind === "conditional") {
|
|
116
121
|
console.log(` ${c.yellow("!")} #${r.n} evidence is a skipped test (proves nothing): ${r.skipped.map((m) => `\`${m}\``).join(", ")}`);
|
|
122
|
+
} else if (r.kind === "unguarded") {
|
|
123
|
+
console.log(` ${c.red("✗")} #${r.n} orchestration claim is not fail-closed: ${r.reason}`);
|
|
117
124
|
} else {
|
|
118
125
|
console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
|
|
119
126
|
}
|
|
@@ -123,9 +130,11 @@ export async function run(_positional, flags) {
|
|
|
123
130
|
const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
124
131
|
console.log("");
|
|
125
132
|
const summary = `${totalCovered} of ${totalCriteria} acceptance criteria across ${reports.length} spec${reports.length === 1 ? "" : "s"} have linked evidence (${pct}%)`;
|
|
126
|
-
const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
|
|
133
|
+
const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
|
|
134
|
+
&& totalConditional === 0 && totalUnguarded === 0;
|
|
127
135
|
const extras = [];
|
|
128
136
|
if (totalConditional > 0) extras.push(`${totalConditional} conditional`);
|
|
137
|
+
if (totalUnguarded > 0) extras.push(`${totalUnguarded} unguarded`);
|
|
129
138
|
if (totalDangling > 0) extras.push(`${totalDangling} dangling`);
|
|
130
139
|
if (totalDeferred > 0) extras.push(`${totalDeferred} deferred (not gated)`);
|
|
131
140
|
const extraSummary = extras.length > 0 ? `, ${extras.join(", ")}` : "";
|
|
@@ -248,13 +257,14 @@ export function summarize(projectRoot) {
|
|
|
248
257
|
const covered = rep.rows.filter((r) => r.kind === "covered").length;
|
|
249
258
|
const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
|
|
250
259
|
const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
|
|
260
|
+
const unguarded = rep.rows.filter((r) => r.kind === "unguarded").length;
|
|
251
261
|
const deferred = rep.rows.filter((r) => r.kind === "deferred").length;
|
|
252
262
|
totalCriteria += rep.rows.length;
|
|
253
263
|
totalCovered += covered;
|
|
254
264
|
totalDangling += dangling;
|
|
255
265
|
totalConditional += conditional;
|
|
256
266
|
totalDeferred += deferred;
|
|
257
|
-
perCap.push({ cap: rep.cap, total: rep.rows.length, covered, dangling, conditional, deferred });
|
|
267
|
+
perCap.push({ cap: rep.cap, total: rep.rows.length, covered, dangling, conditional, unguarded, deferred });
|
|
258
268
|
}
|
|
259
269
|
const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
260
270
|
return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, totalDeferred, pct };
|
|
@@ -297,8 +307,84 @@ function extractAcceptanceCriteria(text) {
|
|
|
297
307
|
return out.filter((s) => s.length > 0);
|
|
298
308
|
}
|
|
299
309
|
|
|
310
|
+
// ORCHESTRATION criteria (change 0029). Coverage measures CITATION: a
|
|
311
|
+
// criterion is covered when it cites a file that exists (and, since G3, a
|
|
312
|
+
// test whose suite is not skipped). That is the right test for "this
|
|
313
|
+
// function behaves", and the wrong one for "the pipeline ran at all".
|
|
314
|
+
//
|
|
315
|
+
// A criterion like "absence of the report is explicit and the step does not
|
|
316
|
+
// fail" is satisfied, on paper, by a job that executed zero cases and
|
|
317
|
+
// printed a well-written empty state — the citation resolves, the suite is
|
|
318
|
+
// not skipped, and the only visible signal is a tidy message saying nothing
|
|
319
|
+
// happened. Coverage calls that proven.
|
|
320
|
+
//
|
|
321
|
+
// Marking a criterion `[orchestration]` says: the claim is that a RUN
|
|
322
|
+
// happened, so the proof must be a fail-closed one. It cites a verify check
|
|
323
|
+
// by name (`verify:<check>`), and that check must declare an `expect` guard
|
|
324
|
+
// — the thing that turns "exit 0" into "exit 0 having actually done
|
|
325
|
+
// something". A named check without a guard is UNGUARDED: the criterion is
|
|
326
|
+
// not proven, and says so, instead of quietly counting as covered.
|
|
327
|
+
const ORCHESTRATION_MARKER = /^orchestration\b/;
|
|
328
|
+
|
|
329
|
+
function citedVerifyChecks(criterion) {
|
|
330
|
+
const out = new Set();
|
|
331
|
+
for (const m of criterion.matchAll(/`verify:([A-Za-z0-9_.-]+)`/g)) out.add(m[1]);
|
|
332
|
+
return [...out];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function guardedVerifyChecks(projectRoot) {
|
|
336
|
+
const configPath = path.join(projectRoot, ".doctrina", "verify.json");
|
|
337
|
+
if (!isFile(configPath)) return null;
|
|
338
|
+
let config;
|
|
339
|
+
try {
|
|
340
|
+
config = JSON.parse(read(configPath));
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
const map = new Map();
|
|
345
|
+
for (const ch of Array.isArray(config?.checks) ? config.checks : []) {
|
|
346
|
+
if (!ch?.name) continue;
|
|
347
|
+
const exp = ch.expect;
|
|
348
|
+
const guarded = Boolean(exp && typeof exp === "object"
|
|
349
|
+
&& (typeof exp.fail_if_output_matches === "string" || typeof exp.require_output_matches === "string"));
|
|
350
|
+
map.set(ch.name, guarded);
|
|
351
|
+
}
|
|
352
|
+
return map;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function classifyOrchestration(criterion, n, projectRoot) {
|
|
356
|
+
const named = citedVerifyChecks(criterion);
|
|
357
|
+
if (named.length === 0) {
|
|
358
|
+
return { kind: "unguarded", n, reason: "cites no verify check — an orchestration claim is proven by a fail-closed check, cited as `verify:<check>`" };
|
|
359
|
+
}
|
|
360
|
+
const checks = guardedVerifyChecks(projectRoot);
|
|
361
|
+
if (checks === null) {
|
|
362
|
+
return { kind: "dangling", n, missing: named.map((x) => `verify:${x}`) };
|
|
363
|
+
}
|
|
364
|
+
const unknown = named.filter((x) => !checks.has(x));
|
|
365
|
+
if (unknown.length > 0) {
|
|
366
|
+
return { kind: "dangling", n, missing: unknown.map((x) => `verify:${x}`) };
|
|
367
|
+
}
|
|
368
|
+
const unguarded = named.filter((x) => !checks.get(x));
|
|
369
|
+
if (unguarded.length > 0) {
|
|
370
|
+
return {
|
|
371
|
+
kind: "unguarded",
|
|
372
|
+
n,
|
|
373
|
+
reason: `verify check${unguarded.length === 1 ? "" : "s"} ${unguarded.join(", ")} declare${unguarded.length === 1 ? "s" : ""} no "expect" guard — a check that exits 0 having run nothing would still pass it`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return { kind: "covered", n, evidence: [] };
|
|
377
|
+
}
|
|
378
|
+
|
|
300
379
|
// Decide whether a single criterion is covered, conditional, dangling, or bare.
|
|
301
380
|
function classify(criterion, n, projectRoot, specDir) {
|
|
381
|
+
// An orchestration criterion is judged on its GUARD, not on whether a
|
|
382
|
+
// cited file exists — the whole point is that existence proves nothing here.
|
|
383
|
+
const marker = criterion.match(/^\[([^\]]+)\]/)?.[1]?.toLowerCase() ?? "";
|
|
384
|
+
if (ORCHESTRATION_MARKER.test(marker)) {
|
|
385
|
+
return classifyOrchestration(criterion, n, projectRoot);
|
|
386
|
+
}
|
|
387
|
+
|
|
302
388
|
const cited = extractBacktickPaths(criterion);
|
|
303
389
|
if (cited.length === 0) return { kind: "bare", n };
|
|
304
390
|
const missing = [];
|
|
@@ -390,15 +476,31 @@ A criterion is covered when a cited path resolves to real proof,
|
|
|
390
476
|
conditional when its only resolving proof is a test file whose suite is
|
|
391
477
|
skipped (\`describe.skip\` / \`xit\` / \`@pytest.mark.skip\` — proves
|
|
392
478
|
nothing), dangling when a cited path is missing on disk, and bare when
|
|
393
|
-
nothing is cited.
|
|
479
|
+
nothing is cited.
|
|
480
|
+
|
|
481
|
+
ORCHESTRATION criteria. Citation is the right proof for "this function
|
|
482
|
+
behaves" and the wrong one for "the pipeline ran at all": a criterion
|
|
483
|
+
like "absence of the report is explicit and the step does not fail" is
|
|
484
|
+
satisfied on paper by a job that executed zero cases and printed a
|
|
485
|
+
well-written empty state. Mark such a criterion [orchestration] and cite
|
|
486
|
+
a verify check by name:
|
|
487
|
+
|
|
488
|
+
3. [orchestration] the e2e suite actually executes scenarios —
|
|
489
|
+
verified by \`verify:e2e\`
|
|
490
|
+
|
|
491
|
+
That check must declare an "expect" guard (see \`doctrina verify --help\`).
|
|
492
|
+
A cited check with no guard is reported UNGUARDED and fails --strict: a
|
|
493
|
+
check that exits 0 having run nothing would otherwise satisfy the claim.
|
|
494
|
+
|
|
495
|
+
A spec that declares a deliberate deferral —
|
|
394
496
|
\`Implementation: planned — <why>\`, the same escape hatch validate
|
|
395
497
|
honours — has its unproven criteria reported as DEFERRED: visible, but
|
|
396
498
|
never a --strict failure (declared debt is not hidden debt).
|
|
397
499
|
|
|
398
500
|
Flags:
|
|
399
|
-
--strict Exit 1 when any criterion is bare, dangling,
|
|
400
|
-
conditional (CI gate). Deferred never
|
|
401
|
-
it the command always exits 0 (a report).
|
|
501
|
+
--strict Exit 1 when any criterion is bare, dangling,
|
|
502
|
+
conditional, or unguarded (CI gate). Deferred never
|
|
503
|
+
fails. Without it the command always exits 0 (a report).
|
|
402
504
|
--only <cap,cap> Scope the report/gate to specific capabilities
|
|
403
505
|
(\`doctrina close\` uses this so an unrelated deferred
|
|
404
506
|
spec cannot block a change's close).
|
package/src/commands/doctor.js
CHANGED
|
@@ -7,6 +7,8 @@ import { exists } from "../lib/fs-ops.js";
|
|
|
7
7
|
import { collectStatus } from "./status.js";
|
|
8
8
|
import { collectFindings } from "./templates.js";
|
|
9
9
|
import { c } from "../lib/colors.js";
|
|
10
|
+
import { flagBool } from "../lib/args.js";
|
|
11
|
+
import { collectRuntimeFindings, checkLocalEnv } from "../lib/runtime.js";
|
|
10
12
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
13
|
|
|
12
14
|
// Aggregate diagnostic: the one command to run when "something looks wrong"
|
|
@@ -21,7 +23,7 @@ const cliEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."
|
|
|
21
23
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
22
24
|
// adding a command never requires editing the entrypoint — the gap that
|
|
23
25
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
24
|
-
export const flags = { boolean: ["json"], string: [] };
|
|
26
|
+
export const flags = { boolean: ["json", "env"], string: [] };
|
|
25
27
|
|
|
26
28
|
export async function run(_positional, _flags) {
|
|
27
29
|
const projectRoot = process.cwd();
|
|
@@ -123,7 +125,48 @@ export async function run(_positional, _flags) {
|
|
|
123
125
|
}
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
// 6.
|
|
128
|
+
// 6. The RUNTIME surface (change 0029). Every row above reads Markdown;
|
|
129
|
+
// this one reads what the Markdown CLAIMS about the running system and
|
|
130
|
+
// checks it — the wiring, the empty-vs-unset default, the enum, the
|
|
131
|
+
// selector that would match nothing. Still a driver: the checks belong
|
|
132
|
+
// to lib/runtime.js and `contract check` renders the same verdict.
|
|
133
|
+
{
|
|
134
|
+
const runtime = collectRuntimeFindings(projectRoot);
|
|
135
|
+
const errs = runtime.findings.filter((f) => f.level === "error");
|
|
136
|
+
const warns = runtime.findings.length - errs.length;
|
|
137
|
+
if (runtime.contracts === 0) {
|
|
138
|
+
row("ok", "runtime", "no contracts — nothing declares a runtime surface");
|
|
139
|
+
} else if (runtime.declared === 0) {
|
|
140
|
+
// Silence is not proof: an undeclared surface is UNCHECKED, and a row
|
|
141
|
+
// that read "ok" here would be the exact false confidence this
|
|
142
|
+
// command exists to prevent.
|
|
143
|
+
row("warn", "runtime", `${runtime.contracts} contract${runtime.contracts === 1 ? "" : "s"}, 0 Wiring/Selectors rows — the runtime surface is unchecked`, "declare Wiring/Selectors rows, then `doctrina contract check`");
|
|
144
|
+
warningsTotal += 1;
|
|
145
|
+
} else if (errs.length > 0) {
|
|
146
|
+
row("fail", "runtime", `${errs.length} declaration${errs.length === 1 ? "" : "s"} do not hold`, "doctrina triage (each finding names its own fix)");
|
|
147
|
+
for (const e of errs.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e.code} ${e.message}`);
|
|
148
|
+
} else if (warns > 0) {
|
|
149
|
+
row("warn", "runtime", `${runtime.declared} declared rows hold; ${warns} advisory finding${warns === 1 ? "" : "s"}`, "doctrina triage");
|
|
150
|
+
warningsTotal += 1;
|
|
151
|
+
} else {
|
|
152
|
+
row("ok", "runtime", `${runtime.declared} declared row${runtime.declared === 1 ? "" : "s"} hold`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 7. The local .env, opt-in. Off by default because it reads a file that
|
|
157
|
+
// is not committed and is none of CI's business; when asked for, it
|
|
158
|
+
// reports NAMES and enum membership only and never the value.
|
|
159
|
+
if (flagBool(_flags, "env", false)) {
|
|
160
|
+
const envFindings = checkLocalEnv(projectRoot);
|
|
161
|
+
if (envFindings.length === 0) {
|
|
162
|
+
row("ok", "local .env", "matches the declared names and enums (or no .env present)");
|
|
163
|
+
} else {
|
|
164
|
+
row("fail", "local .env", `${envFindings.length} finding${envFindings.length === 1 ? "" : "s"} — config would be rejected on boot`, "fix the named variables (values are never printed)");
|
|
165
|
+
for (const e of envFindings.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e.message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 8. Verify configuration (the real build gate must exist to be run).
|
|
127
170
|
if (s.verify.invalid) row("fail", "verify config", ".doctrina/verify.json is invalid JSON", "fix the JSON, then `doctrina verify`");
|
|
128
171
|
else if (!s.verify.configured) {
|
|
129
172
|
row("warn", "verify config", "no verify.json — the build gate is undeclared", "doctrina verify --init");
|
|
@@ -158,9 +201,16 @@ Usage: doctrina doctor
|
|
|
158
201
|
|
|
159
202
|
Aggregate diagnostic: run the structural gate (validate), the index
|
|
160
203
|
drift check, the coverage/trace ratios, the clean-checkout lint
|
|
161
|
-
(verify --clean), the template-shape check,
|
|
162
|
-
|
|
204
|
+
(verify --clean), the template-shape check, the RUNTIME surface (the
|
|
205
|
+
declared wiring, enums and selectors, checked against the code and
|
|
206
|
+
workflows), and the verify-config presence — each reported with its
|
|
207
|
+
exact remediation command.
|
|
208
|
+
|
|
209
|
+
--env also check the local .env against the declared names and
|
|
210
|
+
enums. Reports membership only; a rejected value is never
|
|
211
|
+
printed, so this is safe to run and to paste.
|
|
163
212
|
|
|
164
|
-
A driver over the existing commands; it adds no
|
|
213
|
+
A driver over the existing commands and lib/runtime.js; it adds no
|
|
214
|
+
checks of its own, so it can never disagree with the gates it fronts.
|
|
165
215
|
Read-only. Exits 1 when any area fails, 0 otherwise (warnings allowed).
|
|
166
216
|
`;
|
package/src/commands/next.js
CHANGED
|
@@ -8,6 +8,7 @@ import { deriveIndex, indexesMatch, listHeader } from "../lib/scan.js";
|
|
|
8
8
|
import { flagBool } from "../lib/args.js";
|
|
9
9
|
import { c } from "../lib/colors.js";
|
|
10
10
|
import { emitJson } from "../lib/json-out.js";
|
|
11
|
+
import { collectRuntimeFindings } from "../lib/runtime.js";
|
|
11
12
|
|
|
12
13
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
13
14
|
// adding a command never requires editing the entrypoint — the gap that
|
|
@@ -55,6 +56,26 @@ export async function run(_positional, flags) {
|
|
|
55
56
|
export function computeActions(projectRoot) {
|
|
56
57
|
const actions = [];
|
|
57
58
|
|
|
59
|
+
// RUNTIME FIRST (change 0029). A declared wiring that does not hold is
|
|
60
|
+
// not a queue item — it is the reason the last run lied. After a job goes
|
|
61
|
+
// green having executed nothing, "open a change on the observability
|
|
62
|
+
// capability" sends the agent to polish the Markdown of an empty-state
|
|
63
|
+
// message while the cause sits in a file no other action names. A broken
|
|
64
|
+
// declaration outranks every artifact chore below it.
|
|
65
|
+
try {
|
|
66
|
+
const runtime = collectRuntimeFindings(projectRoot);
|
|
67
|
+
const errs = runtime.findings.filter((f) => f.level === "error");
|
|
68
|
+
if (errs.length > 0) {
|
|
69
|
+
actions.push(
|
|
70
|
+
`doctrina triage — ${errs.length} runtime declaration${errs.length === 1 ? " does" : "s do"} not hold ` +
|
|
71
|
+
`(first: ${errs[0].code} ${errs[0].message.slice(0, 80)}${errs[0].message.length > 80 ? "…" : ""})`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// A malformed contract is `validate`'s finding to make, not a reason
|
|
76
|
+
// for `next` to fall over.
|
|
77
|
+
}
|
|
78
|
+
|
|
58
79
|
// A pending intake is the very first thing to resolve: until it is
|
|
59
80
|
// converted, product.md and the specs are still empty scaffolding.
|
|
60
81
|
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
@@ -178,7 +199,9 @@ export const help = `
|
|
|
178
199
|
Usage: doctrina next [--json]
|
|
179
200
|
|
|
180
201
|
Inspect the .doctrina/ tree and print the recommended next workflow
|
|
181
|
-
actions in priority order:
|
|
202
|
+
actions in priority order: runtime declarations that no longer hold
|
|
203
|
+
(a broken wiring outranks every artifact chore — it is why the last
|
|
204
|
+
run lied), open changes (missing proposal, unchecked
|
|
182
205
|
tasks, deltas ready to apply, applied-but-unarchived), ADRs still in
|
|
183
206
|
proposed status, accepted ADRs with nothing proving them (cite Evidence
|
|
184
207
|
or run "decision land"), a skill-capture nudge when a past fix went
|