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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { PROPERTY_MARKER, propertyLibraryIn } from "./properties.js";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
4
5
|
import { exists, isDir, isFile, read } from "./fs-ops.js";
|
|
5
6
|
import { specHeader } from "./scan.js";
|
|
@@ -27,9 +28,11 @@ import { parseAcceptanceCriteria } from "./criteria.js";
|
|
|
27
28
|
// never a --strict failure. Declared debt and hidden debt stop being punished
|
|
28
29
|
// identically (0.11.0 field review item 4: one deferred capability poisoned
|
|
29
30
|
// the close of every unrelated change).
|
|
30
|
-
export function collect(projectRoot, { only = null } = {}) {
|
|
31
|
+
export function collect(projectRoot, { only = /** @type {Set<string> | null} */ (null) } = {}) {
|
|
31
32
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
32
33
|
const reports = [];
|
|
34
|
+
/** @type {Map<string, boolean>} evidence file → is its suite skipped */
|
|
35
|
+
const skippedOf = new Map();
|
|
33
36
|
if (isDir(specsDir)) {
|
|
34
37
|
for (const cap of readdirSync(specsDir).sort()) {
|
|
35
38
|
if (only && !only.has(cap)) continue;
|
|
@@ -40,7 +43,7 @@ export function collect(projectRoot, { only = null } = {}) {
|
|
|
40
43
|
if (criteria.length === 0) continue;
|
|
41
44
|
const deferred = isDeclaredDeferral(text);
|
|
42
45
|
const rows = criteria.map((crit, i) => {
|
|
43
|
-
const row = classify(crit, i + 1, projectRoot, path.dirname(specPath));
|
|
46
|
+
const row = classify(crit, i + 1, projectRoot, path.dirname(specPath), skippedOf);
|
|
44
47
|
if (deferred && row.kind !== "covered") {
|
|
45
48
|
return { ...row, kind: "deferred", was: row.kind };
|
|
46
49
|
}
|
|
@@ -145,7 +148,7 @@ export function deriveImplementation(row) {
|
|
|
145
148
|
*
|
|
146
149
|
* @returns {Map<string, {derived: string, covered: number, total: number, deferred: boolean, unverified: number}>}
|
|
147
150
|
*/
|
|
148
|
-
export function derivedImplementations(projectRoot, { only = null } = {}) {
|
|
151
|
+
export function derivedImplementations(projectRoot, { only = /** @type {Set<string> | null} */ (null) } = {}) {
|
|
149
152
|
const out = new Map();
|
|
150
153
|
for (const rep of collect(projectRoot, { only })) {
|
|
151
154
|
const row = {
|
|
@@ -301,7 +304,7 @@ function evidenceClause(criterion) {
|
|
|
301
304
|
return m ? criterion.slice(m.index) : null;
|
|
302
305
|
}
|
|
303
306
|
|
|
304
|
-
function classify(criterion, n, projectRoot, specDir) {
|
|
307
|
+
function classify(criterion, n, projectRoot, specDir, skippedOf = /** @type {Map<string, boolean>} */ (new Map())) {
|
|
305
308
|
// An orchestration criterion is judged on its GUARD, not on whether a
|
|
306
309
|
// cited file exists — the whole point is that existence proves nothing here.
|
|
307
310
|
const marker = criterion.match(/^\[([^\]]+)\]/)?.[1]?.toLowerCase() ?? "";
|
|
@@ -349,10 +352,30 @@ function classify(criterion, n, projectRoot, specDir) {
|
|
|
349
352
|
continue;
|
|
350
353
|
}
|
|
351
354
|
const isTest = looksLikeTestFile(token);
|
|
352
|
-
|
|
353
|
-
|
|
355
|
+
// One read per evidence file, not one per criterion that cites it
|
|
356
|
+
// (change 0249): a suite that proves forty criteria was read and
|
|
357
|
+
// scanned forty times, on every `prime`, `status` and `validate`.
|
|
358
|
+
let skipped = false;
|
|
359
|
+
if (isTest && isFile(hit)) {
|
|
360
|
+
if (!skippedOf.has(hit)) skippedOf.set(hit, testSuiteIsSkipped(read(hit)));
|
|
361
|
+
skipped = skippedOf.get(hit) === true;
|
|
362
|
+
}
|
|
363
|
+
resolved.push({ token, isTest, skipped, hit });
|
|
354
364
|
}
|
|
355
365
|
if (resolved.length === 0) return { kind: "dangling", n, missing };
|
|
366
|
+
// A `[property]` criterion claims something of EVERY input (change 0254):
|
|
367
|
+
// the test it cites must be one that generates its inputs. An example
|
|
368
|
+
// test proves three cases; calling that proof of a universal claim is the
|
|
369
|
+
// false confidence this gate exists against.
|
|
370
|
+
if (PROPERTY_MARKER.test(marker)) {
|
|
371
|
+
const generating = resolved.find((r) => r.isTest && !r.skipped && propertyLibraryIn(read(r.hit)) !== null);
|
|
372
|
+
if (!generating) {
|
|
373
|
+
return {
|
|
374
|
+
kind: "unguarded", n, label: "property claim is proven by no property",
|
|
375
|
+
reason: `${resolved.map((r) => `\`${r.token}\``).join(", ")} generates no input — a [property] criterion is proven by a property-based test (Hypothesis \`@given\`, fast-check \`fc.property\`, jqwik \`@Property\`, proptest), or it is an example and its mark is [verified]`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
356
379
|
// Runnable evidence: the resolving test-shaped citations, recorded so
|
|
357
380
|
// `coverage --run` can execute them (existence → passing proof).
|
|
358
381
|
const evidence = resolved.filter((r) => r.isTest && !r.skipped).map((r) => r.token);
|
package/src/lib/delivery.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { readdirSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { isDir, isFile, read, write } from "./fs-ops.js";
|
|
4
|
+
import { eolOf, isDir, isFile, read, withEol, write } from "./fs-ops.js";
|
|
5
5
|
import { listHeader } from "./scan.js";
|
|
6
|
-
import { checklistProgress, setHeader } from "./doc-model.js";
|
|
6
|
+
import { checklistProgress, maskComments, parseChecklist, setHeader } from "./doc-model.js";
|
|
7
|
+
import { parseLedger } from "./ledger.js";
|
|
7
8
|
import { terms } from "./lexicon.js";
|
|
8
9
|
import { slugify, padNumber } from "./dates.js";
|
|
9
|
-
import { artifactRootOf } from "./workspace.js";
|
|
10
|
+
import { artifactRootOf, projectState } from "./workspace.js";
|
|
10
11
|
|
|
11
12
|
// A DELIVERY is one item of work that crosses repositories (ADR 0030): a
|
|
12
13
|
// story, a screen, a fix. It lives in the workspace's `deliveries/` and
|
|
@@ -72,8 +73,8 @@ export function setDeliveryHeader(wsDir, id, name, value) {
|
|
|
72
73
|
let text = read(p);
|
|
73
74
|
const next = setHeader(text, name, value);
|
|
74
75
|
if (next !== null) text = next;
|
|
75
|
-
else text = text.replace(/^(- \*\*Projects
|
|
76
|
-
write(p, text, { force: true });
|
|
76
|
+
else text = text.replace(/^(- \*\*Projects:\*\*[^\r\n]*)/m, `$1\n- **${name}:** ${value}`);
|
|
77
|
+
write(p, withEol(text, eolOf(read(p))), { force: true });
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/**
|
|
@@ -125,11 +126,11 @@ export function deliveryChanges(wsDir, ws, delivery) {
|
|
|
125
126
|
for (const name of delivery.projects) {
|
|
126
127
|
const project = ws.projects.find((p) => p.name === name);
|
|
127
128
|
if (!project) {
|
|
128
|
-
out.push({ project: name, missing: "not registered in the workspace", changes: [] });
|
|
129
|
+
out.push({ project: name, missing: "not registered in the workspace", changes: /** @type {any[]} */ ([]) });
|
|
129
130
|
continue;
|
|
130
131
|
}
|
|
131
132
|
const changesDir = path.join(artifactRootOf(wsDir, project), ".doctrina", "changes");
|
|
132
|
-
const changes = [];
|
|
133
|
+
const changes = /** @type {any[]} */ ([]);
|
|
133
134
|
for (const { dir, archived } of [{ dir: changesDir, archived: false }, { dir: path.join(changesDir, "archive"), archived: true }]) {
|
|
134
135
|
if (!isDir(dir)) continue;
|
|
135
136
|
for (const entry of readdirSync(dir).sort()) {
|
|
@@ -138,16 +139,26 @@ export function deliveryChanges(wsDir, ws, delivery) {
|
|
|
138
139
|
const text = read(proposal);
|
|
139
140
|
if ((listHeader(text, "Delivery") ?? "").trim() !== delivery.id) continue;
|
|
140
141
|
const tasks = path.join(dir, entry, "tasks.md");
|
|
141
|
-
const progress = isFile(tasks) ? checklistProgress(read(tasks)) : { total: 0, done: 0 };
|
|
142
|
+
const progress = isFile(tasks) ? checklistProgress(read(tasks)) : { total: 0, done: 0, placeholders: 0 };
|
|
142
143
|
changes.push({
|
|
143
144
|
id: archived ? entry.replace(/^\d{4}-\d{2}-\d{2}-/, "") : entry,
|
|
144
145
|
archived,
|
|
145
146
|
status: archived ? "archived" : (listHeader(text, "Status") ?? "proposed").toLowerCase(),
|
|
146
147
|
tasksDone: progress.done, tasksTotal: progress.total,
|
|
148
|
+
// Scaffold boxes no one wrote (third audit, change 0234): counted
|
|
149
|
+
// as tasks, they read "3 tasks left — hand the next card" for a
|
|
150
|
+
// change whose card command answers that none is written.
|
|
151
|
+
placeholders: progress.placeholders ?? 0,
|
|
147
152
|
});
|
|
148
153
|
}
|
|
149
154
|
}
|
|
150
|
-
|
|
155
|
+
// Abandoned for this delivery: the ledger keeps the delivery's id (change 0234).
|
|
156
|
+
const ledger = path.join(changesDir, "archive", "LEDGER.md");
|
|
157
|
+
const abandoned = isFile(ledger)
|
|
158
|
+
? parseLedger(read(ledger)).entries.filter((e) => e.kind === "abandoned" && new RegExp(`\\(delivery ${delivery.id}\\)\\s*$`).test(e.title))
|
|
159
|
+
.map((e) => ({ id: e.id, reason: e.title.replace(/^abandoned\s*(—\s*)?/i, "").replace(/\s*\(delivery [^)]*\)\s*$/, "") }))
|
|
160
|
+
: [];
|
|
161
|
+
out.push({ project: name, path: project.path, absent: !isDir(project.path), changes, abandoned });
|
|
151
162
|
}
|
|
152
163
|
return out;
|
|
153
164
|
}
|
|
@@ -181,9 +192,9 @@ export function daysBetween(from, to) {
|
|
|
181
192
|
export function recordBlockers(delivery) {
|
|
182
193
|
const out = [];
|
|
183
194
|
if (sectionText(delivery.text, "Story") === "") out.push("## Story is empty — copy the tracker item's story and acceptance criteria, verbatim");
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
for (const box of
|
|
195
|
+
// The same boxes `delivery tick` lists and ticks — one reader, so a box
|
|
196
|
+
// the check holds you to is always one the CLI can tick.
|
|
197
|
+
for (const box of releaseBoxes(delivery.text).filter((b) => !b.checked)) out.push(`## Release: unticked — ${box.text}`);
|
|
187
198
|
if (sectionText(delivery.text, "Observability") === "") out.push("## Observability is empty — record what was watched after the release (monitor, dashboard or query, the window, what it showed)");
|
|
188
199
|
return out;
|
|
189
200
|
}
|
|
@@ -198,6 +209,8 @@ export function deliveryBlockers(wsDir, ws, delivery) {
|
|
|
198
209
|
if (delivery.projects.length === 0) blockers.push("no project routed — `doctrina delivery route <id> <a,b>`");
|
|
199
210
|
for (const row of deliveryChanges(wsDir, ws, delivery)) {
|
|
200
211
|
if (row.missing) blockers.push(`${row.project}: ${row.missing}`);
|
|
212
|
+
else if (row.absent) blockers.push(`${row.project}: the repository is not at ${row.path} — \`doctrina workspace attach ${row.project} --path <dir>\``);
|
|
213
|
+
else if (row.changes.length === 0 && row.abandoned?.length) blockers.push(`${row.project}: its change ${row.abandoned.map((a) => a.id).join(", ")} was abandoned — re-route the delivery without ${row.project}, or open a new change there with \`doctrina work "<title>" --delivery ${delivery.id}\``);
|
|
201
214
|
else if (row.changes.length === 0) blockers.push(`${row.project}: no change carries this delivery — open one there with \`doctrina work "<title>" --delivery ${delivery.id}\``);
|
|
202
215
|
else for (const ch of row.changes.filter((x) => !x.archived)) {
|
|
203
216
|
blockers.push(`${row.project}: change ${ch.id} is ${ch.status} (${ch.tasksDone}/${ch.tasksTotal} tasks) — close it there with \`doctrina close ${ch.id}\``);
|
|
@@ -206,3 +219,68 @@ export function deliveryBlockers(wsDir, ws, delivery) {
|
|
|
206
219
|
blockers.push(...recordBlockers(delivery));
|
|
207
220
|
return blockers;
|
|
208
221
|
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The `## Release` boxes of a delivery, in order, with their line index in
|
|
225
|
+
* the LF-normalised text (change 0226). The other sections are prose the
|
|
226
|
+
* planner writes; these are claims, and a claim is ticked through the CLI.
|
|
227
|
+
*/
|
|
228
|
+
export function releaseBoxes(text) {
|
|
229
|
+
// One owner for the box grammar (ADR 0021): the document model reads the
|
|
230
|
+
// boxes; a box inside a comment is the template's example, not a claim.
|
|
231
|
+
const lf = String(text).replace(/\r\n/g, "\n");
|
|
232
|
+
return parseChecklist(maskComments(lf), { section: "Release" })
|
|
233
|
+
.filter((b) => b.section === "Release")
|
|
234
|
+
.map((b, i) => ({ ordinal: i + 1, line: b.line, checked: b.checked, text: b.text }));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The text with the given Release boxes ticked (all when `ordinals` is "all"). */
|
|
238
|
+
export function tickRelease(text, ordinals) {
|
|
239
|
+
const lines = String(text).replace(/\r\n/g, "\n").split("\n");
|
|
240
|
+
for (const box of releaseBoxes(text)) {
|
|
241
|
+
if (ordinals === "all" || ordinals.includes(box.ordinal)) {
|
|
242
|
+
lines[box.line] = lines[box.line].replace(/\[\s\]/, "[x]");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return lines.join("\n");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Projects a delivery cannot be routed to yet (change 0226): one whose
|
|
250
|
+
* repository is missing, or one still `pending` — no spec to write a delta
|
|
251
|
+
* against, so the change opened there would have nothing to change.
|
|
252
|
+
* @returns {{ name: string, state: string, reason: string, path: string }[]}
|
|
253
|
+
*/
|
|
254
|
+
export function unroutable(wsDir, ws, names) {
|
|
255
|
+
const out = [];
|
|
256
|
+
for (const name of names) {
|
|
257
|
+
const project = ws.projects.find((p) => p.name === name);
|
|
258
|
+
if (!project) continue;
|
|
259
|
+
const st = projectState(wsDir, project);
|
|
260
|
+
if (st.state !== "ready") out.push({ name, state: st.state, reason: st.reason, path: project.path });
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Open changes in a routed repository that do NOT belong to this delivery
|
|
267
|
+
* (change 0226). Each is a second writer the planner must sequence: one
|
|
268
|
+
* writer per repository at a time holds across deliveries too.
|
|
269
|
+
* @returns {{ project: string, id: string, delivery: string|null }[]}
|
|
270
|
+
*/
|
|
271
|
+
export function otherWriters(wsDir, ws, delivery) {
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const name of delivery.projects) {
|
|
274
|
+
const project = ws.projects.find((p) => p.name === name);
|
|
275
|
+
if (!project) continue;
|
|
276
|
+
const changesDir = path.join(artifactRootOf(wsDir, project), ".doctrina", "changes");
|
|
277
|
+
if (!isDir(changesDir)) continue;
|
|
278
|
+
for (const entry of readdirSync(changesDir).sort()) {
|
|
279
|
+
const proposal = path.join(changesDir, entry, "proposal.md");
|
|
280
|
+
if (entry === "archive" || !isFile(proposal)) continue;
|
|
281
|
+
const of = (listHeader(read(proposal), "Delivery") ?? "").trim() || null;
|
|
282
|
+
if (of !== delivery.id) out.push({ project: name, id: entry, delivery: of });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
package/src/lib/docs-impact.js
CHANGED
|
@@ -123,7 +123,7 @@ function withoutVerification(text) {
|
|
|
123
123
|
return out.join("\n");
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
export function documentedSurfaceSignals(changeDir, projectRoot = null) {
|
|
126
|
+
export function documentedSurfaceSignals(changeDir, projectRoot = /** @type {string | null} */ (null)) {
|
|
127
127
|
const signals = [];
|
|
128
128
|
const known = new Set(COMMAND_NAMES);
|
|
129
129
|
|
|
@@ -257,10 +257,16 @@ export function documentedSurfaceSignals(changeDir, projectRoot = null) {
|
|
|
257
257
|
signals.push(`endpoints: ${[...routes].sort().slice(0, 6).join(", ")}`);
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
// An UPPER_SNAKE name is an environment variable when something says so
|
|
261
|
+
// (change 0239): the shape alone also matches an error code
|
|
262
|
+
// (`CONTA_BLOQUEADA`) or a constant (`MAX_FALHAS`), and every change that
|
|
263
|
+
// merely cited one was asked to document an "environment" it never touched.
|
|
264
|
+
const exampleNames = projectRoot === null ? new Set() : envExampleNames(projectRoot);
|
|
260
265
|
const envVars = new Set();
|
|
261
266
|
for (const m of code.matchAll(/\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/g)) {
|
|
262
267
|
// Not twice: a declared variable is already reported by name above.
|
|
263
|
-
if (
|
|
268
|
+
if (touchedDeclared.includes(m[1])) continue;
|
|
269
|
+
if (looksLikeEnv(m[1], text) || exampleNames.has(m[1])) envVars.add(m[1]);
|
|
264
270
|
}
|
|
265
271
|
if (envVars.size > 0) {
|
|
266
272
|
signals.push(`environment: ${[...envVars].sort().slice(0, 8).join(", ")}`);
|
|
@@ -408,3 +414,32 @@ export function checkDocsImpact(projectRoot, changeDir) {
|
|
|
408
414
|
reason: "alters a documented surface but no docs/ or README change accompanies it",
|
|
409
415
|
};
|
|
410
416
|
}
|
|
417
|
+
|
|
418
|
+
// The suffixes configuration read from the environment carries.
|
|
419
|
+
const ENV_SUFFIX = /_(URL|URI|DSN|HOST|PORT|ENDPOINT|KEY|TOKEN|SECRET|PASSWORD|PASS|USER|USERNAME|PATH|DIR|FILE|HOME|ENV|MODE|LEVEL|TIMEOUT|REGION|BUCKET|ENABLED|DISABLED|DEBUG|FLAG|PROFILE)$/;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Whether the change text marks a name as an environment variable: read the
|
|
423
|
+
* way code reads one (`process.env.X`, `$X`, `${X}`, `getenv("X")`,
|
|
424
|
+
* `os.environ["X"]`, `X=`), named on a line that speaks of the environment,
|
|
425
|
+
* or carrying a configuration suffix (`_URL`, `_TOKEN`, `_PORT`…).
|
|
426
|
+
* @param {string} name
|
|
427
|
+
* @param {string} text the change's authored text
|
|
428
|
+
*/
|
|
429
|
+
export function looksLikeEnv(name, text) {
|
|
430
|
+
if (ENV_SUFFIX.test(name)) return true;
|
|
431
|
+
const n = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
432
|
+
if (new RegExp(`(process\\.env\\.|process\\.env\\[["'\`]|\\benv\\.|\\$\\{?|getenv\\(["']|environ(?:\\.get\\(|\\[)["']|ENV\\[["'])${n}\\b|\\b${n}=`).test(text)) return true;
|
|
433
|
+
return text.split("\n").some((line) => line.includes(name) && /\b(env|environment|environment variable|variável de ambiente|ambiente|secret|segredo|\.env)\b/i.test(line));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The variable names a project's env example files declare. */
|
|
437
|
+
function envExampleNames(projectRoot) {
|
|
438
|
+
const out = new Set();
|
|
439
|
+
for (const f of [".env.example", ".env.sample", ".env.template", ".env.dist", "example.env"]) {
|
|
440
|
+
const file = path.join(projectRoot, f);
|
|
441
|
+
if (!isFile(file)) continue;
|
|
442
|
+
for (const m of read(file).matchAll(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/gm)) out.add(m[1]);
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
package/src/lib/fs-ops.js
CHANGED
|
@@ -19,17 +19,91 @@ import path from "node:path";
|
|
|
19
19
|
// A file that had one loses it when a command rewrites it. That is a
|
|
20
20
|
// normalisation, and the right one — nothing in this tree is served by
|
|
21
21
|
// keeping it.
|
|
22
|
+
/**
|
|
23
|
+
* The line ending a file already uses: CRLF when it carries one, LF otherwise.
|
|
24
|
+
* A command that rewrites a file a person owns keeps the file's ending — a
|
|
25
|
+
* Windows checkout with `core.autocrlf` stores CRLF, and turning it into LF
|
|
26
|
+
* on a tick makes every line of the file show as changed in the diff.
|
|
27
|
+
* @param {string} text
|
|
28
|
+
* @returns {"\r\n"|"\n"}
|
|
29
|
+
*/
|
|
30
|
+
export function eolOf(text) {
|
|
31
|
+
return /\r\n/.test(String(text)) ? "\r\n" : "\n";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `text` with every line ending made `eol` — what a rewrite hands to `write`
|
|
36
|
+
* after editing in LF, so an inserted line never mixes endings.
|
|
37
|
+
* @param {string} text
|
|
38
|
+
* @param {"\r\n"|"\n"} eol
|
|
39
|
+
*/
|
|
40
|
+
export function withEol(text, eol) {
|
|
41
|
+
const lf = String(text).replace(/\r\n/g, "\n");
|
|
42
|
+
return eol === "\n" ? lf : lf.replace(/\n/g, "\r\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ...and so is the line ending (seventh audit, change 0250).
|
|
46
|
+
//
|
|
47
|
+
// A Windows checkout with `core.autocrlf` stores every artifact with CRLF,
|
|
48
|
+
// and the grammar was read with patterns written for LF. `.*$` does not
|
|
49
|
+
// cross a `\r`, so a header stamp matched nothing and reported success; a
|
|
50
|
+
// placeholder bullet `-\r` read as an empty requirement, and a fresh spec
|
|
51
|
+
// carried five EARS warnings an LF checkout never saw; a line diff showed
|
|
52
|
+
// the whole file as changed. Each was fixed where it was found, one caller
|
|
53
|
+
// at a time (`eolOf`/`withEol`), and the next caller forgot.
|
|
54
|
+
//
|
|
55
|
+
// So the rule is the door's: text is READ with LF, whatever the file holds,
|
|
56
|
+
// and WRITTEN BACK with the ending the file already has. No parser
|
|
57
|
+
// downstream has to know which checkout it is running in, and a rewrite
|
|
58
|
+
// never mixes endings or turns a file's every line into a diff.
|
|
22
59
|
export function read(p) {
|
|
23
60
|
const text = readFileSync(p, "utf8");
|
|
24
|
-
|
|
61
|
+
const body = text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
|
|
62
|
+
return body.includes("\r\n") ? body.replace(/\r\n/g, "\n") : body;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Does the file at `p` end its lines with CRLF today? False when absent or unreadable. */
|
|
66
|
+
function storedAsCrlf(p) {
|
|
67
|
+
try {
|
|
68
|
+
return readFileSync(p, "utf8").includes("\r\n");
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
25
72
|
}
|
|
26
73
|
|
|
27
|
-
export function write(p, content, { force = false } = {}) {
|
|
28
|
-
|
|
74
|
+
export function write(p, content, { force = false, atomic = false } = {}) {
|
|
75
|
+
const there = existsSync(p);
|
|
76
|
+
if (there && !force) {
|
|
29
77
|
throw new Error(`refusing to overwrite existing file: ${p} (pass --force to allow)`);
|
|
30
78
|
}
|
|
31
79
|
mkdirSync(path.dirname(p), { recursive: true });
|
|
32
|
-
|
|
80
|
+
// A rewrite keeps the ending the file has; a new file is written as given.
|
|
81
|
+
const text = there && typeof content === "string" && storedAsCrlf(p)
|
|
82
|
+
? content.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n")
|
|
83
|
+
: content;
|
|
84
|
+
if (!atomic) {
|
|
85
|
+
writeFileSync(p, text);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Whole or not at all: written beside the target and moved over it, so a
|
|
89
|
+
// command reading the file at that instant never sees half of it. Where the
|
|
90
|
+
// move is refused (a reader holding the file open on Windows) the plain
|
|
91
|
+
// write still lands.
|
|
92
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
93
|
+
try {
|
|
94
|
+
writeFileSync(tmp, text);
|
|
95
|
+
renameSync(tmp, p);
|
|
96
|
+
} catch {
|
|
97
|
+
writeFileSync(p, text);
|
|
98
|
+
try {
|
|
99
|
+
unlinkSync(tmp);
|
|
100
|
+
} catch { /* never created, or already moved */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Append `line` to the text file at `p`, with the ending the file already uses. */
|
|
105
|
+
export function appendLine(p, line) {
|
|
106
|
+
writeFileSync(p, `${line}${storedAsCrlf(p) ? "\r\n" : "\n"}`, { flag: "a" });
|
|
33
107
|
}
|
|
34
108
|
|
|
35
109
|
export function exists(p) {
|
|
@@ -110,17 +184,34 @@ export function pruneEmptyDirs(dir, stopAt) {
|
|
|
110
184
|
return removed;
|
|
111
185
|
}
|
|
112
186
|
|
|
113
|
-
// Recursive file walk. Returns absolute paths of every regular file.
|
|
114
|
-
|
|
187
|
+
// Recursive file walk. Returns absolute paths of every regular file, sorted.
|
|
188
|
+
//
|
|
189
|
+
// The directory listing already says what each entry is, so it is asked
|
|
190
|
+
// once (change 0249): the walk used to `stat` every entry twice — is it a
|
|
191
|
+
// directory? is it a file? — which was a third of `validate`'s time on this
|
|
192
|
+
// repository. A symbolic link is still followed with a `stat`, as before.
|
|
193
|
+
// `depth` bounds how far down it goes: 1 is the files directly in `root`.
|
|
194
|
+
/**
|
|
195
|
+
* @param {string} root
|
|
196
|
+
* @param {{ depth?: number }} [opts]
|
|
197
|
+
* @returns {string[]}
|
|
198
|
+
*/
|
|
199
|
+
export function walk(root, { depth = Infinity } = {}) {
|
|
200
|
+
/** @type {string[]} */
|
|
115
201
|
const out = [];
|
|
116
202
|
if (!exists(root)) return out;
|
|
117
|
-
|
|
203
|
+
/** @type {[string, number][]} */
|
|
204
|
+
const stack = [[root, 1]];
|
|
118
205
|
while (stack.length) {
|
|
119
|
-
const dir = stack.pop();
|
|
120
|
-
for (const entry of readdirSync(dir)) {
|
|
121
|
-
const full = path.join(dir, entry);
|
|
122
|
-
|
|
123
|
-
|
|
206
|
+
const [dir, level] = /** @type {[string, number]} */ (stack.pop());
|
|
207
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
208
|
+
const full = path.join(dir, entry.name);
|
|
209
|
+
const link = entry.isSymbolicLink();
|
|
210
|
+
if (link ? isDir(full) : entry.isDirectory()) {
|
|
211
|
+
if (level < depth) stack.push([full, level + 1]);
|
|
212
|
+
} else if (link ? isFile(full) : entry.isFile()) {
|
|
213
|
+
out.push(full);
|
|
214
|
+
}
|
|
124
215
|
}
|
|
125
216
|
}
|
|
126
217
|
return out.sort();
|
package/src/lib/gates.js
CHANGED
|
@@ -67,7 +67,7 @@ export const GATES = {
|
|
|
67
67
|
blockers(projectRoot, changeDir) {
|
|
68
68
|
const out = [];
|
|
69
69
|
// Same counter as every other surface (change 0067).
|
|
70
|
-
const countUnchecked = (text, section = null) =>
|
|
70
|
+
const countUnchecked = (text, section = /** @type {string | null} */ (null)) =>
|
|
71
71
|
checklistProgress(text, { section }).total - checklistProgress(text, { section }).done;
|
|
72
72
|
|
|
73
73
|
const tasksPath = path.join(changeDir, "tasks.md");
|
|
@@ -224,6 +224,12 @@ export const SEQUENCES = {
|
|
|
224
224
|
// the CLI's source, which is how a pt-BR project sat red under `clarify`
|
|
225
225
|
// with no clue why (change 0047). Advisory: a default is not a fault.
|
|
226
226
|
{ id: "config", label: "config", level: "advisory", argv: null, rerun: "edit .doctrina/config.json" },
|
|
227
|
+
// Which `doctrina` the terminal runs, and how the repository merges
|
|
228
|
+
// (changes 0242, 0243). Readouts of the machine and the clone, not of the
|
|
229
|
+
// artifacts: the hooks call `doctrina` by name, so a second install ahead
|
|
230
|
+
// on the PATH is the one they run.
|
|
231
|
+
{ id: "cli", label: "cli", level: "advisory", argv: null, rerun: "doctrina --version" },
|
|
232
|
+
{ id: "merges", label: "merges", level: "advisory", argv: null, rerun: "doctrina hooks install" },
|
|
227
233
|
// Also a readout, and also advisory. It appears only when the operator
|
|
228
234
|
// has switched the usage log on: an instrument that is off has nothing
|
|
229
235
|
// to say, and a row saying so every run would train the reader to skip
|
package/src/lib/git.js
CHANGED
|
@@ -69,6 +69,24 @@ export function git(cwd, args, { env } = {}) {
|
|
|
69
69
|
return { state: GIT_STATE.FAILED, stdout: "", lines: [], stderr, code: r.status };
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Where git keeps `name` for the repository `cwd` is in — `hooks`,
|
|
74
|
+
* `info/exclude` — as an absolute path, or null outside a repository. Asked
|
|
75
|
+
* of git, never built from `.git/`: in a worktree and in a submodule `.git`
|
|
76
|
+
* is a FILE, and `core.hooksPath` moves the hooks anywhere (change 0250).
|
|
77
|
+
*/
|
|
78
|
+
export function gitPathOf(cwd, name) {
|
|
79
|
+
const r = git(cwd, ["rev-parse", "--git-path", name]);
|
|
80
|
+
if (r.state !== GIT_STATE.OK || r.stdout.trim() === "") return null;
|
|
81
|
+
return path.resolve(cwd, r.stdout.trim());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `cwd` relative to the top of its repository, with forward slashes ("" at the top), or null outside one. */
|
|
85
|
+
export function repoPrefix(cwd) {
|
|
86
|
+
const r = git(cwd, ["rev-parse", "--show-prefix"]);
|
|
87
|
+
return r.state === GIT_STATE.OK ? r.stdout.trim().replace(/\/+$/, "") : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
72
90
|
// Is this a git repository at all?
|
|
73
91
|
export function isRepo(cwd) {
|
|
74
92
|
return git(cwd, ["rev-parse", "--is-inside-work-tree"]).state === GIT_STATE.OK;
|
package/src/lib/index-json.js
CHANGED
|
@@ -30,6 +30,12 @@ export function load(projectRoot) {
|
|
|
30
30
|
try {
|
|
31
31
|
return JSON.parse(raw);
|
|
32
32
|
} catch (err) {
|
|
33
|
+
// Conflict markers are the one way this file usually stops parsing
|
|
34
|
+
// (change 0242): two branches closed a change each, and git merged the
|
|
35
|
+
// derived index as text. Said as what it is, with the two ways out.
|
|
36
|
+
if (/^(<{7}|={7}|>{7})( |$)/m.test(raw)) {
|
|
37
|
+
throw new Error(".doctrina/index.json carries git's merge conflict markers — it is derived, so `doctrina index rebuild` writes it again from the tree (and `doctrina hooks install` makes git merge it by itself)");
|
|
38
|
+
}
|
|
33
39
|
throw new Error(`.doctrina/index.json is not valid JSON: ${err.message}`);
|
|
34
40
|
}
|
|
35
41
|
}
|
|
@@ -42,7 +48,10 @@ export function save(projectRoot, index) {
|
|
|
42
48
|
index.framework_version = newestVersion(index.framework_version, cliVersion());
|
|
43
49
|
const p = indexPath(projectRoot);
|
|
44
50
|
const text = JSON.stringify(index, null, 2) + "\n";
|
|
45
|
-
|
|
51
|
+
// Atomic (change 0250): every command reads this file, and one that read
|
|
52
|
+
// it while another was half-way through writing it stopped with
|
|
53
|
+
// "Unexpected end of JSON input".
|
|
54
|
+
write(p, text, { force: true, atomic: true });
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
export function blank(projectName, date) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { ARTIFACT_CATEGORIES } from "./index-json.js";
|
|
3
|
+
import { newestVersion } from "./version.js";
|
|
4
|
+
|
|
5
|
+
// THE INDEX MERGES AS WHAT IT IS: A LIST OF ARTIFACTS (change 0242).
|
|
6
|
+
//
|
|
7
|
+
// `index.json` is one file every change rewrites — a version here, a closed
|
|
8
|
+
// change appended there, the date on top. Two branches that each close a
|
|
9
|
+
// change therefore meet on the same lines, and git, which merges text, stops
|
|
10
|
+
// with conflict markers in a file nobody wrote by hand.
|
|
11
|
+
//
|
|
12
|
+
// Merged as data there is nothing to resolve: an entry one branch added is
|
|
13
|
+
// added, an entry one branch removed is removed, and where both changed the
|
|
14
|
+
// same field the newer value wins — a version only grows, a date only moves
|
|
15
|
+
// forward. What this cannot know (a header edited on both sides) the tree
|
|
16
|
+
// still decides: `doctrina index rebuild` derives the index from the files,
|
|
17
|
+
// and the commit hook runs it.
|
|
18
|
+
|
|
19
|
+
const key = (/** @type {any} */ entry) => String(entry?.path ?? entry?.id ?? "");
|
|
20
|
+
const same = (/** @type {any} */ a, /** @type {any} */ b) => JSON.stringify(a) === JSON.stringify(b);
|
|
21
|
+
|
|
22
|
+
/** `1.10.0` over `1.9.0`; anything that is not three numbers compares as text. */
|
|
23
|
+
function laterVersion(a, b) {
|
|
24
|
+
const parts = (/** @type {string} */ v) => String(v).split(".").map((n) => Number(n));
|
|
25
|
+
const [x, y] = [parts(a), parts(b)];
|
|
26
|
+
if (x.some(Number.isNaN) || y.some(Number.isNaN)) return String(a) >= String(b) ? a : b;
|
|
27
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
28
|
+
if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0) ? a : b;
|
|
29
|
+
}
|
|
30
|
+
return a;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Both sides changed `field` to different values: the one that moved furthest. */
|
|
34
|
+
function settle(field, ours, theirs) {
|
|
35
|
+
if (field === "framework_version") return newestVersion(ours, theirs);
|
|
36
|
+
if (field === "version") return laterVersion(ours, theirs);
|
|
37
|
+
if (/(^|_)(updated|date)$/.test(field)) return String(ours) >= String(theirs) ? ours : theirs;
|
|
38
|
+
return ours;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Three-way merge of one object's fields. */
|
|
42
|
+
function mergeFields(base, ours, theirs) {
|
|
43
|
+
/** @type {Record<string, any>} */
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const field of new Set([...Object.keys(ours ?? {}), ...Object.keys(theirs ?? {})])) {
|
|
46
|
+
const [b, o, t] = [base?.[field], ours?.[field], theirs?.[field]];
|
|
47
|
+
let value;
|
|
48
|
+
if (same(o, t) || same(t, b)) value = o;
|
|
49
|
+
else if (same(o, b)) value = t;
|
|
50
|
+
else if (o === undefined || t === undefined) value = o ?? t;
|
|
51
|
+
else value = settle(field, o, t);
|
|
52
|
+
if (value !== undefined) out[field] = value;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Three-way merge of one artifact category, in the order a rebuild writes. */
|
|
58
|
+
function mergeList(base, ours, theirs) {
|
|
59
|
+
const by = (/** @type {any[]} */ list) => new Map((list ?? []).map((e) => [key(e), e]));
|
|
60
|
+
const [b, o, t] = [by(base), by(ours), by(theirs)];
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const k of new Set([...o.keys(), ...t.keys()])) {
|
|
63
|
+
const [eb, eo, et] = [b.get(k), o.get(k), t.get(k)];
|
|
64
|
+
if (eo && et) out.push(mergeFields(eb, eo, et));
|
|
65
|
+
// Present on one side only: added there, or removed on the other. An
|
|
66
|
+
// entry the base never had was added; one it had was removed — unless
|
|
67
|
+
// the side that kept it also changed it, which a removal must not erase.
|
|
68
|
+
else if (!eb) out.push(eo ?? et);
|
|
69
|
+
else if (!same(eo ?? et, eb)) out.push(eo ?? et);
|
|
70
|
+
}
|
|
71
|
+
return out.sort((x, y) => (key(x) < key(y) ? -1 : key(x) > key(y) ? 1 : 0));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Merge two indexes that descend from `base`. Pure: returns the merged index.
|
|
76
|
+
* @param {any} base the common ancestor (`{}` when there is none)
|
|
77
|
+
* @param {any} ours
|
|
78
|
+
* @param {any} theirs
|
|
79
|
+
*/
|
|
80
|
+
export function mergeIndex(base, ours, theirs) {
|
|
81
|
+
const out = mergeFields({ ...base, artifacts: undefined }, { ...ours, artifacts: undefined }, { ...theirs, artifacts: undefined });
|
|
82
|
+
const [ab, ao, at] = [base?.artifacts ?? {}, ours?.artifacts ?? {}, theirs?.artifacts ?? {}];
|
|
83
|
+
/** @type {Record<string, any>} */
|
|
84
|
+
const artifacts = {};
|
|
85
|
+
for (const cat of new Set([...Object.keys(ao), ...Object.keys(at)])) {
|
|
86
|
+
const list = ARTIFACT_CATEGORIES.includes(cat) || Array.isArray(ao[cat] ?? at[cat]);
|
|
87
|
+
artifacts[cat] = list ? mergeList(ab[cat], ao[cat], at[cat]) : mergeFields(ab[cat], ao[cat] ?? {}, at[cat] ?? {});
|
|
88
|
+
}
|
|
89
|
+
return { ...out, artifacts };
|
|
90
|
+
}
|