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.
Files changed (84) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -212
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +4 -82
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +60 -138
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +56 -4
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +181 -23
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +191 -5
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/hooks/pre-commit.sample +6 -2
  82. package/templates/playbooks/bootstrap.md.template +43 -0
  83. package/templates/playbooks/chore.md.template +19 -0
  84. package/templates/playbooks/work.md.template +76 -0
@@ -0,0 +1,182 @@
1
+ // @ts-check
2
+ import process from "node:process";
3
+ import { flagString } from "../lib/args.js";
4
+ import { c } from "../lib/colors.js";
5
+ import { suggest } from "../lib/suggest.js";
6
+ import { sequence } from "../lib/gates.js";
7
+
8
+ // The CI surface, GENERATED from the gate declaration (audit finding F1).
9
+ //
10
+ // `action.yml` used to be a fourth hand-maintained list of gates, beside
11
+ // `close`'s array and `doctor`'s rows. Nothing noticed when they diverged,
12
+ // so a gate could be in the close and absent from CI for a release — which
13
+ // is exactly what happened to the runtime checks.
14
+ //
15
+ // The action stays VERSIONED rather than generated on demand: a project that
16
+ // writes `uses: GCarin1/Doctrina@v1` has no CLI to generate it with, and a
17
+ // composite action that only exists after an npm install is not an action.
18
+ // So this command emits it, and a drift test holds the committed file to
19
+ // what this emits — the same pattern the AGENTS.md surface block uses.
20
+
21
+ const TARGETS = ["github"];
22
+
23
+ // Flags this command accepts. Declared HERE, with the command, so
24
+ // adding a command never requires editing the entrypoint.
25
+ export const flags = { boolean: ["json"], string: ["emit"] };
26
+
27
+ export async function run(_positional, flags) {
28
+ const target = flagString(flags, "emit", "");
29
+ if (!target) {
30
+ console.error(c.red("error:") + " ci requires --emit <target>");
31
+ console.error(c.gray("hint: ") + `available targets: ${TARGETS.join(", ")}`);
32
+ return 2;
33
+ }
34
+ if (!TARGETS.includes(target)) {
35
+ console.error(c.red("error:") + ` unknown CI target "${target}"`);
36
+ const guess = suggest(target, TARGETS);
37
+ console.error(c.gray("hint: ") + (guess ? `did you mean --emit ${guess}?` : `available: ${TARGETS.join(", ")}`));
38
+ return 2;
39
+ }
40
+ process.stdout.write(emitGithub());
41
+ return 0;
42
+ }
43
+
44
+ /**
45
+ * The composite action, rendered from SEQUENCES.ci.
46
+ *
47
+ * Takes the sequence as a parameter so a test can prove the surface follows
48
+ * the DECLARATION rather than a copy of it: add a step to the array passed
49
+ * in, and it appears in the YAML with no edit here.
50
+ */
51
+ export function emitGithub(steps = sequence("ci")) {
52
+ // Two renderings of the same list: the file header says what each gate
53
+ // checks, the action's description just names them.
54
+ const gateSummaries = steps.map((s) => s.summary ?? s.label.replace(/^doctrina /, ""));
55
+ const gateNames = steps.map((s) => s.short ?? s.label.replace(/^doctrina /, "")).join(", ");
56
+ const out = [];
57
+
58
+ out.push("# Reusable composite action: run the Doctrina gates in CI.");
59
+ out.push("#");
60
+ out.push("# - uses: GCarin1/Doctrina@main # or a released tag");
61
+ out.push("# with:");
62
+ out.push("# strict: \"true\" # coverage/trace as hard gates");
63
+ out.push("#");
64
+ out.push("# GENERATED by `doctrina ci --emit github` from SEQUENCES.ci in");
65
+ out.push("# packages/doctrina-cli/src/lib/gates.js — the one declaration `close` and");
66
+ out.push("# `doctor` also read. Edit the declaration, re-emit, and commit; a drift");
67
+ out.push("# test fails when this file and the declaration disagree.");
68
+ out.push("#");
69
+ for (const line of wrapComment(`The gates: ${gateSummaries.join(", ")}.`, 74)) out.push(`# ${line}`);
70
+ out.push("#");
71
+ out.push("# `run-prefix` exists so this repository can dogfood the action against its");
72
+ out.push("# own working tree instead of the published package.");
73
+ out.push("name: \"Doctrina gates\"");
74
+ out.push(`description: "Run the Doctrina gates (${gateNames}) against a project's .doctrina/ tree."`);
75
+ out.push("branding:");
76
+ out.push(" icon: \"check-circle\"");
77
+ out.push(" color: \"purple\"");
78
+ out.push("");
79
+ out.push("inputs:");
80
+ out.push(" strict:");
81
+ out.push(" description: \"Run coverage and trace with --strict (fail on any gap). Set to 'false' for report-only.\"");
82
+ out.push(" required: false");
83
+ out.push(" default: \"true\"");
84
+ out.push(" version:");
85
+ out.push(" description: \"doctrina-cli version to run via npx (ignored when run-prefix is set).\"");
86
+ out.push(" required: false");
87
+ out.push(" default: \"latest\"");
88
+ out.push(" working-directory:");
89
+ out.push(" description: \"Directory holding the .doctrina/ tree.\"");
90
+ out.push(" required: false");
91
+ out.push(" default: \".\"");
92
+ out.push(" run-prefix:");
93
+ out.push(" description: \"Override the command used to invoke the CLI (e.g. 'node packages/doctrina-cli/src/index.js' for this repo's own CI).\"");
94
+ out.push(" required: false");
95
+ out.push(" default: \"\"");
96
+ out.push("");
97
+ out.push("runs:");
98
+ out.push(" using: \"composite\"");
99
+ out.push(" steps:");
100
+ out.push(" - name: Resolve CLI invocation");
101
+ out.push(" id: cli");
102
+ out.push(" shell: bash");
103
+ out.push(" working-directory: ${{ inputs.working-directory }}");
104
+ out.push(" run: |");
105
+ out.push(" PREFIX=\"${{ inputs.run-prefix }}\"");
106
+ out.push(" if [ -z \"$PREFIX\" ]; then PREFIX=\"npx --yes doctrina-cli@${{ inputs.version }}\"; fi");
107
+ out.push(" echo \"prefix=$PREFIX\" >> \"$GITHUB_OUTPUT\"");
108
+
109
+ const prefix = "${{ steps.cli.outputs.prefix }}";
110
+ for (const step of steps) {
111
+ out.push("");
112
+ if (step.why) {
113
+ for (const line of step.why.split("\n")) out.push(` # ${line}`.trimEnd());
114
+ }
115
+ out.push(` - name: ${step.label}`);
116
+ out.push(" shell: bash");
117
+ out.push(" working-directory: ${{ inputs.working-directory }}");
118
+
119
+ const command = `${prefix} ${(step.argv ?? []).join(" ")}`.trim();
120
+ if (step.script) {
121
+ out.push(" run: |");
122
+ for (const line of step.script.replaceAll("{{PREFIX}}", prefix).split("\n")) {
123
+ out.push(` ${line}`.trimEnd());
124
+ }
125
+ } else if (step.strict) {
126
+ // The one shape the declaration cannot flatten to a single line: the
127
+ // action's `strict` input decides whether the gate fails on a gap or
128
+ // only reports it, and an adopting project in its backfill phase needs
129
+ // the report-only half.
130
+ out.push(" run: |");
131
+ out.push(" if [ \"${{ inputs.strict }}\" = \"true\" ]; then");
132
+ out.push(` ${command} --strict`);
133
+ out.push(" else");
134
+ out.push(` ${command}`);
135
+ out.push(" fi");
136
+ } else {
137
+ out.push(` run: ${command}`);
138
+ }
139
+ }
140
+ out.push("");
141
+ return out.join("\n");
142
+ }
143
+
144
+ // Wrap prose to a column so the emitted comments read like hand-written ones
145
+ // rather than one very long line the reader has to scroll.
146
+ function wrapComment(text, width) {
147
+ const out = [];
148
+ let line = "";
149
+ for (const word of text.split(/\s+/)) {
150
+ if (line && (line + " " + word).length > width) {
151
+ out.push(line);
152
+ line = word;
153
+ } else {
154
+ line = line ? `${line} ${word}` : word;
155
+ }
156
+ }
157
+ if (line) out.push(line);
158
+ return out;
159
+ }
160
+
161
+ export const help = `
162
+ Usage: doctrina ci --emit <target>
163
+
164
+ Emit the CI pipeline for the declared gate sequence, on stdout.
165
+
166
+ The gates a pipeline runs are declared once, in SEQUENCES.ci
167
+ (packages/doctrina-cli/src/lib/gates.js) — the same declaration \`close\`
168
+ executes and \`doctor\` reports. This command renders that declaration as CI
169
+ configuration, so the pipeline cannot fall behind the sequence the way a
170
+ hand-maintained YAML file did.
171
+
172
+ Targets:
173
+ github A composite GitHub Action (the repository's own action.yml).
174
+
175
+ The action stays versioned in the repository: a project that writes
176
+ \`uses: <owner>/<repo>@v1\` has no CLI to generate it with. Re-emit after
177
+ changing the declaration and commit the result; the drift test compares the
178
+ committed file against this output.
179
+
180
+ Read-only — it writes nothing, so redirect it yourself:
181
+ doctrina ci --emit github > action.yml
182
+ `;
@@ -5,7 +5,9 @@ import { readdirSync } from "node:fs";
5
5
  import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
