docgov-cli 0.2.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/.claude-plugin/marketplace.json +29 -0
- package/.claude-plugin/plugin.json +41 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/agents/architect.md +65 -0
- package/agents/classifier.md +44 -0
- package/agents/drift-reviewer.md +59 -0
- package/agents/quality-reviewer.md +59 -0
- package/bin/docgov +1160 -0
- package/bin/docgov.cmd +2 -0
- package/core/check.js +298 -0
- package/core/classify.js +233 -0
- package/core/config.js +162 -0
- package/core/context.js +144 -0
- package/core/document.js +132 -0
- package/core/drift.js +225 -0
- package/core/find.js +61 -0
- package/core/frontmatter.js +65 -0
- package/core/git.js +113 -0
- package/core/graph.js +182 -0
- package/core/health.js +101 -0
- package/core/impact.js +146 -0
- package/core/invariants.js +126 -0
- package/core/inventory.js +167 -0
- package/core/links.js +80 -0
- package/core/migrate.js +158 -0
- package/core/onboard.js +271 -0
- package/core/paths.js +53 -0
- package/core/publish.js +92 -0
- package/core/registry.js +71 -0
- package/core/similarity.js +89 -0
- package/core/size.js +87 -0
- package/core/suppressions.js +58 -0
- package/core/taxonomy.js +477 -0
- package/core/templates.js +159 -0
- package/core/util.js +124 -0
- package/core/yaml.js +250 -0
- package/hooks/hooks.json +65 -0
- package/lenses/agent.md +38 -0
- package/lenses/architecture.md +30 -0
- package/lenses/developer.md +26 -0
- package/lenses/operations.md +32 -0
- package/lenses/readme.md +32 -0
- package/lenses/security.md +33 -0
- package/lenses/user.md +30 -0
- package/package.json +39 -0
- package/policy/documentation.md +82 -0
- package/schemas/config.json +239 -0
- package/schemas/frontmatter.json +299 -0
- package/skills/affected/SKILL.md +41 -0
- package/skills/brief/SKILL.md +38 -0
- package/skills/create/SKILL.md +53 -0
- package/skills/find/SKILL.md +32 -0
- package/skills/health/SKILL.md +36 -0
- package/skills/inspect/SKILL.md +58 -0
- package/skills/publish/SKILL.md +45 -0
- package/skills/review/SKILL.md +65 -0
- package/skills/setup/SKILL.md +52 -0
- package/skills/stale/SKILL.md +55 -0
- package/skills/tag/SKILL.md +59 -0
- package/templates/architecture.adr.md +42 -0
- package/templates/architecture.domain.md +44 -0
- package/templates/architecture.trd.md +72 -0
- package/templates/constitution.invariants.md +40 -0
- package/templates/operations.runbook.md +47 -0
- package/templates/product.prd.md +60 -0
- package/templates/security.threat-model.md +51 -0
- package/templates/user.readme.md +43 -0
package/bin/docgov.cmd
ADDED
package/core/check.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { blocks, limitFor, locationFor } from './config.js';
|
|
3
|
+
import { TYPES, AUTHORITY, VISIBILITY, STATUS, typeDef } from './taxonomy.js';
|
|
4
|
+
import { matchAny, matchGlob, EXIT } from './util.js';
|
|
5
|
+
import { classify, destinationFor } from './classify.js';
|
|
6
|
+
import { brokenLinks, brokenAnchors } from './links.js';
|
|
7
|
+
import { assess, readmeOverreach, missingIndexes } from './size.js';
|
|
8
|
+
import { danglingReferences } from './registry.js';
|
|
9
|
+
import { similarPairs } from './similarity.js';
|
|
10
|
+
import { parse as fmParse } from './frontmatter.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The deterministic check engine (PRD §21).
|
|
14
|
+
*
|
|
15
|
+
* Every check here is decidable by software. Nothing in this file asks a model a
|
|
16
|
+
* question, which is exactly why it is allowed to fail CI. Subjective findings
|
|
17
|
+
* live in the agent layer and are advisory by construction.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** check id -> {severity, deterministic, blurb} */
|
|
21
|
+
export const CHECKS = {
|
|
22
|
+
'invalid-yaml': { severity: 'critical', blurb: 'frontmatter is not parseable' },
|
|
23
|
+
'missing-frontmatter': { severity: 'high', blurb: 'no docgov frontmatter block' },
|
|
24
|
+
'missing-id': { severity: 'high', blurb: 'docgov.id is absent' },
|
|
25
|
+
'invalid-id': { severity: 'high', blurb: 'docgov.id does not match the pattern for its type' },
|
|
26
|
+
'duplicate-id': { severity: 'critical', blurb: 'two documents claim the same docgov.id' },
|
|
27
|
+
'unknown-type': { severity: 'high', blurb: 'docgov.type is not a known document class' },
|
|
28
|
+
'unknown-reference': { severity: 'high', blurb: 'a relationship points at an id that does not exist' },
|
|
29
|
+
'invalid-relationship':{ severity: 'medium', blurb: 'relationship name is not in the taxonomy' },
|
|
30
|
+
'broken-link': { severity: 'high', blurb: 'internal link target does not exist' },
|
|
31
|
+
'broken-anchor': { severity: 'low', blurb: 'in-page anchor has no matching heading' },
|
|
32
|
+
'wrong-location': { severity: 'medium', blurb: 'document is not in the canonical location for its type' },
|
|
33
|
+
'visibility-path': { severity: 'critical', blurb: 'document visibility is forbidden in this path' },
|
|
34
|
+
'missing-visibility': { severity: 'medium', blurb: 'visibility is not declared' },
|
|
35
|
+
'invalid-visibility': { severity: 'high', blurb: 'visibility is not a known value' },
|
|
36
|
+
'invalid-status': { severity: 'medium', blurb: 'status is not a known value' },
|
|
37
|
+
'missing-sections': { severity: 'medium', blurb: 'required template sections are absent' },
|
|
38
|
+
'soft-limit': { severity: 'low', blurb: 'document exceeds its soft line limit' },
|
|
39
|
+
'hard-limit': { severity: 'medium', blurb: 'document exceeds its hard line limit' },
|
|
40
|
+
'generated-edit': { severity: 'critical', blurb: 'a generated document was edited by hand' },
|
|
41
|
+
'frozen-edit': { severity: 'high', blurb: 'an archived document was edited' },
|
|
42
|
+
'missing-owner': { severity: 'medium', blurb: 'owner is required in this project mode' },
|
|
43
|
+
'authority-violation': { severity: 'critical', blurb: 'a lower-authority document claims authority over a higher one' },
|
|
44
|
+
'orphan': { severity: 'low', blurb: 'no inbound or outbound relationships' },
|
|
45
|
+
'unclassified': { severity: 'medium', blurb: 'document type could not be determined' },
|
|
46
|
+
'readme-overreach': { severity: 'low', blurb: 'a README section has grown into its own document' },
|
|
47
|
+
'missing-index': { severity: 'low', blurb: 'directory has several documents and no index' },
|
|
48
|
+
'duplicate-candidate': { severity: 'low', blurb: 'two documents are textually very similar' },
|
|
49
|
+
'new-root-document': { severity: 'medium', blurb: 'a new top-level Markdown file was added outside the taxonomy' },
|
|
50
|
+
'expired-suppression': { severity: 'medium', blurb: 'a suppression has expired and is no longer in effect' },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
for (const r of Object.values(CHECKS)) r.deterministic = true;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {{root:string, cfg:object, docs:any[], registry:object, graph:any,
|
|
57
|
+
* inv:object, only?:string[]}} ctx
|
|
58
|
+
* @returns {{findings:object[], stats:object}}
|
|
59
|
+
*/
|
|
60
|
+
export function run({ root, cfg, docs, registry, graph, inv, only = null }) {
|
|
61
|
+
const findings = [];
|
|
62
|
+
const allFiles = new Set(inv.all);
|
|
63
|
+
const scope = only ? docs.filter((d) => only.includes(d.path)) : docs;
|
|
64
|
+
const add = (check, doc, message, extra = {}) => {
|
|
65
|
+
const meta = CHECKS[check] || { severity: 'medium', blurb: check };
|
|
66
|
+
findings.push({
|
|
67
|
+
check, severity: extra.severity || meta.severity, path: doc?.path ?? extra.path ?? '(repository)',
|
|
68
|
+
id: doc?.id ?? extra.id ?? null, message, deterministic: true,
|
|
69
|
+
blocking: blocks(cfg, check), fix: extra.fix || null, ...extra,
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ---- per-document checks
|
|
74
|
+
for (const d of scope) {
|
|
75
|
+
if (d.error) { add('invalid-yaml', d, d.error); continue; }
|
|
76
|
+
|
|
77
|
+
const inArchive = matchAny(d.path, ['docs/99-archive/**', 'docs/archive/**']);
|
|
78
|
+
const generated = d.isGenerated || matchAny(d.path, cfg.generated_paths || []);
|
|
79
|
+
|
|
80
|
+
if ((!d.hasFrontmatter || !d.frontmatter.docgov) && !d.externallyRegistered) {
|
|
81
|
+
add('missing-frontmatter', d, 'no `docgov:` frontmatter block',
|
|
82
|
+
{ fix: `docgov tag --apply --path ${d.path}`
|
|
83
|
+
+ ` — or, for a file GitHub renders, register it under documentation.registrations` });
|
|
84
|
+
} else {
|
|
85
|
+
if (!d.meta.id) add('missing-id', d, 'docgov.id is required');
|
|
86
|
+
if (d.meta.type && !TYPES[d.meta.type]) add('unknown-type', d, `unknown type "${d.meta.type}"`);
|
|
87
|
+
if (d.meta.visibility && !VISIBILITY.includes(d.meta.visibility))
|
|
88
|
+
add('invalid-visibility', d, `visibility "${d.meta.visibility}" is not one of ${VISIBILITY.join(', ')}`);
|
|
89
|
+
if (!d.meta.visibility) add('missing-visibility', d, `visibility not declared (defaulting to ${d.visibility})`);
|
|
90
|
+
if (d.meta.status && !STATUS.includes(d.meta.status))
|
|
91
|
+
add('invalid-status', d, `status "${d.meta.status}" is not one of ${STATUS.join(', ')}`);
|
|
92
|
+
const idPattern = typeDef(d.type).idPattern;
|
|
93
|
+
if (idPattern && d.meta.id && !new RegExp(idPattern).test(d.meta.id))
|
|
94
|
+
add('invalid-id', d, `id "${d.meta.id}" must match ${idPattern}`);
|
|
95
|
+
for (const rel of Object.keys(d.relationships)) {
|
|
96
|
+
if (!['depends_on', 'defines', 'implements', 'derived_from', 'supersedes', 'references',
|
|
97
|
+
'validated_by', 'generated_from', 'exposes', 'documents'].includes(rel))
|
|
98
|
+
add('invalid-relationship', d, `"${rel}" is not a relationship in the taxonomy`);
|
|
99
|
+
}
|
|
100
|
+
if (cfg.profile?.require_owner && !d.owner && !inArchive)
|
|
101
|
+
add('missing-owner', d, `project mode "${cfg.project.mode}" requires docgov.owner`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (d.type === 'unknown') {
|
|
105
|
+
const c = classify(d);
|
|
106
|
+
add('unclassified', d, c.type === 'unknown'
|
|
107
|
+
? 'no classification signal matched'
|
|
108
|
+
: `type not declared; best guess is ${c.type} (${c.confidence}% confidence)`,
|
|
109
|
+
{ suggestion: c.type === 'unknown' ? null : c.type, candidates: c.candidates });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Location
|
|
113
|
+
const allowedAtRoot = !d.path.includes('/') && (cfg.governance.allowed_root_docs || []).includes(d.path);
|
|
114
|
+
if (d.type !== 'unknown' && !inArchive && !typeDef(d.type).anywhere && !allowedAtRoot) {
|
|
115
|
+
const want = locationFor(cfg, d.type);
|
|
116
|
+
const ok = want.endsWith('/') ? d.path.startsWith(want) : d.path === want;
|
|
117
|
+
if (!ok) add('wrong-location', d, `a ${typeDef(d.type).label} belongs in ${want}`,
|
|
118
|
+
{ fix: `docgov tag --apply`, destination: destinationFor(cfg, d.type, d.path) });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Visibility paths (PRD §10)
|
|
122
|
+
for (const vp of cfg.visibility_paths || []) {
|
|
123
|
+
if (!matchGlob(d.path, vp.glob)) continue;
|
|
124
|
+
if (!vp.require.includes(d.visibility))
|
|
125
|
+
add('visibility-path', d, `${vp.glob} may only hold ${vp.require.join(' or ')} documents; this one is ${d.visibility}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Required sections
|
|
129
|
+
if (!generated && !inArchive) {
|
|
130
|
+
const missing = d.missingSections();
|
|
131
|
+
if (missing.length) add('missing-sections', d, `missing: ${missing.join(', ')}`,
|
|
132
|
+
{ missing, required: typeDef(d.type).sections });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Size
|
|
136
|
+
const size = assess(cfg, d);
|
|
137
|
+
if (size.level === 'hard') add('hard-limit', d, `${d.lines} lines exceeds the hard limit of ${size.hard}`, { size });
|
|
138
|
+
else if (size.level === 'soft') add('soft-limit', d, `${d.lines} lines exceeds the soft limit of ${size.soft}`, { size });
|
|
139
|
+
|
|
140
|
+
for (const r of readmeOverreach(cfg, d)) {
|
|
141
|
+
add('readme-overreach', d, `section "${r.section}" is ${r.lines} lines`,
|
|
142
|
+
{ fix: r.moveTo ? `docgov create ${r.moveTo}` : null, detail: r });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (inArchive && !only) { /* archived documents are frozen; edits are caught at write time */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- repository-wide checks
|
|
149
|
+
const seenIds = new Map();
|
|
150
|
+
for (const d of docs) {
|
|
151
|
+
if (!d.meta.id) continue;
|
|
152
|
+
if (seenIds.has(d.meta.id)) {
|
|
153
|
+
add('duplicate-id', d, `id "${d.meta.id}" is also used by ${seenIds.get(d.meta.id)}`,
|
|
154
|
+
{ other: seenIds.get(d.meta.id) });
|
|
155
|
+
} else seenIds.set(d.meta.id, d.path);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const r of danglingReferences(registry)) {
|
|
159
|
+
add('unknown-reference', null, `${r.relationship}: "${r.target}" is not a registered document`,
|
|
160
|
+
{ path: r.path, id: r.id, fix: `docgov registry --rebuild` });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const b of brokenLinks(docs, root, allFiles)) {
|
|
164
|
+
add('broken-link', null, `link to "${b.target}" resolves to ${b.resolved}, which does not exist`, { path: b.path });
|
|
165
|
+
}
|
|
166
|
+
for (const a of brokenAnchors(docs)) {
|
|
167
|
+
add('broken-anchor', null, `anchor ${a.anchor} has no matching heading`, { path: a.path });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const v of graph.authorityViolations()) {
|
|
171
|
+
add('authority-violation', null,
|
|
172
|
+
`${v.fromAuthority} document ${v.rel} a ${v.toAuthority} document`, { path: v.from, other: v.to });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const o of graph.orphans()) {
|
|
176
|
+
if (matchAny(o.path, ['docs/99-archive/**', 'docs/archive/**', 'docs/10-internal/**', 'docs/internal/**'])) continue;
|
|
177
|
+
add('orphan', null, 'no relationships declared and nothing links to it', { path: o.path, id: o.id });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const m of missingIndexes(docs)) {
|
|
181
|
+
add('missing-index', null, `${m.documents} documents and no index`,
|
|
182
|
+
{ path: m.dir, fix: `docgov create ${m.type} "Overview" --path ${m.suggest}` });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const p of similarPairs(docs, { threshold: 0.55, limit: 15 })) {
|
|
186
|
+
add('duplicate-candidate', null, `${Math.round(p.score * 100)}% textual overlap with ${p.b}${p.reasons.length ? ` (${p.reasons.join(', ')})` : ''}`,
|
|
187
|
+
{ path: p.a, other: p.b, score: p.score });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// New top-level Markdown beyond the allowed singletons
|
|
191
|
+
const allowedRoot = new Set(Object.values(TYPES).filter((t) => t.singleton).map((t) => t.compact || t.full)
|
|
192
|
+
.concat(cfg.governance.allowed_root_docs || []));
|
|
193
|
+
const rootDocs = docs.filter((d) => !d.path.includes('/') && !allowedRoot.has(d.path));
|
|
194
|
+
if (rootDocs.length > (cfg.governance.max_new_root_docs ?? 0)) {
|
|
195
|
+
for (const d of rootDocs) {
|
|
196
|
+
add('new-root-document', d, 'top-level Markdown outside the taxonomy',
|
|
197
|
+
{ fix: `docgov tag --apply --path ${d.path}` });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const stats = summarize(findings);
|
|
202
|
+
return { findings: sortFindings(findings), stats };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function sortFindings(findings) {
|
|
206
|
+
const S = ['critical', 'high', 'medium', 'low'];
|
|
207
|
+
return findings.slice().sort((a, b) =>
|
|
208
|
+
Number(b.blocking) - Number(a.blocking) ||
|
|
209
|
+
S.indexOf(a.severity) - S.indexOf(b.severity) ||
|
|
210
|
+
String(a.path).localeCompare(String(b.path)) ||
|
|
211
|
+
a.check.localeCompare(b.check));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function summarize(findings) {
|
|
215
|
+
const s = { total: findings.length, blocking: 0, critical: 0, high: 0, medium: 0, low: 0, byCheck: {} };
|
|
216
|
+
for (const f of findings) {
|
|
217
|
+
s[f.severity] = (s[f.severity] || 0) + 1;
|
|
218
|
+
if (f.blocking) s.blocking++;
|
|
219
|
+
s.byCheck[f.check] = (s.byCheck[f.check] || 0) + 1;
|
|
220
|
+
}
|
|
221
|
+
return s;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Exit code contract (PRD §39), with the resolution FEASIBILITY §3.6 demands:
|
|
226
|
+
* only deterministic violations produce 1. Drift and advisory findings produce 2,
|
|
227
|
+
* which CI may choose to treat as soft.
|
|
228
|
+
*/
|
|
229
|
+
export function exitCode({ findings, driftFindings = [], cfg }) {
|
|
230
|
+
if (findings.some((f) => f.blocking)) return EXIT.VIOLATION;
|
|
231
|
+
const sev = (cfg.drift?.fail_on || ['critical', 'high']);
|
|
232
|
+
if (driftFindings.some((f) => sev.includes(f.severity))) return EXIT.REVIEW;
|
|
233
|
+
return EXIT.OK;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Pre-write decision for the PreToolUse hook: ring 1 (FEASIBILITY §3.1). */
|
|
237
|
+
export function preWrite({ cfg, relPath, registry, isNew, content }) {
|
|
238
|
+
const reasons = [];
|
|
239
|
+
const deny = (check, msg, fix) => reasons.push({ check, message: msg, fix, blocking: blocks(cfg, check) });
|
|
240
|
+
|
|
241
|
+
if (matchAny(relPath, cfg.generated_paths || []) && !cfg.generated.allow_manual_edit) {
|
|
242
|
+
deny('generated-edit', `${relPath} sits in a generated tree; edit the source and regenerate instead`,
|
|
243
|
+
'change the generator input, then run the generator');
|
|
244
|
+
}
|
|
245
|
+
if (matchAny(relPath, ['docs/99-archive/**', 'docs/archive/**'])) {
|
|
246
|
+
deny('frozen-edit', `${relPath} is archived, and the archive is a historical record`,
|
|
247
|
+
'create a new document rather than editing the archive');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const entry = Object.entries(registry.documents || {}).find(([, e]) => e.path === relPath);
|
|
251
|
+
if (entry && entry[1].generated && !cfg.generated.allow_manual_edit) {
|
|
252
|
+
deny('generated-edit', `${relPath} is registered with generation.mode: generated`,
|
|
253
|
+
'regenerate it rather than editing it');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (content != null && /\.mdx?$/.test(relPath)) {
|
|
257
|
+
let meta = null;
|
|
258
|
+
try {
|
|
259
|
+
meta = fmParse(content).data.docgov || null;
|
|
260
|
+
} catch (e) {
|
|
261
|
+
deny('invalid-yaml', `frontmatter in ${relPath} would not parse: ${e.message}`,
|
|
262
|
+
'fix the YAML, or omit the frontmatter block entirely');
|
|
263
|
+
}
|
|
264
|
+
if (meta?.id) {
|
|
265
|
+
const clash = Object.entries(registry.documents || {})
|
|
266
|
+
.find(([id, e]) => id === meta.id && e.path !== relPath);
|
|
267
|
+
if (clash) deny('duplicate-id', `docgov.id "${meta.id}" already belongs to ${clash[1].path}`,
|
|
268
|
+
`update ${clash[1].path} instead, or choose a different id`);
|
|
269
|
+
}
|
|
270
|
+
if (meta?.type && !TYPES[meta.type]) {
|
|
271
|
+
deny('unknown-type', `docgov.type "${meta.type}" is not a known document class`,
|
|
272
|
+
'run `docgov types` to list the document classes');
|
|
273
|
+
}
|
|
274
|
+
if (meta?.visibility && !VISIBILITY.includes(meta.visibility)) {
|
|
275
|
+
deny('invalid-visibility', `visibility "${meta.visibility}" is not one of ${VISIBILITY.join(', ')}`, null);
|
|
276
|
+
}
|
|
277
|
+
if (meta?.type) {
|
|
278
|
+
for (const vp of cfg.visibility_paths || []) {
|
|
279
|
+
if (!matchGlob(relPath, vp.glob)) continue;
|
|
280
|
+
const vis = meta.visibility || typeDef(meta.type).visibility || 'internal';
|
|
281
|
+
if (!vp.require.includes(vis)) {
|
|
282
|
+
deny('visibility-path', `${vp.glob} may only hold ${vp.require.join(' or ')} documents; this one is ${vis}`,
|
|
283
|
+
'move the document, or change its visibility');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (isNew) {
|
|
290
|
+
const topLevel = !relPath.includes('/') && /\.mdx?$/.test(relPath);
|
|
291
|
+
const allowed = new Set(cfg.governance.allowed_root_docs || []);
|
|
292
|
+
if (topLevel && !allowed.has(relPath)) {
|
|
293
|
+
deny('new-root-document', `${relPath} would be a new top-level Markdown file`,
|
|
294
|
+
'run `docgov whatis --path ' + relPath + '` to find where it belongs in the documentation tree');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return reasons;
|
|
298
|
+
}
|
package/core/classify.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { TYPES } from './taxonomy.js';
|
|
3
|
+
import { matchGlob } from './util.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Deterministic, explainable classification.
|
|
7
|
+
*
|
|
8
|
+
* Every candidate carries the signals that produced it, so `docgov whatis`
|
|
9
|
+
* can always answer "why". The LLM layer is only asked to adjudicate when the
|
|
10
|
+
* top two candidates are close (see `needsReview`) — it never sees the easy cases.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** filename / path signals: [regex, type, weight] */
|
|
14
|
+
const NAME_SIGNALS = [
|
|
15
|
+
[/^readme\.mdx?$/i, 'user.readme', 100],
|
|
16
|
+
[/^(readme|index)\.mdx?$/i, 'docs.index', 55],
|
|
17
|
+
[/^contributing/i, 'governance.contributing', 100],
|
|
18
|
+
[/^security\.mdx?$/i, 'security.public-model', 70],
|
|
19
|
+
[/^support/i, 'governance.support', 90],
|
|
20
|
+
[/^changelog/i, 'release.changelog', 100],
|
|
21
|
+
[/^claude\.mdx?$|^agents\.mdx?$|^\.cursorrules$/i, 'agent.instructions', 100],
|
|
22
|
+
[/^code_of_conduct/i, 'governance.code-of-conduct', 95],
|
|
23
|
+
[/^license/i, 'governance.policy', 30],
|
|
24
|
+
[/^product\.mdx?$/i, 'constitution.product', 90],
|
|
25
|
+
[/^principles?\.mdx?$/i, 'constitution.principles', 90],
|
|
26
|
+
[/^invariants?\.mdx?$/i, 'constitution.invariants', 95],
|
|
27
|
+
[/^glossary\.mdx?$/i, 'constitution.glossary', 95],
|
|
28
|
+
[/^domains?\.mdx?$/i, 'constitution.domains', 80],
|
|
29
|
+
[/(^|[-_/])adr[-_]?\d*|^\d{3,4}-.*\.mdx?$/i, 'architecture.adr', 75],
|
|
30
|
+
[/(^|[-_])prd(\b|[-_.])|product[-_]requirements/i, 'product.prd', 85],
|
|
31
|
+
[/(^|[-_])trd(\b|[-_.])|technical[-_]requirements|tech[-_]design|design[-_]doc/i, 'architecture.trd', 80],
|
|
32
|
+
[/feasibilit|spike|evaluation|assessment|trade[-_]?off|prior[-_]art|research[-_]note/i, 'architecture.assessment', 85],
|
|
33
|
+
[/threat[-_]?model/i, 'security.threat-model', 95],
|
|
34
|
+
[/runbook|playbook|oncall|on[-_]call/i, 'operations.runbook', 90],
|
|
35
|
+
[/disaster|dr[-_]plan|business[-_]continuity/i, 'operations.disaster-recovery', 85],
|
|
36
|
+
[/deploy(ment)?/i, 'operations.deployment', 70],
|
|
37
|
+
[/infra(structure)?|terraform|kubernetes|k8s/i, 'operations.infrastructure', 65],
|
|
38
|
+
[/observability|monitoring|telemetry|metrics|logging|alerting/i, 'operations.observability', 70],
|
|
39
|
+
[/getting[-_]?started|quick[-_]?start/i, 'user.getting-started', 90],
|
|
40
|
+
[/tutorial|walkthrough/i, 'user.tutorial', 80],
|
|
41
|
+
[/troubleshoot|common[-_]issues|known[-_]issues/i, 'user.troubleshooting', 85],
|
|
42
|
+
[/^faq|\bfaq\b/i, 'user.faq', 85],
|
|
43
|
+
[/user[-_]guide|admin(istrator)?[-_]guide/i, 'user.guide', 75],
|
|
44
|
+
[/architecture|system[-_]design/i, 'architecture.overview', 65],
|
|
45
|
+
[/migration|upgrad(e|ing)/i, 'release.migration', 75],
|
|
46
|
+
[/deprecat/i, 'release.deprecation', 80],
|
|
47
|
+
[/release[-_]notes?/i, 'release.notes', 85],
|
|
48
|
+
[/roadmap/i, 'product.roadmap', 90],
|
|
49
|
+
[/persona/i, 'product.persona', 90],
|
|
50
|
+
[/\bux\b|user[-_]experience/i, 'design.ux', 75],
|
|
51
|
+
[/accessibilit|a11y|wcag/i, 'design.accessibility', 85],
|
|
52
|
+
[/\bflows?\b|user[-_]journey/i, 'design.flow', 60],
|
|
53
|
+
[/authoriz|authz|rbac|permissions?/i, 'security.authorization', 75],
|
|
54
|
+
[/data[-_]classif/i, 'security.data-classification', 90],
|
|
55
|
+
[/auth(entication)?|identity|oauth|sso/i, 'security.architecture', 55],
|
|
56
|
+
[/test(ing)?[-_]?(strategy|plan|guide)/i, 'engineering.testing', 80],
|
|
57
|
+
[/conventions?|style[-_]guide|standards?/i, 'engineering.conventions', 70],
|
|
58
|
+
[/dependenc/i, 'engineering.dependencies', 75],
|
|
59
|
+
[/development|dev[-_]setup|contributing[-_]setup|local[-_]dev/i, 'engineering.development', 70],
|
|
60
|
+
[/config(uration)?[-_]?(reference|ref)?/i, 'operations.configuration', 60],
|
|
61
|
+
[/\breference\b|api[-_]reference|cli[-_]reference/i, 'user.reference', 70],
|
|
62
|
+
[/lifecycle|versioning|support[-_]policy/i, 'governance.lifecycle', 70],
|
|
63
|
+
[/policy|polic(y|ies)/i, 'governance.policy', 55],
|
|
64
|
+
[/\bnotes?\b|scratch|wip|todo|ideas?/i, 'note.internal', 50],
|
|
65
|
+
[/vision/i, 'product.vision', 80],
|
|
66
|
+
[/feature/i, 'product.feature', 55],
|
|
67
|
+
[/component/i, 'architecture.component', 60],
|
|
68
|
+
[/data[-_]model|schema[-_]design|erd/i, 'architecture.data', 75],
|
|
69
|
+
[/integration/i, 'architecture.integration', 70],
|
|
70
|
+
[/domain/i, 'architecture.domain', 55],
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const PATH_SIGNALS = [
|
|
74
|
+
['docs/00-canonical/**', null, 40, 'constitution'],
|
|
75
|
+
['docs/01-product/**', 'product.prd', 30, null],
|
|
76
|
+
['docs/01-product/features/**', 'product.feature', 45, null],
|
|
77
|
+
['docs/01-product/personas/**', 'product.persona', 60, null],
|
|
78
|
+
['docs/01-product/roadmap/**', 'product.roadmap', 60, null],
|
|
79
|
+
['docs/02-design/ux/**', 'design.ux', 50, null],
|
|
80
|
+
['docs/02-design/flows/**', 'design.flow', 60, null],
|
|
81
|
+
['docs/02-design/accessibility/**', 'design.accessibility', 60, null],
|
|
82
|
+
['docs/03-architecture/overview/**', 'architecture.overview', 55, null],
|
|
83
|
+
['docs/03-architecture/domains/**', 'architecture.domain', 70, null],
|
|
84
|
+
['docs/03-architecture/components/**', 'architecture.component', 45, null],
|
|
85
|
+
['docs/03-architecture/data/**', 'architecture.data', 60, null],
|
|
86
|
+
['docs/03-architecture/integrations/**', 'architecture.integration', 60, null],
|
|
87
|
+
['docs/03-architecture/adr/**', 'architecture.adr', 80, null],
|
|
88
|
+
['docs/adr/**', 'architecture.adr', 80, null],
|
|
89
|
+
['docs/decisions/**', 'architecture.adr', 70, null],
|
|
90
|
+
['docs/04-security/threat-models/**', 'security.threat-model', 70, null],
|
|
91
|
+
['docs/04-security/authorization/**', 'security.authorization', 70, null],
|
|
92
|
+
['docs/04-security/data-classification/**', 'security.data-classification', 70, null],
|
|
93
|
+
['docs/04-security/**', 'security.architecture', 35, null],
|
|
94
|
+
['docs/05-engineering/testing/**', 'engineering.testing', 60, null],
|
|
95
|
+
['docs/05-engineering/conventions/**', 'engineering.conventions', 60, null],
|
|
96
|
+
['docs/05-engineering/dependencies/**', 'engineering.dependencies', 60, null],
|
|
97
|
+
['docs/05-engineering/**', 'engineering.development', 30, null],
|
|
98
|
+
['docs/06-operations/runbooks/**', 'operations.runbook', 75, null],
|
|
99
|
+
['docs/06-operations/deployment/**', 'operations.deployment', 60, null],
|
|
100
|
+
['docs/06-operations/infrastructure/**', 'operations.infrastructure', 60, null],
|
|
101
|
+
['docs/06-operations/configuration/**', 'operations.configuration', 60, null],
|
|
102
|
+
['docs/06-operations/observability/**', 'operations.observability', 60, null],
|
|
103
|
+
['docs/06-operations/disaster-recovery/**', 'operations.disaster-recovery', 70, null],
|
|
104
|
+
['docs/07-release/releases/**', 'release.notes', 60, null],
|
|
105
|
+
['docs/07-release/migrations/**', 'release.migration', 65, null],
|
|
106
|
+
['docs/07-release/deprecations/**', 'release.deprecation', 65, null],
|
|
107
|
+
['docs/08-user/getting-started/**', 'user.getting-started', 70, null],
|
|
108
|
+
['docs/08-user/guides/**', 'user.guide', 50, null],
|
|
109
|
+
['docs/08-user/reference/**', 'user.reference', 65, null],
|
|
110
|
+
['docs/08-user/troubleshooting/**', 'user.troubleshooting', 70, null],
|
|
111
|
+
['docs/08-user/faq/**', 'user.faq', 70, null],
|
|
112
|
+
['docs/09-governance/**', 'governance.policy', 35, null],
|
|
113
|
+
['docs/10-internal/**', 'note.internal', 45, null],
|
|
114
|
+
['docs/internal/**', 'note.internal', 45, null],
|
|
115
|
+
['docs/90-generated/**', 'user.reference', 60, null],
|
|
116
|
+
['docs/99-archive/**', 'archive.document', 95, null],
|
|
117
|
+
['docs/archive/**', 'archive.document', 90, null],
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
/** content signals: [regex over body, type, weight] */
|
|
121
|
+
const CONTENT_SIGNALS = [
|
|
122
|
+
[/^\s*#+\s*(status|decision)\s*$/im, 'architecture.adr', 35],
|
|
123
|
+
[/\bsupersed(es|ed by)\b/i, 'architecture.adr', 20],
|
|
124
|
+
[/\b(trust boundar|threat actor|attack surface|STRIDE|residual risk)/i, 'security.threat-model', 45],
|
|
125
|
+
[/\b(non-?goals?)\b/i, 'product.prd', 20],
|
|
126
|
+
[/\bacceptance criteria\b/i, 'product.prd', 25],
|
|
127
|
+
[/\b(personas?|user stor(y|ies))\b/i, 'product.prd', 15],
|
|
128
|
+
[/\b(rollback|rollout)\b.*\n[\s\S]*\b(observability|migration)\b/i, 'architecture.trd', 30],
|
|
129
|
+
[/\b(RPO|RTO)\b/, 'operations.disaster-recovery', 50],
|
|
130
|
+
[/\b(escalat|pager|severity|SEV-?\d)\b/i, 'operations.runbook', 30],
|
|
131
|
+
[/^\s*#+\s*(trigger|diagnostics|procedure)\s*$/im, 'operations.runbook', 45],
|
|
132
|
+
[/\binvariant\b/i, 'constitution.invariants', 25],
|
|
133
|
+
[/^\s*\|?\s*term\s*\|/im, 'constitution.glossary', 40],
|
|
134
|
+
[/\bnpm install\b|\bpip install\b|\bgetting started\b/i, 'user.getting-started', 20],
|
|
135
|
+
[/\bSLO\b|\bSLI\b|\bprometheus\b|\bgrafana\b/i, 'operations.observability', 35],
|
|
136
|
+
[/\b(kubernetes|terraform|helm|ECS|EKS)\b/i, 'operations.infrastructure', 25],
|
|
137
|
+
[/\bWCAG\b|\bscreen reader\b|\baria-/i, 'design.accessibility', 45],
|
|
138
|
+
[/\bRBAC\b|\brole\b.*\bpermission\b/i, 'security.authorization', 30],
|
|
139
|
+
[/\bdeprecat(ed|ion)\b/i, 'release.deprecation', 25],
|
|
140
|
+
[/^\s*#+\s*v?\d+\.\d+\.\d+/m, 'release.notes', 40],
|
|
141
|
+
[/\bopenapi\b|\bswagger\b/i, 'user.reference', 15],
|
|
142
|
+
[/\bAUTO-?GENERATED\b|\bDo not edit\b/i, 'user.reference', 55],
|
|
143
|
+
[/^#+\s*\d+\.\s+verdict|^#+\s*verdict\s*$/im, 'architecture.assessment', 45],
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const CONFIDENT = 60;
|
|
147
|
+
const AMBIGUOUS_GAP = 15;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {{path:string, body?:string, frontmatter?:object}} doc
|
|
151
|
+
* @returns {{type:string, confidence:number, signals:string[], candidates:{type:string,score:number}[], needsReview:boolean, declared:boolean}}
|
|
152
|
+
*/
|
|
153
|
+
export function classify(doc) {
|
|
154
|
+
const declared = doc.frontmatter?.docgov?.type;
|
|
155
|
+
if (declared && TYPES[declared]) {
|
|
156
|
+
return { type: declared, confidence: 100, signals: ['declared in frontmatter'],
|
|
157
|
+
candidates: [{ type: declared, score: 100 }], needsReview: false, declared: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const rel = doc.path;
|
|
161
|
+
const base = path.basename(rel);
|
|
162
|
+
const scores = new Map();
|
|
163
|
+
const signals = new Map();
|
|
164
|
+
const bump = (type, w, why) => {
|
|
165
|
+
if (!type || !TYPES[type]) return;
|
|
166
|
+
scores.set(type, (scores.get(type) || 0) + w);
|
|
167
|
+
if (!signals.has(type)) signals.set(type, []);
|
|
168
|
+
signals.get(type).push(why);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
for (const [re, type, w] of NAME_SIGNALS) {
|
|
172
|
+
if (re.test(base)) bump(type, w, `filename matches ${re.source.slice(0, 34)}`);
|
|
173
|
+
else if (re.test(rel)) bump(type, Math.round(w * 0.6), `path matches ${re.source.slice(0, 34)}`);
|
|
174
|
+
}
|
|
175
|
+
for (const [glob, type, w, authority] of PATH_SIGNALS) {
|
|
176
|
+
if (!matchGlob(rel, glob)) continue;
|
|
177
|
+
if (type) bump(type, w, `located in ${glob}`);
|
|
178
|
+
if (authority) for (const [id, t] of Object.entries(TYPES)) if (t.authority === authority) bump(id, w, `located in ${glob}`);
|
|
179
|
+
}
|
|
180
|
+
const body = doc.body ?? '';
|
|
181
|
+
for (const [re, type, w] of CONTENT_SIGNALS) {
|
|
182
|
+
if (re.test(body)) bump(type, w, `content matches ${re.source.slice(0, 34)}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Root-level singletons are a strong tie-break: only one README can exist.
|
|
186
|
+
if (!rel.includes('/')) {
|
|
187
|
+
for (const [id, t] of Object.entries(TYPES)) {
|
|
188
|
+
if (t.singleton && (t.compact || t.full) === rel) bump(id, 60, 'canonical singleton path');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Singleton classes (README, CHANGELOG, the constitution documents) exist exactly once,
|
|
193
|
+
// at a fixed path. A nested README.md is a directory index, not *the* README, and letting
|
|
194
|
+
// it win would send every subdirectory README to the repository root.
|
|
195
|
+
for (const [type, t] of Object.entries(TYPES)) {
|
|
196
|
+
if (!t.singleton || !scores.has(type)) continue;
|
|
197
|
+
const canonical = t.compact || t.full;
|
|
198
|
+
if (rel !== canonical) {
|
|
199
|
+
scores.delete(type);
|
|
200
|
+
signals.delete(type);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const candidates = [...scores.entries()]
|
|
205
|
+
.map(([type, score]) => ({ type, score }))
|
|
206
|
+
.sort((a, b) => b.score - a.score || a.type.localeCompare(b.type));
|
|
207
|
+
|
|
208
|
+
if (candidates.length === 0) {
|
|
209
|
+
return { type: 'unknown', confidence: 0, signals: ['no signal matched'], candidates: [], needsReview: true, declared: false };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const top = candidates[0];
|
|
213
|
+
const gap = top.score - (candidates[1]?.score ?? 0);
|
|
214
|
+
const confidence = Math.min(99, top.score);
|
|
215
|
+
return {
|
|
216
|
+
type: top.type,
|
|
217
|
+
confidence,
|
|
218
|
+
signals: signals.get(top.type) || [],
|
|
219
|
+
candidates: candidates.slice(0, 5),
|
|
220
|
+
needsReview: confidence < CONFIDENT || gap < AMBIGUOUS_GAP,
|
|
221
|
+
declared: false,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Suggested destination path for a doc of `type`, given the active layout. */
|
|
226
|
+
export function destinationFor(cfg, type, currentPath) {
|
|
227
|
+
const t = TYPES[type] || TYPES.unknown;
|
|
228
|
+
if (t.anywhere) return currentPath; // belongs to its directory
|
|
229
|
+
const loc = cfg.project.layout === 'full' ? t.full : (t.compact || t.full);
|
|
230
|
+
if (!loc.endsWith('/')) return loc; // singleton or fixed file
|
|
231
|
+
const base = path.basename(currentPath);
|
|
232
|
+
return loc + base;
|
|
233
|
+
}
|