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/find.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { AUTHORITY } from './taxonomy.js';
|
|
2
|
+
import { vectorize, cosine } from './similarity.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Authority-aware search (PRD §32).
|
|
6
|
+
*
|
|
7
|
+
* The ranking is deliberately not pure relevance: an agent that reads the user
|
|
8
|
+
* guide before the canonical spec writes confidently wrong code. Authority is a
|
|
9
|
+
* first-class ranking term.
|
|
10
|
+
*/
|
|
11
|
+
export function find({ docs, query, limit = 10 }) {
|
|
12
|
+
const q = String(query || '').toLowerCase().trim();
|
|
13
|
+
if (!q) return [];
|
|
14
|
+
const terms = q.match(/[a-z][a-z0-9_-]{1,}/g) || [q];
|
|
15
|
+
|
|
16
|
+
const pseudo = { id: '__query__', tokens: () => terms };
|
|
17
|
+
const vecs = vectorize([...docs, pseudo]);
|
|
18
|
+
const qv = vecs.get('__query__');
|
|
19
|
+
|
|
20
|
+
const results = docs.map((d) => {
|
|
21
|
+
const text = `${d.title}\n${d.path}\n${d.body}`.toLowerCase();
|
|
22
|
+
let lexical = 0;
|
|
23
|
+
for (const t of terms) {
|
|
24
|
+
const hits = text.split(t).length - 1;
|
|
25
|
+
if (!hits) continue;
|
|
26
|
+
lexical += Math.min(30, 6 + hits * 2);
|
|
27
|
+
if ((d.title || '').toLowerCase().includes(t)) lexical += 18;
|
|
28
|
+
if (d.path.toLowerCase().includes(t)) lexical += 12;
|
|
29
|
+
if (d.id.toLowerCase().includes(t)) lexical += 14;
|
|
30
|
+
if ((d.domain || '').toLowerCase() === t) lexical += 25;
|
|
31
|
+
}
|
|
32
|
+
if (text.includes(q)) lexical += 25;
|
|
33
|
+
const semantic = Math.round(cosine(qv, vecs.get(d.id)) * 60);
|
|
34
|
+
const rank = AUTHORITY[d.authority]?.rank ?? 9;
|
|
35
|
+
const authorityBonus = Math.max(0, (8 - rank) * 5);
|
|
36
|
+
const statusPenalty = d.status === 'deprecated' || d.status === 'superseded' ? 30 : 0;
|
|
37
|
+
return {
|
|
38
|
+
path: d.path, id: d.id, title: d.title, type: d.type, authority: d.authority,
|
|
39
|
+
label: AUTHORITY[d.authority]?.label || d.authority, domain: d.domain, status: d.status,
|
|
40
|
+
score: lexical + semantic + authorityBonus - statusPenalty,
|
|
41
|
+
snippet: snippet(d.body, terms),
|
|
42
|
+
};
|
|
43
|
+
}).filter((r) => r.score > authorityOnly(r));
|
|
44
|
+
|
|
45
|
+
return results.sort((a, b) =>
|
|
46
|
+
(AUTHORITY[a.authority]?.rank ?? 9) - (AUTHORITY[b.authority]?.rank ?? 9) ||
|
|
47
|
+
b.score - a.score).slice(0, limit);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A document must match on content, not merely be authoritative. */
|
|
51
|
+
function authorityOnly(r) { return Math.max(0, (8 - (AUTHORITY[r.authority]?.rank ?? 9)) * 5); }
|
|
52
|
+
|
|
53
|
+
function snippet(body, terms, width = 160) {
|
|
54
|
+
const text = body.replace(/```[\s\S]*?```/g, ' ').replace(/\s+/g, ' ');
|
|
55
|
+
const lower = text.toLowerCase();
|
|
56
|
+
let at = -1;
|
|
57
|
+
for (const t of terms) { at = lower.indexOf(t); if (at >= 0) break; }
|
|
58
|
+
if (at < 0) return text.slice(0, width).trim();
|
|
59
|
+
const start = Math.max(0, at - 50);
|
|
60
|
+
return `${start > 0 ? '…' : ''}${text.slice(start, start + width).trim()}…`;
|
|
61
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as yaml from './yaml.js';
|
|
2
|
+
import { DocGovError } from './util.js';
|
|
3
|
+
|
|
4
|
+
const FENCE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} source
|
|
8
|
+
* @returns {{data:object, body:string, raw:string|null, hasFrontmatter:boolean}}
|
|
9
|
+
*/
|
|
10
|
+
export function parse(source) {
|
|
11
|
+
const m = FENCE.exec(source);
|
|
12
|
+
if (!m) return { data: {}, body: source, raw: null, hasFrontmatter: false };
|
|
13
|
+
let data;
|
|
14
|
+
try { data = yaml.parse(m[1]) || {}; }
|
|
15
|
+
catch (e) { throw new DocGovError(`invalid frontmatter YAML: ${e.message}`); }
|
|
16
|
+
if (typeof data !== 'object' || Array.isArray(data)) throw new DocGovError('frontmatter must be a mapping');
|
|
17
|
+
return { data, body: source.slice(m[0].length), raw: m[1], hasFrontmatter: true };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Rewrite (or insert) frontmatter, preserving body byte-for-byte. */
|
|
21
|
+
export function stringify(data, body) {
|
|
22
|
+
const keys = Object.keys(data || {});
|
|
23
|
+
if (keys.length === 0) return body;
|
|
24
|
+
return `---\n${yaml.stringify(data)}---\n${body.startsWith('\n') ? body.slice(1) : body}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Merge patch into a document's `docgov` block without touching other frontmatter keys. */
|
|
28
|
+
export function patchDocgov(source, patch) {
|
|
29
|
+
const { data, body } = parse(source);
|
|
30
|
+
const next = { ...data, docgov: { ...(data.docgov || {}), ...patch } };
|
|
31
|
+
if (!('docgov' in data)) {
|
|
32
|
+
// keep docgov first so the governance block is the first thing a reader sees
|
|
33
|
+
const reordered = { docgov: next.docgov };
|
|
34
|
+
for (const k of Object.keys(data)) reordered[k] = data[k];
|
|
35
|
+
return stringify(reordered, body);
|
|
36
|
+
}
|
|
37
|
+
return stringify(next, body);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** First markdown H1, used as a title fallback. */
|
|
41
|
+
export function firstHeading(body) {
|
|
42
|
+
const m = /^#[ \t]+(.+?)[ \t]*$/m.exec(body);
|
|
43
|
+
return m ? m[1].trim() : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Top-level section map: H2 title -> {start,end,lines}. */
|
|
47
|
+
export function sections(body, level = 2) {
|
|
48
|
+
const prefix = '#'.repeat(level);
|
|
49
|
+
const lines = body.split('\n');
|
|
50
|
+
const out = [];
|
|
51
|
+
let cur = null;
|
|
52
|
+
let inFence = false;
|
|
53
|
+
for (let i = 0; i < lines.length; i++) {
|
|
54
|
+
const l = lines[i];
|
|
55
|
+
if (/^\s*(```|~~~)/.test(l)) inFence = !inFence;
|
|
56
|
+
if (inFence) continue;
|
|
57
|
+
const m = new RegExp(`^${prefix}[ \\t]+(.+?)[ \\t]*$`).exec(l);
|
|
58
|
+
if (m) {
|
|
59
|
+
if (cur) { cur.end = i; cur.lines = cur.end - cur.start; out.push(cur); }
|
|
60
|
+
cur = { title: m[1].trim(), start: i, end: lines.length, lines: 0 };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (cur) { cur.lines = cur.end - cur.start; out.push(cur); }
|
|
64
|
+
return out;
|
|
65
|
+
}
|
package/core/git.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { exists, toPosix } from './util.js';
|
|
4
|
+
|
|
5
|
+
/** Git is the audit log (PRD §38). DocGov reads it, never replaces it. */
|
|
6
|
+
|
|
7
|
+
function git(root, args, { allowFail = false } = {}) {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
10
|
+
} catch (e) {
|
|
11
|
+
if (allowFail) return '';
|
|
12
|
+
throw new Error(`git ${args.join(' ')} failed: ${String(e.stderr || e.message).trim()}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isRepo(root) { return exists(path.join(root, '.git')); }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Is the tree clean enough to migrate?
|
|
20
|
+
*
|
|
21
|
+
* DocGov's own state under .docgov/ does not count: `review` writes the plan that
|
|
22
|
+
* `migrate` then executes, so counting it would make the two commands mutually
|
|
23
|
+
* exclusive. Everything else must be committed, because that is what makes a
|
|
24
|
+
* migration revertible.
|
|
25
|
+
*/
|
|
26
|
+
export function isClean(root, { ignore = ['.docgov/'] } = {}) {
|
|
27
|
+
if (!isRepo(root)) return false;
|
|
28
|
+
const out = git(root, ['status', '--porcelain'], { allowFail: true });
|
|
29
|
+
if (out === '') return true;
|
|
30
|
+
return out.split('\n').filter(Boolean).every((line) => {
|
|
31
|
+
const p = line.slice(3).replace(/^"|"$/g, '');
|
|
32
|
+
return ignore.some((prefix) => p.startsWith(prefix));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Paths with uncommitted changes, excluding DocGov's own state. */
|
|
37
|
+
export function dirtyPaths(root, { ignore = ['.docgov/'] } = {}) {
|
|
38
|
+
if (!isRepo(root)) return [];
|
|
39
|
+
return git(root, ['status', '--porcelain'], { allowFail: true })
|
|
40
|
+
.split('\n').filter(Boolean)
|
|
41
|
+
.map((line) => line.slice(3).replace(/^"|"$/g, ''))
|
|
42
|
+
.filter((p) => !ignore.some((prefix) => p.startsWith(prefix)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function currentBranch(root) {
|
|
46
|
+
return git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFail: true }) || 'HEAD';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function headSha(root) { return git(root, ['rev-parse', 'HEAD'], { allowFail: true }); }
|
|
50
|
+
|
|
51
|
+
/** @returns {{path:string, status:string, oldPath?:string}[]} */
|
|
52
|
+
export function changedFiles(root, base = 'HEAD') {
|
|
53
|
+
if (!isRepo(root)) return [];
|
|
54
|
+
const out = git(root, ['diff', '--name-status', '-M', base], { allowFail: true });
|
|
55
|
+
const staged = git(root, ['diff', '--name-status', '-M', '--cached', base], { allowFail: true });
|
|
56
|
+
const untracked = git(root, ['ls-files', '--others', '--exclude-standard'], { allowFail: true });
|
|
57
|
+
const seen = new Map();
|
|
58
|
+
for (const block of [out, staged]) {
|
|
59
|
+
for (const line of block.split('\n').filter(Boolean)) {
|
|
60
|
+
const parts = line.split('\t');
|
|
61
|
+
const status = parts[0][0];
|
|
62
|
+
if (status === 'R') seen.set(toPosix(parts[2]), { path: toPosix(parts[2]), status: 'R', oldPath: toPosix(parts[1]) });
|
|
63
|
+
else seen.set(toPosix(parts[1]), { path: toPosix(parts[1]), status });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const f of untracked.split('\n').filter(Boolean)) {
|
|
67
|
+
if (!seen.has(toPosix(f))) seen.set(toPosix(f), { path: toPosix(f), status: 'A' });
|
|
68
|
+
}
|
|
69
|
+
return [...seen.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Unified diff for one path, used to extract changed values for drift review. */
|
|
73
|
+
export function diffFor(root, relPath, base = 'HEAD') {
|
|
74
|
+
return git(root, ['diff', '-U2', base, '--', relPath], { allowFail: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Commits touching a path since `since`. */
|
|
78
|
+
export function commitsSince(root, relPath, since = '90 days ago') {
|
|
79
|
+
const out = git(root, ['log', `--since=${since}`, '--format=%H%x09%an%x09%ad%x09%s', '--date=short', '--', relPath], { allowFail: true });
|
|
80
|
+
return out.split('\n').filter(Boolean).map((l) => {
|
|
81
|
+
const [sha, author, date, subject] = l.split('\t');
|
|
82
|
+
return { sha, author, date, subject };
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function lastCommitDate(root, relPath) {
|
|
87
|
+
const d = git(root, ['log', '-1', '--format=%ad', '--date=short', '--', relPath], { allowFail: true });
|
|
88
|
+
return d || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Primary author of a path, as an owner hint for `docgov review`. */
|
|
92
|
+
export function primaryAuthor(root, relPath) {
|
|
93
|
+
const out = git(root, ['shortlog', '-sne', 'HEAD', '--', relPath], { allowFail: true });
|
|
94
|
+
const first = out.split('\n').filter(Boolean)[0];
|
|
95
|
+
if (!first) return null;
|
|
96
|
+
const m = /^\s*\d+\s+(.+?)\s+<(.+?)>/.exec(first);
|
|
97
|
+
return m ? { name: m[1], email: m[2] } : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function move(root, from, to) {
|
|
101
|
+
const dir = path.dirname(path.join(root, to));
|
|
102
|
+
execFileSync('mkdir', ['-p', dir]);
|
|
103
|
+
git(root, ['mv', '-f', from, to]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createBranch(root, name) { git(root, ['checkout', '-b', name]); }
|
|
107
|
+
export function checkout(root, ref) { git(root, ['checkout', ref]); }
|
|
108
|
+
export function stashPush(root, msg) { return git(root, ['stash', 'push', '-u', '-m', msg], { allowFail: true }); }
|
|
109
|
+
export function resetHard(root, ref = 'HEAD') { git(root, ['reset', '--hard', ref]); }
|
|
110
|
+
export function add(root, paths) { git(root, ['add', '--', ...paths]); }
|
|
111
|
+
export function commit(root, message) { git(root, ['commit', '-m', message, '--no-verify']); }
|
|
112
|
+
export function revParse(root, ref) { return git(root, ['rev-parse', ref], { allowFail: true }); }
|
|
113
|
+
export function mergeBase(root, a, b) { return git(root, ['merge-base', a, b], { allowFail: true }); }
|
package/core/graph.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { RELATIONSHIPS, AUTHORITY } from './taxonomy.js';
|
|
3
|
+
import { write, read, exists, matchAny } from './util.js';
|
|
4
|
+
|
|
5
|
+
export const GRAPH_PATH = '.docgov/graph.json';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The documentation graph (PRD §8). Nodes are documents, contracts and code
|
|
9
|
+
* path-globs; edges are typed relationships plus their inverses, so impact
|
|
10
|
+
* analysis can walk in either direction.
|
|
11
|
+
*/
|
|
12
|
+
export class Graph {
|
|
13
|
+
constructor() {
|
|
14
|
+
/** @type {Map<string, {id:string,path:string,type:string,authority:string,visibility:string,domain:string|null,kind:string}>} */
|
|
15
|
+
this.nodes = new Map();
|
|
16
|
+
/** @type {{from:string,to:string,rel:string,inferred?:boolean}[]} */
|
|
17
|
+
this.edges = [];
|
|
18
|
+
this._out = new Map();
|
|
19
|
+
this._in = new Map();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
addNode(n) { this.nodes.set(n.id, n); return n; }
|
|
23
|
+
|
|
24
|
+
addEdge(from, to, rel, inferred = false) {
|
|
25
|
+
this.edges.push({ from, to, rel, inferred });
|
|
26
|
+
(this._out.get(from) ?? this._out.set(from, []).get(from)).push({ to, rel, inferred });
|
|
27
|
+
(this._in.get(to) ?? this._in.set(to, []).get(to)).push({ from, rel, inferred });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
out(id, rel) { return (this._out.get(id) || []).filter((e) => !rel || e.rel === rel); }
|
|
31
|
+
in(id, rel) { return (this._in.get(id) || []).filter((e) => !rel || e.rel === rel); }
|
|
32
|
+
|
|
33
|
+
/** Breadth-first closure over chosen edge directions. */
|
|
34
|
+
reach(startIds, { rels = null, direction = 'out', maxDepth = 4 } = {}) {
|
|
35
|
+
const seen = new Map();
|
|
36
|
+
let frontier = [...startIds];
|
|
37
|
+
for (let depth = 1; depth <= maxDepth && frontier.length; depth++) {
|
|
38
|
+
const next = [];
|
|
39
|
+
for (const id of frontier) {
|
|
40
|
+
const edges = direction === 'out' ? this.out(id) : this.in(id);
|
|
41
|
+
for (const e of edges) {
|
|
42
|
+
if (rels && !rels.includes(e.rel)) continue;
|
|
43
|
+
const target = direction === 'out' ? e.to : e.from;
|
|
44
|
+
if (seen.has(target) || startIds.includes(target)) continue;
|
|
45
|
+
seen.set(target, { depth, via: e.rel, from: id });
|
|
46
|
+
next.push(target);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
frontier = next;
|
|
50
|
+
}
|
|
51
|
+
return seen;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Documents nothing can reach and which reach nothing (PRD §31).
|
|
56
|
+
*
|
|
57
|
+
* Inferred link edges count: a document that links to others is visible to impact
|
|
58
|
+
* analysis, which is what this rule actually cares about. Only a document with no
|
|
59
|
+
* edge of any kind is invisible, and invisible means nothing will ever tell anyone
|
|
60
|
+
* it went stale.
|
|
61
|
+
*/
|
|
62
|
+
orphans() {
|
|
63
|
+
return [...this.nodes.values()]
|
|
64
|
+
.filter((n) => n.kind === 'document' && this.out(n.id).length === 0 && this.in(n.id).length === 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Edges that point at a node the graph does not contain. */
|
|
68
|
+
dangling() {
|
|
69
|
+
return this.edges.filter((e) => !this.nodes.has(e.to)).map((e) => ({ ...e, fromPath: this.nodes.get(e.from)?.path }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A lower-authority document claiming authority over a higher one is a
|
|
74
|
+
* structural error, not a matter of taste (PRD §5).
|
|
75
|
+
*/
|
|
76
|
+
authorityViolations() {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const e of this.edges) {
|
|
79
|
+
if (e.rel !== 'defines' && e.rel !== 'supersedes') continue;
|
|
80
|
+
const from = this.nodes.get(e.from), to = this.nodes.get(e.to);
|
|
81
|
+
if (!from || !to) continue;
|
|
82
|
+
const fr = AUTHORITY[from.authority]?.rank ?? 9;
|
|
83
|
+
const tr = AUTHORITY[to.authority]?.rank ?? 9;
|
|
84
|
+
if (fr > tr) out.push({ from: from.path, to: to.path, rel: e.rel, fromAuthority: from.authority, toAuthority: to.authority });
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
toJSON() {
|
|
90
|
+
return {
|
|
91
|
+
version: 1,
|
|
92
|
+
generated: new Date().toISOString().slice(0, 10),
|
|
93
|
+
nodes: [...this.nodes.values()],
|
|
94
|
+
edges: this.edges,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the graph from documents + registry + config domains.
|
|
101
|
+
* @param {import('./document.js').Document[]} docs
|
|
102
|
+
* @param {object} registry
|
|
103
|
+
* @param {object} cfg
|
|
104
|
+
* @param {{path:string,kind:string}[]} [contracts]
|
|
105
|
+
*/
|
|
106
|
+
export function build(docs, registry, cfg, contracts = []) {
|
|
107
|
+
const g = new Graph();
|
|
108
|
+
const byPath = new Map();
|
|
109
|
+
|
|
110
|
+
for (const d of docs) {
|
|
111
|
+
const n = g.addNode({ id: d.id, path: d.path, type: d.type, authority: d.authority,
|
|
112
|
+
visibility: d.visibility, domain: d.domain, status: d.status, kind: 'document',
|
|
113
|
+
lines: d.lines, hash: d.hash, generated: d.isGenerated });
|
|
114
|
+
byPath.set(d.path, n);
|
|
115
|
+
}
|
|
116
|
+
for (const c of contracts) {
|
|
117
|
+
const id = c.path.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '').toLowerCase();
|
|
118
|
+
if (!g.nodes.has(id)) g.addNode({ id, path: c.path, type: `contract.${c.kind}`, authority: 'machine-contract',
|
|
119
|
+
visibility: 'internal', domain: null, kind: 'contract' });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Declared relationships, plus their inverses so traversal works both ways.
|
|
123
|
+
for (const d of docs) {
|
|
124
|
+
for (const [rel, targets] of Object.entries(d.relationships)) {
|
|
125
|
+
for (const t of targets) {
|
|
126
|
+
g.addEdge(d.id, t, rel);
|
|
127
|
+
const inv = RELATIONSHIPS[rel]?.inverse;
|
|
128
|
+
if (inv && g.nodes.has(t)) g.addEdge(t, d.id, inv, true);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Inferred `references` edges from internal markdown links: free, and the main
|
|
134
|
+
// reason a greenfield repo's graph is not empty on day one.
|
|
135
|
+
for (const d of docs) {
|
|
136
|
+
for (const target of d.links().internal) {
|
|
137
|
+
const resolved = resolveLink(d.path, target);
|
|
138
|
+
const node = byPath.get(resolved);
|
|
139
|
+
if (node && node.id !== d.id) g.addEdge(d.id, node.id, 'references', true);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Domain -> code path edges from config, so impact analysis can map a code diff
|
|
144
|
+
// to the documents that claim to describe it.
|
|
145
|
+
for (const [domain, spec] of Object.entries(cfg.domains || {})) {
|
|
146
|
+
const nodeId = `code:${domain}`;
|
|
147
|
+
g.addNode({ id: nodeId, path: (spec.paths || []).join(', '), type: 'code.domain', authority: 'implementation',
|
|
148
|
+
visibility: 'internal', domain, kind: 'code', paths: spec.paths || [] });
|
|
149
|
+
for (const docId of spec.docs || []) if (g.nodes.has(docId)) g.addEdge(docId, nodeId, 'documents');
|
|
150
|
+
}
|
|
151
|
+
// `documents:` in frontmatter is the per-document version of the same mapping.
|
|
152
|
+
for (const d of docs) {
|
|
153
|
+
const globs = d.meta.documents;
|
|
154
|
+
if (!globs) continue;
|
|
155
|
+
const list = Array.isArray(globs) ? globs : [globs];
|
|
156
|
+
const nodeId = `code:${d.id}`;
|
|
157
|
+
g.addNode({ id: nodeId, path: list.join(', '), type: 'code.paths', authority: 'implementation',
|
|
158
|
+
visibility: 'internal', domain: d.domain, kind: 'code', paths: list });
|
|
159
|
+
g.addEdge(d.id, nodeId, 'documents');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return g;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function resolveLink(fromPath, target) {
|
|
166
|
+
const dir = path.posix.dirname(fromPath);
|
|
167
|
+
let p = path.posix.normalize(path.posix.join(dir, target));
|
|
168
|
+
if (p.startsWith('./')) p = p.slice(2);
|
|
169
|
+
return p;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function save(root, g) { return write(path.join(root, GRAPH_PATH), JSON.stringify(g.toJSON(), null, 2) + '\n'); }
|
|
173
|
+
|
|
174
|
+
export function loadSaved(root) {
|
|
175
|
+
const f = path.join(root, GRAPH_PATH);
|
|
176
|
+
return exists(f) ? JSON.parse(read(f)) : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Which code-node globs does a changed file belong to? */
|
|
180
|
+
export function codeNodesFor(g, changedPath) {
|
|
181
|
+
return [...g.nodes.values()].filter((n) => n.kind === 'code' && matchAny(changedPath, n.paths || []));
|
|
182
|
+
}
|
package/core/health.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { AUTHORITY, TYPES } from './taxonomy.js';
|
|
2
|
+
import { pct } from './util.js';
|
|
3
|
+
import { inboundCounts } from './links.js';
|
|
4
|
+
import { coverageGaps } from './inventory.js';
|
|
5
|
+
import { limitFor, qualityFor } from './config.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Documentation health (PRD §31).
|
|
9
|
+
*
|
|
10
|
+
* Every component here is computed from countable facts, so the number moves for
|
|
11
|
+
* a reason you can point at. Nothing is a model's opinion.
|
|
12
|
+
*/
|
|
13
|
+
export function health({ cfg, docs, inv, graph, registry, findings, stale = [] }) {
|
|
14
|
+
const n = Math.max(1, docs.length);
|
|
15
|
+
const gaps = coverageGaps(inv);
|
|
16
|
+
const inbound = inboundCounts(docs);
|
|
17
|
+
|
|
18
|
+
const classified = docs.filter((d) => d.type !== 'unknown').length;
|
|
19
|
+
const registered = docs.filter((d) => d.registered).length;
|
|
20
|
+
// Coverage has two halves: how much of what exists is understood, and how much
|
|
21
|
+
// of what the repository's stack implies actually exists.
|
|
22
|
+
const expected = gaps.length + new Set(docs.map((d) => d.type)).size;
|
|
23
|
+
const coverage = Math.round(0.5 * pct(classified, n) + 0.5 * pct(expected - gaps.length, expected));
|
|
24
|
+
|
|
25
|
+
const staleRisk = stale.length ? Math.round(stale.reduce((a, s) => a + s.risk, 0) / stale.length) : 0;
|
|
26
|
+
const freshness = Math.max(0, 100 - Math.round(staleRisk * (stale.length / n)));
|
|
27
|
+
|
|
28
|
+
const byCheck = (c) => findings.filter((f) => f.check === c).length;
|
|
29
|
+
const consistency = clamp(100
|
|
30
|
+
- byCheck('duplicate-id') * 20
|
|
31
|
+
- byCheck('authority-violation') * 20
|
|
32
|
+
- byCheck('unknown-reference') * 8
|
|
33
|
+
- byCheck('duplicate-candidate') * 4
|
|
34
|
+
- byCheck('invalid-relationship') * 4);
|
|
35
|
+
|
|
36
|
+
const structure = clamp(100
|
|
37
|
+
- byCheck('wrong-location') * 6
|
|
38
|
+
- byCheck('hard-limit') * 8
|
|
39
|
+
- byCheck('soft-limit') * 2
|
|
40
|
+
- byCheck('missing-sections') * 3
|
|
41
|
+
- byCheck('new-root-document') * 5);
|
|
42
|
+
|
|
43
|
+
const linked = [...inbound.values()].filter((c) => c > 0).length;
|
|
44
|
+
const discoverability = clamp(Math.round(0.6 * pct(linked, n) + 0.4 * (100 - byCheck('missing-index') * 10)));
|
|
45
|
+
|
|
46
|
+
const withRels = docs.filter((d) => Object.keys(d.relationships).length > 0).length;
|
|
47
|
+
const crossLinking = clamp(Math.round(0.5 * pct(withRels, n) + 0.5 * (100 - byCheck('broken-link') * 8)));
|
|
48
|
+
|
|
49
|
+
const canonical = docs.filter((d) => (AUTHORITY[d.authority]?.rank ?? 9) <= 1);
|
|
50
|
+
const canonicalIntegrity = clamp(100
|
|
51
|
+
- graph.authorityViolations().length * 25
|
|
52
|
+
- canonical.filter((d) => d.missingSections().length).length * 8
|
|
53
|
+
- canonical.filter((d) => !d.registered).length * 10);
|
|
54
|
+
|
|
55
|
+
const metadata = clamp(Math.round(pct(registered, n)
|
|
56
|
+
- byCheck('missing-visibility') * 2
|
|
57
|
+
- byCheck('missing-owner') * 2));
|
|
58
|
+
|
|
59
|
+
const components = { coverage: clamp(coverage), freshness, consistency, structure, discoverability,
|
|
60
|
+
'cross-linking': crossLinking, 'canonical integrity': canonicalIntegrity, metadata };
|
|
61
|
+
|
|
62
|
+
const weights = { coverage: 0.18, freshness: 0.14, consistency: 0.16, structure: 0.13,
|
|
63
|
+
discoverability: 0.11, 'cross-linking': 0.10, 'canonical integrity': 0.12, metadata: 0.06 };
|
|
64
|
+
const overall = Math.round(Object.entries(components).reduce((a, [k, v]) => a + v * weights[k], 0));
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
overall, components,
|
|
68
|
+
issues: {
|
|
69
|
+
stale: stale.filter((s) => s.risk >= (cfg.drift?.stale_threshold ?? 60)).length,
|
|
70
|
+
oversized: byCheck('hard-limit') + byCheck('soft-limit'),
|
|
71
|
+
orphans: byCheck('orphan'),
|
|
72
|
+
brokenLinks: byCheck('broken-link'),
|
|
73
|
+
unclassified: byCheck('unclassified'),
|
|
74
|
+
missingCrossReferences: byCheck('unknown-reference'),
|
|
75
|
+
duplicateCandidates: byCheck('duplicate-candidate'),
|
|
76
|
+
coverageGaps: gaps.length,
|
|
77
|
+
},
|
|
78
|
+
gaps,
|
|
79
|
+
counts: { documents: docs.length, classified, registered, canonical: canonical.length,
|
|
80
|
+
contracts: inv.contracts.length, lines: inv.counts.markdownLines },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function clamp(n) { return Math.max(0, Math.min(100, Math.round(n))); }
|
|
85
|
+
|
|
86
|
+
/** Quality-gate scaffolding (PRD §20). Deterministic dimensions only; the rest is the reviewer agent's. */
|
|
87
|
+
export function qualityFloor({ cfg, doc, findings }) {
|
|
88
|
+
const mine = findings.filter((f) => f.path === doc.path);
|
|
89
|
+
const threshold = qualityFor(cfg, doc.type);
|
|
90
|
+
const { soft, hard } = limitFor(cfg, doc.type);
|
|
91
|
+
const structure = clamp(100 - doc.missingSections().length * 12 - (hard && doc.lines > hard ? 20 : 0));
|
|
92
|
+
const grounding = clamp(100 - mine.filter((f) => f.check === 'broken-link').length * 15
|
|
93
|
+
- mine.filter((f) => f.check === 'unknown-reference').length * 10);
|
|
94
|
+
const crossRefs = clamp(Object.keys(doc.relationships).length ? 100 : 55);
|
|
95
|
+
const freshness = doc.status === 'draft' ? 60 : 100;
|
|
96
|
+
return {
|
|
97
|
+
path: doc.path, type: doc.type, threshold,
|
|
98
|
+
deterministic: { structure, 'technical grounding': grounding, 'cross references': crossRefs, freshness },
|
|
99
|
+
note: 'Clarity, completeness, audience fit and security judgement are scored by the quality-reviewer agent and are advisory.',
|
|
100
|
+
};
|
|
101
|
+
}
|
package/core/impact.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { changedFiles, isRepo } from './git.js';
|
|
2
|
+
import { codeNodesFor } from './graph.js';
|
|
3
|
+
import { AUTHORITY } from './taxonomy.js';
|
|
4
|
+
import { matchAny } from './util.js';
|
|
5
|
+
import { MD_RE, CONTRACT_RE, TEST_RE, isCode, isMappable } from './paths.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Change impact analysis (PRD §25) and the documentation change checklist (PRD §27).
|
|
9
|
+
*
|
|
10
|
+
* Impact is a graph reachability question, so it is fully deterministic. The
|
|
11
|
+
* checklist turns the answer into a checklist an agent can work through and a
|
|
12
|
+
* CI job can verify.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{root:string,cfg:object,docs:any[],graph:any,base?:string,paths?:string[]}} ctx
|
|
18
|
+
*/
|
|
19
|
+
export function analyze({ root, cfg, docs, graph, base = 'HEAD', paths = null }) {
|
|
20
|
+
const changed = paths
|
|
21
|
+
? paths.map((p) => ({ path: p, status: 'M' }))
|
|
22
|
+
: (isRepo(root) ? changedFiles(root, base) : []);
|
|
23
|
+
const changedPaths = changed.map((c) => c.path);
|
|
24
|
+
const changedDocs = new Set(changedPaths.filter((p) => MD_RE.test(p)));
|
|
25
|
+
|
|
26
|
+
const domains = new Set();
|
|
27
|
+
for (const [domain, spec] of Object.entries(cfg.domains || {})) {
|
|
28
|
+
if (changedPaths.some((p) => matchAny(p, spec.paths || []))) domains.add(domain);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Seed: documents directly mapped to changed code, plus changed documents themselves.
|
|
32
|
+
const seeds = new Set();
|
|
33
|
+
const reasons = new Map();
|
|
34
|
+
const note = (id, why) => { if (!reasons.has(id)) reasons.set(id, []); reasons.get(id).push(why); };
|
|
35
|
+
|
|
36
|
+
for (const c of changed) {
|
|
37
|
+
if (isMappable(c.path)) {
|
|
38
|
+
for (const node of codeNodesFor(graph, c.path)) {
|
|
39
|
+
for (const e of graph.in(node.id, 'documents')) { seeds.add(e.from); note(e.from, `maps ${c.path}`); }
|
|
40
|
+
if (node.domain) domains.add(node.domain);
|
|
41
|
+
}
|
|
42
|
+
const contractNode = [...graph.nodes.values()].find((n) => n.path === c.path);
|
|
43
|
+
if (contractNode) {
|
|
44
|
+
for (const e of graph.in(contractNode.id)) { seeds.add(e.from); note(e.from, `${e.rel} ${c.path}`); }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (MD_RE.test(c.path)) {
|
|
48
|
+
const n = [...graph.nodes.values()].find((x) => x.path === c.path);
|
|
49
|
+
if (n) { seeds.add(n.id); note(n.id, 'changed in this diff'); if (n.domain) domains.add(n.domain); }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const d of docs) {
|
|
53
|
+
if (d.domain && domains.has(d.domain) && !seeds.has(d.id)) { seeds.add(d.id); note(d.id, `domain ${d.domain} affected`); }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Propagate along dependency edges: whatever depends on a seed is also impacted.
|
|
57
|
+
const reached = graph.reach([...seeds], { direction: 'in', rels: ['depends_on', 'derived_from', 'implements', 'references'], maxDepth: 3 });
|
|
58
|
+
for (const [id, info] of reached) note(id, `${info.via} a document that changed (depth ${info.depth})`);
|
|
59
|
+
|
|
60
|
+
const affected = [];
|
|
61
|
+
for (const id of new Set([...seeds, ...reached.keys()])) {
|
|
62
|
+
const n = graph.nodes.get(id);
|
|
63
|
+
if (!n || n.kind !== 'document') continue;
|
|
64
|
+
const rank = AUTHORITY[n.authority]?.rank ?? 9;
|
|
65
|
+
const direct = seeds.has(id);
|
|
66
|
+
affected.push({
|
|
67
|
+
id, path: n.path, type: n.type, authority: n.authority, domain: n.domain,
|
|
68
|
+
updated: changedDocs.has(n.path),
|
|
69
|
+
required: direct && rank <= 3,
|
|
70
|
+
reasons: reasons.get(id) || [],
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
affected.sort((a, b) => Number(b.required) - Number(a.required) || a.path.localeCompare(b.path));
|
|
74
|
+
|
|
75
|
+
const behaviorChanged = changedPaths.some((p) => isCode(p) && !TEST_RE.test(p));
|
|
76
|
+
const apiChanged = changedPaths.some((p) => CONTRACT_RE.test(p)) ||
|
|
77
|
+
changedPaths.some((p) => /(^|\/)(api|routes?|controllers?|handlers?|endpoints?)\//.test(p));
|
|
78
|
+
const securityChanged = changedPaths.some((p) => /(^|\/)(auth|authz|authentication|authorization|security|crypto|session|permissions?)\//i.test(p));
|
|
79
|
+
const testsChanged = changedPaths.some((p) => TEST_RE.test(p));
|
|
80
|
+
const userVisible = apiChanged || changedPaths.some((p) => /(^|\/)(ui|components?|pages?|views?|cli)\//.test(p));
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
changed, domains: [...domains].sort(), affected,
|
|
84
|
+
signals: { behaviorChanged, apiChanged, securityChanged, testsChanged, userVisible },
|
|
85
|
+
level: level(affected, { behaviorChanged, apiChanged, securityChanged }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function level(affected, s) {
|
|
90
|
+
const req = affected.filter((a) => a.required).length;
|
|
91
|
+
if (s.securityChanged || req >= 4) return 'HIGH';
|
|
92
|
+
if (s.apiChanged || req >= 2) return 'MEDIUM';
|
|
93
|
+
if (req >= 1 || s.behaviorChanged) return 'LOW';
|
|
94
|
+
return 'NONE';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** PRD §27 manifest: a deterministic checklist, written to .docgov/checklist.yaml. */
|
|
98
|
+
export function checklist(impact) {
|
|
99
|
+
const required = impact.affected.filter((a) => a.required);
|
|
100
|
+
return {
|
|
101
|
+
change: {
|
|
102
|
+
domain: impact.domains,
|
|
103
|
+
behavior_changed: impact.signals.behaviorChanged,
|
|
104
|
+
api_changed: impact.signals.apiChanged,
|
|
105
|
+
security_changed: impact.signals.securityChanged,
|
|
106
|
+
user_visible: impact.signals.userVisible,
|
|
107
|
+
tests_changed: impact.signals.testsChanged,
|
|
108
|
+
impact_level: impact.level,
|
|
109
|
+
},
|
|
110
|
+
docs: {
|
|
111
|
+
required: required.map((a) => a.id),
|
|
112
|
+
reviewed: required.filter((a) => a.updated).map((a) => a.id),
|
|
113
|
+
outstanding: required.filter((a) => !a.updated).map((a) => a.id),
|
|
114
|
+
optional: impact.affected.filter((a) => !a.required).map((a) => a.id),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The PR comment body (PRD §26). Deterministic; blocking is the caller's choice. */
|
|
120
|
+
export function prReport(impact, drift, cfg) {
|
|
121
|
+
const m = checklist(impact);
|
|
122
|
+
const L = [];
|
|
123
|
+
L.push('DocGov Documentation Review');
|
|
124
|
+
L.push('───────────────────────────');
|
|
125
|
+
L.push(`Code impact: ${impact.level}`);
|
|
126
|
+
L.push(`Documentation impact: ${m.docs.required.length ? (m.docs.outstanding.length ? 'OUTSTANDING' : 'COVERED') : 'NONE'}`);
|
|
127
|
+
L.push('');
|
|
128
|
+
const tick = (ok, text) => L.push(`${ok ? '✓' : '✗'} ${text}`);
|
|
129
|
+
if (impact.signals.apiChanged) tick(impact.changed.some((c) => /openapi|\.proto$|\.graphql$/.test(c.path)), 'API contract updated');
|
|
130
|
+
if (impact.signals.behaviorChanged) tick(impact.signals.testsChanged, 'tests updated');
|
|
131
|
+
for (const a of impact.affected.filter((x) => x.required)) tick(a.updated, `${a.path} (${a.authority})`);
|
|
132
|
+
if (impact.signals.securityChanged) L.push('⚠ security-sensitive paths changed — security documentation review recommended');
|
|
133
|
+
for (const a of impact.affected.filter((x) => !x.required && !x.updated).slice(0, 5)) L.push(`· optional: ${a.path}`);
|
|
134
|
+
if (drift?.findings?.length) {
|
|
135
|
+
L.push('');
|
|
136
|
+
L.push(`Drift: ${drift.findings.filter((f) => f.severity === 'critical').length} critical, ` +
|
|
137
|
+
`${drift.findings.filter((f) => f.severity === 'high').length} high, ` +
|
|
138
|
+
`${drift.findings.filter((f) => f.severity === 'medium').length} medium`);
|
|
139
|
+
for (const f of drift.findings.slice(0, 5)) L.push(` ${f.severity.toUpperCase().padEnd(8)} ${f.id} ${f.document} — ${f.why}`);
|
|
140
|
+
}
|
|
141
|
+
L.push('');
|
|
142
|
+
const blocked = m.docs.outstanding.length > 0 && !cfg.governance.warn_only;
|
|
143
|
+
L.push(`Documentation readiness: ${blocked ? 'REVIEW REQUIRED' : 'OK'}`);
|
|
144
|
+
if (blocked) L.push(`Outstanding: ${m.docs.outstanding.join(', ')}`);
|
|
145
|
+
return { text: L.join('\n'), checklist: m, blocked };
|
|
146
|
+
}
|