6
6
  import { flagBool, flagString } from "../lib/args.js";
7
7
  import { c } from "../lib/colors.js";
8
- import { notADoctrinaProject } from "../lib/exit-codes.js";
8
+ import { detectLanguage } from "../lib/lexicon.js";
9
+ import { loadConfig } from "../lib/config.js";
10
+ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
9
11
 
10
12
  // Two lexicons, one linter (0.11.0 field review item 3: an English-only
11
13
  // clarify is permanently red on a PT-BR project — "some" is the verb *sumir*,
@@ -26,7 +28,16 @@ const RULES_EN = [
26
28
  },
27
29
  {
28
30
  name: "vague",
29
- re: /\b(many|few|some|several)\b(?!\s+\d)/gi,
31
+ // Two exclusions, both structural rather than a vocabulary loosening.
32
+ // The lookahead was already here: `many 5` quantifies. The lookbehind is
33
+ // change 0079: `how many` is INTERROGATIVE, and a requirement that says
34
+ // "shall report how many contracts declared no rows" names exactly the
35
+ // number the command must print — the opposite of vague. Five of this
36
+ // repository's seventeen smells were that phrase, and the noise is what
37
+ // hid the one real finding among them ("name some of them", which said
38
+ // nothing about how many). A gate that cries wolf teaches people to
39
+ // ignore it.
40
+ re: /(?<!\bhow\s+)\b(many|few|some|several)\b(?!\s+\d)/gi,
30
41
  hint: "quantify (use a number or a precise scope)",
31
42
  },
