doctrina-cli 0.15.1 → 0.17.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 +27 -72
- package/package.json +3 -3
- package/src/commands/adapter.js +27 -3
- package/src/commands/analyze.js +16 -212
- package/src/commands/change.js +144 -267
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +65 -21
- package/src/commands/close.js +349 -45
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +284 -111
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +66 -7
- package/src/commands/doctor.js +293 -130
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +17 -3
- package/src/commands/index-rebuild.js +55 -44
- package/src/commands/init.js +88 -16
- package/src/commands/intake.js +85 -99
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +65 -88
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +23 -91
- package/src/commands/report.js +40 -109
- 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 +23 -39
- package/src/commands/spec.js +38 -11
- package/src/commands/status.js +69 -171
- package/src/commands/templates.js +35 -148
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +44 -877
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +243 -267
- package/src/index.js +139 -12
- package/src/lib/actions.js +490 -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/args.js +14 -0
- package/src/lib/change-ops.js +133 -0
- package/src/lib/commands.js +135 -10
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +431 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +370 -3
- package/src/lib/docs-impact.js +270 -27
- package/src/lib/ears.js +24 -1
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +231 -29
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +42 -24
- package/src/lib/intake-model.js +155 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +228 -0
- package/src/lib/metrics-model.js +189 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +105 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +328 -25
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +231 -0
- package/src/lib/spec-ops.js +72 -8
- package/src/lib/templates-model.js +289 -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 +1381 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +413 -0
- package/src/lib/work-model.js +102 -0
- package/templates/AGENTS.md.template +21 -28
- package/templates/README.md +35 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +16 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +16 -2
- package/templates/playbooks/bootstrap.md.template +47 -0
- package/templates/playbooks/chore.md.template +24 -0
- package/templates/playbooks/work.md.template +83 -0
- package/src/commands/constitution.js +0 -145
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.
|
|
@@ -90,7 +93,12 @@ function headerPattern(name) {
|
|
|
90
93
|
* line: number, conforming: boolean } | null}
|
|
91
94
|
*/
|
|
92
95
|
export function readHeader(text, name) {
|
|
93
|
-
|
|
96
|
+
// Match against the MASKED text so a header written inside an HTML comment
|
|
97
|
+
// — a template's example of the form, say — is never mistaken for one the
|
|
98
|
+
// author wrote. Masking replaces comment content with spaces and keeps
|
|
99
|
+
// every line terminator, so offsets and line numbers are the document's own
|
|
100
|
+
// and `raw` below is still the real line.
|
|
101
|
+
const m = headerPattern(name).exec(maskComments(String(text)));
|
|
94
102
|
if (!m) return null;
|
|
95
103
|
const [raw, indent, dash, value] = m;
|
|
96
104
|
const style = dash ? HEADER_STYLE.LIST : HEADER_STYLE.BARE;
|
|
@@ -130,7 +138,13 @@ export function setHeader(text, name, value, opts = {}) {
|
|
|
130
138
|
const useStyle = style ?? existing.style;
|
|
131
139
|
const prefix = useStyle === HEADER_STYLE.LIST ? "- " : "";
|
|
132
140
|
const replacement = `${existing.indent}${prefix}**${name}:** ${value}`.replace(/\s+$/, "");
|
|
133
|
-
|
|
141
|
+
// Write over the line `readHeader` actually found, by OFFSET. Replacing on
|
|
142
|
+
// a pattern would find the first textual match, which — now that reading
|
|
143
|
+
// skips annotation — may be an example inside a comment sitting above the
|
|
144
|
+
// real header. Masking preserves offsets, so the index is the real one.
|
|
145
|
+
const at = maskComments(String(text)).search(headerPattern(name));
|
|
146
|
+
if (at < 0) return null;
|
|
147
|
+
return String(text).slice(0, at) + String(text).slice(at).replace(headerPattern(name), replacement);
|
|
134
148
|
}
|
|
135
149
|
|
|
136
150
|
// The PREAMBLE: everything before the first `## ` section. Metadata headers
|
|
@@ -147,7 +161,22 @@ export function preamble(text) {
|
|
|
147
161
|
// Every header in the document's preamble, in order, with conformance
|
|
148
162
|
// recorded.
|
|
149
163
|
export function readAllHeaders(fullText) {
|
|
150
|
-
|
|
164
|
+
// AN HTML COMMENT IS ANNOTATION, NOT A HEADER.
|
|
165
|
+
//
|
|
166
|
+
// This module is the one owner of that rule — every other reader in the
|
|
167
|
+
// tree defers to it — and this function did not apply it. The preamble is
|
|
168
|
+
// exactly where a template puts its guidance, so an EXAMPLE header written
|
|
169
|
+
// to show an author the form counted as a header the author wrote.
|
|
170
|
+
//
|
|
171
|
+
// It showed as a disagreement rather than a wrong answer: `readHeader`
|
|
172
|
+
// takes the first match in the whole document, this one collects every
|
|
173
|
+
// match in the preamble, and a commented example made the two return
|
|
174
|
+
// different sets. A round-trip test on the real tree caught it the moment
|
|
175
|
+
// the proposal template gained such an example.
|
|
176
|
+
//
|
|
177
|
+
// Masking preserves line terminators, so the reported line numbers are the
|
|
178
|
+
// document's own either way.
|
|
179
|
+
const text = preamble(maskComments(fullText));
|
|
151
180
|
const out = [];
|
|
152
181
|
const re = /^([ \t]*)(-[ \t]+)?\*\*([A-Za-z][A-Za-z .-]*?)[ \t]*(?::\*\*|\*\*[ \t]*:)[ \t]*(.*?)[ \t]*$/gm;
|
|
153
182
|
let m;
|
|
@@ -285,3 +314,341 @@ export function parseArtifact(text, { kind = ARTIFACT_KIND.UNKNOWN } = {}) {
|
|
|
285
314
|
nonConforming: nonConformingHeaders(text, kind),
|
|
286
315
|
};
|
|
287
316
|
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// The rest of the on-disk grammar (ADR 0021, change 0043)
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
//
|
|
322
|
+
// ADR 0021 declares ONE document model that owns how a Doctrina artifact is
|
|
323
|
+
// read off disk. Three parsers lived outside it — skill frontmatter in a
|
|
324
|
+
// command module, and the two delta parsers in another — and `lib/scan.js`
|
|
325
|
+
// imported them FROM `commands/`, inverting the dependency the layering
|
|
326
|
+
// depends on. Change 0037 broke that edge by moving them into lib/; this is
|
|
327
|
+
// the second half: they belong to the module the ADR names, not to two more
|
|
328
|
+
// libraries beside it.
|
|
329
|
+
//
|
|
330
|
+
// Pure text predicates, all of them. Nothing here reads a file.
|
|
331
|
+
|
|
332
|
+
export function parseFrontmatter(text, key) {
|
|
333
|
+
// Match frontmatter blocks bounded by `---` lines at start of file.
|
|
334
|
+
const fmMatch = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
|
|
335
|
+
if (!fmMatch) return null;
|
|
336
|
+
const block = fmMatch[1];
|
|
337
|
+
const lineRe = new RegExp(`^${key}\\s*:\\s*(.+)$`, "m");
|
|
338
|
+
const m = block.match(lineRe);
|
|
339
|
+
return m ? m[1].trim() : null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function parseOperation(text) {
|
|
343
|
+
const m = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
|
|
344
|
+
if (!m) return null;
|
|
345
|
+
const op = m[1];
|
|
346
|
+
if (op === "ADDED" || op === "MODIFIED" || op === "REMOVED") return op;
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function parseCapabilityFromDelta(text, deltaPath) {
|
|
351
|
+
// Prefer the explicit header "# Spec Delta — capability: <name>"
|
|
352
|
+
const m = text.match(/^#\s+Spec Delta\s*[—-]\s*capability:\s*([a-z][a-z0-9-]*)/m);
|
|
353
|
+
if (m) return m[1];
|
|
354
|
+
// Fall back to the parent directory name of the delta file
|
|
355
|
+
const parent = path.basename(path.dirname(deltaPath));
|
|
356
|
+
if (/^[a-z][a-z0-9-]*$/.test(parent)) return parent;
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The value a shipped template writes for `name`, or null when the template
|
|
362
|
+
* cannot be located (an unusual install) or does not carry that header.
|
|
363
|
+
*
|
|
364
|
+
* @param {string} templateName e.g. "spec.md.template"
|
|
365
|
+
* @param {string} name
|
|
366
|
+
* @returns {string|null}
|
|
367
|
+
*/
|
|
368
|
+
export function templateHeaderValue(templateName, name) {
|
|
369
|
+
try {
|
|
370
|
+
return getHeader(read(path.join(locateTemplatesDir(), templateName)), name);
|
|
371
|
+
} catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Is this header value still the scaffold's placeholder?
|
|
378
|
+
*
|
|
379
|
+
* A check written to catch a MISSING header is dead in the normal flow when
|
|
380
|
+
* the scaffold writes a value for it: the escape hatch arrives pre-armed
|
|
381
|
+
* (change 0057). The templates mark a placeholder by wrapping the whole
|
|
382
|
+
* value in angle brackets, so that is the portable rule; when the templates
|
|
383
|
+
* can be located, the template's own value settles it exactly.
|
|
384
|
+
*
|
|
385
|
+
* @param {string|null|undefined} value
|
|
386
|
+
* @param {{ template?: string, name?: string }} [opts]
|
|
387
|
+
* @returns {boolean}
|
|
388
|
+
*/
|
|
389
|
+
export function isPlaceholderHeaderValue(value, opts = {}) {
|
|
390
|
+
if (value === null || value === undefined) return true;
|
|
391
|
+
const v = String(value).trim();
|
|
392
|
+
if (v === "") return true;
|
|
393
|
+
// `<...>` wrapping the WHOLE value — the templates' placeholder convention.
|
|
394
|
+
// A real value may still contain angle brackets ("n/a — <why>" does not,
|
|
395
|
+
// but a prose value could), which is why the whole value must be wrapped.
|
|
396
|
+
if (/^<[\s\S]*>$/.test(v)) return true;
|
|
397
|
+
if (opts.template && opts.name) {
|
|
398
|
+
const tpl = templateHeaderValue(opts.template, opts.name);
|
|
399
|
+
if (tpl !== null && tpl.trim() === v) return true;
|
|
400
|
+
}
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Every GitHub-style checkbox in `text`, in reading order.
|
|
406
|
+
*
|
|
407
|
+
* One owner for the box grammar (ADR 0021). It had six, and they disagreed:
|
|
408
|
+
* `snapshot.js` required TEXT after the box (`\[[ xX]\]\s+(.*)`), so the three
|
|
409
|
+
* empty placeholders `work` scaffolds were invisible to it and `prime` printed
|
|
410
|
+
* "tasks 0/3" for a change with six open boxes — the three it counted being
|
|
411
|
+
* the closing steps, not the work. `gates.js` required no text and counted
|
|
412
|
+
* six; `change tick` counted eight, adding the proposal's Verification boxes.
|
|
413
|
+
* Three numbers for the same file, side by side in the same session.
|
|
414
|
+
*
|
|
415
|
+
* Each entry carries whether the box is a scaffold PLACEHOLDER — a box with
|
|
416
|
+
* nothing written after it — because that is a real distinction (`analyze`
|
|
417
|
+
* refuses a change that still has them) and it is the one the counters must
|
|
418
|
+
* agree to make explicitly rather than by accident.
|
|
419
|
+
*
|
|
420
|
+
* @param {string} text
|
|
421
|
+
* @param {{ section?: string|null }} [opts] limit to one `## ` section
|
|
422
|
+
* @returns {Array<{ line: number, checked: boolean, text: string, placeholder: boolean, section: string|null }>}
|
|
423
|
+
*/
|
|
424
|
+
export function parseChecklist(text, opts = {}) {
|
|
425
|
+
const section = opts.section ?? null;
|
|
426
|
+
const lines = String(text).split(/\r?\n/);
|
|
427
|
+
const out = [];
|
|
428
|
+
let inSection = section === null;
|
|
429
|
+
let heading = null;
|
|
430
|
+
for (let i = 0; i < lines.length; i++) {
|
|
431
|
+
if (/^##\s+/.test(lines[i])) {
|
|
432
|
+
heading = lines[i].replace(/^##\s+/, "").trim();
|
|
433
|
+
if (section !== null) {
|
|
434
|
+
inSection = new RegExp(`^##\\s+${section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i")
|
|
435
|
+
.test(lines[i]);
|
|
436
|
+
}
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (!inSection) continue;
|
|
440
|
+
// Any Markdown bullet marker opens a box (change 0104): `* [ ]` and
|
|
441
|
+
// `+ [ ]` are task items to every renderer, and a box the grammar
|
|
442
|
+
// cannot see is one `tick` cannot tick and the archive gate cannot
|
|
443
|
+
// count — an unchecked task that closes green.
|
|
444
|
+
const m = /^\s*[-*+]\s*\[([ xX])\]\s*(.*)$/.exec(lines[i]);
|
|
445
|
+
if (!m) continue;
|
|
446
|
+
const body = m[2].trim();
|
|
447
|
+
out.push({ line: i, checked: m[1] !== " ", text: body, placeholder: body === "", section: heading });
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* How many boxes a checklist holds and how many are done — the pair every
|
|
454
|
+
* progress line prints. Placeholders count: a box nobody wrote is a box
|
|
455
|
+
* nobody finished, and hiding it is what made "0/3" mean six.
|
|
456
|
+
*
|
|
457
|
+
* @param {string} text
|
|
458
|
+
* @param {{ section?: string|null }} [opts]
|
|
459
|
+
* @returns {{ total: number, done: number, open: string[], placeholders: number }}
|
|
460
|
+
*/
|
|
461
|
+
// A tasks.md scaffolded before change 0195 ends with "## Closing steps" —
|
|
462
|
+
// apply, archive, update the index. Those are what `doctrina close` DOES,
|
|
463
|
+
// so asking for them ticked before the close ran was asking the agent to
|
|
464
|
+
// claim three things that had not happened yet. The template no longer
|
|
465
|
+
// writes them; a change that still carries them is not held to them.
|
|
466
|
+
const CLOSING_STEPS = /^closing steps$/i;
|
|
467
|
+
|
|
468
|
+
export function checklistProgress(text, opts = {}) {
|
|
469
|
+
const boxes = parseChecklist(text, opts)
|
|
470
|
+
.filter((b) => opts.section != null || !CLOSING_STEPS.test(b.section ?? ""));
|
|
471
|
+
return {
|
|
472
|
+
total: boxes.length,
|
|
473
|
+
done: boxes.filter((b) => b.checked).length,
|
|
474
|
+
open: boxes.filter((b) => !b.checked).map((b) => b.text),
|
|
475
|
+
placeholders: boxes.filter((b) => b.placeholder).length,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Is this section still the shipped template's — empty, or nothing but the
|
|
481
|
+
* template's own instructional comment and placeholder?
|
|
482
|
+
*
|
|
483
|
+
* The framework writes artifacts from templates and then trusts that someone
|
|
484
|
+
* filled them in. Exactly one gate checked that (`analyze`, over a change
|
|
485
|
+
* proposal); everything else took the mould for a decision (change 0065).
|
|
486
|
+
* This is the same ruler `isPlaceholderHeaderValue` applies to a header,
|
|
487
|
+
* applied to a section body: an HTML comment is annotation (change 0055), so
|
|
488
|
+
* a section that is only annotation is a section nobody wrote.
|
|
489
|
+
*
|
|
490
|
+
* @param {string} text the whole artifact
|
|
491
|
+
* @param {string} name the `## ` section name
|
|
492
|
+
* @param {{ template?: string }} [opts] template file to compare against
|
|
493
|
+
* @returns {boolean}
|
|
494
|
+
*/
|
|
495
|
+
export function isUnwrittenSection(text, name, opts = {}) {
|
|
496
|
+
const body = getSection(text, name);
|
|
497
|
+
if (body === null) return true;
|
|
498
|
+
const prose = maskComments(body).trim();
|
|
499
|
+
if (prose === "") return true;
|
|
500
|
+
// A body that is nothing but the template's placeholder bullets or an
|
|
501
|
+
// angle-bracket placeholder is unwritten too.
|
|
502
|
+
const stripped = prose.replace(/^[-*]\s*$/gm, "").trim();
|
|
503
|
+
if (stripped === "") return true;
|
|
504
|
+
if (/^<[\s\S]*>$/.test(stripped)) return true;
|
|
505
|
+
if (opts.template) {
|
|
506
|
+
try {
|
|
507
|
+
const tpl = read(path.join(locateTemplatesDir(), opts.template));
|
|
508
|
+
const tplBody = getSection(tpl, name);
|
|
509
|
+
if (tplBody !== null && maskComments(tplBody).trim() === prose) return true;
|
|
510
|
+
} catch {
|
|
511
|
+
// No templates dir — the checks above already carry the common cases.
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The named sections of `text` that nobody has written yet, in order.
|
|
519
|
+
*
|
|
520
|
+
* @param {string} text
|
|
521
|
+
* @param {string[]} names
|
|
522
|
+
* @param {{ template?: string }} [opts]
|
|
523
|
+
* @returns {string[]}
|
|
524
|
+
*/
|
|
525
|
+
export function unwrittenSections(text, names, opts = {}) {
|
|
526
|
+
return names.filter((name) => isUnwrittenSection(text, name, opts));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Is the on-disk spec still the untouched `spec new <cap>` scaffold? Precise
|
|
530
|
+
// check: render the shipped capability template for the same capability and
|
|
531
|
+
// compare, ignoring the date-bearing "Last updated" line and whitespace
|
|
532
|
+
// normalisation. When the template cannot be located (unusual installs),
|
|
533
|
+
// fall back to the scaffold's own placeholder fingerprints — text no real
|
|
534
|
+
// spec keeps. Used by `change apply` so an ADDED delta can replace a
|
|
535
|
+
// scaffold (the canonical spec-new → delta flow) without ever clobbering a
|
|
536
|
+
// spec that carries real content.
|
|
537
|
+
export function isUntouchedScaffold(specText, capability) {
|
|
538
|
+
const normalize = (s) =>
|
|
539
|
+
s.replace(/\r\n/g, "\n")
|
|
540
|
+
.split("\n")
|
|
541
|
+
.filter((line) => !/^\*\*Last updated:\*\*/.test(line))
|
|
542
|
+
.join("\n")
|
|
543
|
+
.trim();
|
|
544
|
+
try {
|
|
545
|
+
const tplPath = path.join(locateTemplatesDir(), "spec.md.template");
|
|
546
|
+
const rendered = read(tplPath)
|
|
547
|
+
.replace(/\{\{CAPABILITY\}\}/g, capability)
|
|
548
|
+
.replace(/\{\{DATE\}\}/g, "");
|
|
549
|
+
if (normalize(rendered) === normalize(specText)) return true;
|
|
550
|
+
} catch {
|
|
551
|
+
// fall through to the fingerprint heuristic
|
|
552
|
+
}
|
|
553
|
+
// Fingerprints: the Purpose placeholder comment AND an empty Ubiquitous
|
|
554
|
+
// section survive only in a scaffold nobody edited.
|
|
555
|
+
return (
|
|
556
|
+
specText.includes("<!-- One paragraph: what this capability does and why it exists. -->") &&
|
|
557
|
+
/##\s+Requirements \(EARS\)[\s\S]*?### Ubiquitous\s*\n\s*-\s*\n/.test(specText)
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The byte ranges every HTML comment occupies in `text`, in file order.
|
|
564
|
+
*
|
|
565
|
+
* A comment is annotation, not content: the scaffolded spec carries the
|
|
566
|
+
* five-line EARS legend inside one, and the guessed delta (change 0044)
|
|
567
|
+
* carries its `RANKED GUESS` note inside another. Every reader of an
|
|
568
|
+
* on-disk artifact has to agree on that, so the ranges are computed HERE
|
|
569
|
+
* and nowhere else (ADR 0021 — one owner for the on-disk grammar).
|
|
570
|
+
*
|
|
571
|
+
* @param {string} text
|
|
572
|
+
* @returns {Array<[number, number]>}
|
|
573
|
+
*/
|
|
574
|
+
export function commentRanges(text) {
|
|
575
|
+
/** @type {Array<[number, number]>} */
|
|
576
|
+
const ranges = [];
|
|
577
|
+
for (const m of text.matchAll(/<!--[\s\S]*?-->/g)) {
|
|
578
|
+
ranges.push([m.index ?? 0, (m.index ?? 0) + m[0].length]);
|
|
579
|
+
}
|
|
580
|
+
return ranges;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Is this offset inside one of `ranges`?
|
|
585
|
+
*
|
|
586
|
+
* @param {Array<[number, number]>} ranges
|
|
587
|
+
* @param {number} offset
|
|
588
|
+
* @returns {boolean}
|
|
589
|
+
*/
|
|
590
|
+
export function isInsideComment(ranges, offset) {
|
|
591
|
+
return ranges.some(([a, b]) => offset >= a && offset < b);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* `text` with every HTML comment blanked out — same length, same line
|
|
596
|
+
* count, same offset for every character that survives.
|
|
597
|
+
*
|
|
598
|
+
* This is the skip-by-position rule of `spec-ops.matchOpsBlock` in a form
|
|
599
|
+
* a line- or regex-oriented scanner can use directly: a bullet inside a
|
|
600
|
+
* comment stops looking like a bullet, and a command name inside one stops
|
|
601
|
+
* looking like a reference, WITHOUT any surviving character moving. Masking
|
|
602
|
+
* is for SCANNING only. Never write the masked text back to disk, and never
|
|
603
|
+
* derive an authored value from it — a delta whose `append-criterion` value
|
|
604
|
+
* legitimately contains `<!-- illustrative -->` must land with the marker
|
|
605
|
+
* intact, which is why `applyOps` still works on the raw text.
|
|
606
|
+
*
|
|
607
|
+
* @param {string} text
|
|
608
|
+
* @returns {string}
|
|
609
|
+
*/
|
|
610
|
+
export function maskComments(text) {
|
|
611
|
+
let out = text;
|
|
612
|
+
for (const [a, b] of commentRanges(text)) {
|
|
613
|
+
const blanked = text.slice(a, b).replace(/[^\r\n]/g, " ");
|
|
614
|
+
out = out.slice(0, a) + blanked + out.slice(b);
|
|
615
|
+
}
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The title a change proposal's H1 states, or null.
|
|
621
|
+
*
|
|
622
|
+
* The H1 the template writes is `# Change <id> — <title>`, and the id itself
|
|
623
|
+
* contains hyphens (`NNNN-slug`) — which is what four separate copies of this
|
|
624
|
+
* regex kept getting wrong in four slightly different ways. The one that read
|
|
625
|
+
* `[^—-]*` for the id stopped at the FIRST hyphen, which in any multi-word id
|
|
626
|
+
* is the id's own, so `prime`, `handoff` and `report` printed the slug glued
|
|
627
|
+
* in front of the title on every change `work` had ever generated. The ones
|
|
628
|
+
* that read `\s*[—-]\s*` accepted a bare hyphen with no spaces around it, so
|
|
629
|
+
* an H1 with no separator at all had its last segment read as the title.
|
|
630
|
+
*
|
|
631
|
+
* The separator is a dash WITH whitespace on both sides; an id's hyphens
|
|
632
|
+
* never have that, which is the whole ambiguity, resolved. Both the em dash
|
|
633
|
+
* and the plain hyphen are accepted, because the character was never the
|
|
634
|
+
* problem — a hand-written H1 using `-` reads exactly as clearly.
|
|
635
|
+
*
|
|
636
|
+
* The `Change <id> —` prefix is optional: an H1 written without it is a title
|
|
637
|
+
* in its own right and is returned whole (ADR 0021 — one owner for the
|
|
638
|
+
* on-disk grammar).
|
|
639
|
+
*
|
|
640
|
+
* @param {string} text the proposal, or just its first line
|
|
641
|
+
* @returns {string|null}
|
|
642
|
+
*/
|
|
643
|
+
export function parseChangeTitle(text) {
|
|
644
|
+
const first = String(text ?? "").split(/\r?\n/).find((l) => /^#\s+\S/.test(l));
|
|
645
|
+
if (!first) return null;
|
|
646
|
+
const m = first.match(/^#\s+(?:Change\s+\S+\s+[—-]\s+)?(.+)$/);
|
|
647
|
+
const title = m?.[1]?.trim();
|
|
648
|
+
return title ? title : null;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** A value still wrapped in the template's angle brackets: `<one-sentence …>`. */
|
|
652
|
+
export function isScaffoldValue(value) {
|
|
653
|
+
return /^<[^<>]*>$/.test(String(value ?? "").trim());
|
|
654
|
+
}
|