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
|
@@ -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
|
+
}
|