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.
Files changed (102) hide show
  1. package/README.md +7 -7
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +202 -27
  5. package/src/commands/clarify.js +3 -206
  6. package/src/commands/close.js +1 -1
  7. package/src/commands/context.js +33 -2
  8. package/src/commands/contract.js +1 -0
  9. package/src/commands/coverage.js +69 -6
  10. package/src/commands/delivery.js +135 -17
  11. package/src/commands/doctor.js +83 -5
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/hooks.js +56 -11
  14. package/src/commands/index-rebuild.js +47 -1
  15. package/src/commands/init.js +64 -8
  16. package/src/commands/intake.js +16 -5
  17. package/src/commands/metrics.js +16 -1
  18. package/src/commands/next.js +41 -6
  19. package/src/commands/onboard.js +113 -0
  20. package/src/commands/prime.js +53 -4
  21. package/src/commands/review.js +40 -4
  22. package/src/commands/search.js +2 -2
  23. package/src/commands/skill.js +2 -1
  24. package/src/commands/status.js +28 -0
  25. package/src/commands/templates.js +2 -1
  26. package/src/commands/triage.js +16 -1
  27. package/src/commands/ui.js +512 -0
  28. package/src/commands/upgrade.js +62 -4
  29. package/src/commands/validate.js +18 -0
  30. package/src/commands/verify.js +35 -7
  31. package/src/commands/why.js +1 -1
  32. package/src/commands/work.js +90 -42
  33. package/src/commands/workspace.js +89 -66
  34. package/src/index.js +107 -62
  35. package/src/lib/actions.js +62 -3
  36. package/src/lib/adapters.js +83 -1
  37. package/src/lib/agent-bridge.js +400 -0
  38. package/src/lib/applied-deltas.js +82 -0
  39. package/src/lib/args.js +34 -2
  40. package/src/lib/change-numbers.js +115 -0
  41. package/src/lib/change-ops.js +2 -2
  42. package/src/lib/clarify-scan.js +221 -0
  43. package/src/lib/code-inventory.js +594 -104
  44. package/src/lib/commands.js +58 -4
  45. package/src/lib/config.js +27 -8
  46. package/src/lib/conflict-markers.js +61 -0
  47. package/src/lib/console-model.js +668 -0
  48. package/src/lib/console-prefs.js +67 -0
  49. package/src/lib/console-room.js +201 -0
  50. package/src/lib/console-state.js +150 -0
  51. package/src/lib/console-view.js +557 -0
  52. package/src/lib/context-slice.js +34 -3
  53. package/src/lib/coverage-model.js +29 -6
  54. package/src/lib/delivery.js +90 -12
  55. package/src/lib/docs-impact.js +37 -2
  56. package/src/lib/fs-ops.js +103 -12
  57. package/src/lib/gates.js +7 -1
  58. package/src/lib/git.js +18 -0
  59. package/src/lib/index-json.js +10 -1
  60. package/src/lib/index-merge.js +90 -0
  61. package/src/lib/install.js +80 -0
  62. package/src/lib/intake-model.js +5 -2
  63. package/src/lib/json-out.js +9 -1
  64. package/src/lib/ledger.js +2 -3
  65. package/src/lib/lock.js +58 -0
  66. package/src/lib/merge-setup.js +55 -0
  67. package/src/lib/metrics-model.js +2 -2
  68. package/src/lib/onboard.js +197 -0
  69. package/src/lib/preset.js +177 -0
  70. package/src/lib/prompt.js +1 -1
  71. package/src/lib/properties.js +149 -0
  72. package/src/lib/runtime.js +19 -7
  73. package/src/lib/scan.js +13 -8
  74. package/src/lib/shell.js +45 -0
  75. package/src/lib/snapshot.js +5 -1
  76. package/src/lib/spec-ops.js +2 -2
  77. package/src/lib/task-card.js +101 -8
  78. package/src/lib/templates-model.js +3 -2
  79. package/src/lib/triage-model.js +75 -0
  80. package/src/lib/validation-model.js +23 -5
  81. package/src/lib/views.js +5 -4
  82. package/src/lib/work-model.js +8 -2
  83. package/src/lib/workspace-agents.js +67 -20
  84. package/src/lib/workspace-guard.js +65 -0
  85. package/src/lib/workspace-locate.js +141 -0
  86. package/src/lib/workspace-tools.js +104 -17
  87. package/src/lib/workspace-view.js +391 -0
  88. package/src/lib/workspace.js +83 -131
  89. package/templates/AGENTS.md.template +9 -5
  90. package/templates/README.md +4 -2
  91. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  92. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  93. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  94. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  95. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  96. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  97. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  98. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  99. package/templates/adapters/kiro/README.md +22 -0
  100. package/templates/playbooks/absorb.md.template +39 -16
  101. package/templates/playbooks/bootstrap.md.template +18 -6
  102. 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
- const skipped = isTest && isFile(hit) ? testSuiteIsSkipped(read(hit)) : false;
353
- resolved.push({ token, isTest, skipped });
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);
@@ -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:\*\*.*)$/m, `$1\n- **${name}:** ${value}`);
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
- out.push({ project: name, path: project.path, changes });
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
- const release = sectionText(delivery.text, "Release");
185
- const open = release.split("\n").filter((l) => /^[-*]\s*\[\s\]/.test(l));
186
- for (const box of open) out.push(`## Release: unticked — ${box.replace(/^[-*]\s*\[\s\]\s*/, "")}`);
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
+ }
@@ -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 (!touchedDeclared.includes(m[1])) envVars.add(m[1]);
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
- return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
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
- if (existsSync(p) && !force) {
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
- writeFileSync(p, content);
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
- export function walk(root) {
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
- const stack = [root];
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
- if (isDir(full)) stack.push(full);
123
- else if (isFile(full)) out.push(full);
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;
@@ -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
- write(p, text, { force: true });
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
+ }