doctrina-cli 0.21.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 +6 -6
- package/package.json +1 -1
- package/src/commands/change.js +111 -14
- package/src/commands/clarify.js +2 -2
- package/src/commands/close.js +1 -1
- package/src/commands/coverage.js +69 -6
- package/src/commands/doctor.js +62 -4
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +51 -7
- package/src/commands/metrics.js +1 -1
- package/src/commands/onboard.js +113 -0
- package/src/commands/review.js +1 -1
- package/src/commands/search.js +2 -2
- 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 +46 -6
- package/src/commands/verify.js +3 -3
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +53 -40
- package/src/commands/workspace.js +16 -3
- package/src/index.js +9 -0
- package/src/lib/actions.js +19 -0
- package/src/lib/adapters.js +23 -3
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +1 -1
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +3 -3
- package/src/lib/code-inventory.js +22 -5
- package/src/lib/commands.js +50 -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/coverage-model.js +29 -6
- package/src/lib/delivery.js +2 -2
- package/src/lib/docs-impact.js +1 -1
- package/src/lib/fs-ops.js +80 -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 +4 -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/spec-ops.js +2 -2
- package/src/lib/task-card.js +1 -1
- 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 +2 -1
- package/src/lib/workspace-agents.js +2 -2
- package/src/lib/workspace-tools.js +41 -1
- package/src/lib/workspace.js +1 -1
- package/templates/AGENTS.md.template +8 -4
- package/templates/README.md +2 -1
- 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/skills/doctrina-onboard/SKILL.md.template +18 -0
- package/templates/adapters/kiro/README.md +22 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read } from "./fs-ops.js";
|
|
5
|
+
import { checklistProgress, parseChangeTitle } from "./doc-model.js";
|
|
6
|
+
import { intakeStatus } from "./intake-model.js";
|
|
7
|
+
import { parseAcceptanceCriteria } from "./criteria.js";
|
|
8
|
+
import { collect } from "./coverage-model.js";
|
|
9
|
+
import { gitLines } from "./git.js";
|
|
10
|
+
|
|
11
|
+
// THE CYCLE ONCE, ON THE REAL REPOSITORY (change 0256).
|
|
12
|
+
//
|
|
13
|
+
// A newcomer had the reference (2,900 lines), a getting-started page, and
|
|
14
|
+
// `next`, which names one action and assumes the reader knows why. Nothing
|
|
15
|
+
// walked the whole cycle once. OpenSpec's `onboard` does that with a model
|
|
16
|
+
// narrating; here the CLI reads where the project stands and prints the
|
|
17
|
+
// walk — what each step is for, what to run, what to tell the human, and
|
|
18
|
+
// where to stop and ask — and the agent is the guide.
|
|
19
|
+
//
|
|
20
|
+
// Every "done" below is read from the tree, never stored: a tour that
|
|
21
|
+
// remembers its own progress is a second place for a fact to go stale.
|
|
22
|
+
|
|
23
|
+
const CODE_FILE = /\.(py|js|mjs|cjs|ts|tsx|jsx|go|rs|java|kt|rb|php|cs|swift|scala|c|cc|cpp|h|hpp|sh)$/i;
|
|
24
|
+
const TODO_LINE = /\b(TODO|FIXME)\b[:\s-]*(.{0,90})/;
|
|
25
|
+
const FILES_READ = 400;
|
|
26
|
+
const FILE_BYTES = 200_000;
|
|
27
|
+
export const CANDIDATES_SHOWN = 3;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Where the project stands, read from the tree.
|
|
31
|
+
* @param {string} projectRoot
|
|
32
|
+
*/
|
|
33
|
+
export function onboardState(projectRoot) {
|
|
34
|
+
const dir = path.join(projectRoot, ".doctrina");
|
|
35
|
+
const installed = isDir(dir);
|
|
36
|
+
const specsDir = path.join(dir, "specs");
|
|
37
|
+
const specs = isDir(specsDir)
|
|
38
|
+
? readdirSync(specsDir).filter((cap) => isFile(path.join(specsDir, cap, "spec.md"))).sort()
|
|
39
|
+
: [];
|
|
40
|
+
const intake = path.join(dir, "intake.md");
|
|
41
|
+
const intakePending = isFile(intake) && intakeStatus(read(intake)) === "pending";
|
|
42
|
+
const changesDir = path.join(dir, "changes");
|
|
43
|
+
const open = [];
|
|
44
|
+
if (isDir(changesDir)) {
|
|
45
|
+
for (const id of readdirSync(changesDir).filter((d) => /^\d{4}-/.test(d)).sort()) {
|
|
46
|
+
const proposal = path.join(changesDir, id, "proposal.md");
|
|
47
|
+
if (!isFile(proposal)) continue;
|
|
48
|
+
const tasksPath = path.join(changesDir, id, "tasks.md");
|
|
49
|
+
const p = isFile(tasksPath) ? checklistProgress(read(tasksPath)) : { total: 0, done: 0, placeholders: 0 };
|
|
50
|
+
open.push({
|
|
51
|
+
id,
|
|
52
|
+
title: parseChangeTitle(read(proposal)) ?? "",
|
|
53
|
+
planned: p.total > 0 && p.placeholders === 0,
|
|
54
|
+
ticked: p.total > 0 && p.placeholders === 0 && p.done === p.total,
|
|
55
|
+
tasks: { total: p.total, done: p.done },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const archiveDir = path.join(changesDir, "archive");
|
|
60
|
+
const archived = isDir(archiveDir)
|
|
61
|
+
? readdirSync(archiveDir).filter((d) => /^\d{4}-\d{2}-\d{2}-\d{4}-/.test(d)).sort()
|
|
62
|
+
: [];
|
|
63
|
+
const hasCode = codeFiles(projectRoot, 1).length > 0;
|
|
64
|
+
return {
|
|
65
|
+
installed, specs, intakePending, hasCode, open,
|
|
66
|
+
closed: archived.length,
|
|
67
|
+
lastClosed: archived.length > 0 ? `.doctrina/changes/archive/${archived[archived.length - 1]}/` : null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Tracked code files, as git lists them; none outside a repository. */
|
|
72
|
+
function codeFiles(projectRoot, limit) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const f of gitLines(projectRoot, ["ls-files", "--cached", "--others", "--exclude-standard"])) {
|
|
75
|
+
if (f.startsWith(".doctrina/") || /(^|\/)(node_modules|vendor|dist|build)\//.test(f) || !CODE_FILE.test(f)) continue;
|
|
76
|
+
out.push(f);
|
|
77
|
+
if (out.length >= limit) break;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Small, real first tasks read from this repository: a criterion with no
|
|
84
|
+
* proof yet, then a TODO somebody left in the code.
|
|
85
|
+
* @param {string} projectRoot
|
|
86
|
+
* @returns {{ kind: "criterion" | "todo", where: string, text: string, prompt: string }[]}
|
|
87
|
+
*/
|
|
88
|
+
export function firstTaskCandidates(projectRoot) {
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const report of collect(projectRoot)) {
|
|
91
|
+
if (out.length >= CANDIDATES_SHOWN) break;
|
|
92
|
+
const criteria = parseAcceptanceCriteria(read(report.specPath));
|
|
93
|
+
for (const row of report.rows) {
|
|
94
|
+
if (out.length >= CANDIDATES_SHOWN) break;
|
|
95
|
+
const kind = "was" in row ? row.was : row.kind;
|
|
96
|
+
if (kind !== "bare" && kind !== "dangling") continue;
|
|
97
|
+
const body = String(criteria[row.n - 1]?.body ?? "").replace(/^\[[a-z]+\]\s*/i, "").replace(/\s+—\s+(verified|proven|evidenced|demonstrated) by .*$/i, "").trim();
|
|
98
|
+
if (body === "") continue;
|
|
99
|
+
out.push({
|
|
100
|
+
kind: /** @type {"criterion"} */ ("criterion"),
|
|
101
|
+
where: `${report.cap} #${row.n}`,
|
|
102
|
+
text: `${kind === "bare" ? "no proof cited" : "the proof it cites is missing"}: ${clip(body, 90)}`,
|
|
103
|
+
prompt: `prove ${report.cap} criterion ${row.n}: ${clip(body, 70)}`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let read_ = 0;
|
|
108
|
+
for (const f of codeFiles(projectRoot, FILES_READ)) {
|
|
109
|
+
if (out.length >= CANDIDATES_SHOWN * 2) break;
|
|
110
|
+
const abs = path.join(projectRoot, f);
|
|
111
|
+
if (!isFile(abs)) continue;
|
|
112
|
+
let text;
|
|
113
|
+
try {
|
|
114
|
+
text = read(abs);
|
|
115
|
+
} catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (text.length > FILE_BYTES || ++read_ > FILES_READ) continue;
|
|
119
|
+
const lines = text.split("\n");
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
const m = lines[i].match(TODO_LINE);
|
|
122
|
+
if (!m || m[2].trim().length < 8) continue;
|
|
123
|
+
const note = m[2].trim().replace(/\s*(\*\/|-->)\s*$/, "");
|
|
124
|
+
out.push({ kind: /** @type {"todo"} */ ("todo"), where: `${f}:${i + 1}`, text: `${m[1]} ${clip(note, 90)}`, prompt: clip(note, 70) });
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return out.slice(0, CANDIDATES_SHOWN * 2);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const clip = (/** @type {string} */ s, /** @type {number} */ n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The walk: every step with whether the tree says it is done, what it is
|
|
135
|
+
* for, what to run and where to ask. `current` is the first one not done.
|
|
136
|
+
* @param {ReturnType<typeof onboardState>} state
|
|
137
|
+
*/
|
|
138
|
+
export function onboardSteps(state) {
|
|
139
|
+
const first = state.open[0] ?? null;
|
|
140
|
+
const id = first?.id ?? "<id>";
|
|
141
|
+
const ran = state.closed > 0;
|
|
142
|
+
const steps = [
|
|
143
|
+
{ id: "install", title: "Install",
|
|
144
|
+
done: state.installed,
|
|
145
|
+
evidence: state.installed ? ".doctrina/ and AGENTS.md are here" : "",
|
|
146
|
+
say: "Doctrina keeps what the system should do in `.doctrina/` and tells every agent how to work in `AGENTS.md`; nothing else in the repository is touched.",
|
|
147
|
+
run: ["doctrina init"],
|
|
148
|
+
ask: "" },
|
|
149
|
+
{ id: "describe", title: "Describe the project",
|
|
150
|
+
done: state.installed && state.specs.length > 0 && !state.intakePending,
|
|
151
|
+
evidence: state.specs.length > 0 ? `${state.specs.length} capability spec${state.specs.length === 1 ? "" : "s"} (${state.specs.slice(0, 4).join(", ")}${state.specs.length > 4 ? ", …" : ""})` : "",
|
|
152
|
+
say: "The specs are the current truth: one per capability, each requirement a testable sentence, each criterion citing its proof. Everything after this step changes them through a change.",
|
|
153
|
+
run: state.intakePending
|
|
154
|
+
? ["doctrina intake # the stored description is pending: execute the playbook it prints"]
|
|
155
|
+
: [state.hasCode ? "doctrina intake --from-code # describe what the code does today" : 'doctrina intake --text "<what the project is, in a paragraph>"'],
|
|
156
|
+
ask: state.hasCode ? "what the code cannot tell: who uses it, and what must never break." : "for the project in a paragraph: who it is for and what it must do." },
|
|
157
|
+
{ id: "first-task", title: "Pick a first task and open it",
|
|
158
|
+
done: state.open.length > 0 || ran,
|
|
159
|
+
evidence: first ? `${first.id} is open` : (ran ? "a change was opened and closed" : ""),
|
|
160
|
+
say: "A request that changes behaviour becomes a change before code is edited; the CLI scaffolds it and prints the playbook. Small and real beats a toy: the cycle is learned on work that matters.",
|
|
161
|
+
run: ['doctrina work "<the task>"'],
|
|
162
|
+
ask: "to choose one of the candidates, or to name a small task of their own — something done in under an hour." },
|
|
163
|
+
{ id: "plan", title: "Write the delta and the tasks",
|
|
164
|
+
done: ran || state.open.some((ch) => ch.planned),
|
|
165
|
+
evidence: first?.planned ? `${first.id} has ${first.tasks.total} task${first.tasks.total === 1 ? "" : "s"} written` : "",
|
|
166
|
+
say: "The delta is the sentence the spec gains or loses; the tasks are how the code gets there. Nothing is implemented yet — this is the moment the human reads and corrects.",
|
|
167
|
+
run: [`doctrina work --resume ${id} # the playbook`, `doctrina change check ${id} # would the close pass?`],
|
|
168
|
+
ask: "whether the requirement you wrote is what they meant — show it to them before any code.",
|
|
169
|
+
askAfter: true },
|
|
170
|
+
{ id: "implement", title: "Implement and prove",
|
|
171
|
+
done: ran || state.open.some((ch) => ch.ticked),
|
|
172
|
+
evidence: first?.ticked ? `every task of ${first.id} is ticked` : "",
|
|
173
|
+
say: "Each task is implemented and ticked, and each new criterion cites the test that proves it: `coverage` reads that citation, not the claim.",
|
|
174
|
+
run: [`doctrina change tick ${id} <n>`, "doctrina verify"],
|
|
175
|
+
ask: "" },
|
|
176
|
+
{ id: "close", title: "Close",
|
|
177
|
+
done: ran,
|
|
178
|
+
evidence: ran ? `${state.closed} change${state.closed === 1 ? "" : "s"} closed — the last at ${state.lastClosed}` : "",
|
|
179
|
+
say: "One command merges the delta into the spec, runs every gate and archives the change. If it refuses, the change is not done — it names what is missing.",
|
|
180
|
+
run: [`doctrina close ${id}`],
|
|
181
|
+
ask: "" },
|
|
182
|
+
];
|
|
183
|
+
const at = steps.findIndex((s) => !s.done);
|
|
184
|
+
return { steps, current: at < 0 ? null : steps[at].id };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** What a project that already ran the cycle shows a newcomer. */
|
|
188
|
+
export function tourLines(state) {
|
|
189
|
+
const cap = state.specs[0] ?? "<capability>";
|
|
190
|
+
return [
|
|
191
|
+
"doctrina prime # the state of the project in one read — every session starts here",
|
|
192
|
+
`doctrina why ${cap}${" ".repeat(Math.max(1, 17 - cap.length))}# why a capability exists: intent, proof, decisions, history`,
|
|
193
|
+
...(state.lastClosed ? [`${state.lastClosed}proposal.md # a finished change, start to end`] : []),
|
|
194
|
+
'doctrina work "<request>" # the next request — it says by itself when no change is needed',
|
|
195
|
+
"doctrina hooks install # once per clone: the commit hook and the merge drivers",
|
|
196
|
+
];
|
|
197
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read, walk, write } from "./fs-ops.js";
|
|
5
|
+
import { configPath } from "./config.js";
|
|
6
|
+
|
|
7
|
+
// A PRESET: THE ORGANISATION'S HOUSE, IN A FOLDER (change 0253).
|
|
8
|
+
//
|
|
9
|
+
// Every project of one company starts from the same things — its language,
|
|
10
|
+
// the rules no repository may break, the decisions that bind all of them,
|
|
11
|
+
// its templates, the checks its pipeline runs — and each `doctrina init`
|
|
12
|
+
// started from the CLI's defaults, so the house style was re-typed, or
|
|
13
|
+
// forgotten, per repository. A preset is a folder (a repository the team
|
|
14
|
+
// clones, a submodule, a path on a shared drive — the CLI makes no network
|
|
15
|
+
// call) that `init --preset` and `upgrade --preset` lay over a project:
|
|
16
|
+
//
|
|
17
|
+
// preset.json { "name", "description", "config": { … } }
|
|
18
|
+
// templates/** → .doctrina/templates/** (per-file overrides, ADR 0019)
|
|
19
|
+
// decisions/*.md → .doctrina/decisions/
|
|
20
|
+
// skills/*.md → .doctrina/skills/
|
|
21
|
+
// verify.json → .doctrina/verify.json (only when the project has none)
|
|
22
|
+
//
|
|
23
|
+
// ADDITIVE, like every scaffold update: a file the project already has is
|
|
24
|
+
// kept, a config key it already declares is kept, and the plan says so.
|
|
25
|
+
|
|
26
|
+
export const PRESET_FILE = "preset.json";
|
|
27
|
+
const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
28
|
+
const CONFIG_KEYS = ["language", "context_budget", "rules", "surface"];
|
|
29
|
+
const FOLDERS = [
|
|
30
|
+
{ from: "templates", to: ".doctrina/templates", only: null },
|
|
31
|
+
{ from: "decisions", to: ".doctrina/decisions", only: /\.md$/ },
|
|
32
|
+
{ from: "skills", to: ".doctrina/skills", only: /\.md$/ },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read a preset folder. Throws an Error whose message says what is wrong
|
|
37
|
+
* with it — the caller prints it and exits 2.
|
|
38
|
+
* @param {string} dir
|
|
39
|
+
* @returns {{ dir: string, name: string, description: string, config: Record<string, any> }}
|
|
40
|
+
*/
|
|
41
|
+
export function readPreset(dir) {
|
|
42
|
+
const root = path.resolve(dir);
|
|
43
|
+
if (!isDir(root)) throw new Error(`preset ${dir} is not a folder`);
|
|
44
|
+
const file = path.join(root, PRESET_FILE);
|
|
45
|
+
if (!isFile(file)) throw new Error(`${dir} has no ${PRESET_FILE} — a preset is a folder with one: { "name": "acme", "config": { … } }`);
|
|
46
|
+
let data;
|
|
47
|
+
try {
|
|
48
|
+
data = JSON.parse(read(file));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new Error(`${path.join(dir, PRESET_FILE)} is not valid JSON: ${/** @type {Error} */ (err).message}`);
|
|
51
|
+
}
|
|
52
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error(`${PRESET_FILE} must be an object`);
|
|
53
|
+
if (typeof data.name !== "string" || !NAME.test(data.name)) throw new Error(`${PRESET_FILE} needs a "name" of lowercase letters, digits and hyphens`);
|
|
54
|
+
const config = data.config ?? {};
|
|
55
|
+
if (typeof config !== "object" || Array.isArray(config)) throw new Error(`${PRESET_FILE}: "config" must be an object`);
|
|
56
|
+
const unknown = Object.keys(config).filter((k) => !CONFIG_KEYS.includes(k));
|
|
57
|
+
if (unknown.length > 0) throw new Error(`${PRESET_FILE}: config key${unknown.length === 1 ? "" : "s"} ${unknown.map((k) => `"${k}"`).join(", ")} unknown — a preset sets ${CONFIG_KEYS.join(", ")}`);
|
|
58
|
+
return { dir: root, name: data.name, description: typeof data.description === "string" ? data.description : "", config };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const posix = (/** @type {string} */ p) => p.split(path.sep).join("/");
|
|
62
|
+
const adrNumber = (/** @type {string} */ name) => name.match(/^(\d{4})-/)?.[1] ?? null;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What laying the preset over the project would do. Reads only.
|
|
66
|
+
* @returns {{
|
|
67
|
+
* files: { rel: string, from: string, action: "copy" | "same" | "kept", note: string }[],
|
|
68
|
+
* config: { key: string, value: any, action: "set" | "kept" | "same" }[],
|
|
69
|
+
* verify: string[],
|
|
70
|
+
* }}
|
|
71
|
+
*/
|
|
72
|
+
export function planPreset(projectRoot, preset) {
|
|
73
|
+
/** @type {{ rel: string, from: string, action: "copy" | "same" | "kept", note: string }[]} */
|
|
74
|
+
const files = [];
|
|
75
|
+
const taken = new Map();
|
|
76
|
+
const decisions = path.join(projectRoot, ".doctrina", "decisions");
|
|
77
|
+
if (isDir(decisions)) {
|
|
78
|
+
for (const f of readdirSync(decisions)) {
|
|
79
|
+
const n = adrNumber(f);
|
|
80
|
+
if (n) taken.set(n, f);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const add = (/** @type {string} */ from, /** @type {string} */ rel) => {
|
|
84
|
+
const dest = path.join(projectRoot, rel);
|
|
85
|
+
if (isFile(dest)) {
|
|
86
|
+
files.push({ rel, from, action: read(dest) === read(from) ? "same" : "kept", note: "the project has its own" });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// An ADR's number is its identity: a preset decision whose number the
|
|
90
|
+
// project already gave to another one is not renumbered behind its back.
|
|
91
|
+
const n = rel.startsWith(".doctrina/decisions/") ? adrNumber(path.basename(rel)) : null;
|
|
92
|
+
if (n && taken.has(n)) {
|
|
93
|
+
files.push({ rel, from, action: "kept", note: `ADR number ${n} is ${taken.get(n)} here — copy it under a free number by hand` });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
files.push({ rel, from, action: "copy", note: "" });
|
|
97
|
+
};
|
|
98
|
+
for (const folder of FOLDERS) {
|
|
99
|
+
const base = path.join(preset.dir, folder.from);
|
|
100
|
+
for (const from of walk(base)) {
|
|
101
|
+
if (folder.only && !folder.only.test(from)) continue;
|
|
102
|
+
add(from, `${folder.to}/${posix(path.relative(base, from))}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** @type {string[]} */
|
|
106
|
+
let verify = [];
|
|
107
|
+
const presetVerify = path.join(preset.dir, "verify.json");
|
|
108
|
+
if (isFile(presetVerify)) {
|
|
109
|
+
add(presetVerify, ".doctrina/verify.json");
|
|
110
|
+
try {
|
|
111
|
+
verify = (JSON.parse(read(presetVerify)).checks ?? []).map((/** @type {{ run?: string }} */ ch) => String(ch.run ?? "")).filter(Boolean);
|
|
112
|
+
} catch { /* an unreadable verify.json is reported by `doctrina verify` once copied */ }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @type {Record<string, any>} */
|
|
116
|
+
let current = {};
|
|
117
|
+
try {
|
|
118
|
+
current = JSON.parse(read(configPath(projectRoot)));
|
|
119
|
+
} catch { /* no config.json yet, or an unreadable one: nothing is declared */ }
|
|
120
|
+
const config = Object.entries(preset.config).map(([key, value]) => ({
|
|
121
|
+
key, value,
|
|
122
|
+
action: /** @type {"set" | "kept" | "same"} */ (!(key in current) ? "set"
|
|
123
|
+
: JSON.stringify(current[key]) === JSON.stringify(value) ? "same" : "kept"),
|
|
124
|
+
}));
|
|
125
|
+
return { files, config, verify };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** How many things the plan would write. */
|
|
129
|
+
export function presetPending(plan) {
|
|
130
|
+
return plan.files.filter((f) => f.action === "copy").length + plan.config.filter((c) => c.action === "set").length;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Write what the plan calls for. The caller re-derives the index. */
|
|
134
|
+
export function applyPreset(projectRoot, preset, plan) {
|
|
135
|
+
for (const f of plan.files) {
|
|
136
|
+
if (f.action === "copy") write(path.join(projectRoot, f.rel), read(f.from));
|
|
137
|
+
}
|
|
138
|
+
const file = configPath(projectRoot);
|
|
139
|
+
/** @type {Record<string, any>} */
|
|
140
|
+
let current = {};
|
|
141
|
+
try {
|
|
142
|
+
current = JSON.parse(read(file));
|
|
143
|
+
} catch { /* written whole below */ }
|
|
144
|
+
/** @type {Record<string, any>} */
|
|
145
|
+
const next = { ...current };
|
|
146
|
+
for (const c of plan.config) if (c.action === "set") next[c.key] = c.value;
|
|
147
|
+
next.preset = preset.name;
|
|
148
|
+
write(file, JSON.stringify(next, null, 2) + "\n", { force: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The plan as lines a person reads: what is copied, what is kept and why,
|
|
153
|
+
* and — said every time — the commands a preset's verify.json would run.
|
|
154
|
+
* @param {boolean} written
|
|
155
|
+
* @returns {string[]}
|
|
156
|
+
*/
|
|
157
|
+
export function describePreset(preset, plan, written) {
|
|
158
|
+
const verb = written ? "copied " : "would copy";
|
|
159
|
+
const lines = [`preset ${preset.name}${preset.description ? ` — ${preset.description}` : ""}`];
|
|
160
|
+
for (const f of plan.files) {
|
|
161
|
+
if (f.action === "copy") lines.push(` ${verb} ${f.rel}`);
|
|
162
|
+
else if (f.action === "kept") lines.push(` kept ${f.rel} — ${f.note}`);
|
|
163
|
+
}
|
|
164
|
+
for (const c of plan.config) {
|
|
165
|
+
const shown = Array.isArray(c.value) ? `${c.value.length} rule${c.value.length === 1 ? "" : "s"}` : JSON.stringify(c.value);
|
|
166
|
+
if (c.action === "set") lines.push(` ${written ? "set " : "would set "} config ${c.key} = ${shown}`);
|
|
167
|
+
else if (c.action === "kept") lines.push(` kept config ${c.key} — the project declares another value`);
|
|
168
|
+
}
|
|
169
|
+
const copiesVerify = plan.files.some((f) => f.rel === ".doctrina/verify.json" && f.action === "copy");
|
|
170
|
+
if (copiesVerify && plan.verify.length > 0) {
|
|
171
|
+
lines.push(` note the preset's verify.json declares what \`doctrina verify\` RUNS on this machine: ${plan.verify.map((r) => `\`${r}\``).join(", ")} — read it before running the gate`);
|
|
172
|
+
}
|
|
173
|
+
const same = plan.files.filter((f) => f.action === "same").length;
|
|
174
|
+
if (same > 0) lines.push(` same ${same} file${same === 1 ? "" : "s"} already as the preset has ${same === 1 ? "it" : "them"}`);
|
|
175
|
+
if (presetPending(plan) === 0) lines.push(" nothing to add — the project already carries the preset");
|
|
176
|
+
return lines;
|
|
177
|
+
}
|
package/src/lib/prompt.js
CHANGED
|
@@ -25,7 +25,7 @@ export async function ask(question, { defaultValue = "" } = {}) {
|
|
|
25
25
|
// Ask for consent. Off a terminal there is nobody to ask, so the answer is
|
|
26
26
|
// `whenNonInteractive` — which callers set to false for destructive
|
|
27
27
|
// operations: silence is not consent.
|
|
28
|
-
export async function confirm(question, { defaultYes = false, whenNonInteractive = null } = {}) {
|
|
28
|
+
export async function confirm(question, { defaultYes = false, whenNonInteractive = /** @type {boolean | null} */ (null) } = {}) {
|
|
29
29
|
if (!isInteractive()) {
|
|
30
30
|
return whenNonInteractive === null ? defaultYes : whenNonInteractive;
|
|
31
31
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read } from "./fs-ops.js";
|
|
5
|
+
import { parseAcceptanceCriteria } from "./criteria.js";
|
|
6
|
+
|
|
7
|
+
// A REQUIREMENT THAT IS AN INVARIANT ASKS FOR A PROPERTY (change 0254).
|
|
8
|
+
//
|
|
9
|
+
// "The system shall never return a code already in use" is a claim about
|
|
10
|
+
// EVERY input, and the evidence a criterion cited for it was an example
|
|
11
|
+
// test: three inputs somebody thought of. Coverage called that proven. A
|
|
12
|
+
// property-based test generates the inputs — hundreds of them, the ones
|
|
13
|
+
// nobody thought of — which is the kind of proof a universal claim wants.
|
|
14
|
+
//
|
|
15
|
+
// Kiro derives such tests from EARS requirements with a model. The CLI has
|
|
16
|
+
// none and does not pretend to: it does the two deterministic halves. It
|
|
17
|
+
// says WHICH requirements read as invariants (`coverage --properties`), and
|
|
18
|
+
// it refuses to count a criterion marked `[property]` as proven unless the
|
|
19
|
+
// test it cites really is a property-based one. Writing the property is the
|
|
20
|
+
// agent's work, as writing the test always was.
|
|
21
|
+
|
|
22
|
+
/** Marks a criterion whose proof must generate its inputs. */
|
|
23
|
+
export const PROPERTY_MARKER = /^property\b/;
|
|
24
|
+
|
|
25
|
+
/** What a property-based test looks like, per library. */
|
|
26
|
+
const LIBRARIES = [
|
|
27
|
+
{ name: "Hypothesis", re: /@given\s*\(|\bfrom\s+hypothesis\b|\bimport\s+hypothesis\b/ },
|
|
28
|
+
{ name: "fast-check", re: /\bfc\.(assert|property|asyncProperty)\s*\(|["']fast-check["']/ },
|
|
29
|
+
{ name: "jqwik", re: /@Property\b|\bnet\.jqwik\b/ },
|
|
30
|
+
{ name: "proptest", re: /\bproptest!\s*\{/ },
|
|
31
|
+
{ name: "quickcheck", re: /\bquickcheck!\s*\{|#\[quickcheck\]|\bquickCheck\b/ },
|
|
32
|
+
{ name: "rapid", re: /\brapid\.Check\s*\(|\bquick\.Check\s*\(/ },
|
|
33
|
+
{ name: "FsCheck", re: /\bProp\.ForAll\b|\[Property\b/ },
|
|
34
|
+
{ name: "ScalaCheck", re: /\bforAll\s*[({]/ },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** The property library a test file uses, or null when it generates nothing. */
|
|
38
|
+
export function propertyLibraryIn(text) {
|
|
39
|
+
return LIBRARIES.find((lib) => lib.re.test(String(text)))?.name ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The shapes a universal claim takes in a requirement, with the property
|
|
43
|
+
// each one suggests. English and Portuguese, as the specs are.
|
|
44
|
+
const SHAPES = [
|
|
45
|
+
{ id: "round-trip",
|
|
46
|
+
re: /\b(round[- ]trip|and back\b|restor\w+ (?:the |its )?(?:original|same|previous)|revers(?:ible|ibly|es)\b|decod\w+|deserializ\w+|ida e volta|restaura\w* (?:o |a )?(?:original|mesm[oa]|anterior))/i,
|
|
47
|
+
property: "for any input x, undoing what was done to x gives x back" },
|
|
48
|
+
{ id: "idempotence",
|
|
49
|
+
re: /\b(idempoten\w+|twice|a second time|more than once|applied again|run again|duas vezes|de novo|mais de uma vez|outra vez)\b/i,
|
|
50
|
+
property: "for any input x, doing it twice leaves what doing it once left" },
|
|
51
|
+
{ id: "bound",
|
|
52
|
+
re: /\b(at most|at least (?!one\b)|no more than|no fewer than|not exceed|never exceed|within (?:\d|(?:\S+ ){0,3}(?:budget|limit|threshold|ratio|bound|quota|window|deadline)\b)|between \d|no máximo|pelo menos (?!um\b|uma\b)|no mínimo|não excede|entre \d)/i,
|
|
53
|
+
property: "for any input, the result stays inside the stated bound" },
|
|
54
|
+
{ id: "uniqueness",
|
|
55
|
+
re: /(?<![\w-])(unique|distinct(?!ive| from)|no two|never the same|(?:not|never) .{0,40}already (?:in use|used|taken|issued|exist\w*|registered)|único|única|distint\w+|nunca .{0,20}repet\w+|já (?:em uso|usad\w+|exist\w+))/i,
|
|
56
|
+
property: "for any sequence of inputs, no two results collide" },
|
|
57
|
+
{ id: "order",
|
|
58
|
+
re: /\b(sorted|ordered|in order(?! to\b)|ascending|descending|monoton\w+|ordenad\w+|em ordem|crescente|decrescente)\b/i,
|
|
59
|
+
property: "for any input, the output is in the stated order" },
|
|
60
|
+
{ id: "invariant",
|
|
61
|
+
re: /\b(always|never|for every|for any|for all|whatever|sempre|nunca|para todo|para toda|para qualquer|qualquer que seja)\b/i,
|
|
62
|
+
property: "for any input, the stated condition holds" },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** Every requirement bullet under `## Requirements`, with its section and ordinal. */
|
|
66
|
+
export function requirementsOf(specText) {
|
|
67
|
+
const out = [];
|
|
68
|
+
let inside = false;
|
|
69
|
+
let section = null;
|
|
70
|
+
let n = 0;
|
|
71
|
+
let cur = null;
|
|
72
|
+
for (const line of String(specText).split(/\r?\n/)) {
|
|
73
|
+
if (/^##\s+/.test(line) && !/^###/.test(line)) {
|
|
74
|
+
inside = /^##\s+Requirements\b/i.test(line);
|
|
75
|
+
section = null;
|
|
76
|
+
cur = null;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!inside) continue;
|
|
80
|
+
const h = line.match(/^###\s+(\S+)/);
|
|
81
|
+
if (h) {
|
|
82
|
+
section = h[1].toLowerCase().replace(/[^a-z-]/g, "").split("-")[0];
|
|
83
|
+
n = 0;
|
|
84
|
+
cur = null;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (section && /^-\s+\S/.test(line)) {
|
|
88
|
+
n += 1;
|
|
89
|
+
cur = { section, n, text: line.replace(/^-\s+/, "").trim() };
|
|
90
|
+
out.push(cur);
|
|
91
|
+
} else if (cur && /^\s+\S/.test(line)) {
|
|
92
|
+
cur.text += " " + line.trim();
|
|
93
|
+
} else if (line.trim() === "") {
|
|
94
|
+
cur = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The first shape a requirement's text takes, or null when it states no universal claim. */
|
|
101
|
+
export function propertyShape(text) {
|
|
102
|
+
const prose = String(text).replace(/`[^`]*`/g, "");
|
|
103
|
+
return SHAPES.find((s) => s.re.test(prose)) ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The property library this project's manifests point at, with how sure that is. */
|
|
107
|
+
export function projectPropertyLibrary(projectRoot) {
|
|
108
|
+
const text = (/** @type {string} */ rel) => (isFile(path.join(projectRoot, rel)) ? read(path.join(projectRoot, rel)) : "");
|
|
109
|
+
const py = text("pyproject.toml") + text("requirements.txt") + text("requirements-dev.txt") + text("setup.cfg") + text("Pipfile");
|
|
110
|
+
const js = text("package.json");
|
|
111
|
+
if (/\bhypothesis\b/i.test(py)) return { name: "Hypothesis", declared: true };
|
|
112
|
+
if (/"fast-check"/.test(js)) return { name: "fast-check", declared: true };
|
|
113
|
+
if (py.trim() !== "") return { name: "Hypothesis", declared: false };
|
|
114
|
+
if (js.trim() !== "") return { name: "fast-check", declared: false };
|
|
115
|
+
if (isFile(path.join(projectRoot, "pom.xml")) || isFile(path.join(projectRoot, "build.gradle")) || isFile(path.join(projectRoot, "build.gradle.kts"))) return { name: "jqwik", declared: false };
|
|
116
|
+
if (isFile(path.join(projectRoot, "Cargo.toml"))) return { name: "proptest", declared: false };
|
|
117
|
+
if (isFile(path.join(projectRoot, "go.mod"))) return { name: "rapid", declared: false };
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Per capability: the requirements that read as invariants, and how many of
|
|
123
|
+
* its criteria are already properties.
|
|
124
|
+
* @param {string} projectRoot
|
|
125
|
+
* @param {{ only?: Set<string> | null }} [opts]
|
|
126
|
+
*/
|
|
127
|
+
export function propertyCandidates(projectRoot, { only = /** @type {Set<string> | null} */ (null) } = {}) {
|
|
128
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
129
|
+
const out = [];
|
|
130
|
+
if (!isDir(specsDir)) return out;
|
|
131
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
132
|
+
if (only && !only.has(cap)) continue;
|
|
133
|
+
const file = path.join(specsDir, cap, "spec.md");
|
|
134
|
+
if (!isFile(file)) continue;
|
|
135
|
+
const text = read(file);
|
|
136
|
+
const candidates = [];
|
|
137
|
+
for (const req of requirementsOf(text)) {
|
|
138
|
+
const shape = propertyShape(req.text);
|
|
139
|
+
if (shape) candidates.push({ ref: `${req.section} ${req.n}`, text: req.text, shape: shape.id, property: shape.property });
|
|
140
|
+
}
|
|
141
|
+
// The specific shapes first: "at most", "twice", "and back" name the
|
|
142
|
+
// property to write; a bare "always" only says that there is one.
|
|
143
|
+
const rank = (/** @type {string} */ id) => SHAPES.findIndex((s) => s.id === id);
|
|
144
|
+
candidates.sort((a, b) => rank(a.shape) - rank(b.shape));
|
|
145
|
+
const properties = parseAcceptanceCriteria(text).filter((row) => row.marker !== null && PROPERTY_MARKER.test(row.marker)).length;
|
|
146
|
+
out.push({ cap, candidates, properties });
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
package/src/lib/runtime.js
CHANGED
|
@@ -203,7 +203,7 @@ export function readWorkflowEnv(text) {
|
|
|
203
203
|
for (let i = 0; i < lines.length; i++) {
|
|
204
204
|
const line = lines[i];
|
|
205
205
|
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
206
|
-
const indent = line.match(/^\s*/)[0].length;
|
|
206
|
+
const indent = (line.match(/^\s*/) ?? [""])[0].length;
|
|
207
207
|
|
|
208
208
|
if (/^jobs:\s*$/.test(line)) {
|
|
209
209
|
sawJobs = true;
|
|
@@ -681,17 +681,29 @@ function compileGlob(glob) {
|
|
|
681
681
|
*/
|
|
682
682
|
export function filesMatching(projectRoot, glob) {
|
|
683
683
|
const re = globToRegExp(glob);
|
|
684
|
+
const text = String(glob);
|
|
685
|
+
const segments = text.split("/");
|
|
684
686
|
// Walk from the deepest literal directory prefix so a narrow glob never
|
|
685
|
-
// costs a full-tree walk.
|
|
686
|
-
|
|
687
|
+
// costs a full-tree walk. The prefix ends at the first segment that is a
|
|
688
|
+
// pattern: taking every literal segment, wherever it stood, let
|
|
689
|
+
// `a/*/b/c.js` start from `a/b` when that directory existed.
|
|
687
690
|
let base = projectRoot;
|
|
688
|
-
|
|
691
|
+
let consumed = 0;
|
|
692
|
+
for (const seg of segments) {
|
|
693
|
+
if (/[*?{]/.test(seg)) break;
|
|
689
694
|
const next = path.join(base, seg);
|
|
690
|
-
if (isDir(next))
|
|
691
|
-
|
|
695
|
+
if (!isDir(next)) break;
|
|
696
|
+
base = next;
|
|
697
|
+
consumed += 1;
|
|
692
698
|
}
|
|
693
699
|
if (!isDir(base)) return [];
|
|
694
|
-
|
|
700
|
+
// ...and no deeper than the pattern can reach (change 0249). A pattern
|
|
701
|
+
// with no `**` names a fixed number of levels; `tsconfig.json` names one.
|
|
702
|
+
// Without the bound, each root-level file a spec declared walked the whole
|
|
703
|
+
// repository — `node_modules` and `.git` included — to find it.
|
|
704
|
+
const spans = text.endsWith("/") || segments.includes("**") || /\{[^}]*\/[^}]*\}/.test(text);
|
|
705
|
+
const depth = spans ? Infinity : Math.max(1, segments.length - consumed);
|
|
706
|
+
return walk(base, { depth }).filter((f) => re.test(relPath(projectRoot, f).replaceAll("\\", "/")));
|
|
695
707
|
}
|
|
696
708
|
|
|
697
709
|
// ---------------------------------------------------------------------------
|
package/src/lib/scan.js
CHANGED
|
@@ -24,6 +24,7 @@ export function listHeader(text, name) {
|
|
|
24
24
|
return getHeader(text, name);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** @returns {string[]} */
|
|
27
28
|
export function parseDependsOn(text) {
|
|
28
29
|
const raw = specHeader(text, "Depends on");
|
|
29
30
|
if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
|
|
@@ -77,6 +78,7 @@ function dirEntries(dir) {
|
|
|
77
78
|
// The capabilities an ADR governs, from its optional "Scope:" header
|
|
78
79
|
// (M4). Returns [] when absent, "n/a", or "—" — an unscoped ADR is global
|
|
79
80
|
// and belongs in every pack, which is what keeps this backward compatible.
|
|
81
|
+
/** @returns {string[]} */
|
|
80
82
|
export function parseAdrScope(text) {
|
|
81
83
|
const raw = listHeader(text, "Scope");
|
|
82
84
|
if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
|
|
@@ -175,7 +177,7 @@ export function changeEntry(proposal, id, prev, date) {
|
|
|
175
177
|
// carried the lane: an archived change lost the lane it was born in, so the
|
|
176
178
|
// report's "Lanes" section counted the whole history as unknown (change
|
|
177
179
|
// 0192), which is the question change 0042 recorded the lane to answer.
|
|
178
|
-
export function archivedChangeEntry(proposal, { id, archiveName, applied, specsAffected, prev = null, title = undefined }) {
|
|
180
|
+
export function archivedChangeEntry(proposal, { id, archiveName, applied, specsAffected, prev = /** @type {any} */ (null), title = /** @type {string | null | undefined} */ (undefined) }) {
|
|
179
181
|
return {
|
|
180
182
|
id,
|
|
181
183
|
title: (title === undefined ? parseChangeTitle(proposal) : title) ?? prev?.title ?? id,
|
|
@@ -213,12 +215,12 @@ export function deriveIndex(projectRoot, current) {
|
|
|
213
215
|
version: "0.1.0",
|
|
214
216
|
last_updated: date,
|
|
215
217
|
},
|
|
216
|
-
specs: [],
|
|
217
|
-
decisions: [],
|
|
218
|
-
changes: [],
|
|
219
|
-
changes_archive: [],
|
|
220
|
-
skills: [],
|
|
221
|
-
contracts: [],
|
|
218
|
+
specs: /** @type {any[]} */ ([]),
|
|
219
|
+
decisions: /** @type {any[]} */ ([]),
|
|
220
|
+
changes: /** @type {any[]} */ ([]),
|
|
221
|
+
changes_archive: /** @type {any[]} */ ([]),
|
|
222
|
+
skills: /** @type {any[]} */ ([]),
|
|
223
|
+
contracts: /** @type {any[]} */ ([]),
|
|
222
224
|
},
|
|
223
225
|
};
|
|
224
226
|
|
|
@@ -533,7 +535,10 @@ export function collectStagedIndexDrift(projectRoot) {
|
|
|
533
535
|
const nothing = { ok: true, applicable: false, missing: [], unindexed: [], reason: null };
|
|
534
536
|
if (!isRepo(projectRoot)) return { ...nothing, reason: "not a git repository" };
|
|
535
537
|
|
|
536
|
-
|
|
538
|
+
// `:./` reads the path from the current directory. A bare `:.doctrina/…`
|
|
539
|
+
// is read from the top of the repository, so for a project in a subfolder
|
|
540
|
+
// the check answered "no staged index" for every commit (change 0250).
|
|
541
|
+
const blob = git(projectRoot, ["show", ":./.doctrina/index.json"]);
|
|
537
542
|
if (blob.state !== GIT_STATE.OK || !blob.stdout.trim()) {
|
|
538
543
|
// Not staged at all — this commit does not carry an index, so it cannot
|
|
539
544
|
// carry one that disagrees with itself.
|
package/src/lib/spec-ops.js
CHANGED
|
@@ -50,7 +50,7 @@ const HEADER_RE = (name) =>
|
|
|
50
50
|
// see ADR 0007"): only the WORD is checked.
|
|
51
51
|
export const STATUS_VALUES = ["draft", "active", "deprecated"];
|
|
52
52
|
export const IMPLEMENTATION_VALUES = ["planned", "partial", "implemented", "verified"];
|
|
53
|
-
export const CRITERION_MARKS = ["verified", "unverified", "orchestration"];
|
|
53
|
+
export const CRITERION_MARKS = ["verified", "unverified", "orchestration", "property"];
|
|
54
54
|
|
|
55
55
|
/** The state word of a header/mark value: the first token, note stripped. */
|
|
56
56
|
export function stateWord(value) {
|
|
@@ -440,7 +440,7 @@ function locateRequirements(text) {
|
|
|
440
440
|
if (h) {
|
|
441
441
|
if (current) current.endIndex = i;
|
|
442
442
|
const token = SECTIONS[h[1].toLowerCase().replace(/[^a-z-]/g, "")] ?? null;
|
|
443
|
-
current = { headingIndex: i, endIndex: lines.length, bullets: [] };
|
|
443
|
+
current = { headingIndex: i, endIndex: lines.length, bullets: /** @type {number[]} */ ([]) };
|
|
444
444
|
if (token) sections[token] = current;
|
|
445
445
|
continue;
|
|
446
446
|
}
|