doctrina-cli 0.15.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/adapter.js +24 -1
- package/src/commands/analyze.js +8 -212
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/change-ops.js +131 -0
- package/src/lib/commands.js +56 -4
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +181 -23
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
package/src/lib/doc-model.js
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
// a non-canonical form is REPORTED (so `validate --fix` can repair it)
|
|
19
19
|
// rather than silently accepted or silently missed.
|
|
20
20
|
// STRICT ON WRITE. One canonical form per artifact kind, always.
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { read } from "./fs-ops.js";
|
|
23
|
+
import { locateTemplatesDir } from "./templates.js";
|
|
21
24
|
|
|
22
25
|
// Artifact kinds and the header form each one canonically uses. Specs and
|
|
23
26
|
// contracts use bare bold; ADRs, proposals, and the intake use list items.
|
|
@@ -285,3 +288,326 @@ export function parseArtifact(text, { kind = ARTIFACT_KIND.UNKNOWN } = {}) {
|
|
|
285
288
|
nonConforming: nonConformingHeaders(text, kind),
|
|
286
289
|
};
|
|
287
290
|
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// The rest of the on-disk grammar (ADR 0021, change 0043)
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
//
|
|
296
|
+
// ADR 0021 declares ONE document model that owns how a Doctrina artifact is
|
|
297
|
+
// read off disk. Three parsers lived outside it — skill frontmatter in a
|
|
298
|
+
// command module, and the two delta parsers in another — and `lib/scan.js`
|
|
299
|
+
// imported them FROM `commands/`, inverting the dependency the layering
|
|
300
|
+
// depends on. Change 0037 broke that edge by moving them into lib/; this is
|
|
301
|
+
// the second half: they belong to the module the ADR names, not to two more
|
|
302
|
+
// libraries beside it.
|
|
303
|
+
//
|
|
304
|
+
// Pure text predicates, all of them. Nothing here reads a file.
|
|
305
|
+
|
|
306
|
+
export function parseFrontmatter(text, key) {
|
|
307
|
+
// Match frontmatter blocks bounded by `---` lines at start of file.
|
|
308
|
+
const fmMatch = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
|
|
309
|
+
if (!fmMatch) return null;
|
|
310
|
+
const block = fmMatch[1];
|
|
311
|
+
const lineRe = new RegExp(`^${key}\\s*:\\s*(.+)$`, "m");
|
|
312
|
+
const m = block.match(lineRe);
|
|
313
|
+
return m ? m[1].trim() : null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function parseOperation(text) {
|
|
317
|
+
const m = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
|
|
318
|
+
if (!m) return null;
|
|
319
|
+
const op = m[1];
|
|
320
|
+
if (op === "ADDED" || op === "MODIFIED" || op === "REMOVED") return op;
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function parseCapabilityFromDelta(text, deltaPath) {
|
|
325
|
+
// Prefer the explicit header "# Spec Delta — capability: <name>"
|
|
326
|
+
const m = text.match(/^#\s+Spec Delta\s*[—-]\s*capability:\s*([a-z][a-z0-9-]*)/m);
|
|
327
|
+
if (m) return m[1];
|
|
328
|
+
// Fall back to the parent directory name of the delta file
|
|
329
|
+
const parent = path.basename(path.dirname(deltaPath));
|
|
330
|
+
if (/^[a-z][a-z0-9-]*$/.test(parent)) return parent;
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The value a shipped template writes for `name`, or null when the template
|
|
336
|
+
* cannot be located (an unusual install) or does not carry that header.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} templateName e.g. "spec.md.template"
|
|
339
|
+
* @param {string} name
|
|
340
|
+
* @returns {string|null}
|
|
341
|
+
*/
|
|
342
|
+
export function templateHeaderValue(templateName, name) {
|
|
343
|
+
try {
|
|
344
|
+
return getHeader(read(path.join(locateTemplatesDir(), templateName)), name);
|
|
345
|
+
} catch {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Is this header value still the scaffold's placeholder?
|
|
352
|
+
*
|
|
353
|
+
* A check written to catch a MISSING header is dead in the normal flow when
|
|
354
|
+
* the scaffold writes a value for it: the escape hatch arrives pre-armed
|
|
355
|
+
* (change 0057). The templates mark a placeholder by wrapping the whole
|
|
356
|
+
* value in angle brackets, so that is the portable rule; when the templates
|
|
357
|
+
* can be located, the template's own value settles it exactly.
|
|
358
|
+
*
|
|
359
|
+
* @param {string|null|undefined} value
|
|
360
|
+
* @param {{ template?: string, name?: string }} [opts]
|
|
361
|
+
* @returns {boolean}
|
|
362
|
+
*/
|
|
363
|
+
export function isPlaceholderHeaderValue(value, opts = {}) {
|
|
364
|
+
if (value === null || value === undefined) return true;
|
|
365
|
+
const v = String(value).trim();
|
|
366
|
+
if (v === "") return true;
|
|
367
|
+
// `<...>` wrapping the WHOLE value — the templates' placeholder convention.
|
|
368
|
+
// A real value may still contain angle brackets ("n/a — <why>" does not,
|
|
369
|
+
// but a prose value could), which is why the whole value must be wrapped.
|
|
370
|
+
if (/^<[\s\S]*>$/.test(v)) return true;
|
|
371
|
+
if (opts.template && opts.name) {
|
|
372
|
+
const tpl = templateHeaderValue(opts.template, opts.name);
|
|
373
|
+
if (tpl !== null && tpl.trim() === v) return true;
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Every GitHub-style checkbox in `text`, in reading order.
|
|
380
|
+
*
|
|
381
|
+
* One owner for the box grammar (ADR 0021). It had six, and they disagreed:
|
|
382
|
+
* `snapshot.js` required TEXT after the box (`\[[ xX]\]\s+(.*)`), so the three
|
|
383
|
+
* empty placeholders `work` scaffolds were invisible to it and `prime` printed
|
|
384
|
+
* "tasks 0/3" for a change with six open boxes — the three it counted being
|
|
385
|
+
* the closing steps, not the work. `gates.js` required no text and counted
|
|
386
|
+
* six; `change tick` counted eight, adding the proposal's Verification boxes.
|
|
387
|
+
* Three numbers for the same file, side by side in the same session.
|
|
388
|
+
*
|
|
389
|
+
* Each entry carries whether the box is a scaffold PLACEHOLDER — a box with
|
|
390
|
+
* nothing written after it — because that is a real distinction (`analyze`
|
|
391
|
+
* refuses a change that still has them) and it is the one the counters must
|
|
392
|
+
* agree to make explicitly rather than by accident.
|
|
393
|
+
*
|
|
394
|
+
* @param {string} text
|
|
395
|
+
* @param {{ section?: string|null }} [opts] limit to one `## ` section
|
|
396
|
+
* @returns {Array<{ line: number, checked: boolean, text: string, placeholder: boolean }>}
|
|
397
|
+
*/
|
|
398
|
+
export function parseChecklist(text, opts = {}) {
|
|
399
|
+
const section = opts.section ?? null;
|
|
400
|
+
const lines = String(text).split(/\r?\n/);
|
|
401
|
+
const out = [];
|
|
402
|
+
let inSection = section === null;
|
|
403
|
+
for (let i = 0; i < lines.length; i++) {
|
|
404
|
+
if (/^##\s+/.test(lines[i])) {
|
|
405
|
+
if (section !== null) {
|
|
406
|
+
inSection = new RegExp(`^##\\s+${section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i")
|
|
407
|
+
.test(lines[i]);
|
|
408
|
+
}
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (!inSection) continue;
|
|
412
|
+
// Any Markdown bullet marker opens a box (change 0104): `* [ ]` and
|
|
413
|
+
// `+ [ ]` are task items to every renderer, and a box the grammar
|
|
414
|
+
// cannot see is one `tick` cannot tick and the archive gate cannot
|
|
415
|
+
// count — an unchecked task that closes green.
|
|
416
|
+
const m = /^\s*[-*+]\s*\[([ xX])\]\s*(.*)$/.exec(lines[i]);
|
|
417
|
+
if (!m) continue;
|
|
418
|
+
const body = m[2].trim();
|
|
419
|
+
out.push({ line: i, checked: m[1] !== " ", text: body, placeholder: body === "" });
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* How many boxes a checklist holds and how many are done — the pair every
|
|
426
|
+
* progress line prints. Placeholders count: a box nobody wrote is a box
|
|
427
|
+
* nobody finished, and hiding it is what made "0/3" mean six.
|
|
428
|
+
*
|
|
429
|
+
* @param {string} text
|
|
430
|
+
* @param {{ section?: string|null }} [opts]
|
|
431
|
+
* @returns {{ total: number, done: number, open: string[], placeholders: number }}
|
|
432
|
+
*/
|
|
433
|
+
export function checklistProgress(text, opts = {}) {
|
|
434
|
+
const boxes = parseChecklist(text, opts);
|
|
435
|
+
return {
|
|
436
|
+
total: boxes.length,
|
|
437
|
+
done: boxes.filter((b) => b.checked).length,
|
|
438
|
+
open: boxes.filter((b) => !b.checked).map((b) => b.text),
|
|
439
|
+
placeholders: boxes.filter((b) => b.placeholder).length,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Is this section still the shipped template's — empty, or nothing but the
|
|
445
|
+
* template's own instructional comment and placeholder?
|
|
446
|
+
*
|
|
447
|
+
* The framework writes artifacts from templates and then trusts that someone
|
|
448
|
+
* filled them in. Exactly one gate checked that (`analyze`, over a change
|
|
449
|
+
* proposal); everything else took the mould for a decision (change 0065).
|
|
450
|
+
* This is the same ruler `isPlaceholderHeaderValue` applies to a header,
|
|
451
|
+
* applied to a section body: an HTML comment is annotation (change 0055), so
|
|
452
|
+
* a section that is only annotation is a section nobody wrote.
|
|
453
|
+
*
|
|
454
|
+
* @param {string} text the whole artifact
|
|
455
|
+
* @param {string} name the `## ` section name
|
|
456
|
+
* @param {{ template?: string }} [opts] template file to compare against
|
|
457
|
+
* @returns {boolean}
|
|
458
|
+
*/
|
|
459
|
+
export function isUnwrittenSection(text, name, opts = {}) {
|
|
460
|
+
const body = getSection(text, name);
|
|
461
|
+
if (body === null) return true;
|
|
462
|
+
const prose = maskComments(body).trim();
|
|
463
|
+
if (prose === "") return true;
|
|
464
|
+
// A body that is nothing but the template's placeholder bullets or an
|
|
465
|
+
// angle-bracket placeholder is unwritten too.
|
|
466
|
+
const stripped = prose.replace(/^[-*]\s*$/gm, "").trim();
|
|
467
|
+
if (stripped === "") return true;
|
|
468
|
+
if (/^<[\s\S]*>$/.test(stripped)) return true;
|
|
469
|
+
if (opts.template) {
|
|
470
|
+
try {
|
|
471
|
+
const tpl = read(path.join(locateTemplatesDir(), opts.template));
|
|
472
|
+
const tplBody = getSection(tpl, name);
|
|
473
|
+
if (tplBody !== null && maskComments(tplBody).trim() === prose) return true;
|
|
474
|
+
} catch {
|
|
475
|
+
// No templates dir — the checks above already carry the common cases.
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The named sections of `text` that nobody has written yet, in order.
|
|
483
|
+
*
|
|
484
|
+
* @param {string} text
|
|
485
|
+
* @param {string[]} names
|
|
486
|
+
* @param {{ template?: string }} [opts]
|
|
487
|
+
* @returns {string[]}
|
|
488
|
+
*/
|
|
489
|
+
export function unwrittenSections(text, names, opts = {}) {
|
|
490
|
+
return names.filter((name) => isUnwrittenSection(text, name, opts));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Is the on-disk spec still the untouched `spec new <cap>` scaffold? Precise
|
|
494
|
+
// check: render the shipped capability template for the same capability and
|
|
495
|
+
// compare, ignoring the date-bearing "Last updated" line and whitespace
|
|
496
|
+
// normalisation. When the template cannot be located (unusual installs),
|
|
497
|
+
// fall back to the scaffold's own placeholder fingerprints — text no real
|
|
498
|
+
// spec keeps. Used by `change apply` so an ADDED delta can replace a
|
|
499
|
+
// scaffold (the canonical spec-new → delta flow) without ever clobbering a
|
|
500
|
+
// spec that carries real content.
|
|
501
|
+
export function isUntouchedScaffold(specText, capability) {
|
|
502
|
+
const normalize = (s) =>
|
|
503
|
+
s.replace(/\r\n/g, "\n")
|
|
504
|
+
.split("\n")
|
|
505
|
+
.filter((line) => !/^\*\*Last updated:\*\*/.test(line))
|
|
506
|
+
.join("\n")
|
|
507
|
+
.trim();
|
|
508
|
+
try {
|
|
509
|
+
const tplPath = path.join(locateTemplatesDir(), "spec.md.template");
|
|
510
|
+
const rendered = read(tplPath)
|
|
511
|
+
.replace(/\{\{CAPABILITY\}\}/g, capability)
|
|
512
|
+
.replace(/\{\{DATE\}\}/g, "");
|
|
513
|
+
if (normalize(rendered) === normalize(specText)) return true;
|
|
514
|
+
} catch {
|
|
515
|
+
// fall through to the fingerprint heuristic
|
|
516
|
+
}
|
|
517
|
+
// Fingerprints: the Purpose placeholder comment AND an empty Ubiquitous
|
|
518
|
+
// section survive only in a scaffold nobody edited.
|
|
519
|
+
return (
|
|
520
|
+
specText.includes("<!-- One paragraph: what this capability does and why it exists. -->") &&
|
|
521
|
+
/##\s+Requirements \(EARS\)[\s\S]*?### Ubiquitous\s*\n\s*-\s*\n/.test(specText)
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The byte ranges every HTML comment occupies in `text`, in file order.
|
|
528
|
+
*
|
|
529
|
+
* A comment is annotation, not content: the scaffolded spec carries the
|
|
530
|
+
* five-line EARS legend inside one, and the guessed delta (change 0044)
|
|
531
|
+
* carries its `RANKED GUESS` note inside another. Every reader of an
|
|
532
|
+
* on-disk artifact has to agree on that, so the ranges are computed HERE
|
|
533
|
+
* and nowhere else (ADR 0021 — one owner for the on-disk grammar).
|
|
534
|
+
*
|
|
535
|
+
* @param {string} text
|
|
536
|
+
* @returns {Array<[number, number]>}
|
|
537
|
+
*/
|
|
538
|
+
export function commentRanges(text) {
|
|
539
|
+
/** @type {Array<[number, number]>} */
|
|
540
|
+
const ranges = [];
|
|
541
|
+
for (const m of text.matchAll(/<!--[\s\S]*?-->/g)) {
|
|
542
|
+
ranges.push([m.index ?? 0, (m.index ?? 0) + m[0].length]);
|
|
543
|
+
}
|
|
544
|
+
return ranges;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Is this offset inside one of `ranges`?
|
|
549
|
+
*
|
|
550
|
+
* @param {Array<[number, number]>} ranges
|
|
551
|
+
* @param {number} offset
|
|
552
|
+
* @returns {boolean}
|
|
553
|
+
*/
|
|
554
|
+
export function isInsideComment(ranges, offset) {
|
|
555
|
+
return ranges.some(([a, b]) => offset >= a && offset < b);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* `text` with every HTML comment blanked out — same length, same line
|
|
560
|
+
* count, same offset for every character that survives.
|
|
561
|
+
*
|
|
562
|
+
* This is the skip-by-position rule of `spec-ops.matchOpsBlock` in a form
|
|
563
|
+
* a line- or regex-oriented scanner can use directly: a bullet inside a
|
|
564
|
+
* comment stops looking like a bullet, and a command name inside one stops
|
|
565
|
+
* looking like a reference, WITHOUT any surviving character moving. Masking
|
|
566
|
+
* is for SCANNING only. Never write the masked text back to disk, and never
|
|
567
|
+
* derive an authored value from it — a delta whose `append-criterion` value
|
|
568
|
+
* legitimately contains `<!-- illustrative -->` must land with the marker
|
|
569
|
+
* intact, which is why `applyOps` still works on the raw text.
|
|
570
|
+
*
|
|
571
|
+
* @param {string} text
|
|
572
|
+
* @returns {string}
|
|
573
|
+
*/
|
|
574
|
+
export function maskComments(text) {
|
|
575
|
+
let out = text;
|
|
576
|
+
for (const [a, b] of commentRanges(text)) {
|
|
577
|
+
const blanked = text.slice(a, b).replace(/[^\r\n]/g, " ");
|
|
578
|
+
out = out.slice(0, a) + blanked + out.slice(b);
|
|
579
|
+
}
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The title a change proposal's H1 states, or null.
|
|
585
|
+
*
|
|
586
|
+
* The H1 the template writes is `# Change <id> — <title>`, and the id itself
|
|
587
|
+
* contains hyphens (`NNNN-slug`) — which is what four separate copies of this
|
|
588
|
+
* regex kept getting wrong in four slightly different ways. The one that read
|
|
589
|
+
* `[^—-]*` for the id stopped at the FIRST hyphen, which in any multi-word id
|
|
590
|
+
* is the id's own, so `prime`, `handoff` and `report` printed the slug glued
|
|
591
|
+
* in front of the title on every change `work` had ever generated. The ones
|
|
592
|
+
* that read `\s*[—-]\s*` accepted a bare hyphen with no spaces around it, so
|
|
593
|
+
* an H1 with no separator at all had its last segment read as the title.
|
|
594
|
+
*
|
|
595
|
+
* The separator is a dash WITH whitespace on both sides; an id's hyphens
|
|
596
|
+
* never have that, which is the whole ambiguity, resolved. Both the em dash
|
|
597
|
+
* and the plain hyphen are accepted, because the character was never the
|
|
598
|
+
* problem — a hand-written H1 using `-` reads exactly as clearly.
|
|
599
|
+
*
|
|
600
|
+
* The `Change <id> —` prefix is optional: an H1 written without it is a title
|
|
601
|
+
* in its own right and is returned whole (ADR 0021 — one owner for the
|
|
602
|
+
* on-disk grammar).
|
|
603
|
+
*
|
|
604
|
+
* @param {string} text the proposal, or just its first line
|
|
605
|
+
* @returns {string|null}
|
|
606
|
+
*/
|
|
607
|
+
export function parseChangeTitle(text) {
|
|
608
|
+
const first = String(text ?? "").split(/\r?\n/).find((l) => /^#\s+\S/.test(l));
|
|
609
|
+
if (!first) return null;
|
|
610
|
+
const m = first.match(/^#\s+(?:Change\s+\S+\s+[—-]\s+)?(.+)$/);
|
|
611
|
+
const title = m?.[1]?.trim();
|
|
612
|
+
return title ? title : null;
|
|
613
|
+
}
|
package/src/lib/docs-impact.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import { isFile, read, walk } from "./fs-ops.js";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
5
5
|
import { COMMAND_NAMES } from "./commands.js";
|
|
6
6
|
import { locateTemplatesDir } from "./templates.js";
|
|
7
|
+
import { changedFiles, isRepo } from "./git.js";
|
|
8
|
+
import { maskComments, getSection } from "./doc-model.js";
|
|
9
|
+
import { listHeader } from "./scan.js";
|
|
7
10
|
|
|
8
11
|
// "Docs ship inside the change, never after it" (audit item D2).
|
|
9
12
|
//
|
|
@@ -46,11 +49,81 @@ function scaffoldLines() {
|
|
|
46
49
|
return lines;
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
function escapeRe(x) {
|
|
53
|
+
return x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The names THIS project declares as its integration surface, read from its
|
|
57
|
+
// own contracts: the variables of the Environment and Wiring tables, the
|
|
58
|
+
// services of the Ports table, and the backticked tokens of Interfaces.
|
|
59
|
+
//
|
|
60
|
+
// A contract is the right home for this. It is where a project already states
|
|
61
|
+
// what an external consumer integrates against, it is versioned beside the
|
|
62
|
+
// code, and `contract check` already holds parts of it to the implementation
|
|
63
|
+
// (ADR 0023 — the runtime surface is declared, never inferred). The same
|
|
64
|
+
// principle, applied to documentation: declared, never guessed.
|
|
65
|
+
//
|
|
66
|
+
// Returns [] for a project with no contracts, which is what keeps the change
|
|
67
|
+
// backward compatible: that project's gate behaves exactly as it did.
|
|
68
|
+
export function declaredSurfaceNames(projectRoot) {
|
|
69
|
+
const dir = path.join(projectRoot, ".doctrina", "contracts");
|
|
70
|
+
if (!isDir(dir)) return [];
|
|
71
|
+
const names = new Set();
|
|
72
|
+
for (const file of walk(dir)) {
|
|
73
|
+
if (!file.endsWith(".md")) continue;
|
|
74
|
+
const text = maskComments(read(file));
|
|
75
|
+
|
|
76
|
+
// First column of a table, for the sections whose first column IS the name.
|
|
77
|
+
for (const section of ["Ports", "Environment", "Wiring", "Selectors", "Budgets"]) {
|
|
78
|
+
const body = getSection(text, section);
|
|
79
|
+
if (!body) continue;
|
|
80
|
+
for (const line of body.split(/\r?\n/)) {
|
|
81
|
+
const m = /^\s*\|\s*([A-Za-z][\w.-]*)\s*\|/.exec(line);
|
|
82
|
+
if (!m) continue;
|
|
83
|
+
const cell = m[1];
|
|
84
|
+
// Skip the header row and the template's own placeholder rows.
|
|
85
|
+
if (/^(service|variable|selector|limit|name)$/i.test(cell)) continue;
|
|
86
|
+
if (/^-+$/.test(cell)) continue;
|
|
87
|
+
names.add(cell);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Interfaces is prose with backticked tokens: endpoints, flags, shapes.
|
|
92
|
+
const interfaces = getSection(text, "Interfaces");
|
|
93
|
+
if (interfaces) {
|
|
94
|
+
for (const m of interfaces.matchAll(/`([^`\n]{2,60})`/g)) {
|
|
95
|
+
const token = m[1].trim();
|
|
96
|
+
// A command with a subcommand ("ledgerly reconcile") is exactly the
|
|
97
|
+
// shape worth declaring, so single internal spaces are allowed —
|
|
98
|
+
// anything with punctuation or prose in it is not a name.
|
|
99
|
+
if (/^[\w./:@+-]+(?: [\w./:@+-]+)*$/.test(token)) names.add(token);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return [...names];
|
|
104
|
+
}
|
|
105
|
+
|
|
49
106
|
// Does this change alter something the documentation makes promises about?
|
|
50
107
|
// Read from the change's own artifacts — the deltas it will merge into
|
|
51
108
|
// specs, plus the proposal that states its shape. Returns the list of
|
|
52
109
|
// signals found (empty when the change touches no documented surface).
|
|
53
|
-
|
|
110
|
+
// Drop the proposal's `## Verification` section before reading it for
|
|
111
|
+
// surface signals. That section answers "how will you know this landed", so
|
|
112
|
+
// the commands it names are the ones the author will RUN — the same reason
|
|
113
|
+
// the template's own checklist lines are subtracted below. Running a gate is
|
|
114
|
+
// not changing it (change 0088).
|
|
115
|
+
function withoutVerification(text) {
|
|
116
|
+
const lines = text.split(/\r?\n/);
|
|
117
|
+
const out = [];
|
|
118
|
+
let skipping = false;
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
if (/^##\s+/.test(line)) skipping = /^##\s+Verification\b/i.test(line);
|
|
121
|
+
if (!skipping) out.push(line);
|
|
122
|
+
}
|
|
123
|
+
return out.join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function documentedSurfaceSignals(changeDir, projectRoot = null) {
|
|
54
127
|
const signals = [];
|
|
55
128
|
const known = new Set(COMMAND_NAMES);
|
|
56
129
|
|
|
@@ -59,15 +132,39 @@ export function documentedSurfaceSignals(changeDir) {
|
|
|
59
132
|
// and `doctrina coverage` — so scanning the raw file made every change look
|
|
60
133
|
// like it touched a documented surface. A gate that fires on everything is
|
|
61
134
|
// a gate that gets ignored, so the template's lines are subtracted first.
|
|
135
|
+
// An HTML comment is annotation, not authored surface. The guessed delta
|
|
136
|
+
// change 0044 scaffolds carries a `RANKED GUESS` note that names
|
|
137
|
+
// `doctrina work` — not template text, so the subtraction below never
|
|
138
|
+
// reaches it, and every change on the default path arrived at the docs
|
|
139
|
+
// gate carrying a phantom `commands: work`. Comments are blanked FIRST,
|
|
140
|
+
// by the document model, which is the one owner of that rule.
|
|
62
141
|
const boilerplate = scaffoldLines();
|
|
63
|
-
const authored = (text) => text
|
|
142
|
+
const authored = (text) => maskComments(text)
|
|
64
143
|
.split(/\r?\n/)
|
|
65
144
|
.filter((line) => !boilerplate.has(normaliseScaffoldLine(line)))
|
|
66
145
|
.join("\n");
|
|
67
146
|
|
|
68
147
|
const sources = [];
|
|
69
148
|
const proposal = path.join(changeDir, "proposal.md");
|
|
70
|
-
if (isFile(proposal))
|
|
149
|
+
if (isFile(proposal)) {
|
|
150
|
+
const text = read(proposal);
|
|
151
|
+
|
|
152
|
+
// A CHORE declares that no behaviour changes and no spec moves — that is
|
|
153
|
+
// what the lane means, and `analyze` already reads it that way ("0 spec
|
|
154
|
+
// deltas, metadata-only change"). Asking such a change to document a
|
|
155
|
+
// surface it declared it does not touch contradicts its own lane. The
|
|
156
|
+
// declaration is the author's, recorded in the proposal header and the
|
|
157
|
+
// index (change 0042), so it is auditable rather than invisible.
|
|
158
|
+
//
|
|
159
|
+
// This is the case that mattered: of the archived changes, every chore
|
|
160
|
+
// names a command in code context and none of them changes one. Change
|
|
161
|
+
// 0085 reorganised headings in AGENTS.md and was refused for "commands:
|
|
162
|
+
// close, templates" — the two commands its own proposal cited to describe
|
|
163
|
+
// the finding — and had to close with --force (change 0088).
|
|
164
|
+
if (/chore/i.test(listHeader(text, "Lane") ?? "")) return signals;
|
|
165
|
+
|
|
166
|
+
sources.push(authored(withoutVerification(text)));
|
|
167
|
+
}
|
|
71
168
|
for (const p of walk(path.join(changeDir, "specs"))) {
|
|
72
169
|
if (p.endsWith("delta.md")) sources.push(authored(read(p)));
|
|
73
170
|
}
|
|
@@ -89,12 +186,52 @@ export function documentedSurfaceSignals(changeDir) {
|
|
|
89
186
|
signals.push(`commands: ${[...commands].sort().join(", ")}`);
|
|
90
187
|
}
|
|
91
188
|
|
|
189
|
+
// The surface the PROJECT declares, matched by name (change 0075's sibling
|
|
190
|
+
// problem, found in the second audit). Matching commands against Doctrina's
|
|
191
|
+
// own catalog made the gate maximally sensitive inside this repository and
|
|
192
|
+
// inert everywhere else: in an adopting project a new command, a public HTTP
|
|
193
|
+
// endpoint, a renamed environment variable and a changed config key all
|
|
194
|
+
// produced ZERO signals, and the change closed with no documentation and no
|
|
195
|
+
// complaint. The vocabulary was already in the right place — the contract
|
|
196
|
+
// declares Ports, Environment, Wiring, Selectors and Interfaces — so the gate
|
|
197
|
+
// reads it instead of carrying a catalog that only fits its author.
|
|
198
|
+
const declared = projectRoot === null ? [] : declaredSurfaceNames(projectRoot);
|
|
199
|
+
const hit = declared.filter((name) => new RegExp(`\\b${escapeRe(name)}\\b`).test(code));
|
|
200
|
+
// Report the most specific declaration only: "ledgerly reconcile" says more
|
|
201
|
+
// than the bare service name it starts with.
|
|
202
|
+
const touchedDeclared = hit.filter((a) => !hit.some((b) => b !== a && b.includes(a)));
|
|
203
|
+
if (touchedDeclared.length > 0) {
|
|
204
|
+
signals.push(`declared surface: ${touchedDeclared.sort().slice(0, 8).join(", ")}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
92
207
|
const flags = new Set();
|
|
93
208
|
for (const m of code.matchAll(/--([a-z][a-z0-9-]{2,})/g)) flags.add(`--${m[1]}`);
|
|
94
209
|
if (flags.size > 0) {
|
|
95
210
|
signals.push(`flags: ${[...flags].sort().slice(0, 8).join(", ")}`);
|
|
96
211
|
}
|
|
97
212
|
|
|
213
|
+
// Surface SHAPES, for the surface a change is ADDING — which by definition
|
|
214
|
+
// is not in the contract yet, and is the case the declared-name match cannot
|
|
215
|
+
// reach. Deterministic and language-agnostic (ADR 0005): a route path, an
|
|
216
|
+
// HTTP method in front of one, an environment-variable identifier. Read from
|
|
217
|
+
// code spans only, so prose that happens to contain a slash is not a signal.
|
|
218
|
+
const routes = new Set();
|
|
219
|
+
for (const m of code.matchAll(/(?:^|[\s`"'(])((?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+)?(\/[a-z][\w.-]*(?:\/[\w.:{}$<>-]+)+)/gi)) {
|
|
220
|
+
routes.add(((m[1] ?? "").toUpperCase().trim() + " " + m[2]).trim());
|
|
221
|
+
}
|
|
222
|
+
if (routes.size > 0) {
|
|
223
|
+
signals.push(`endpoints: ${[...routes].sort().slice(0, 6).join(", ")}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const envVars = new Set();
|
|
227
|
+
for (const m of code.matchAll(/\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/g)) {
|
|
228
|
+
// Not twice: a declared variable is already reported by name above.
|
|
229
|
+
if (!touchedDeclared.includes(m[1])) envVars.add(m[1]);
|
|
230
|
+
}
|
|
231
|
+
if (envVars.size > 0) {
|
|
232
|
+
signals.push(`environment: ${[...envVars].sort().slice(0, 8).join(", ")}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
98
235
|
if (/\bexit\s+code|\bexits?\s+(?:with\s+)?[0-4]\b/i.test(text)) {
|
|
99
236
|
signals.push("exit codes");
|
|
100
237
|
}
|
|
@@ -116,38 +253,70 @@ export function documentedSurfaceSignals(changeDir) {
|
|
|
116
253
|
// all, and attributing a hunk to a change would need guesswork the rest of
|
|
117
254
|
// the framework refuses to do (ADR 0005).
|
|
118
255
|
export function docsTouched(projectRoot) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const set = new Set();
|
|
125
|
-
for (const f of git(["diff", "--name-only", "HEAD"])) set.add(f);
|
|
126
|
-
for (const f of git(["ls-files", "--others", "--exclude-standard"])) set.add(f);
|
|
127
|
-
for (const base of ["main", "master"]) {
|
|
128
|
-
const mb = git(["merge-base", "HEAD", base]);
|
|
129
|
-
if (mb.length > 0) {
|
|
130
|
-
for (const f of git(["diff", "--name-only", `${mb[0]}..HEAD`])) set.add(f);
|
|
131
|
-
break;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return [...set]
|
|
135
|
-
.map((f) => f.replace(/\\/g, "/"))
|
|
256
|
+
// mergeBase: a branch's EARLIER commits count as documentation that moved
|
|
257
|
+
// with the change — the gate asks "did docs ship with this work", not "did
|
|
258
|
+
// docs change since the last commit".
|
|
259
|
+
return changedFiles(projectRoot, { mergeBase: true })
|
|
260
|
+
.files
|
|
136
261
|
.filter((f) => DOC_PATHS.some((re) => re.test(f)));
|
|
137
262
|
}
|
|
138
263
|
|
|
139
264
|
export function isGitRepo(projectRoot) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
265
|
+
return isRepo(projectRoot);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Where THIS project keeps its documentation, read off its own tree.
|
|
269
|
+
//
|
|
270
|
+
// The gate is portable; the instruction it printed was not. It named
|
|
271
|
+
// `docs/en/` AND `docs/pt/` and a skill that exists only in Doctrina's own
|
|
272
|
+
// repository, so an adopting project with neither read that it had to
|
|
273
|
+
// translate (change 0058). The gate itself never asked for any of that — it
|
|
274
|
+
// accepts anything under `docs/` or a README — so the remedy is derived from
|
|
275
|
+
// what the checked project actually has.
|
|
276
|
+
//
|
|
277
|
+
// Returns the documentation homes in reading order, or [] for a project that
|
|
278
|
+
// has none yet.
|
|
279
|
+
export function documentationHomes(projectRoot) {
|
|
280
|
+
const homes = [];
|
|
281
|
+
const docsDir = path.join(projectRoot, "docs");
|
|
282
|
+
// Only a directory that holds prose is somewhere to write prose: naming
|
|
283
|
+
// `docs/assets/` — an SVG and nothing else — turned change 0058's portable
|
|
284
|
+
// hint into a five-item list with a wrong item in it.
|
|
285
|
+
const holdsMarkdown = (dir) => walk(dir).some((f) => f.endsWith(".md"));
|
|
286
|
+
if (isDir(docsDir)) {
|
|
287
|
+
// A per-language or per-audience split is a convention, not a rule: name
|
|
288
|
+
// the subdirectories this project HAS, and fall back to `docs/` itself.
|
|
289
|
+
const subs = readdirSync(docsDir, { withFileTypes: true })
|
|
290
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
291
|
+
.filter((e) => holdsMarkdown(path.join(docsDir, e.name)))
|
|
292
|
+
.map((e) => `docs/${e.name}/`)
|
|
293
|
+
.sort();
|
|
294
|
+
if (subs.length > 0) homes.push(...subs);
|
|
295
|
+
else if (holdsMarkdown(docsDir)) homes.push("docs/");
|
|
296
|
+
}
|
|
297
|
+
for (const readme of ["README.md", "README.pt.md"]) {
|
|
298
|
+
if (isFile(path.join(projectRoot, readme))) homes.push(readme);
|
|
299
|
+
}
|
|
300
|
+
return homes;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The hint the docs gate prints when it refuses. Names the places this
|
|
304
|
+
// project documents in, and says "a README" when it documents nowhere yet —
|
|
305
|
+
// never a path the checked project does not have.
|
|
306
|
+
export function docsRemedy(projectRoot) {
|
|
307
|
+
const homes = documentationHomes(projectRoot);
|
|
308
|
+
if (homes.length === 0) return "document it in a README, or under docs/";
|
|
309
|
+
if (homes.length === 1) return `document it in ${homes[0]}`;
|
|
310
|
+
const last = homes[homes.length - 1];
|
|
311
|
+
return `document it in ${homes.slice(0, -1).join(", ")} or ${last}` +
|
|
312
|
+
(homes.length > 2 ? " — whichever this change belongs in" : "");
|
|
144
313
|
}
|
|
145
314
|
|
|
146
315
|
// The gate itself: { ok, signals, touched, reason }. `ok` is true when the
|
|
147
316
|
// change touches no documented surface, when documentation moved with it,
|
|
148
317
|
// or when git cannot answer.
|
|
149
318
|
export function checkDocsImpact(projectRoot, changeDir) {
|
|
150
|
-
const signals = documentedSurfaceSignals(changeDir);
|
|
319
|
+
const signals = documentedSurfaceSignals(changeDir, projectRoot);
|
|
151
320
|
if (signals.length === 0) {
|
|
152
321
|
return { ok: true, signals, touched: [], reason: "touches no documented surface" };
|
|
153
322
|
}
|
package/src/lib/flag-catalog.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { COMMAND_NAMES } from "./commands.js";
|
|
6
5
|
|
|
7
6
|
// One catalog of "which flags does command X accept", with two consumers:
|
|
8
7
|
//
|
|
@@ -64,12 +63,6 @@ export async function declaredFlags(commandName) {
|
|
|
64
63
|
]);
|
|
65
64
|
}
|
|
66
65
|
|
|
67
|
-
export async function loadAllFlagSpecs() {
|
|
68
|
-
const out = new Map();
|
|
69
|
-
for (const name of COMMAND_NAMES) out.set(name, await loadFlagSpec(name));
|
|
70
|
-
return out;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
66
|
// Flag names a source file READS, by scanning its call sites:
|
|
74
67
|
// flagBool(flags, "x") flagString(flags, "x") flags.has("x") flags.get("x")
|
|
75
68
|
// Deterministic text scan, not execution — it sees every branch, including
|
|
@@ -92,15 +85,6 @@ export function readCommandSource(commandName) {
|
|
|
92
85
|
return readFileSync(modulePathFor(commandName), "utf8");
|
|
93
86
|
}
|
|
94
87
|
|
|
95
|
-
// Flags a command's `help` string documents: every `--flag` token in it.
|
|
96
|
-
// The docs gate compares prose against this too, so help and reference
|
|
97
|
-
// cannot drift apart from the declaration.
|
|
98
|
-
export function scanHelpFlags(helpText) {
|
|
99
|
-
const out = new Set();
|
|
100
|
-
for (const m of String(helpText ?? "").matchAll(/--([a-z][a-z0-9-]*)/g)) out.add(m[1]);
|
|
101
|
-
return out;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
88
|
function pathToUrl(p) {
|
|
105
89
|
return "file:///" + p.replace(/\\/g, "/");
|
|
106
90
|
}
|