doctrina-cli 0.20.0 → 0.22.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 +7 -7
- package/package.json +1 -1
- package/src/commands/adapter.js +10 -0
- package/src/commands/change.js +202 -27
- package/src/commands/clarify.js +3 -206
- package/src/commands/close.js +1 -1
- package/src/commands/context.js +33 -2
- package/src/commands/contract.js +1 -0
- package/src/commands/coverage.js +69 -6
- package/src/commands/delivery.js +135 -17
- package/src/commands/doctor.js +83 -5
- package/src/commands/handoff.js +11 -2
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +64 -8
- package/src/commands/intake.js +16 -5
- package/src/commands/metrics.js +16 -1
- package/src/commands/next.js +41 -6
- package/src/commands/onboard.js +113 -0
- package/src/commands/prime.js +53 -4
- package/src/commands/review.js +40 -4
- package/src/commands/search.js +2 -2
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- package/src/commands/templates.js +2 -1
- package/src/commands/triage.js +16 -1
- package/src/commands/ui.js +512 -0
- package/src/commands/upgrade.js +62 -4
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +35 -7
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +90 -42
- package/src/commands/workspace.js +89 -66
- package/src/index.js +107 -62
- package/src/lib/actions.js +62 -3
- package/src/lib/adapters.js +83 -1
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +34 -2
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +594 -104
- package/src/lib/commands.js +58 -4
- package/src/lib/config.js +27 -8
- package/src/lib/conflict-markers.js +61 -0
- package/src/lib/console-model.js +668 -0
- package/src/lib/console-prefs.js +67 -0
- package/src/lib/console-room.js +201 -0
- package/src/lib/console-state.js +150 -0
- package/src/lib/console-view.js +557 -0
- package/src/lib/context-slice.js +34 -3
- package/src/lib/coverage-model.js +29 -6
- package/src/lib/delivery.js +90 -12
- package/src/lib/docs-impact.js +37 -2
- package/src/lib/fs-ops.js +103 -12
- package/src/lib/gates.js +7 -1
- package/src/lib/git.js +18 -0
- package/src/lib/index-json.js +10 -1
- package/src/lib/index-merge.js +90 -0
- package/src/lib/install.js +80 -0
- package/src/lib/intake-model.js +5 -2
- package/src/lib/json-out.js +9 -1
- package/src/lib/ledger.js +2 -3
- package/src/lib/lock.js +58 -0
- package/src/lib/merge-setup.js +55 -0
- package/src/lib/metrics-model.js +2 -2
- package/src/lib/onboard.js +197 -0
- package/src/lib/preset.js +177 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/properties.js +149 -0
- package/src/lib/runtime.js +19 -7
- package/src/lib/scan.js +13 -8
- package/src/lib/shell.js +45 -0
- package/src/lib/snapshot.js +5 -1
- package/src/lib/spec-ops.js +2 -2
- package/src/lib/task-card.js +101 -8
- package/src/lib/templates-model.js +3 -2
- package/src/lib/triage-model.js +75 -0
- package/src/lib/validation-model.js +23 -5
- package/src/lib/views.js +5 -4
- package/src/lib/work-model.js +8 -2
- package/src/lib/workspace-agents.js +67 -20
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +104 -17
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +83 -131
- package/templates/AGENTS.md.template +9 -5
- package/templates/README.md +4 -2
- package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
- package/templates/adapters/kiro/README.md +22 -0
- package/templates/playbooks/absorb.md.template +39 -16
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +12 -6
package/src/commands/clarify.js
CHANGED
|
@@ -5,112 +5,9 @@ import { readdirSync } from "node:fs";
|
|
|
5
5
|
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
6
6
|
import { flagBool, flagString } from "../lib/args.js";
|
|
7
7
|
import { c } from "../lib/colors.js";
|
|
8
|
-
import {
|
|
9
|
-
import { loadConfig } from "../lib/config.js";
|
|
8
|
+
import { scanFile } from "../lib/clarify-scan.js";
|
|
10
9
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
10
|
|
|
12
|
-
// Two lexicons, one linter (0.11.0 field review item 3: an English-only
|
|
13
|
-
// clarify is permanently red on a PT-BR project — "some" is the verb *sumir*,
|
|
14
|
-
// "TODO o pensamento" is the pronoun *todo* — which teaches people to ignore
|
|
15
|
-
// the gate). The language comes from `.doctrina/config.json` ("language":
|
|
16
|
-
// "pt-BR" | "en"), else a per-file stopword heuristic. Portuguese mode swaps
|
|
17
|
-
// the ambiguous EN tokens for a PT lexicon; TODO only counts with a colon
|
|
18
|
-
// ("TODO:") since bare TODO is the pronoun.
|
|
19
|
-
const RULES_EN = [
|
|
20
|
-
{
|
|
21
|
-
// "may" is intentionally NOT in this list: EARS Optional grammar
|
|
22
|
-
// uses "the system may ..." legitimately, and a clarify pass that
|
|
23
|
-
// flags every EARS Optional is unusable on Doctrina-style specs.
|
|
24
|
-
// The remaining weasel terms are unambiguous.
|
|
25
|
-
name: "weasel",
|
|
26
|
-
re: /\b(might|could|should\s+probably|perhaps|maybe|approximately|roughly)\b/gi,
|
|
27
|
-
hint: "state explicitly with a definite verb",
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
name: "vague",
|
|
31
|
-
// Two exclusions, both structural rather than a vocabulary loosening.
|
|
32
|
-
// The lookahead was already here: `many 5` quantifies. The lookbehind is
|
|
33
|
-
// change 0079: `how many` is INTERROGATIVE, and a requirement that says
|
|
34
|
-
// "shall report how many contracts declared no rows" names exactly the
|
|
35
|
-
// number the command must print — the opposite of vague. Five of this
|
|
36
|
-
// repository's seventeen smells were that phrase, and the noise is what
|
|
37
|
-
// hid the one real finding among them ("name some of them", which said
|
|
38
|
-
// nothing about how many). A gate that cries wolf teaches people to
|
|
39
|
-
// ignore it.
|
|
40
|
-
//
|
|
41
|
-
// The quality adjectives sit here for the same reason the quantifiers do:
|
|
42
|
-
// "robust" names no threshold, so it cannot be met or missed. They were
|
|
43
|
-
// in the Portuguese lexicon and nowhere in this one, so the SAME sentence
|
|
44
|
-
// translated came back with three smells in one language and none in the
|
|
45
|
-
// other — and this project keeps its own pages in both. A smell test that
|
|
46
|
-
// answers differently per language is not testing the writing.
|
|
47
|
-
re: /(?<!\bhow\s+)\b(many|few|some|several|adequate|robust|scalable)\b(?!\s+\d)/gi,
|
|
48
|
-
hint: "quantify (use a number or a precise scope)",
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
name: "placeholder",
|
|
52
|
-
re: /\b(TBD|TODO|FIXME|XXX|\?\?\?)\b/g,
|
|
53
|
-
hint: "resolve before applying",
|
|
54
|
-
},
|
|
55
|
-
];
|
|
56
|
-
|
|
57
|
-
// How much of the previous line a rule may look back into. Long enough for
|
|
58
|
-
// a wrapped two-word phrase, short enough that the probe stays cheap.
|
|
59
|
-
const LOOKBEHIND_TAIL = 40;
|
|
60
|
-
|
|
61
|
-
const RULES_PT = [
|
|
62
|
-
{
|
|
63
|
-
name: "weasel",
|
|
64
|
-
re: /\b(talvez|possivelmente|provavelmente|aproximadamente|quem sabe|eventualmente)\b/gi,
|
|
65
|
-
hint: "afirme explicitamente, com um verbo definido",
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
// ASCII-folded variants included so "varios"/"vários" both match.
|
|
69
|
-
name: "vague",
|
|
70
|
-
re: /\b(muitos|muitas|poucos|poucas|v[aá]rios|v[aá]rias|alguns|algumas|adequado|adequada|robusto|robusta|escal[aá]vel)\b(?!\s+\d)/gi,
|
|
71
|
-
hint: "quantifique (um número ou um escopo preciso)",
|
|
72
|
-
},
|
|
73
|
-
{
|
|
74
|
-
// Bare TODO is the Portuguese pronoun; only TODO: is a marker here.
|
|
75
|
-
name: "placeholder",
|
|
76
|
-
re: /\b(TBD|FIXME|XXX|\?\?\?)\b|\bTODO:/g,
|
|
77
|
-
hint: "resolva antes de aplicar",
|
|
78
|
-
},
|
|
79
|
-
];
|
|
80
|
-
|
|
81
|
-
// Language for a file: the project-declared `.doctrina/config.json`
|
|
82
|
-
// ("language": "pt-BR" / "pt" / "en") wins; otherwise a deterministic
|
|
83
|
-
// stopword count over the file text decides. Never semantic (ADR 0005).
|
|
84
|
-
|
|
85
|
-
function projectLanguage(projectRoot) {
|
|
86
|
-
// One configuration reader (lib/config.js, change 0047): the key still
|
|
87
|
-
// lives in .doctrina/config.json, but `doctor` can now show what it
|
|
88
|
-
// resolved to, which is what a pt-BR project sitting red needed.
|
|
89
|
-
return loadConfig(projectRoot).language;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Which lexicon a document is written in — the shared detector (change
|
|
93
|
-
// 0040), so `clarify` and any future consumer count the same grammar words.
|
|
94
|
-
function detectLang(text) {
|
|
95
|
-
return detectLanguage(text);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function rulesFor(projectRoot, text, forcedLang = null) {
|
|
99
|
-
const lang = forcedLang ?? projectLanguage(projectRoot);
|
|
100
|
-
if (lang) return lang === "pt" ? RULES_PT : RULES_EN;
|
|
101
|
-
// Detected per file, the document is scanned with BOTH lexicons (change
|
|
102
|
-
// 0107). A bilingual spec — Portuguese purpose, English EARS, or the
|
|
103
|
-
// reverse — detected as one language hid every smell written in the
|
|
104
|
-
// other: `clarify --all` found nothing where `--lang pt` found two. The
|
|
105
|
-
// two vocabularies do not overlap, so a smell in either is a smell; the
|
|
106
|
-
// detected language only decides which hint text the reader gets first.
|
|
107
|
-
const detected = detectLang(text);
|
|
108
|
-
const [first, second] = detected === "pt" ? [RULES_PT, RULES_EN] : [RULES_EN, RULES_PT];
|
|
109
|
-
// The `placeholder` rule is language-neutral and lives in both lists:
|
|
110
|
-
// one copy, or TBD is reported twice.
|
|
111
|
-
return [...first, ...second.filter((r) => r.name !== "placeholder")];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
11
|
// --lang pt|en wins over the project config and the per-file heuristic
|
|
115
12
|
// (operator review 2026-07-19 §4.8: on a PT-BR project with no config.json,
|
|
116
13
|
// a file whose stopword count tips English gets the EN lexicon and "some" —
|
|
@@ -118,7 +15,7 @@ function rulesFor(projectRoot, text, forcedLang = null) {
|
|
|
118
15
|
// guessing; persist it with .doctrina/config.json { "language": "pt-BR" }.
|
|
119
16
|
function forcedLang(flags) {
|
|
120
17
|
const raw = flagString(flags, "lang");
|
|
121
|
-
if (!raw
|
|
18
|
+
if (!raw) return null;
|
|
122
19
|
const lang = String(raw).toLowerCase();
|
|
123
20
|
if (lang.startsWith("pt")) return "pt";
|
|
124
21
|
if (lang.startsWith("en")) return "en";
|
|
@@ -176,7 +73,7 @@ export async function run(positional, flags) {
|
|
|
176
73
|
// and clarify each. ADRs are immutable and the archive is history, so
|
|
177
74
|
// neither is included; AGENTS.md is dense command text, a different
|
|
178
75
|
// register from prose specs.
|
|
179
|
-
function clarifyAll(projectRoot, lang = null) {
|
|
76
|
+
function clarifyAll(projectRoot, lang = /** @type {string | null} */ (null)) {
|
|
180
77
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
181
78
|
throw notADoctrinaProject();
|
|
182
79
|
}
|
|
@@ -221,106 +118,6 @@ function clarifyAll(projectRoot, lang = null) {
|
|
|
221
118
|
return 1;
|
|
222
119
|
}
|
|
223
120
|
|
|
224
|
-
function scanFile(fullPath, projectRoot = process.cwd(), lang = null) {
|
|
225
|
-
const text = read(fullPath);
|
|
226
|
-
const skippedRanges = collectSkippedRanges(text);
|
|
227
|
-
const rules = rulesFor(projectRoot, text, lang);
|
|
228
|
-
const smells = [];
|
|
229
|
-
|
|
230
|
-
// Per-line rules: regex pass with skip-range filter. A line carrying the
|
|
231
|
-
// inline suppression marker `<!-- clarify:ok -->` is deliberately accepted
|
|
232
|
-
// by the author — skip it entirely (the escape hatch for a false positive
|
|
233
|
-
// the lexicon cannot know about; "block, never imprison").
|
|
234
|
-
const lines = text.split("\n");
|
|
235
|
-
let cursor = 0;
|
|
236
|
-
for (let i = 0; i < lines.length; i++) {
|
|
237
|
-
const line = lines[i];
|
|
238
|
-
const lineStart = cursor;
|
|
239
|
-
const lineEnd = cursor + line.length;
|
|
240
|
-
if (line.includes("clarify:ok")) {
|
|
241
|
-
cursor = lineEnd + 1;
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
// A rule reads the previous line as CONTEXT, never as content: a match
|
|
245
|
-
// is only reported when it starts inside this line. Prose wraps, and
|
|
246
|
-
// "report per spec how / many criteria..." split the interrogative
|
|
247
|
-
// across a line break — which a line-at-a-time scanner cannot see, so
|
|
248
|
-
// the phrase came back as a smell on one line of the same document and
|
|
249
|
-
// not on the next (change 0079). The prefix is trimmed to the tail of
|
|
250
|
-
// the previous line: enough for a lookbehind, cheap on every line.
|
|
251
|
-
const prev = i > 0 ? lines[i - 1].slice(-LOOKBEHIND_TAIL) : "";
|
|
252
|
-
const probe = prev === "" ? line : `${prev}\n${line}`;
|
|
253
|
-
const offset = probe.length - line.length;
|
|
254
|
-
for (const rule of rules) {
|
|
255
|
-
rule.re.lastIndex = 0;
|
|
256
|
-
let m;
|
|
257
|
-
while ((m = rule.re.exec(probe))) {
|
|
258
|
-
if (m.index < offset) continue; // the match belongs to the previous line
|
|
259
|
-
const absoluteStart = lineStart + m.index - offset;
|
|
260
|
-
if (isInSkippedRange(absoluteStart, skippedRanges)) continue;
|
|
261
|
-
smells.push({
|
|
262
|
-
line: i + 1,
|
|
263
|
-
rule: rule.name,
|
|
264
|
-
token: m[0],
|
|
265
|
-
hint: rule.hint,
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
cursor = lineEnd + 1; // +1 for the newline
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Section rule: empty Acceptance criteria.
|
|
273
|
-
if (hasEmptyAcceptanceCriteria(text)) {
|
|
274
|
-
smells.push({
|
|
275
|
-
line: findAcceptanceLine(text),
|
|
276
|
-
rule: "empty-section",
|
|
277
|
-
token: "Acceptance criteria",
|
|
278
|
-
hint: "fill in observable, verifiable signals",
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
smells.sort((a, b) => a.line - b.line);
|
|
283
|
-
return smells;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Build ranges to skip: fenced code blocks, HTML comments, inline backticks.
|
|
287
|
-
function collectSkippedRanges(text) {
|
|
288
|
-
const ranges = [];
|
|
289
|
-
// Fenced code blocks (``` ... ```)
|
|
290
|
-
for (const m of text.matchAll(/```[\s\S]*?```/g)) ranges.push([m.index, m.index + m[0].length]);
|
|
291
|
-
// HTML comments (<!-- ... -->)
|
|
292
|
-
for (const m of text.matchAll(/<!--[\s\S]*?-->/g)) ranges.push([m.index, m.index + m[0].length]);
|
|
293
|
-
// Inline backtick spans (`...`)
|
|
294
|
-
for (const m of text.matchAll(/`[^`\n]+`/g)) ranges.push([m.index, m.index + m[0].length]);
|
|
295
|
-
return ranges;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function isInSkippedRange(offset, ranges) {
|
|
299
|
-
for (const [a, b] of ranges) if (offset >= a && offset < b) return true;
|
|
300
|
-
return false;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function hasEmptyAcceptanceCriteria(text) {
|
|
304
|
-
const headingRe = /^##\s+Acceptance criteria\s*$/m;
|
|
305
|
-
const m = headingRe.exec(text);
|
|
306
|
-
if (!m) return false;
|
|
307
|
-
const after = text.slice(m.index + m[0].length);
|
|
308
|
-
// Find the body between this heading and the next heading of equal level.
|
|
309
|
-
const nextHeadingMatch = after.match(/^##\s+/m);
|
|
310
|
-
const body = nextHeadingMatch ? after.slice(0, nextHeadingMatch.index) : after;
|
|
311
|
-
// Strip HTML comments before checking emptiness.
|
|
312
|
-
const stripped = body.replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
313
|
-
return stripped.length === 0;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function findAcceptanceLine(text) {
|
|
317
|
-
const lines = text.split("\n");
|
|
318
|
-
for (let i = 0; i < lines.length; i++) {
|
|
319
|
-
if (/^##\s+Acceptance criteria\s*$/.test(lines[i])) return i + 1;
|
|
320
|
-
}
|
|
321
|
-
return 1;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
121
|
export const help = `
|
|
325
122
|
Usage: doctrina clarify <path> [--lang pt|en]
|
|
326
123
|
doctrina clarify --all [--lang pt|en]
|
package/src/commands/close.js
CHANGED
|
@@ -463,7 +463,7 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
463
463
|
if (isFile(ledgerPath)) {
|
|
464
464
|
// Written in the ledger's own entry grammar (change 0046): a waived
|
|
465
465
|
// gate no reader can find is the same as an unrecorded one.
|
|
466
|
-
appendLedgerLine(projectRoot, docsGapLine(id, docsGap.signals));
|
|
466
|
+
appendLedgerLine(projectRoot, docsGapLine(id, /** @type {{ signals: string[] }} */ (/** @type {unknown} */ (docsGap)).signals));
|
|
467
467
|
console.log(c.yellow("ledger") + " recorded the docs gap");
|
|
468
468
|
}
|
|
469
469
|
}
|
package/src/commands/context.js
CHANGED
|
@@ -14,7 +14,9 @@ import { GIT_STATE, historyState, changedFiles } from "../lib/git.js";
|
|
|
14
14
|
import { terms as queryTerms, relevance } from "../lib/lexicon.js";
|
|
15
15
|
import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
|
|
16
16
|
import * as idx from "../lib/index-json.js";
|
|
17
|
-
import { adrDigest, sliceSpec } from "../lib/context-slice.js";
|
|
17
|
+
import { adrDigest, sliceSpec, withoutComments } from "../lib/context-slice.js";
|
|
18
|
+
import { contextRouting, workspaceActions, workspaceFindings, workspaceHandoff, workspaceRootAt, workspaceSnapshot, workspaceStatus } from "../lib/workspace-view.js";
|
|
19
|
+
import { cdThen, qarg } from "../lib/shell.js";
|
|
18
20
|
|
|
19
21
|
// Materialise the AGENTS.md read order as a command: print the exact
|
|
20
22
|
// context pack for a task, in the order an agent should read it. This is
|
|
@@ -85,6 +87,21 @@ export async function run(positional, cmdFlags) {
|
|
|
85
87
|
const diffRef = flagString(cmdFlags, "diff");
|
|
86
88
|
const query = flagString(cmdFlags, "for");
|
|
87
89
|
const projectRoot = process.cwd();
|
|
90
|
+
// At a workspace root there is no one spec tree to read: the pack is per
|
|
91
|
+
// repository. Name which repositories the task is about, and the command
|
|
92
|
+
// that reads each one's pack (change 0230).
|
|
93
|
+
const wsRoot = workspaceRootAt(projectRoot);
|
|
94
|
+
if (wsRoot) {
|
|
95
|
+
const ranked = contextRouting(workspaceSnapshot(wsRoot), query ?? capability ?? "");
|
|
96
|
+
console.log(c.bold("The context pack is read inside a repository.") + c.gray(query ? ` Projects for "${query}":` : " Projects:"));
|
|
97
|
+
for (const r of ranked) {
|
|
98
|
+
const mark = r.state === "ready" ? "" : c.yellow(` (${r.state})`);
|
|
99
|
+
console.log(` ${r.name.padEnd(16)} ${query ? c.gray(`${r.score} shared term${r.score === 1 ? "" : "s"}${r.shared.length ? ` (${r.shared.slice(0, 5).join(", ")})` : ""}`) : ""}${mark}`);
|
|
100
|
+
console.log(` ${c.cyan(cdThen(r.path, `doctrina context ${capability ?? "<capability>"}${query ? ` --for ${qarg(query)}` : ""} --concat`))}`);
|
|
101
|
+
}
|
|
102
|
+
if (ranked.length === 0) console.log(c.gray(" none registered — `doctrina workspace add <path>`"));
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
88
105
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
89
106
|
throw notADoctrinaProject();
|
|
90
107
|
}
|
|
@@ -319,7 +336,17 @@ export async function run(positional, cmdFlags) {
|
|
|
319
336
|
for (const ch of open) {
|
|
320
337
|
if (ch.id === focusId) {
|
|
321
338
|
// The change being worked on: every file, whole, in CORE.
|
|
322
|
-
for (const f of ch.files)
|
|
339
|
+
for (const f of ch.files) {
|
|
340
|
+
const item = pushFile(relPath(projectRoot, f), `open change: ${ch.id}`);
|
|
341
|
+
// Read, not written: the templates' authoring comments stay out
|
|
342
|
+
// unless --full (change 0228).
|
|
343
|
+
const bare = item && !full ? withoutComments(read(f)) : null;
|
|
344
|
+
if (item && bare !== null) {
|
|
345
|
+
item.slice = bare;
|
|
346
|
+
item.tokens = estimateTokens(bare);
|
|
347
|
+
item.note = `${item.note} — comments left out`;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
323
350
|
continue;
|
|
324
351
|
}
|
|
325
352
|
// A PARKED change is ONE entry in a queue, not three documents. It
|
|
@@ -749,6 +776,10 @@ active spec), open changes, accepted ADRs. Skills are
|
|
|
749
776
|
listed name + description + when-trigger only — they are on-demand by
|
|
750
777
|
design. The change archive is excluded.
|
|
751
778
|
|
|
779
|
+
At a workspace root, where no one spec tree exists, it ranks the
|
|
780
|
+
registered projects against --for and prints the command that reads
|
|
781
|
+
each one's pack inside its repository.
|
|
782
|
+
|
|
752
783
|
The pack is LEAN by default: AGENTS.md is not repeated (your agent
|
|
753
784
|
loads it every turn), an accepted ADR is a digest — its title and the
|
|
754
785
|
opening of its Decision; \`doctrina show NNNN\` reads the rest — and
|
package/src/commands/contract.js
CHANGED
|
@@ -278,6 +278,7 @@ the port map, the environment contract, and API/WS/event interfaces.
|
|
|
278
278
|
|
|
279
279
|
Subcommands:
|
|
280
280
|
new <name> Scaffold .doctrina/contracts/<name>.md and index it
|
|
281
|
+
(--force overwrites an existing contract)
|
|
281
282
|
list One line per contract: id, status, last updated
|
|
282
283
|
check [<name>] Verify the mechanically checkable parts.
|
|
283
284
|
|
package/src/commands/coverage.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { exists, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
6
|
+
import { propertyCandidates, projectPropertyLibrary } from "../lib/properties.js";
|
|
6
7
|
import { flagBool, flagString } from "../lib/args.js";
|
|
7
8
|
import { c } from "../lib/colors.js";
|
|
8
9
|
import { emitJson } from "../lib/json-out.js";
|
|
@@ -39,7 +40,7 @@ export { collect, summarize, deriveImplementation, derivedImplementations, imple
|
|
|
39
40
|
// wrap it in the generic envelope.
|
|
40
41
|
export const jsonNative = true;
|
|
41
42
|
|
|
42
|
-
export const flags = { boolean: ["json", "run", "strict"], string: ["only"] };
|
|
43
|
+
export const flags = { boolean: ["json", "run", "strict", "properties"], string: ["only"] };
|
|
43
44
|
|
|
44
45
|
export async function run(_positional, flags) {
|
|
45
46
|
const projectRoot = process.cwd();
|
|
@@ -77,6 +78,11 @@ export async function run(_positional, flags) {
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// --properties: which requirements read as claims about EVERY input, and
|
|
82
|
+
// so ask for a property-based test (change 0254). A readout, exit 0: it
|
|
83
|
+
// proposes; the `[property]` mark on a criterion is what the gate enforces.
|
|
84
|
+
if (flagBool(flags, "properties", false)) return listProperties(projectRoot, only, json);
|
|
85
|
+
|
|
80
86
|
const reports = collect(projectRoot, { only });
|
|
81
87
|
|
|
82
88
|
// --run: execute the cited evidence instead of only checking it exists —
|
|
@@ -153,8 +159,8 @@ export async function run(_positional, flags) {
|
|
|
153
159
|
if (r.unverified) {
|
|
154
160
|
console.log(` ${c.gray("○")} #${r.n} proof resolves but the criterion is still marked [unverified] — flip the mark when the test proves it (spec set <cap> --criterion ${r.n}:verified)`);
|
|
155
161
|
}
|
|
156
|
-
if (r.missing
|
|
157
|
-
console.log(` ${c.yellow("!")} #${r.n} also cites evidence that does not resolve: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
|
|
162
|
+
if ((r.missing ?? []).length > 0) {
|
|
163
|
+
console.log(` ${c.yellow("!")} #${r.n} also cites evidence that does not resolve: ${(r.missing ?? []).map((m) => `\`${m}\``).join(", ")}`);
|
|
158
164
|
}
|
|
159
165
|
continue;
|
|
160
166
|
}
|
|
@@ -165,9 +171,9 @@ export async function run(_positional, flags) {
|
|
|
165
171
|
} else if (r.kind === "conditional") {
|
|
166
172
|
console.log(` ${c.yellow("!")} #${r.n} evidence is a skipped test (proves nothing): ${r.skipped.map((m) => `\`${m}\``).join(", ")}`);
|
|
167
173
|
} else if (r.kind === "unguarded") {
|
|
168
|
-
console.log(` ${c.red("✗")} #${r.n} orchestration claim is not fail-closed: ${r.reason}`);
|
|
174
|
+
console.log(` ${c.red("✗")} #${r.n} ${r.label ?? "orchestration claim is not fail-closed"}: ${r.reason}`);
|
|
169
175
|
} else {
|
|
170
|
-
console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
|
|
176
|
+
console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${(r.missing ?? []).map((m) => `\`${m}\``).join(", ")}`);
|
|
171
177
|
}
|
|
172
178
|
}
|
|
173
179
|
}
|
|
@@ -250,8 +256,49 @@ function runEvidence(projectRoot, reports, strict) {
|
|
|
250
256
|
}
|
|
251
257
|
|
|
252
258
|
|
|
259
|
+
const CANDIDATES_SHOWN = 5;
|
|
260
|
+
|
|
261
|
+
function listProperties(projectRoot, only, json) {
|
|
262
|
+
const caps = propertyCandidates(projectRoot, { only });
|
|
263
|
+
const lib = projectPropertyLibrary(projectRoot);
|
|
264
|
+
if (json) {
|
|
265
|
+
emitJson("coverage", { properties: caps, library: lib });
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
const total = caps.reduce((n, cap) => n + cap.candidates.length, 0);
|
|
269
|
+
const proven = caps.reduce((n, cap) => n + cap.properties, 0);
|
|
270
|
+
console.log(c.bold("Property candidates") + c.gray(" — requirements that claim something of every input"));
|
|
271
|
+
console.log("");
|
|
272
|
+
if (total === 0) {
|
|
273
|
+
console.log(c.gray("no requirement here reads as an invariant (always/never, at most, unique, round trip, twice, in order)"));
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
for (const cap of caps) {
|
|
277
|
+
if (cap.candidates.length === 0) continue;
|
|
278
|
+
console.log(`${c.cyan(cap.cap)} ${cap.candidates.length} requirement${cap.candidates.length === 1 ? "" : "s"} read${cap.candidates.length === 1 ? "s" : ""} as invariant${cap.candidates.length === 1 ? "" : "s"}, ` +
|
|
279
|
+
`${cap.properties} criteri${cap.properties === 1 ? "on is a property" : "a are properties"}`);
|
|
280
|
+
for (const cand of cap.candidates.slice(0, CANDIDATES_SHOWN)) {
|
|
281
|
+
const text = cand.text.length > 110 ? `${cand.text.slice(0, 107)}…` : cand.text;
|
|
282
|
+
console.log(` ${cand.ref.padEnd(13)} ${text}`);
|
|
283
|
+
console.log(c.gray(` ${"".padEnd(13)} ${cand.shape}: ${cand.property}`));
|
|
284
|
+
}
|
|
285
|
+
if (cap.candidates.length > CANDIDATES_SHOWN) {
|
|
286
|
+
console.log(c.gray(` … and ${cap.candidates.length - CANDIDATES_SHOWN} more — doctrina coverage --properties --only ${cap.cap} --json`));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
console.log("");
|
|
290
|
+
console.log(`${total} candidate${total === 1 ? "" : "s"}, ${proven} propert${proven === 1 ? "y" : "ies"} claimed. A candidate is a reading of the words, not a verdict: choose the ones whose failure would be a bug.`);
|
|
291
|
+
const where = lib
|
|
292
|
+
? `${lib.name}${lib.declared ? "" : " (not declared by this project yet — add it as a test dependency)"}`
|
|
293
|
+
: "the property library of the project's language";
|
|
294
|
+
console.log(`Write each as a test that generates its inputs with ${where}, then claim it:`);
|
|
295
|
+
console.log(c.cyan(" doctrina change delta <id> <capability> --property \"<what holds for every input> — verified by \`<the property test>\`\""));
|
|
296
|
+
console.log(c.gray("A criterion marked [property] counts as proven only when the test it cites is property-based."));
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
253
300
|
export const help = `
|
|
254
|
-
Usage: doctrina coverage [--strict] [--only <cap,cap>] [--run] [--json]
|
|
301
|
+
Usage: doctrina coverage [--strict] [--only <cap,cap>] [--run] [--properties] [--json]
|
|
255
302
|
|
|
256
303
|
Report how many acceptance criteria across .doctrina/specs/ cite an
|
|
257
304
|
artifact or test that exists on disk. Each numbered criterion may cite
|
|
@@ -277,6 +324,22 @@ That check must declare an "expect" guard (see \`doctrina verify --help\`).
|
|
|
277
324
|
A cited check with no guard is reported UNGUARDED and fails --strict: a
|
|
278
325
|
check that exits 0 having run nothing would otherwise satisfy the claim.
|
|
279
326
|
|
|
327
|
+
PROPERTY criteria. A requirement that claims something of EVERY input —
|
|
328
|
+
"never returns a code already in use", "at most 100 items", "decoding
|
|
329
|
+
gives back what was encoded" — is not proven by three examples. Mark the
|
|
330
|
+
criterion [property] and cite a property-based test:
|
|
331
|
+
|
|
332
|
+
4. [property] no two codes collide, whatever the URLs —
|
|
333
|
+
verified by \`tests/test_codes_property.py\`
|
|
334
|
+
|
|
335
|
+
It counts as covered only when that test generates its inputs
|
|
336
|
+
(Hypothesis @given, fast-check fc.property, jqwik @Property, proptest,
|
|
337
|
+
rapid, FsCheck, ScalaCheck); cited to an example test it is reported and
|
|
338
|
+
fails --strict. \`doctrina coverage --properties\` lists, per capability,
|
|
339
|
+
the requirements whose words read as an invariant, the property each
|
|
340
|
+
suggests and the library this project would write it with — a readout,
|
|
341
|
+
never a gate.
|
|
342
|
+
|
|
280
343
|
A spec that declares a deliberate deferral —
|
|
281
344
|
\`Implementation: planned — <why>\`, the same escape hatch validate
|
|
282
345
|
honours — has its unproven criteria reported as DEFERRED: visible, but
|