32
43
  {
@@ -36,6 +47,10 @@ const RULES_EN = [
36
47
  },
37
48
  ];
38
49
 
50
+ // How much of the previous line a rule may look back into. Long enough for
51
+ // a wrapped two-word phrase, short enough that the probe stays cheap.
52
+ const LOOKBEHIND_TAIL = 40;
53
+
39
54
  const RULES_PT = [
40
55
  {
41
56
  name: "weasel",
@@ -59,30 +74,34 @@ const RULES_PT = [
59
74
  // Language for a file: the project-declared `.doctrina/config.json`
60
75
  // ("language": "pt-BR" / "pt" / "en") wins; otherwise a deterministic
61
76
  // stopword count over the file text decides. Never semantic (ADR 0005).
62
- const PT_STOPWORDS = /\b(que|n[aã]o|para|uma|como|mais|ser|quando|est[aá]|s[aã]o|pela|pelo|dos|das|ou seja|deve)\b/gi;
63
- const EN_STOPWORDS = /\b(the|and|that|with|shall|when|this|from|are|not|for|must)\b/gi;
64
77
 
65
78
  function projectLanguage(projectRoot) {
66
- const cfgPath = path.join(projectRoot, ".doctrina", "config.json");
67
- if (isFile(cfgPath)) {
68
- try {
69
- const lang = String(JSON.parse(read(cfgPath))?.language ?? "").toLowerCase();
70
- if (lang.startsWith("pt")) return "pt";
71
- if (lang.startsWith("en")) return "en";
72
- } catch { /* fall through to detection */ }
73
- }
74
- return null;
79
+ // One configuration reader (lib/config.js, change 0047): the key still
80
+ // lives in .doctrina/config.json, but `doctor` can now show what it
81
+ // resolved to, which is what a pt-BR project sitting red needed.
82
+ return loadConfig(projectRoot).language;
75
83
  }
76
84
 
85
+ // Which lexicon a document is written in — the shared detector (change
86
+ // 0040), so `clarify` and any future consumer count the same grammar words.
77
87
  function detectLang(text) {
78
- const pt = (text.match(PT_STOPWORDS) ?? []).length;
79
- const en = (text.match(EN_STOPWORDS) ?? []).length;
80
- return pt > en ? "pt" : "en";
88
+ return detectLanguage(text);
81
89
  }
82
90
 
83
91
  function rulesFor(projectRoot, text, forcedLang = null) {
84
- const lang = forcedLang ?? projectLanguage(projectRoot) ?? detectLang(text);
85
- return lang === "pt" ? RULES_PT : RULES_EN;
92
+ const lang = forcedLang ?? projectLanguage(projectRoot);
93
+ if (lang) return lang === "pt" ? RULES_PT : RULES_EN;
94
+ // Detected per file, the document is scanned with BOTH lexicons (change
95
+ // 0107). A bilingual spec — Portuguese purpose, English EARS, or the
96
+ // reverse — detected as one language hid every smell written in the
97
+ // other: `clarify --all` found nothing where `--lang pt` found two. The
98
+ // two vocabularies do not overlap, so a smell in either is a smell; the
99
+ // detected language only decides which hint text the reader gets first.
100
+ const detected = detectLang(text);
101
+ const [first, second] = detected === "pt" ? [RULES_PT, RULES_EN] : [RULES_EN, RULES_PT];
102
+ // The `placeholder` rule is language-neutral and lives in both lists:
103
+ // one copy, or TBD is reported twice.
104
+ return [...first, ...second.filter((r) => r.name !== "placeholder")];
86
105
  }
87
106
 
88
107
  // --lang pt|en wins over the project config and the per-file heuristic
@@ -117,8 +136,15 @@ export async function run(positional, flags) {
117
136
  }
118
137
  const fullPath = path.resolve(projectRoot, target);
119
138
  if (!isFile(fullPath)) {
139
+ // USAGE, not GATE (third audit, finding 9). A path that does not exist
140
+ // is a wrong invocation: retrying it unchanged never succeeds, and an
141
+ // agent branching on the exit code reads 1 as "the work is not ready"
142
+ // and iterates forever (ADR 0018). Every sibling that takes a path —
143
+ // `intake --file`, `init --intake-file`, `templates check --path` —
144
+ // already exits 2 here; `clarify` was the straggler.
120
145
  console.error(c.red("error:") + ` file not found: ${target}`);
121
- return 1;
146
+ console.error(c.gray("hint: ") + "pass a path to an existing Markdown file, or `--all` for the tree");
147
+ return EXIT.USAGE;
122
148
  }
123
149
 
124
150
  console.log(`clarify ${relPath(projectRoot, fullPath)}`);
@@ -208,11 +234,22 @@ function scanFile(fullPath, projectRoot = process.cwd(), lang = null) {
208
234
  cursor = lineEnd + 1;
209
235
  continue;
210
236
  }
237
+ // A rule reads the previous line as CONTEXT, never as content: a match
238
+ // is only reported when it starts inside this line. Prose wraps, and
239
+ // "report per spec how / many criteria..." split the interrogative
240
+ // across a line break — which a line-at-a-time scanner cannot see, so
241
+ // the phrase came back as a smell on one line of the same document and
242
+ // not on the next (change 0079). The prefix is trimmed to the tail of
243
+ // the previous line: enough for a lookbehind, cheap on every line.
244
+ const prev = i > 0 ? lines[i - 1].slice(-LOOKBEHIND_TAIL) : "";
245
+ const probe = prev === "" ? line : `${prev}\n${line}`;
246
+ const offset = probe.length - line.length;
211
247
  for (const rule of rules) {
212
248
  rule.re.lastIndex = 0;
213
249
  let m;
214
- while ((m = rule.re.exec(line))) {
215
- const absoluteStart = lineStart + m.index;
250
+ while ((m = rule.re.exec(probe))) {
251
+ if (m.index < offset) continue; // the match belongs to the previous line
252
+ const absoluteStart = lineStart + m.index - offset;
216
253
  if (isInSkippedRange(absoluteStart, skippedRanges)) continue;
217
254
  smells.push({
218
255
  line: i + 1,