doctrina-cli 0.15.0 → 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 -199
- 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 +7 -75
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +121 -160
- 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 +109 -6
- 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 +210 -24
- 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 +238 -11
- 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/contract.md.template +7 -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/commands.js
CHANGED
|
@@ -20,7 +20,7 @@ export const COMMAND_NAMES = [
|
|
|
20
20
|
// gates
|
|
21
21
|
"analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor",
|
|
22
22
|
// maintenance
|
|
23
|
-
"templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade",
|
|
23
|
+
"templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade", "ci",
|
|
24
24
|
];
|
|
25
25
|
|
|
26
26
|
// The full operation surface — every `doctrina <command> [<subcommand>]` a
|
|
@@ -93,6 +93,7 @@ export const OPERATIONS = [
|
|
|
93
93
|
["metrics", "Local git-derived adoption metrics (no network)"],
|
|
94
94
|
["report", "Markdown digest for a period: changes, gates, git summary"],
|
|
95
95
|
["completion", "Print bash/zsh/pwsh completions (generated from the catalog)"],
|
|
96
|
+
["ci", "Emit the CI pipeline for the declared gate sequence (--emit github)"],
|
|
96
97
|
["upgrade", "Bring an existing project up to the installed CLI (--write applies)"],
|
|
97
98
|
];
|
|
98
99
|
|
|
@@ -118,7 +119,7 @@ export const COMMAND_META = {
|
|
|
118
119
|
intake: { moment: "Bootstrap", when: "you have a full project description and no specs yet", purpose: "store the intent and print the bootstrap playbook" },
|
|
119
120
|
adapter: { moment: "Bootstrap", when: "adding or removing an agent's pointer files", purpose: "install/remove agent adapters (additive)" },
|
|
120
121
|
|
|
121
|
-
prime: { moment: "Orient", when: "at the START of every session", purpose: "gates,
|
|
122
|
+
prime: { moment: "Orient", when: "at the START of every session", purpose: "gates, open work and next steps in one read (--rules for the full standing rules)" },
|
|
122
123
|
status: { moment: "Orient", when: "you need the health of the tree at a glance", purpose: "index, coverage, trace, and artifact counts" },
|
|
123
124
|
next: { moment: "Orient", when: "you finished something and do not know what follows", purpose: "the recommended next workflow action" },
|
|
124
125
|
context: { moment: "Orient", when: "before working on any task, to load the right files", purpose: "the read pack, assembled to fit a token budget" },
|
|
@@ -131,7 +132,7 @@ export const COMMAND_META = {
|
|
|
131
132
|
triage: { moment: "Change", when: "a request arrives — BEFORE scaffolding, especially if it smells like an incident", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
|
|
132
133
|
work: { moment: "Change", when: "a request arrives that changes behaviour (triage says PRODUCT)", purpose: "scaffold a change and print the playbook to execute" },
|
|
133
134
|
spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
|
|
134
|
-
change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "new / apply / archive / check / tick /
|
|
135
|
+
change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "new / apply / archive / check (--verbose) / tick / abandon" },
|
|
135
136
|
decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
|
|
136
137
|
contract: { moment: "Change", when: "the change touches ports, env vars, or public endpoints", purpose: "own and verify the integration surface" },
|
|
137
138
|
intent: { moment: "Change", when: "new product intent appears after the intake", purpose: "append and list product intent anchors" },
|
|
@@ -155,6 +156,7 @@ export const COMMAND_META = {
|
|
|
155
156
|
metrics: { moment: "Maintain", when: "reporting adoption over a period", purpose: "local git-derived adoption metrics" },
|
|
156
157
|
report: { moment: "Maintain", when: "summarising a period for a human", purpose: "a Markdown digest of changes, gates, and git" },
|
|
157
158
|
completion: { moment: "Maintain", when: "setting up a human's shell", purpose: "print shell completions from the catalog" },
|
|
159
|
+
ci: { moment: "Maintain", when: "the declared gate sequence changed and CI must follow", purpose: "emit the CI pipeline from the gate declaration" },
|
|
158
160
|
};
|
|
159
161
|
|
|
160
162
|
// The order moments appear in the generated block.
|
|
@@ -195,10 +197,48 @@ const SURFACE_HINTS = {
|
|
|
195
197
|
clarify: "--all (--lang pt|en)",
|
|
196
198
|
close: "<id...>",
|
|
197
199
|
upgrade: "--write",
|
|
200
|
+
ci: "--emit github",
|
|
198
201
|
prime: "(session start)",
|
|
199
202
|
handoff: "(before compaction/handover)",
|
|
200
203
|
};
|
|
201
204
|
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Deprecations (change 0049).
|
|
207
|
+
//
|
|
208
|
+
// The surface has a hard 40-line budget precisely so that adding a command
|
|
209
|
+
// forces the question "what comes off?" — and for two releases the answer was
|
|
210
|
+
// "nothing", while the budget was met by compressing a whole moment onto one
|
|
211
|
+
// line (audit finding F23). These are the first two removals, and both are
|
|
212
|
+
// MERGES: the surviving command already does everything the retired one did,
|
|
213
|
+
// which is why they can go without waiting for usage evidence. Usage counts
|
|
214
|
+
// can tell you a command is unloved; only redundancy can tell you it is
|
|
215
|
+
// unnecessary, and an unloved command that does something nothing else does
|
|
216
|
+
// has to stay.
|
|
217
|
+
//
|
|
218
|
+
// A deprecated operation keeps working, warns once on the way through, and
|
|
219
|
+
// leaves the surface block — an agent should not reach for it, and the block
|
|
220
|
+
// is the list of things to reach for. Removal is a later, separate change.
|
|
221
|
+
export const DEPRECATED = Object.freeze({
|
|
222
|
+
"constitution": {
|
|
223
|
+
since: "0.16.0",
|
|
224
|
+
use: "doctrina prime --rules",
|
|
225
|
+
why: "prime renders the same standing-rules view, from the same collection",
|
|
226
|
+
},
|
|
227
|
+
"change diff": {
|
|
228
|
+
since: "0.16.0",
|
|
229
|
+
use: "doctrina change check --verbose",
|
|
230
|
+
why: "check runs every ops block against the target spec and now prints the same per-delta preview",
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
/** The deprecation record for an invocation, or null. */
|
|
235
|
+
export function deprecationFor(argv) {
|
|
236
|
+
const words = argv.filter((a) => !a.startsWith("-"));
|
|
237
|
+
const two = words.slice(0, 2).join(" ");
|
|
238
|
+
const one = words[0] ?? "";
|
|
239
|
+
return DEPRECATED[two] ?? DEPRECATED[one] ?? null;
|
|
240
|
+
}
|
|
241
|
+
|
|
202
242
|
// The declared size budget for the generated block. A surface that cannot
|
|
203
243
|
// describe itself in this many lines is too large — the answer is to cut
|
|
204
244
|
// commands (M8), never to raise the number.
|
|
@@ -219,8 +259,13 @@ const COMPACT_MOMENTS = new Set(["Maintain"]);
|
|
|
219
259
|
// a name index never told it. Same shape `context` prints for skills —
|
|
220
260
|
// description plus `when:`, body on demand.
|
|
221
261
|
export function surfaceMarkdown() {
|
|
262
|
+
// A deprecated operation stays in the catalog — `--help` and the shell
|
|
263
|
+
// completions must still know the name someone types — but leaves this
|
|
264
|
+
// block: it is the list of commands to REACH FOR, and pointing an agent at
|
|
265
|
+
// a name that warns when used is worse than not listing it.
|
|
222
266
|
const subsByCmd = new Map();
|
|
223
267
|
for (const [op] of OPERATIONS) {
|
|
268
|
+
if (DEPRECATED[op]) continue;
|
|
224
269
|
const [cmd, sub] = op.split(" ");
|
|
225
270
|
if (!subsByCmd.has(cmd)) subsByCmd.set(cmd, []);
|
|
226
271
|
if (sub) subsByCmd.get(cmd).push(sub);
|
|
@@ -242,7 +287,7 @@ export function surfaceMarkdown() {
|
|
|
242
287
|
"canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.",
|
|
243
288
|
];
|
|
244
289
|
for (const moment of MOMENTS) {
|
|
245
|
-
const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment);
|
|
290
|
+
const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment && !DEPRECATED[n]);
|
|
246
291
|
if (cmds.length === 0) continue;
|
|
247
292
|
if (COMPACT_MOMENTS.has(moment)) {
|
|
248
293
|
// Each name still carries the `doctrina ` prefix: the compact line is
|
|
@@ -402,6 +447,19 @@ export const AGENT_CHANGELOG_END = "<!-- doctrina:changed:end -->";
|
|
|
402
447
|
// introduced it. Only entries that alter what an agent should DO belong
|
|
403
448
|
// here — a bug fix nobody's behaviour depends on does not.
|
|
404
449
|
export const AGENT_CHANGELOG = {
|
|
450
|
+
// Each entry lists only ITS OWN delta. The series carries the rest
|
|
451
|
+
// forward (see agentChangelogEntries), so a patch never has to restate
|
|
452
|
+
// the minor that introduced the commands, and never silently erases it.
|
|
453
|
+
"0.16.0": [
|
|
454
|
+
"A reference or path that does not resolve now answers exit 2 (USAGE) everywhere — `clarify`, `show`, `why`, `analyze`, `context`, `change check`, `spec set`, `decision accept|scope`. Correct the invocation; retrying it unchanged never succeeds.",
|
|
455
|
+
"The `--json` envelope names the OPERATION in `command` and carries arguments in `args` — branch on those two, not on a joined string. An undeclared flag now answers with `{ok: false, exit_code: 2}` instead of an empty stdout.",
|
|
456
|
+
"`analyze` executes a MODIFIED delta's `ops` block, so `analyze`, `change apply` and `close` all refuse the same unappliable block. Fix the ops before closing; a delta with no ops block is still a legitimate manual merge.",
|
|
457
|
+
"`validate` refuses more: an active spec declaring no acceptance criteria, an empty artifact, a scaffold section left unwritten, a reference that resolves to nothing. Write the criteria before flipping a spec to `active`.",
|
|
458
|
+
"`change new <id>` requires the `NNNN-slug` shape (lowercase letters, digits, hyphens) and writes nothing otherwise — `doctrina work \"<prompt>\"` derives one for you.",
|
|
459
|
+
],
|
|
460
|
+
"0.15.1": [
|
|
461
|
+
"Exporting a variable under a name that is not its source's is routine — declare it in the Wiring row's Origin cell as `<origin>:<source>` (e.g. `secrets:NPM_TOKEN`) and RT02 stays silent until either side moves.",
|
|
462
|
+
],
|
|
405
463
|
"0.15.0": [
|
|
406
464
|
"`doctrina triage \"<prompt>\"` — classify a request as PRODUCT / RUNTIME / CHORE BEFORE scaffolding. `work` now holds a runtime-shaped prompt with exit 3 and points here; `--force` opens the change anyway.",
|
|
407
465
|
"`contract check` now holds the declared wiring to the implementation: a `vars`/`secrets` variable no workflow exports, a default an empty CI value never triggers, an unvalidated enum, a selector matching zero targets (RT01-RT05).",
|
|
@@ -420,9 +478,54 @@ export const AGENT_CHANGELOG = {
|
|
|
420
478
|
|
|
421
479
|
// The block written into AGENTS.md for a given version. Empty string when
|
|
422
480
|
// that version has no agent-visible changes.
|
|
481
|
+
// How many bullets the block may carry. Not a style preference: AGENTS.md
|
|
482
|
+
// is always-loaded context under a hard 150-line budget, and at the current
|
|
483
|
+
// template size five bullets is exactly what fits. Raising this means
|
|
484
|
+
// cutting elsewhere — see the `agents-md-lines` budget in the contract.
|
|
485
|
+
export const AGENT_CHANGELOG_MAX_BULLETS = 5;
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The bullets an agent arriving at `version` needs, newest first.
|
|
489
|
+
*
|
|
490
|
+
* Keyed per version, but rendered per MINOR SERIES. The block REPLACES its
|
|
491
|
+
* predecessor in AGENTS.md rather than accumulating, so rendering only the
|
|
492
|
+
* current version's own entry meant every patch silently erased the minor
|
|
493
|
+
* that introduced the commands: an agent upgrading 0.14.0 -> 0.15.1 would
|
|
494
|
+
* read one bug fix and never learn `triage` exists. The alternative —
|
|
495
|
+
* making each patch author restate the whole minor by hand — worked once
|
|
496
|
+
* and does not scale, because the restating is invisible and the third
|
|
497
|
+
* patch quietly drops a line to fit the cap.
|
|
498
|
+
*
|
|
499
|
+
* So the series carries itself, newest first, and the cap truncates the
|
|
500
|
+
* OLDEST. A fact that must outlive its series does not belong here: it
|
|
501
|
+
* belongs in the surface block or the docs, which is the honest place for
|
|
502
|
+
* something permanent.
|
|
503
|
+
*/
|
|
504
|
+
export function agentChangelogEntries(version) {
|
|
505
|
+
const series = String(version).split(".").slice(0, 2).join(".");
|
|
506
|
+
const versions = Object.keys(AGENT_CHANGELOG)
|
|
507
|
+
.filter((v) => v.split(".").slice(0, 2).join(".") === series && compareVersions(v, version) <= 0)
|
|
508
|
+
.sort((a, b) => compareVersions(b, a));
|
|
509
|
+
const out = [];
|
|
510
|
+
for (const v of versions) out.push(...(AGENT_CHANGELOG[v] ?? []));
|
|
511
|
+
return out.slice(0, AGENT_CHANGELOG_MAX_BULLETS);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Numeric semver comparison — "0.15.10" must sort above "0.15.9", which a
|
|
515
|
+
// string compare gets wrong.
|
|
516
|
+
function compareVersions(a, b) {
|
|
517
|
+
const pa = String(a).split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
518
|
+
const pb = String(b).split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
519
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
520
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
521
|
+
if (d !== 0) return d;
|
|
522
|
+
}
|
|
523
|
+
return 0;
|
|
524
|
+
}
|
|
525
|
+
|
|
423
526
|
export function agentChangelogMarkdown(version) {
|
|
424
|
-
const entries =
|
|
425
|
-
if (
|
|
527
|
+
const entries = agentChangelogEntries(version);
|
|
528
|
+
if (entries.length === 0) return "";
|
|
426
529
|
return [
|
|
427
530
|
`## What changed in ${version}`,
|
|
428
531
|
"",
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The project's configuration, in one place, with WHERE each value came from.
|
|
3
|
+
//
|
|
4
|
+
// Five files configured a Doctrina project and only one of them had an
|
|
5
|
+
// `--init`: `index.json` (the context budget, under `config`), `config.json`
|
|
6
|
+
// (the language, read by `clarify` alone), `rules.json` (project rules,
|
|
7
|
+
// read by `validate` alone), `verify.json`, and `verify.signoffs.json`.
|
|
8
|
+
// `config.json` and `rules.json` were created by nothing, documented in no
|
|
9
|
+
// surface block, and reported by no command — they existed only for someone
|
|
10
|
+
// who had read the source. The symptom that found this (audit finding F19):
|
|
11
|
+
// a pt-BR project sits permanently red under `clarify` with no clue why.
|
|
12
|
+
//
|
|
13
|
+
// `.doctrina/config.json` is now the declared home. The old locations are
|
|
14
|
+
// still READ — a project that configured a budget in `index.json` keeps
|
|
15
|
+
// working, and removal is announced rather than performed here — but every
|
|
16
|
+
// consumer asks this module, and every value carries its source so `doctor`
|
|
17
|
+
// can print "configured" or "default" instead of leaving a project to guess.
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { isFile, read } from "./fs-ops.js";
|
|
20
|
+
|
|
21
|
+
export const CONFIG_REL = ".doctrina/config.json";
|
|
22
|
+
export const RULES_REL = ".doctrina/rules.json";
|
|
23
|
+
|
|
24
|
+
// The built-in default for every option. `language: null` means "detect per
|
|
25
|
+
// file", which is what the absence of a declaration has always meant.
|
|
26
|
+
export const DEFAULTS = Object.freeze({
|
|
27
|
+
language: null,
|
|
28
|
+
context_budget: 15000,
|
|
29
|
+
rules: [],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Where a value came from, most authoritative first. The strings are printed
|
|
33
|
+
// by `doctor`, so they name FILES a reader can open, not internal ids.
|
|
34
|
+
export const SOURCES = Object.freeze({
|
|
35
|
+
config: CONFIG_REL,
|
|
36
|
+
index: ".doctrina/index.json (config block — legacy home)",
|
|
37
|
+
rules: RULES_REL + " (legacy home)",
|
|
38
|
+
default: "default",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export function configPath(projectRoot) {
|
|
42
|
+
return path.join(projectRoot, ".doctrina", "config.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read the effective configuration.
|
|
47
|
+
*
|
|
48
|
+
* Precedence, per option: `.doctrina/config.json` → the legacy home for that
|
|
49
|
+
* option → the built-in default. Malformed JSON is REPORTED, never thrown:
|
|
50
|
+
* a project cannot be left unable to assemble a context pack because someone
|
|
51
|
+
* left a trailing comma in a preferences file — `validate` is where that
|
|
52
|
+
* becomes an error.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} projectRoot
|
|
55
|
+
* @returns {{
|
|
56
|
+
* language: string|null, context_budget: number, rules: any[],
|
|
57
|
+
* sources: {language: string, context_budget: string, rules: string},
|
|
58
|
+
* errors: string[],
|
|
59
|
+
* unknown: string[]
|
|
60
|
+
* }}
|
|
61
|
+
*/
|
|
62
|
+
export function loadConfig(projectRoot) {
|
|
63
|
+
const errors = [];
|
|
64
|
+
const cfg = readJson(configPath(projectRoot), CONFIG_REL, errors);
|
|
65
|
+
// Keys the CLI does not know (change 0115). `{"context_budjet": 10}` was
|
|
66
|
+
// silence at every door, and the author believed the budget was set.
|
|
67
|
+
// Reported, never rejected: the effective values are unchanged.
|
|
68
|
+
const KNOWN_KEYS = new Set(["language", "context_budget", "rules", "$comment", "$schema"]);
|
|
69
|
+
const unknown = cfg && typeof cfg === "object" && !Array.isArray(cfg)
|
|
70
|
+
? Object.keys(cfg).filter((k) => !KNOWN_KEYS.has(k))
|
|
71
|
+
: [];
|
|
72
|
+
/** @type {{language: string, context_budget: string, rules: string}} */
|
|
73
|
+
const sources = { language: SOURCES.default, context_budget: SOURCES.default, rules: SOURCES.default };
|
|
74
|
+
|
|
75
|
+
// language — config.json has always been its only home.
|
|
76
|
+
//
|
|
77
|
+
// PRESENCE is what makes an option configured, not the value: a key
|
|
78
|
+
// scaffolded as its default is still a declaration, and reporting it as
|
|
79
|
+
// "default" would tell a reader the file does not say what it plainly
|
|
80
|
+
// says. Only a key that is absent, or one whose value is rejected, falls
|
|
81
|
+
// back to the default.
|
|
82
|
+
let language = DEFAULTS.language;
|
|
83
|
+
if (cfg && "language" in cfg) {
|
|
84
|
+
const rawLang = cfg.language;
|
|
85
|
+
sources.language = SOURCES.config;
|
|
86
|
+
if (rawLang === null) {
|
|
87
|
+
language = null; // an explicit "detect per file"
|
|
88
|
+
} else if (typeof rawLang === "string" && normalizeLanguage(rawLang) !== null) {
|
|
89
|
+
language = normalizeLanguage(rawLang);
|
|
90
|
+
} else {
|
|
91
|
+
errors.push(`${CONFIG_REL}: unknown language ${JSON.stringify(rawLang)} (use "en" or "pt", or null to detect per file)`);
|
|
92
|
+
sources.language = SOURCES.default;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// context_budget — declared here, historically in index.json's config block.
|
|
97
|
+
let context_budget = DEFAULTS.context_budget;
|
|
98
|
+
if (cfg && "context_budget" in cfg && isPositiveInt(cfg.context_budget)) {
|
|
99
|
+
context_budget = cfg.context_budget;
|
|
100
|
+
sources.context_budget = SOURCES.config;
|
|
101
|
+
} else {
|
|
102
|
+
if (cfg && "context_budget" in cfg) {
|
|
103
|
+
errors.push(`${CONFIG_REL}: context_budget must be a positive integer (got ${JSON.stringify(cfg.context_budget)})`);
|
|
104
|
+
}
|
|
105
|
+
const fromIndex = readJson(path.join(projectRoot, ".doctrina", "index.json"), ".doctrina/index.json", [])?.config?.context_budget;
|
|
106
|
+
if (isPositiveInt(fromIndex)) {
|
|
107
|
+
context_budget = fromIndex;
|
|
108
|
+
sources.context_budget = SOURCES.index;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// rules — declared here, historically a file of their own.
|
|
113
|
+
let rules = DEFAULTS.rules;
|
|
114
|
+
if (cfg && Array.isArray(cfg.rules)) {
|
|
115
|
+
rules = cfg.rules;
|
|
116
|
+
sources.rules = SOURCES.config;
|
|
117
|
+
} else {
|
|
118
|
+
if (cfg && "rules" in cfg) {
|
|
119
|
+
errors.push(`${CONFIG_REL}: rules must be an array`);
|
|
120
|
+
}
|
|
121
|
+
const rulesPath = path.join(projectRoot, ".doctrina", "rules.json");
|
|
122
|
+
if (isFile(rulesPath)) {
|
|
123
|
+
const legacy = readJson(rulesPath, RULES_REL, errors);
|
|
124
|
+
if (legacy && !Array.isArray(legacy.rules)) {
|
|
125
|
+
errors.push(`${RULES_REL}: expected { "rules": [...] }`);
|
|
126
|
+
} else if (legacy) {
|
|
127
|
+
rules = legacy.rules;
|
|
128
|
+
sources.rules = SOURCES.rules;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { language, context_budget, rules, sources, errors, unknown };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The configuration as rows a reader can scan: option, effective value, and
|
|
138
|
+
* where it came from. `doctor` prints these; the point is that a project can
|
|
139
|
+
* see what it configured without reading the CLI's source.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} projectRoot
|
|
142
|
+
* @returns {{option: string, value: string, source: string, configured: boolean}[]}
|
|
143
|
+
*/
|
|
144
|
+
export function configRows(projectRoot) {
|
|
145
|
+
const cfg = loadConfig(projectRoot);
|
|
146
|
+
const show = (v) => (v === null ? "auto-detect per file" : String(v));
|
|
147
|
+
return [
|
|
148
|
+
{ option: "language", value: show(cfg.language), source: cfg.sources.language, configured: cfg.sources.language !== SOURCES.default },
|
|
149
|
+
{ option: "context_budget", value: `${cfg.context_budget} tokens`, source: cfg.sources.context_budget, configured: cfg.sources.context_budget !== SOURCES.default },
|
|
150
|
+
{ option: "rules", value: `${cfg.rules.length} rule${cfg.rules.length === 1 ? "" : "s"}`, source: cfg.sources.rules, configured: cfg.sources.rules !== SOURCES.default },
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** "pt-BR" → "pt", "en-GB" → "en", anything else → null. */
|
|
155
|
+
export function normalizeLanguage(raw) {
|
|
156
|
+
const lang = String(raw ?? "").toLowerCase();
|
|
157
|
+
if (lang.startsWith("pt")) return "pt";
|
|
158
|
+
if (lang.startsWith("en")) return "en";
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isPositiveInt(n) {
|
|
163
|
+
return Number.isInteger(n) && n > 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A malformed file is a finding, not an exception: every caller of this
|
|
167
|
+
// module is either a gate that will report it or a command that must keep
|
|
168
|
+
// working without it.
|
|
169
|
+
function readJson(file, rel, errors) {
|
|
170
|
+
if (!isFile(file)) return null;
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(read(file));
|
|
173
|
+
} catch (err) {
|
|
174
|
+
errors.push(`${rel} is not valid JSON: ${err.message}`);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The file `init` writes, from the same module that reads it.
|
|
181
|
+
*
|
|
182
|
+
* Written from the definition rather than from a template, for the reason
|
|
183
|
+
* index.json is: a template copy is how a project comes to be born already
|
|
184
|
+
* needing a scaffold update (audit item C5).
|
|
185
|
+
*
|
|
186
|
+
* It declares NOTHING. A scaffold that restated every default would make the
|
|
187
|
+
* file lie in two directions: `doctor` could no longer tell a choice from a
|
|
188
|
+
* default, and a written default would silently outrank a legacy declaration
|
|
189
|
+
* — a project that had set its budget in index.json would find it ignored by
|
|
190
|
+
* a file it never edited. So the scaffold is documentation of the options,
|
|
191
|
+
* and the first key a project adds is the first thing it has actually chosen.
|
|
192
|
+
* JSON carries no comments, hence `$comment`.
|
|
193
|
+
*
|
|
194
|
+
* @returns {string} the file's contents, newline-terminated
|
|
195
|
+
*/
|
|
196
|
+
export function scaffoldConfig() {
|
|
197
|
+
return JSON.stringify({
|
|
198
|
+
$comment:
|
|
199
|
+
"Doctrina project configuration. Nothing is declared here yet — add a key to " +
|
|
200
|
+
"change it. Options: \"language\" (\"en\" | \"pt\", or null to detect per file); " +
|
|
201
|
+
`"context_budget" (positive integer, default ${DEFAULTS.context_budget}); ` +
|
|
202
|
+
"\"rules\" (array of { id, forbid, paths, message } — permanent project " +
|
|
203
|
+
"constraints enforced by `doctrina validate`). Run `doctrina doctor` to see the " +
|
|
204
|
+
"effective value of each option and where it came from.",
|
|
205
|
+
}, null, 2) + "\n";
|
|
206
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read } from "./fs-ops.js";
|
|
5
|
+
import { listHeader } from "./scan.js";
|
|
6
|
+
|
|
7
|
+
// The standing-rules MODEL: the accepted ADRs and the named sections of
|
|
8
|
+
// product.md that together are the project's constitution. `constitution`
|
|
9
|
+
// renders them in full and `prime` digests them to titles, so the reading
|
|
10
|
+
// belongs here rather than in either command (audit finding F7).
|
|
11
|
+
|
|
12
|
+
// Accepted ADRs (NNNN-slug.md with Status: accepted), oldest first, with the
|
|
13
|
+
// title read from the `# ADR NNNN — <title>` heading. Exported for `prime`,
|
|
14
|
+
// which digests the same standing rules into the session primer.
|
|
15
|
+
export function acceptedDecisions(projectRoot) {
|
|
16
|
+
const dir = path.join(projectRoot, ".doctrina", "decisions");
|
|
17
|
+
const out = [];
|
|
18
|
+
if (!isDir(dir)) return out;
|
|
19
|
+
for (const f of readdirSync(dir).sort()) {
|
|
20
|
+
const m = f.match(/^(\d{4})-.*\.md$/);
|
|
21
|
+
if (!m) continue;
|
|
22
|
+
const text = read(path.join(dir, f));
|
|
23
|
+
if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
|
|
24
|
+
const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
|
|
25
|
+
out.push({ id: m[1], title: titleMatch ? titleMatch[1].trim() : f });
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Bullets under a named `## <section>` of product.md, each accumulated across
|
|
31
|
+
// its continuation lines so a wrapped bullet reads as one rule. Exported for
|
|
32
|
+
// `prime`.
|
|
33
|
+
/**
|
|
34
|
+
* Does `product.md` carry this `## ` section at all? Separate from whether it
|
|
35
|
+
* holds anything, so a finding can name the remedy that actually resolves it
|
|
36
|
+
* (rule C2): "add the section" and "fill the section" are different asks.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} projectRoot
|
|
39
|
+
* @param {string} name
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function hasProductSection(projectRoot, name) {
|
|
43
|
+
const p = path.join(projectRoot, ".doctrina", "product.md");
|
|
44
|
+
if (!isFile(p)) return false;
|
|
45
|
+
return new RegExp(`^##\\s+${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "im")
|
|
46
|
+
.test(read(p));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function productSection(projectRoot, name) {
|
|
50
|
+
const p = path.join(projectRoot, ".doctrina", "product.md");
|
|
51
|
+
if (!isFile(p)) return [];
|
|
52
|
+
const lines = read(p).split(/\r?\n/);
|
|
53
|
+
let inSection = false;
|
|
54
|
+
let inComment = false;
|
|
55
|
+
const out = [];
|
|
56
|
+
let cur = null;
|
|
57
|
+
const flush = () => {
|
|
58
|
+
if (cur) out.push(cur.replace(/\s+/g, " ").trim());
|
|
59
|
+
cur = null;
|
|
60
|
+
};
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
if (/^##\s+/.test(line)) {
|
|
63
|
+
if (inSection) {
|
|
64
|
+
flush();
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
if (new RegExp(`^##\\s+${name}\\b`, "i").test(line)) inSection = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!inSection) continue;
|
|
71
|
+
// An HTML comment is annotation, not content (change 0055): the template's
|
|
72
|
+
// own instruction for this section must never read as a declared item.
|
|
73
|
+
if (inComment) {
|
|
74
|
+
if (line.includes("-->")) inComment = false;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (line.trim().startsWith("<!--")) {
|
|
78
|
+
if (!line.includes("-->")) inComment = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const m = line.match(/^\s*[-*]\s+(.+)$/);
|
|
82
|
+
if (m) {
|
|
83
|
+
flush();
|
|
84
|
+
cur = m[1];
|
|
85
|
+
} else if (cur && line.trim() !== "") {
|
|
86
|
+
cur += " " + line.trim();
|
|
87
|
+
} else if (line.trim() === "") {
|
|
88
|
+
flush();
|
|
89
|
+
} else {
|
|
90
|
+
// PROSE (change 0069). The section's own template comment invites it —
|
|
91
|
+
// "Explicit things this project will NOT try to be" asks for no bullets
|
|
92
|
+
// — and reading only bullets made `prime --rules` report a filled
|
|
93
|
+
// section as "none declared" and tell the author to add a section that
|
|
94
|
+
// was already there. Rule C2: a finding may only name a remedy that
|
|
95
|
+
// resolves it, and this one named an act already done. A paragraph is
|
|
96
|
+
// one item; a blank line ends it, exactly as it ends a bullet.
|
|
97
|
+
cur = line.trim();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
flush();
|
|
101
|
+
return out;
|
|
102
|
+
}
|