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/core/onboard.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { classify, destinationFor } from './classify.js';
|
|
3
|
+
import { TYPES, AUTHORITY, typeDef } from './taxonomy.js';
|
|
4
|
+
import { similarPairs } from './similarity.js';
|
|
5
|
+
import { coverageGaps } from './inventory.js';
|
|
6
|
+
import { assess, readmeOverreach, splitCandidates } from './size.js';
|
|
7
|
+
import { brokenLinks } from './links.js';
|
|
8
|
+
import { primaryAuthor, isRepo, isClean, lastCommitDate } from './git.js';
|
|
9
|
+
import { locationFor } from './config.js';
|
|
10
|
+
import { matchAny, table, plural } from './util.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Existing-project review (PRD §15, §44).
|
|
14
|
+
*
|
|
15
|
+
* The hard rule: this produces a plan and changes nothing. The plan is a file a
|
|
16
|
+
* human reads and edits; `docgov fix` executes exactly what the plan says.
|
|
17
|
+
* Separating proposal from execution is what makes the "without losing
|
|
18
|
+
* information" promise checkable rather than aspirational.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const PLAN_PATH = '.docgov/fix-plan.md';
|
|
22
|
+
export const PLAN_DATA_PATH = '.docgov/fix-plan.json';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{root:string, cfg:object, docs:any[], inv:object, graph:any, registry:object}} ctx
|
|
26
|
+
*/
|
|
27
|
+
export function plan({ root, cfg, docs, inv, graph, registry }) {
|
|
28
|
+
const actions = [];
|
|
29
|
+
const classifications = [];
|
|
30
|
+
const gitAvailable = isRepo(root);
|
|
31
|
+
|
|
32
|
+
for (const d of docs) {
|
|
33
|
+
const c = classify(d);
|
|
34
|
+
const owner = d.owner || (gitAvailable ? (primaryAuthor(root, d.path)?.name ?? null) : null);
|
|
35
|
+
const lastChanged = gitAvailable ? lastCommitDate(root, d.path) : null;
|
|
36
|
+
classifications.push({
|
|
37
|
+
path: d.path, current: d.type, proposed: c.type, confidence: c.confidence,
|
|
38
|
+
needsReview: c.needsReview, signals: c.signals.slice(0, 3), candidates: c.candidates,
|
|
39
|
+
owner, lastChanged, lines: d.lines, registered: d.registered,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const archived = matchAny(d.path, ['docs/99-archive/**', 'docs/archive/**']);
|
|
43
|
+
const target = archived ? d.path : destinationFor(cfg, c.type, d.path);
|
|
44
|
+
|
|
45
|
+
if (c.type === 'unknown') {
|
|
46
|
+
actions.push({ kind: 'CLASSIFY', path: d.path, reason: 'no classification signal matched',
|
|
47
|
+
risk: 'low', requiresJudgement: true });
|
|
48
|
+
} else if (target !== d.path) {
|
|
49
|
+
actions.push({ kind: 'MOVE', path: d.path, to: target, type: c.type,
|
|
50
|
+
reason: `a ${typeDef(c.type).label} belongs in ${locationFor(cfg, c.type)}`,
|
|
51
|
+
risk: c.needsReview ? 'medium' : 'low', requiresJudgement: c.needsReview });
|
|
52
|
+
}
|
|
53
|
+
if (!d.registered) {
|
|
54
|
+
actions.push({ kind: 'ANNOTATE', path: d.path, to: target, type: c.type,
|
|
55
|
+
reason: 'no docgov frontmatter; add id, type, authority, visibility',
|
|
56
|
+
risk: 'low', requiresJudgement: false });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const size = assess(cfg, d);
|
|
60
|
+
if (size.recommendSplit) {
|
|
61
|
+
actions.push({ kind: 'SPLIT', path: d.path, type: c.type, risk: 'high', requiresJudgement: true,
|
|
62
|
+
reason: `${d.lines} lines across ${size.independentConcepts} independently addressable concepts`,
|
|
63
|
+
into: size.candidates.map((s) => ({ title: s.title, lines: s.lines,
|
|
64
|
+
to: path.posix.join(stripFile(target), s.suggested) })) });
|
|
65
|
+
}
|
|
66
|
+
for (const r of readmeOverreach(cfg, d)) {
|
|
67
|
+
actions.push({ kind: 'EXTRACT', path: d.path, section: r.section, lines: r.lines,
|
|
68
|
+
to: r.moveTo ? locationFor(cfg, r.moveTo) : null, type: r.moveTo,
|
|
69
|
+
reason: `README section "${r.section}" is ${r.lines} lines; a README links depth, it does not contain it`,
|
|
70
|
+
risk: 'medium', requiresJudgement: true });
|
|
71
|
+
}
|
|
72
|
+
if (d.status === 'deprecated' || d.status === 'superseded' || /^(old|deprecated|legacy)[-_/]/i.test(d.path)) {
|
|
73
|
+
if (!archived) actions.push({ kind: 'ARCHIVE', path: d.path,
|
|
74
|
+
to: `${cfg.project.layout === 'full' ? 'docs/99-archive' : 'docs/archive'}/${path.basename(d.path)}`,
|
|
75
|
+
reason: `status is ${d.status}`, risk: 'low', requiresJudgement: false });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const duplicates = similarPairs(docs, { threshold: 0.5, limit: 25 });
|
|
80
|
+
for (const p of duplicates) {
|
|
81
|
+
if (p.score < 0.72) continue;
|
|
82
|
+
actions.push({ kind: 'MERGE', path: p.a, other: p.b, score: p.score, risk: 'high', requiresJudgement: true,
|
|
83
|
+
reason: `${Math.round(p.score * 100)}% textual overlap${p.reasons.length ? ` (${p.reasons.join(', ')})` : ''}` });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const gaps = coverageGaps(inv);
|
|
87
|
+
for (const g of gaps) {
|
|
88
|
+
actions.push({ kind: 'CREATE', type: g.type, to: locationFor(cfg, g.type), risk: 'low',
|
|
89
|
+
requiresJudgement: false,
|
|
90
|
+
reason: g.because === 'baseline'
|
|
91
|
+
? 'every repository should have this'
|
|
92
|
+
: `${g.because} detected (${g.evidence}) but no ${g.label} exists` });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const broken = brokenLinks(docs, root, new Set(inv.all));
|
|
96
|
+
|
|
97
|
+
const contradictionCandidates = duplicates
|
|
98
|
+
.filter((p) => p.score >= 0.5)
|
|
99
|
+
.map((p) => {
|
|
100
|
+
const a = docs.find((d) => d.path === p.a), b = docs.find((d) => d.path === p.b);
|
|
101
|
+
const ra = AUTHORITY[a?.authority]?.rank ?? 9, rb = AUTHORITY[b?.authority]?.rank ?? 9;
|
|
102
|
+
return { ...p, authorityA: a?.authority, authorityB: b?.authority,
|
|
103
|
+
sameAuthority: ra === rb,
|
|
104
|
+
note: ra === rb ? 'equal authority — a contradiction here has no tie-break'
|
|
105
|
+
: `${ra < rb ? p.a : p.b} wins a contradiction` };
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const summary = {
|
|
109
|
+
documents: docs.length,
|
|
110
|
+
unclassified: classifications.filter((c) => c.proposed === 'unknown').length,
|
|
111
|
+
lowConfidence: classifications.filter((c) => c.needsReview && c.proposed !== 'unknown').length,
|
|
112
|
+
moves: actions.filter((a) => a.kind === 'MOVE').length,
|
|
113
|
+
annotations: actions.filter((a) => a.kind === 'ANNOTATE').length,
|
|
114
|
+
splits: actions.filter((a) => a.kind === 'SPLIT').length,
|
|
115
|
+
extracts: actions.filter((a) => a.kind === 'EXTRACT').length,
|
|
116
|
+
merges: actions.filter((a) => a.kind === 'MERGE').length,
|
|
117
|
+
archives: actions.filter((a) => a.kind === 'ARCHIVE').length,
|
|
118
|
+
creates: actions.filter((a) => a.kind === 'CREATE').length,
|
|
119
|
+
brokenLinks: broken.length,
|
|
120
|
+
duplicateCandidates: duplicates.length,
|
|
121
|
+
needJudgement: actions.filter((a) => a.requiresJudgement).length,
|
|
122
|
+
highRisk: actions.filter((a) => a.risk === 'high').length,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
version: 1, generated: new Date().toISOString(),
|
|
127
|
+
layout: cfg.project.layout, mode: cfg.project.mode,
|
|
128
|
+
git: { repo: gitAvailable, clean: gitAvailable ? isClean(root) : false },
|
|
129
|
+
summary, classifications, actions, duplicates, contradictionCandidates, gaps, brokenLinks: broken,
|
|
130
|
+
stack: inv.stack.map((s) => ({ id: s.id, evidence: s.evidence[0], count: s.count })),
|
|
131
|
+
contracts: inv.contracts,
|
|
132
|
+
agentInstructions: inv.agentInstructions,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function stripFile(p) { return p.replace(/\.mdx?$/, ''); }
|
|
137
|
+
|
|
138
|
+
/** The human-readable plan. This is the artifact the user approves. */
|
|
139
|
+
export function render(planData, cfg) {
|
|
140
|
+
const L = [];
|
|
141
|
+
const s = planData.summary;
|
|
142
|
+
L.push('# DocGov fix plan');
|
|
143
|
+
L.push('');
|
|
144
|
+
L.push(`Generated ${planData.generated.slice(0, 19).replace('T', ' ')} · layout \`${planData.layout}\` · mode \`${planData.mode}\``);
|
|
145
|
+
L.push('');
|
|
146
|
+
L.push('**Nothing has changed yet.** This plan is a proposal. Edit it freely — delete any action you');
|
|
147
|
+
L.push('disagree with — then run `docgov fix` to execute exactly what remains.');
|
|
148
|
+
L.push('');
|
|
149
|
+
|
|
150
|
+
if (!planData.git.repo) {
|
|
151
|
+
L.push('> ⚠ This is not a git repository. `docgov fix` refuses to run without git, because the');
|
|
152
|
+
L.push('> only honest way to promise "without losing information" is to make every change revertible.');
|
|
153
|
+
L.push('');
|
|
154
|
+
} else if (!planData.git.clean) {
|
|
155
|
+
L.push('> ⚠ The working tree is dirty. Commit or stash before migrating.');
|
|
156
|
+
L.push('');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
L.push('## Current state');
|
|
160
|
+
L.push('');
|
|
161
|
+
L.push(`- ${plural(s.documents, 'document')}, ${plural(planData.contracts.length, 'machine contract')}`);
|
|
162
|
+
L.push(`- ${plural(s.unclassified, 'document')} could not be classified, ${s.lowConfidence} classified with low confidence`);
|
|
163
|
+
L.push(`- ${plural(s.brokenLinks, 'broken internal link')}, ${plural(s.duplicateCandidates, 'suspected duplicate pair')}`);
|
|
164
|
+
if (planData.stack.length) L.push(`- stack detected: ${planData.stack.map((x) => x.id).join(', ')}`);
|
|
165
|
+
if (planData.agentInstructions.length) L.push(`- existing agent instructions: ${planData.agentInstructions.join(', ')}`);
|
|
166
|
+
L.push('');
|
|
167
|
+
|
|
168
|
+
L.push('## Proposed state');
|
|
169
|
+
L.push('');
|
|
170
|
+
L.push('```');
|
|
171
|
+
L.push(tree(planData, cfg));
|
|
172
|
+
L.push('```');
|
|
173
|
+
L.push('');
|
|
174
|
+
|
|
175
|
+
const groups = [
|
|
176
|
+
['MOVE', 'Moves', 'Relocated to the canonical position for their class. Links are repaired automatically.'],
|
|
177
|
+
['ANNOTATE', 'Annotations', 'Frontmatter added so the document becomes addressable by id.'],
|
|
178
|
+
['SPLIT', 'Splits', 'Each of these holds several independently addressable concepts. Needs your judgement.'],
|
|
179
|
+
['EXTRACT', 'Extractions', 'README sections that have outgrown a README.'],
|
|
180
|
+
['MERGE', 'Merges', 'Suspected duplicates. DocGov will not merge prose on its own — these are for you.'],
|
|
181
|
+
['ARCHIVE', 'Archives', 'Superseded or deprecated documents moved to the archive namespace.'],
|
|
182
|
+
['CREATE', 'Missing documents', 'The repository implies these should exist.'],
|
|
183
|
+
['CLASSIFY', 'Needs classification', 'No signal matched. Tell DocGov what these are.'],
|
|
184
|
+
];
|
|
185
|
+
for (const [kind, title, blurb] of groups) {
|
|
186
|
+
const items = planData.actions.filter((a) => a.kind === kind);
|
|
187
|
+
if (!items.length) continue;
|
|
188
|
+
L.push(`## ${title} (${items.length})`);
|
|
189
|
+
L.push('');
|
|
190
|
+
L.push(blurb);
|
|
191
|
+
L.push('');
|
|
192
|
+
for (const a of items) {
|
|
193
|
+
if (kind === 'MOVE' || kind === 'ARCHIVE') L.push(`- \`${a.path}\` → \`${a.to}\` \n ${a.reason}`);
|
|
194
|
+
else if (kind === 'SPLIT') {
|
|
195
|
+
L.push(`- \`${a.path}\` — ${a.reason}`);
|
|
196
|
+
for (const part of a.into) L.push(` - \`${part.to}\` ← "${part.title}" (${part.lines} lines)`);
|
|
197
|
+
L.push(' - parent becomes an index that links the parts');
|
|
198
|
+
} else if (kind === 'EXTRACT') L.push(`- \`${a.path}\` § "${a.section}" (${a.lines} lines) → \`${a.to || '(choose a destination)'}\``);
|
|
199
|
+
else if (kind === 'MERGE') L.push(`- \`${a.path}\` + \`${a.other}\` — ${a.reason}`);
|
|
200
|
+
else if (kind === 'CREATE') L.push(`- \`${a.to}\` (${a.type}) — ${a.reason}`);
|
|
201
|
+
else L.push(`- \`${a.path}\` — ${a.reason}`);
|
|
202
|
+
}
|
|
203
|
+
L.push('');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (planData.contradictionCandidates.length) {
|
|
207
|
+
L.push('## Suspected contradictions');
|
|
208
|
+
L.push('');
|
|
209
|
+
L.push('Textual overlap narrows the candidates; only a reviewer can confirm a real contradiction.');
|
|
210
|
+
L.push('Run `/docgov:inspect --contradictions` to have the architect agent adjudicate these pairs.');
|
|
211
|
+
L.push('');
|
|
212
|
+
for (const c of planData.contradictionCandidates.slice(0, 12)) {
|
|
213
|
+
L.push(`- \`${c.a}\` (${c.authorityA}) vs \`${c.b}\` (${c.authorityB}) — ${Math.round(c.score * 100)}% overlap. ${c.note}`);
|
|
214
|
+
}
|
|
215
|
+
L.push('');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (planData.brokenLinks.length) {
|
|
219
|
+
L.push('## Broken internal links');
|
|
220
|
+
L.push('');
|
|
221
|
+
for (const b of planData.brokenLinks.slice(0, 25)) L.push(`- \`${b.path}\` → \`${b.target}\``);
|
|
222
|
+
if (planData.brokenLinks.length > 25) L.push(`- … and ${planData.brokenLinks.length - 25} more`);
|
|
223
|
+
L.push('');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
L.push('## Risk');
|
|
227
|
+
L.push('');
|
|
228
|
+
L.push(table([
|
|
229
|
+
{ Risk: 'low', Actions: planData.actions.filter((a) => a.risk === 'low').length, Meaning: 'mechanical, fully reversible' },
|
|
230
|
+
{ Risk: 'medium', Actions: planData.actions.filter((a) => a.risk === 'medium').length, Meaning: 'correct destination is a judgement call' },
|
|
231
|
+
{ Risk: 'high', Actions: planData.actions.filter((a) => a.risk === 'high').length, Meaning: 'content must be rewritten; never automatic' },
|
|
232
|
+
], ['Risk', 'Actions', 'Meaning']));
|
|
233
|
+
L.push('');
|
|
234
|
+
L.push(`${s.needJudgement} of ${planData.actions.length} actions need a human or an agent to decide something.`);
|
|
235
|
+
L.push('');
|
|
236
|
+
L.push('## Execute');
|
|
237
|
+
L.push('');
|
|
238
|
+
L.push('```bash');
|
|
239
|
+
L.push('docgov fix --dry-run # show every file operation, touch nothing');
|
|
240
|
+
L.push('docgov fix # on a new branch, mechanical actions only');
|
|
241
|
+
L.push('docgov fix --include split,merge,extract # also the judgement calls, one at a time');
|
|
242
|
+
L.push('```');
|
|
243
|
+
L.push('');
|
|
244
|
+
L.push('`migrate` runs MOVE, ANNOTATE and ARCHIVE automatically and repairs every internal link.');
|
|
245
|
+
L.push('SPLIT, MERGE and EXTRACT are left to `/docgov:tag`, which uses an agent to rewrite prose.');
|
|
246
|
+
return L.join('\n');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function tree(planData, cfg) {
|
|
250
|
+
const dirs = new Map();
|
|
251
|
+
for (const c of planData.classifications) {
|
|
252
|
+
const a = planData.actions.find((x) => x.kind === 'MOVE' && x.path === c.path);
|
|
253
|
+
const final = a ? a.to : c.path;
|
|
254
|
+
const dir = final.includes('/') ? final.slice(0, final.lastIndexOf('/')) : '.';
|
|
255
|
+
if (!dirs.has(dir)) dirs.set(dir, []);
|
|
256
|
+
dirs.get(dir).push({ file: final.split('/').pop(), from: a ? c.path : null });
|
|
257
|
+
}
|
|
258
|
+
for (const a of planData.actions.filter((x) => x.kind === 'CREATE')) {
|
|
259
|
+
const dir = a.to.replace(/\/$/, '');
|
|
260
|
+
if (!dirs.has(dir)) dirs.set(dir, []);
|
|
261
|
+
dirs.get(dir).push({ file: '(to create)', from: null, create: true });
|
|
262
|
+
}
|
|
263
|
+
const out = [];
|
|
264
|
+
for (const dir of [...dirs.keys()].sort()) {
|
|
265
|
+
out.push(`${dir}/`);
|
|
266
|
+
for (const f of dirs.get(dir).sort((x, y) => x.file.localeCompare(y.file))) {
|
|
267
|
+
out.push(` ${f.file}${f.from ? ` ← ${f.from}` : ''}${f.create ? ' (new)' : ''}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return out.join('\n');
|
|
271
|
+
}
|
package/core/paths.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path predicates, in one place because drift and impact were each carrying their own
|
|
3
|
+
* copy and they drifted apart — which is how `bin/docgov` became invisible to the drift
|
|
4
|
+
* engine that governs it.
|
|
5
|
+
*
|
|
6
|
+
* The important distinction here:
|
|
7
|
+
*
|
|
8
|
+
* `isMappable` gates a **graph lookup**. The question is "does some document claim this
|
|
9
|
+
* path?", and the graph is the authority on that, not a file-extension list. Filtering by
|
|
10
|
+
* extension first silently discarded every extensionless executable, shell script,
|
|
11
|
+
* Dockerfile, Makefile and config file a document had explicitly mapped.
|
|
12
|
+
*
|
|
13
|
+
* `CODE_RE` gates a **heuristic** — "did behaviour probably change?" — where a list of
|
|
14
|
+
* known source extensions is exactly the right tool and a false negative costs nothing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const MD_RE = /\.mdx?$/i;
|
|
18
|
+
|
|
19
|
+
export const CONTRACT_RE =
|
|
20
|
+
/\.(proto|graphql|gql)$|openapi.*\.(ya?ml|json)$|schema\.prisma$|(^|\/)schemas?\/.*\.json$/;
|
|
21
|
+
|
|
22
|
+
export const TEST_RE = /(^|\/)(test|tests|spec|__tests__)\/|\.(test|spec)\./;
|
|
23
|
+
|
|
24
|
+
/** Known source extensions, plus scripts and infrastructure-as-code. Heuristic use only. */
|
|
25
|
+
export const CODE_RE = new RegExp(
|
|
26
|
+
'\\.(' + [
|
|
27
|
+
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'py', 'go', 'rs', 'rb', 'java', 'kt', 'kts',
|
|
28
|
+
'swift', 'cs', 'php', 'ex', 'exs', 'erl', 'scala', 'clj', 'dart', 'lua', 'pl', 'r',
|
|
29
|
+
'c', 'cc', 'cpp', 'h', 'hpp', 'm', 'mm', 'sql', 'vue', 'svelte', 'astro',
|
|
30
|
+
'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'tf', 'hcl',
|
|
31
|
+
].join('|') + ')$',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** Files that are conventionally executable or build logic despite having no extension. */
|
|
35
|
+
const EXTENSIONLESS_CODE =
|
|
36
|
+
/(^|\/)(bin|sbin|scripts?|hooks|cmd)\/[^/.]+$|(^|\/)(Dockerfile|Makefile|Rakefile|Gemfile|Brewfile|Procfile|Justfile|CMakeLists\.txt)$/i;
|
|
37
|
+
|
|
38
|
+
/** Does this path look like code for the purposes of the behaviour heuristic? */
|
|
39
|
+
export function isCode(p) {
|
|
40
|
+
return CODE_RE.test(p) || EXTENSIONLESS_CODE.test(p);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Can this path be mapped to a document by the graph?
|
|
45
|
+
*
|
|
46
|
+
* Deliberately broad. If a document declares `documents: ["assets/schema.png"]` then that
|
|
47
|
+
* image is something the document claims, and a change to it is something a reviewer should
|
|
48
|
+
* see. Only markdown (which is the documentation, not the thing documented) and DocGov's own
|
|
49
|
+
* state are excluded.
|
|
50
|
+
*/
|
|
51
|
+
export function isMappable(p) {
|
|
52
|
+
return !MD_RE.test(p) && !p.startsWith('.docgov/');
|
|
53
|
+
}
|
package/core/publish.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { VISIBILITY } from './taxonomy.js';
|
|
2
|
+
import { matchAny } from './util.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Publishing analysis (PRD §29).
|
|
6
|
+
*
|
|
7
|
+
* DocGov never copies an internal document outward. It identifies what is
|
|
8
|
+
* publishable, flags what leaks, and hands the rewrite to a human or an agent
|
|
9
|
+
* under the external lens. The boundary is deliberate, not incidental (PRD §43).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Patterns that should never cross the internal/external boundary. */
|
|
13
|
+
const LEAK_PATTERNS = [
|
|
14
|
+
{ id: 'internal-host', re: /\b(?:[a-z0-9-]+\.)+(?:internal|local|corp|intranet|lan)\b/gi, what: 'internal hostname' },
|
|
15
|
+
{ id: 'private-ip', re: /\b(?:10\.\d{1,3}|192\.168|172\.(?:1[6-9]|2\d|3[01]))\.\d{1,3}(?:\.\d{1,3})?\b/g, what: 'private IP address' },
|
|
16
|
+
{ id: 'aws-key', re: /\bAKIA[0-9A-Z]{16}\b/g, what: 'AWS access key id' },
|
|
17
|
+
{ id: 'bearer', re: /\b(?:bearer|token|api[_-]?key|secret)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{16,}/gi, what: 'credential-shaped string' },
|
|
18
|
+
{ id: 'private-key', re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g, what: 'private key block' },
|
|
19
|
+
{ id: 'jira', re: /\b(?:[A-Z]{2,10}-\d{1,6})\b/g, what: 'internal ticket reference', soft: true },
|
|
20
|
+
{ id: 'internal-path', re: /\/(?:Users|home)\/[a-z0-9._-]+\//gi, what: 'developer machine path' },
|
|
21
|
+
{ id: 'employee-email', re: /\b[a-z0-9._%+-]+@(?!example\.)(?:[a-z0-9-]+\.)+[a-z]{2,}\b/gi, what: 'email address', soft: true },
|
|
22
|
+
{ id: 'internal-jargon', re: /\b(?:TODO|FIXME|HACK|XXX|WIP)\b/g, what: 'work-in-progress marker' },
|
|
23
|
+
{ id: 'threat-detail', re: /\b(?:exploit|attack vector|unmitigated|known vulnerability|CVE-\d{4}-\d+)\b/gi, what: 'unmitigated-risk language', soft: true },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{cfg:object, docs:any[], target?:string}} args
|
|
28
|
+
*/
|
|
29
|
+
export function analyze({ cfg, docs, target = 'docs/11-external' }) {
|
|
30
|
+
const publishable = [], blocked = [], rewrite = [];
|
|
31
|
+
|
|
32
|
+
for (const d of docs) {
|
|
33
|
+
const isPublic = d.visibility === 'public' || d.visibility === 'generated-public';
|
|
34
|
+
const leaks = scan(d);
|
|
35
|
+
const hard = leaks.filter((l) => !l.soft);
|
|
36
|
+
|
|
37
|
+
if (!isPublic) {
|
|
38
|
+
if (d.visibility === 'confidential') {
|
|
39
|
+
blocked.push({ path: d.path, reason: 'confidential documents are never publishable', leaks: leaks.length });
|
|
40
|
+
} else {
|
|
41
|
+
rewrite.push({
|
|
42
|
+
path: d.path, currentVisibility: d.visibility, leaks,
|
|
43
|
+
reason: 'internal document — publishing requires an external-lens rewrite, not a copy',
|
|
44
|
+
suggestedTarget: `${target}/${d.path.split('/').pop()}`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (hard.length) blocked.push({ path: d.path, reason: `${hard.length} sensitive pattern(s) detected`, leaks: hard });
|
|
50
|
+
else publishable.push({ path: d.path, visibility: d.visibility, softFlags: leaks.length });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { publishable, blocked, rewrite, target,
|
|
54
|
+
gate: 'Nothing is published by this command. Review, then publish through your own pipeline.' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function scan(doc) {
|
|
58
|
+
const out = [];
|
|
59
|
+
const body = doc.body ?? String(doc);
|
|
60
|
+
for (const p of LEAK_PATTERNS) {
|
|
61
|
+
const matches = [...body.matchAll(p.re)].slice(0, 3);
|
|
62
|
+
for (const m of matches) {
|
|
63
|
+
out.push({ id: p.id, what: p.what, sample: redact(m[0]), soft: Boolean(p.soft),
|
|
64
|
+
line: body.slice(0, m.index).split('\n').length });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function redact(s) {
|
|
71
|
+
if (s.length <= 8) return s;
|
|
72
|
+
return `${s.slice(0, 4)}…${s.slice(-3)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The external lens brief handed to whichever agent does the rewrite. */
|
|
76
|
+
export function rewriteBrief(entry) {
|
|
77
|
+
return [
|
|
78
|
+
`Rewrite ${entry.path} for an external audience. This is a new artifact, not a copy.`,
|
|
79
|
+
'',
|
|
80
|
+
'Remove or generalize:',
|
|
81
|
+
...entry.leaks.map((l) => ` - line ${l.line}: ${l.what} (${l.sample})`),
|
|
82
|
+
'',
|
|
83
|
+
'External lens requirements:',
|
|
84
|
+
' - no internal jargon, team names, or ticket references',
|
|
85
|
+
' - no unmitigated risks, internal hostnames, or infrastructure topology',
|
|
86
|
+
' - task-oriented: what the reader wants to do, not how the system is built',
|
|
87
|
+
' - state prerequisites explicitly',
|
|
88
|
+
' - every example runnable as written',
|
|
89
|
+
'',
|
|
90
|
+
'A human must approve the result before it is published.',
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|
package/core/registry.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import * as yaml from './yaml.js';
|
|
3
|
+
import { read, write, exists, DocGovError } from './util.js';
|
|
4
|
+
|
|
5
|
+
export const REGISTRY_PATH = '.docgov/registry.yaml';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The registry is the authoritative id -> path map. It is the reason
|
|
9
|
+
* "is this id a duplicate?" is answered by software and not by a model (PRD §47).
|
|
10
|
+
*/
|
|
11
|
+
export function load(root) {
|
|
12
|
+
const file = path.join(root, REGISTRY_PATH);
|
|
13
|
+
if (!exists(file)) return { version: 1, documents: {} };
|
|
14
|
+
let r;
|
|
15
|
+
try { r = yaml.parse(read(file)) || {}; }
|
|
16
|
+
catch (e) { throw new DocGovError(`${REGISTRY_PATH} is not valid: ${e.message}`); }
|
|
17
|
+
r.documents ||= {};
|
|
18
|
+
return r;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function save(root, registry) {
|
|
22
|
+
const sorted = {};
|
|
23
|
+
for (const k of Object.keys(registry.documents).sort()) sorted[k] = registry.documents[k];
|
|
24
|
+
return write(path.join(root, REGISTRY_PATH), yaml.stringify({ version: registry.version || 1, documents: sorted }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rebuild from documents on disk. Reports id collisions rather than silently
|
|
29
|
+
* picking a winner.
|
|
30
|
+
* @param {import('./document.js').Document[]} docs
|
|
31
|
+
*/
|
|
32
|
+
export function build(docs, contracts = []) {
|
|
33
|
+
const documents = {};
|
|
34
|
+
const collisions = [];
|
|
35
|
+
for (const d of docs) {
|
|
36
|
+
const id = d.id;
|
|
37
|
+
if (documents[id]) collisions.push({ id, paths: [documents[id].path, d.path] });
|
|
38
|
+
else documents[id] = d.toRegistryEntry();
|
|
39
|
+
}
|
|
40
|
+
for (const c of contracts) {
|
|
41
|
+
const id = contractId(c.path);
|
|
42
|
+
if (documents[id]) collisions.push({ id, paths: [documents[id].path, c.path] });
|
|
43
|
+
else documents[id] = { path: c.path, type: `contract.${c.kind === 'openapi' ? 'openapi' : 'schema'}`,
|
|
44
|
+
authority: 'machine-contract', visibility: 'internal', machine: true };
|
|
45
|
+
}
|
|
46
|
+
return { registry: { version: 1, documents }, collisions };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function contractId(p) {
|
|
50
|
+
return p.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '').toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function pathOf(registry, id) { return registry.documents[id]?.path || null; }
|
|
54
|
+
|
|
55
|
+
export function idAt(registry, relPath) {
|
|
56
|
+
for (const [id, e] of Object.entries(registry.documents)) if (e.path === relPath) return id;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Ids referenced by relationships that do not exist in the registry (PRD §21). */
|
|
61
|
+
export function danglingReferences(registry) {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const [id, e] of Object.entries(registry.documents)) {
|
|
64
|
+
for (const [rel, targets] of Object.entries(e.relationships || {})) {
|
|
65
|
+
for (const t of targets) {
|
|
66
|
+
if (!registry.documents[t]) out.push({ id, relationship: rel, target: t, path: e.path });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tf-idf cosine similarity over document bodies.
|
|
3
|
+
*
|
|
4
|
+
* This exists so duplicate and contradiction detection can narrow ~5,000 candidate
|
|
5
|
+
* pairs down to ~20 before any model is asked to look (FEASIBILITY §3.5). Local,
|
|
6
|
+
* deterministic, no embedding service — which keeps PRD §43's local-first promise.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const STOP = new Set(('the and for that this with are was from have has not you your our they them their can will'
|
|
10
|
+
+ ' should would could but all any its where when what which who how why into than then also more most other'
|
|
11
|
+
+ ' such only own same too very just about over under via per use used using based each one two new'
|
|
12
|
+
+ ' docs doc documentation document section see also note example examples').split(/\s+/));
|
|
13
|
+
|
|
14
|
+
/** @param {string[]} tokens */
|
|
15
|
+
function termFreq(tokens) {
|
|
16
|
+
const tf = new Map();
|
|
17
|
+
let n = 0;
|
|
18
|
+
for (const t of tokens) {
|
|
19
|
+
if (STOP.has(t) || t.length < 4) continue;
|
|
20
|
+
tf.set(t, (tf.get(t) || 0) + 1);
|
|
21
|
+
n++;
|
|
22
|
+
}
|
|
23
|
+
return { tf, n };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{id:string, tokens:()=>string[]}[]} docs
|
|
28
|
+
* @returns {Map<string, Map<string, number>>} id -> term -> tf-idf weight (L2 normalized)
|
|
29
|
+
*/
|
|
30
|
+
export function vectorize(docs) {
|
|
31
|
+
const freqs = docs.map((d) => ({ id: d.id, ...termFreq(d.tokens()) }));
|
|
32
|
+
const df = new Map();
|
|
33
|
+
for (const f of freqs) for (const t of f.tf.keys()) df.set(t, (df.get(t) || 0) + 1);
|
|
34
|
+
const N = Math.max(1, docs.length);
|
|
35
|
+
const out = new Map();
|
|
36
|
+
for (const f of freqs) {
|
|
37
|
+
const vec = new Map();
|
|
38
|
+
let norm = 0;
|
|
39
|
+
for (const [t, c] of f.tf) {
|
|
40
|
+
const idf = Math.log(1 + N / (df.get(t) || 1));
|
|
41
|
+
const w = (c / Math.max(1, f.n)) * idf;
|
|
42
|
+
vec.set(t, w);
|
|
43
|
+
norm += w * w;
|
|
44
|
+
}
|
|
45
|
+
norm = Math.sqrt(norm) || 1;
|
|
46
|
+
for (const [t, w] of vec) vec.set(t, w / norm);
|
|
47
|
+
out.set(f.id, vec);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function cosine(a, b) {
|
|
53
|
+
if (!a || !b) return 0;
|
|
54
|
+
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
|
|
55
|
+
let s = 0;
|
|
56
|
+
for (const [t, w] of small) { const o = large.get(t); if (o) s += w * o; }
|
|
57
|
+
return s;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Candidate near-duplicate pairs, most similar first.
|
|
62
|
+
* @param {{id:string,path:string,type:string,domain:string|null,tokens:()=>string[]}[]} docs
|
|
63
|
+
* @param {{threshold?:number, limit?:number}} [opts]
|
|
64
|
+
*/
|
|
65
|
+
export function similarPairs(docs, opts = {}) {
|
|
66
|
+
const { threshold = 0.45, limit = 40 } = opts;
|
|
67
|
+
const vecs = vectorize(docs);
|
|
68
|
+
const pairs = [];
|
|
69
|
+
for (let i = 0; i < docs.length; i++) {
|
|
70
|
+
for (let j = i + 1; j < docs.length; j++) {
|
|
71
|
+
const a = docs[i], b = docs[j];
|
|
72
|
+
const score = cosine(vecs.get(a.id), vecs.get(b.id));
|
|
73
|
+
if (score < threshold) continue;
|
|
74
|
+
const reasons = [];
|
|
75
|
+
if (a.type === b.type && a.type !== 'unknown') reasons.push('same document type');
|
|
76
|
+
if (a.domain && a.domain === b.domain) reasons.push('same domain');
|
|
77
|
+
pairs.push({ a: a.path, b: b.path, aId: a.id, bId: b.id, score: Math.round(score * 100) / 100, reasons });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return pairs.sort((x, y) => y.score - x.score).slice(0, limit);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Terms that carry a document's topic, for a human-readable "about" line. */
|
|
84
|
+
export function topTerms(doc, allDocs, k = 8) {
|
|
85
|
+
const vecs = vectorize(allDocs);
|
|
86
|
+
const v = vecs.get(doc.id);
|
|
87
|
+
if (!v) return [];
|
|
88
|
+
return [...v.entries()].sort((a, b) => b[1] - a[1]).slice(0, k).map(([t]) => t);
|
|
89
|
+
}
|
package/core/size.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { limitFor } from './config.js';
|
|
2
|
+
import { typeDef } from './taxonomy.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Size discipline (PRD §13).
|
|
6
|
+
*
|
|
7
|
+
* Line count alone never triggers a split — it only decides whether a semantic
|
|
8
|
+
* reviewer is worth spending. The split *candidates* are computed structurally:
|
|
9
|
+
* a document whose H2 sections each carry enough substance to stand alone is a
|
|
10
|
+
* document of several documents.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const SPLITTABLE_MIN_LINES = 40;
|
|
14
|
+
|
|
15
|
+
export function assess(cfg, doc) {
|
|
16
|
+
const { soft, hard } = limitFor(cfg, doc.type);
|
|
17
|
+
const over = hard > 0 && doc.lines > hard;
|
|
18
|
+
const warn = soft > 0 && doc.lines > soft;
|
|
19
|
+
const candidates = splitCandidates(doc);
|
|
20
|
+
return {
|
|
21
|
+
path: doc.path, type: doc.type, lines: doc.lines, soft, hard,
|
|
22
|
+
level: over ? 'hard' : warn ? 'soft' : 'ok',
|
|
23
|
+
independentConcepts: candidates.length,
|
|
24
|
+
candidates,
|
|
25
|
+
// Structural evidence, not an opinion: several fat sections in one file.
|
|
26
|
+
recommendSplit: candidates.length >= 3 && (warn || over),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** H2 sections substantial enough to become their own document. */
|
|
31
|
+
export function splitCandidates(doc) {
|
|
32
|
+
return doc.sections
|
|
33
|
+
.filter((s) => s.lines >= SPLITTABLE_MIN_LINES)
|
|
34
|
+
.map((s) => ({ title: s.title, lines: s.lines, suggested: fileNameFor(s.title) }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fileNameFor(title) {
|
|
38
|
+
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48) + '.md';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* README-specific extraction advice (PRD §28). A README section that has grown
|
|
43
|
+
* past `maxSection` lines belongs somewhere else with a summary left behind.
|
|
44
|
+
*/
|
|
45
|
+
export function readmeOverreach(cfg, doc, maxSection = 40) {
|
|
46
|
+
if (doc.type !== 'user.readme') return [];
|
|
47
|
+
const EXTRACT = {
|
|
48
|
+
architecture: 'architecture.overview', design: 'architecture.overview',
|
|
49
|
+
development: 'engineering.development', contributing: 'governance.contributing',
|
|
50
|
+
security: 'security.public-model', deployment: 'operations.deployment',
|
|
51
|
+
configuration: 'operations.configuration', api: 'user.reference',
|
|
52
|
+
troubleshooting: 'user.troubleshooting', faq: 'user.faq', testing: 'engineering.testing',
|
|
53
|
+
roadmap: 'product.roadmap', changelog: 'release.notes',
|
|
54
|
+
};
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const s of doc.sections) {
|
|
57
|
+
if (s.lines <= maxSection) continue;
|
|
58
|
+
const key = Object.keys(EXTRACT).find((k) => s.title.toLowerCase().includes(k));
|
|
59
|
+
out.push({ section: s.title, lines: s.lines, moveTo: key ? EXTRACT[key] : null,
|
|
60
|
+
replaceWith: `${Math.min(12, Math.ceil(s.lines / 15))}-line summary plus a link` });
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Progressive disclosure check (PRD §14): does a tree have an index document? */
|
|
66
|
+
export function missingIndexes(docs) {
|
|
67
|
+
const dirs = new Map();
|
|
68
|
+
for (const d of docs) {
|
|
69
|
+
const dir = d.path.includes('/') ? d.path.slice(0, d.path.lastIndexOf('/')) : '';
|
|
70
|
+
if (!dir) continue;
|
|
71
|
+
if (!dirs.has(dir)) dirs.set(dir, []);
|
|
72
|
+
dirs.get(dir).push(d);
|
|
73
|
+
}
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const [dir, group] of dirs) {
|
|
76
|
+
if (group.length < 3) continue;
|
|
77
|
+
const hasIndex = group.some((d) => /\/(README|index)\.mdx?$/i.test(d.path));
|
|
78
|
+
if (!hasIndex) out.push({ dir, documents: group.length, suggest: `${dir}/README.md`, type: 'docs.index' });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function requiredSectionReport(doc) {
|
|
84
|
+
const def = typeDef(doc.type);
|
|
85
|
+
const missing = doc.missingSections();
|
|
86
|
+
return { path: doc.path, type: doc.type, required: (def.sections || []).length, missing };
|
|
87
|
+
}
|