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.
Files changed (68) hide show
  1. package/.claude-plugin/marketplace.json +29 -0
  2. package/.claude-plugin/plugin.json +41 -0
  3. package/LICENSE +21 -0
  4. package/README.md +136 -0
  5. package/agents/architect.md +65 -0
  6. package/agents/classifier.md +44 -0
  7. package/agents/drift-reviewer.md +59 -0
  8. package/agents/quality-reviewer.md +59 -0
  9. package/bin/docgov +1160 -0
  10. package/bin/docgov.cmd +2 -0
  11. package/core/check.js +298 -0
  12. package/core/classify.js +233 -0
  13. package/core/config.js +162 -0
  14. package/core/context.js +144 -0
  15. package/core/document.js +132 -0
  16. package/core/drift.js +225 -0
  17. package/core/find.js +61 -0
  18. package/core/frontmatter.js +65 -0
  19. package/core/git.js +113 -0
  20. package/core/graph.js +182 -0
  21. package/core/health.js +101 -0
  22. package/core/impact.js +146 -0
  23. package/core/invariants.js +126 -0
  24. package/core/inventory.js +167 -0
  25. package/core/links.js +80 -0
  26. package/core/migrate.js +158 -0
  27. package/core/onboard.js +271 -0
  28. package/core/paths.js +53 -0
  29. package/core/publish.js +92 -0
  30. package/core/registry.js +71 -0
  31. package/core/similarity.js +89 -0
  32. package/core/size.js +87 -0
  33. package/core/suppressions.js +58 -0
  34. package/core/taxonomy.js +477 -0
  35. package/core/templates.js +159 -0
  36. package/core/util.js +124 -0
  37. package/core/yaml.js +250 -0
  38. package/hooks/hooks.json +65 -0
  39. package/lenses/agent.md +38 -0
  40. package/lenses/architecture.md +30 -0
  41. package/lenses/developer.md +26 -0
  42. package/lenses/operations.md +32 -0
  43. package/lenses/readme.md +32 -0
  44. package/lenses/security.md +33 -0
  45. package/lenses/user.md +30 -0
  46. package/package.json +39 -0
  47. package/policy/documentation.md +82 -0
  48. package/schemas/config.json +239 -0
  49. package/schemas/frontmatter.json +299 -0
  50. package/skills/affected/SKILL.md +41 -0
  51. package/skills/brief/SKILL.md +38 -0
  52. package/skills/create/SKILL.md +53 -0
  53. package/skills/find/SKILL.md +32 -0
  54. package/skills/health/SKILL.md +36 -0
  55. package/skills/inspect/SKILL.md +58 -0
  56. package/skills/publish/SKILL.md +45 -0
  57. package/skills/review/SKILL.md +65 -0
  58. package/skills/setup/SKILL.md +52 -0
  59. package/skills/stale/SKILL.md +55 -0
  60. package/skills/tag/SKILL.md +59 -0
  61. package/templates/architecture.adr.md +42 -0
  62. package/templates/architecture.domain.md +44 -0
  63. package/templates/architecture.trd.md +72 -0
  64. package/templates/constitution.invariants.md +40 -0
  65. package/templates/operations.runbook.md +47 -0
  66. package/templates/product.prd.md +60 -0
  67. package/templates/security.threat-model.md +51 -0
  68. package/templates/user.readme.md +43 -0
package/core/config.js ADDED
@@ -0,0 +1,162 @@
1
+ import path from 'node:path';
2
+ import * as yaml from './yaml.js';
3
+ import { TYPES, MODES, VISIBILITY_PATHS, GENERATED_PATHS } from './taxonomy.js';
4
+ import { read, exists, write, merge, DocGovError, findRepoRoot } from './util.js';
5
+
6
+ export const CONFIG_PATH = '.docgov/config.yaml';
7
+
8
+ /**
9
+ * Enforcement profiles per project mode (PRD §9).
10
+ * `block` lists check ids that may fail a write or CI; everything else warns.
11
+ * Over-enforcement is the product's main failure mode, so solo blocks almost nothing.
12
+ */
13
+ export const MODE_PROFILES = {
14
+ solo: {
15
+ block: ['generated-edit', 'duplicate-id', 'invalid-yaml'],
16
+ require_owner: false, require_review: false, classify_new_files: true,
17
+ },
18
+ team: {
19
+ block: ['generated-edit', 'duplicate-id', 'invalid-yaml', 'missing-frontmatter',
20
+ 'unknown-reference', 'broken-link', 'wrong-location'],
21
+ require_owner: true, require_review: true, classify_new_files: true,
22
+ },
23
+ enterprise: {
24
+ block: ['generated-edit', 'duplicate-id', 'invalid-yaml', 'missing-frontmatter',
25
+ 'unknown-reference', 'broken-link', 'wrong-location', 'visibility-path',
26
+ 'missing-sections', 'hard-limit', 'frozen-edit', 'missing-visibility'],
27
+ require_owner: true, require_review: true, classify_new_files: true,
28
+ },
29
+ 'open-source': {
30
+ block: ['generated-edit', 'duplicate-id', 'invalid-yaml', 'broken-link', 'visibility-path'],
31
+ require_owner: false, require_review: true, classify_new_files: true,
32
+ },
33
+ };
34
+
35
+ function defaultLimits() {
36
+ const limits = {};
37
+ for (const [id, t] of Object.entries(TYPES)) {
38
+ if (t.soft || t.hard) limits[id] = { soft_lines: t.soft, hard_lines: t.hard };
39
+ }
40
+ return limits;
41
+ }
42
+
43
+ function defaultQuality() {
44
+ const q = {};
45
+ for (const [id, t] of Object.entries(TYPES)) if (t.quality) q[id] = t.quality;
46
+ return q;
47
+ }
48
+
49
+ export function defaults() {
50
+ return {
51
+ version: 1,
52
+ project: { name: null, mode: 'solo', visibility: 'internal', layout: 'compact' },
53
+ documentation: {
54
+ root: 'docs',
55
+ include: ['**/*.md', '**/*.mdx'],
56
+ // Agent infrastructure is instructions, not documentation. Governing it would
57
+ // make DocGov police the files that configure DocGov.
58
+ exclude: ['.claude/**', '.docgov/**', '**/node_modules/**', '**/SKILL.md',
59
+ '.github/ISSUE_TEMPLATE/**', '**/PULL_REQUEST_TEMPLATE.md', '**/CHANGELOG_UNRELEASED.md'],
60
+ // Documents governed from here instead of from frontmatter in the file. GitHub renders
61
+ // YAML frontmatter in Markdown as a table, so the files it surfaces on a project's
62
+ // front page — README, CONTRIBUTING, SECURITY, CHANGELOG — should not carry any.
63
+ // path -> the same keys a `docgov:` block would hold.
64
+ registrations: {},
65
+ },
66
+ governance: {
67
+ canonical_changes_require_review: true,
68
+ prevent_duplicate_domains: true,
69
+ enforce: null, // null = derive from mode profile
70
+ warn_only: false,
71
+ max_new_root_docs: 0, // new top-level *.md beyond the allowlist below
72
+ // Project-level artifacts that legitimately live at the repository root. A repository
73
+ // whose own specification is a root document should say so here rather than be nagged
74
+ // about it on every check.
75
+ allowed_root_docs: ['README.md', 'CONTRIBUTING.md', 'SECURITY.md', 'CHANGELOG.md',
76
+ 'CLAUDE.md', 'AGENTS.md', 'SUPPORT.md', 'CODE_OF_CONDUCT.md', 'LICENSE.md'],
77
+ },
78
+ limits: defaultLimits(),
79
+ quality: defaultQuality(),
80
+ visibility_paths: VISIBILITY_PATHS,
81
+ generated_paths: GENERATED_PATHS,
82
+ drift: { enabled: true, stale_threshold: 60, lookback: '30 days ago' },
83
+ generated: { allow_manual_edit: false },
84
+ contracts: { openapi: ['openapi/**/*.{yaml,yml,json}', 'api/**/openapi.{yaml,yml,json}'] },
85
+ domains: {}, // domain -> { paths: [...], docs: [...], owner }
86
+ policy_packs: [], // V3: paths or file: URLs merged over defaults
87
+ suppressions_file: '.docgov/suppressions.yaml',
88
+ };
89
+ }
90
+
91
+ /** @returns {{root:string, raw:object, cfg:object, initialized:boolean}} */
92
+ export function load(cwd = process.cwd()) {
93
+ const root = findRepoRoot(cwd);
94
+ const file = path.join(root, CONFIG_PATH);
95
+ let raw = {};
96
+ const initialized = exists(file);
97
+ if (initialized) {
98
+ try { raw = yaml.parse(read(file)) || {}; }
99
+ catch (e) { throw new DocGovError(`${CONFIG_PATH} is not valid: ${e.message}`); }
100
+ }
101
+ let cfg = merge(defaults(), raw);
102
+ // Excludes are a deny-list, so they accumulate rather than replace. Adding one entry
103
+ // should not silently re-enable governance over agent infrastructure.
104
+ if (raw.documentation?.exclude) {
105
+ cfg.documentation.exclude = [...new Set([...defaults().documentation.exclude, ...raw.documentation.exclude])];
106
+ }
107
+
108
+ for (const p of cfg.policy_packs || []) {
109
+ const abs = path.isAbsolute(p) ? p : path.join(root, p);
110
+ const packFile = exists(path.join(abs, 'policy.yaml')) ? path.join(abs, 'policy.yaml') : abs;
111
+ if (!exists(packFile)) throw new DocGovError(`policy pack not found: ${p}`);
112
+ let pack;
113
+ try { pack = yaml.parse(read(packFile)) || {}; }
114
+ catch (e) { throw new DocGovError(`policy pack ${p} is not valid: ${e.message}`); }
115
+ // Packs layer under local config: org sets the floor, repo keeps the last word.
116
+ cfg = merge(merge(cfg, pack), raw);
117
+ }
118
+
119
+ if (!MODES.includes(cfg.project.mode))
120
+ throw new DocGovError(`project.mode must be one of ${MODES.join(', ')} (got ${cfg.project.mode})`);
121
+
122
+ // A Claude Code plugin's own components are payload, not documentation. Governing them
123
+ // would make DocGov police the skills, agents and templates that implement DocGov.
124
+ if (exists(path.join(root, '.claude-plugin', 'plugin.json'))) {
125
+ const payload = ['skills/**', 'agents/**', 'hooks/**', 'templates/**', 'lenses/**',
126
+ 'rules/**', 'policy/**', 'commands/**', 'output-styles/**', 'workflows/**'];
127
+ const ex = cfg.documentation.exclude || [];
128
+ cfg.documentation.exclude = [...new Set([...ex, ...payload])];
129
+ }
130
+
131
+ const profile = MODE_PROFILES[cfg.project.mode];
132
+ cfg.governance.enforce = cfg.governance.enforce || profile.block;
133
+ cfg.profile = profile;
134
+ return { root, raw, cfg, initialized, file };
135
+ }
136
+
137
+ export function save(root, raw) {
138
+ return write(path.join(root, CONFIG_PATH), yaml.stringify(raw));
139
+ }
140
+
141
+ /** Does this check id block, given mode + warn_only? */
142
+ export function blocks(cfg, checkId) {
143
+ if (cfg.governance.warn_only) return false;
144
+ return (cfg.governance.enforce || []).includes(checkId);
145
+ }
146
+
147
+ export function limitFor(cfg, type) {
148
+ const l = (cfg.limits || {})[type] || {};
149
+ const t = TYPES[type] || {};
150
+ return { soft: l.soft_lines ?? t.soft ?? 0, hard: l.hard_lines ?? t.hard ?? 0 };
151
+ }
152
+
153
+ export function qualityFor(cfg, type) {
154
+ return (cfg.quality || {})[type] ?? (TYPES[type] || {}).quality ?? 0;
155
+ }
156
+
157
+ /** Canonical directory or file for a type under the active layout. */
158
+ export function locationFor(cfg, type) {
159
+ const t = TYPES[type] || TYPES.unknown;
160
+ const loc = cfg.project.layout === 'full' ? t.full : (t.compact || t.full);
161
+ return loc;
162
+ }
@@ -0,0 +1,144 @@
1
+ import { AUTHORITY, typeDef } from './taxonomy.js';
2
+ import { collect as collectInvariants, applicable } from './invariants.js';
3
+
4
+ /**
5
+ * Context packs (PRD §33).
6
+ *
7
+ * A skill injects this with `!`-prefix command substitution, so the pack is the
8
+ * only documentation tokens the agent pays for. Authority order is the point:
9
+ * the agent reads the constitution before the guide, never the other way round.
10
+ */
11
+
12
+ const DEFAULT_BUDGET = 12000; // characters, ~3k tokens
13
+
14
+ /**
15
+ * @param {{cfg:object, docs:any[], graph:any, topic:string, budget?:number, include?:string[]}} args
16
+ */
17
+ export function pack({ cfg, docs, graph, topic, budget = DEFAULT_BUDGET, include = null }) {
18
+ const needle = String(topic || '').toLowerCase();
19
+ const byId = new Map(docs.map((d) => [d.id, d]));
20
+
21
+ const scored = docs.map((d) => ({ doc: d, score: relevance(d, needle, cfg) }))
22
+ .filter((x) => x.score > 0)
23
+ .sort((a, b) => b.score - a.score);
24
+
25
+ const seedIds = scored.slice(0, 6).map((x) => x.doc.id);
26
+ // Pull in what the seeds depend on — a TRD without its canonical domain is a trap.
27
+ const pulled = graph.reach(seedIds, { direction: 'out', rels: ['depends_on', 'derived_from', 'implements', 'generated_from'], maxDepth: 2 });
28
+
29
+ const selected = [];
30
+ const seen = new Set();
31
+ const push = (doc, why) => {
32
+ if (!doc || seen.has(doc.id)) return;
33
+ seen.add(doc.id);
34
+ selected.push({ doc, why });
35
+ };
36
+
37
+ // Constitution always travels with the pack: it is short and it governs everything.
38
+ for (const d of docs) if (AUTHORITY[d.authority]?.rank === 0) push(d, 'project constitution');
39
+ for (const x of scored.slice(0, 8)) push(x.doc, `matches "${topic}"`);
40
+ for (const [id, info] of pulled) push(byId.get(id), `${info.via} of a matched document`);
41
+ if (include) for (const id of include) push(byId.get(id), 'explicitly requested');
42
+
43
+ selected.sort((a, b) => (AUTHORITY[a.doc.authority]?.rank ?? 9) - (AUTHORITY[b.doc.authority]?.rank ?? 9));
44
+
45
+ const invSet = collectInvariants(docs, cfg);
46
+ const domainPaths = cfg.domains?.[needle]?.paths || [];
47
+ const invariants = domainPaths.length
48
+ ? applicable(invSet, domainPaths.map((g) => g.replace(/\*+/g, 'x')), cfg)
49
+ : invSet.invariants.filter((i) => (i.domain || '').includes(needle) || i.id.toLowerCase().includes(needle));
50
+
51
+ const contracts = [...graph.nodes.values()].filter((n) => n.kind === 'contract' &&
52
+ (n.path.toLowerCase().includes(needle) || seen.has(n.id)));
53
+
54
+ return render({ topic, selected, invariants, contracts, budget, cfg });
55
+ }
56
+
57
+ function relevance(d, needle, cfg) {
58
+ if (!needle) return 1;
59
+ let s = 0;
60
+ if (d.domain && d.domain.toLowerCase() === needle) s += 60;
61
+ if (d.id.toLowerCase().includes(needle)) s += 40;
62
+ if (d.path.toLowerCase().includes(needle)) s += 30;
63
+ if ((d.title || '').toLowerCase().includes(needle)) s += 25;
64
+ const body = d.body.toLowerCase();
65
+ const hits = body.split(needle).length - 1;
66
+ if (hits) s += Math.min(25, 4 + hits * 2);
67
+ // Authority bonus so the canonical spec outranks a blog-flavoured guide on ties.
68
+ s += Math.max(0, 8 - (AUTHORITY[d.authority]?.rank ?? 9));
69
+ return s;
70
+ }
71
+
72
+ /**
73
+ * Pack rendering: full body while the budget lasts, then headings-only so the
74
+ * agent still knows the document exists and can read it on demand.
75
+ */
76
+ function render({ topic, selected, invariants, contracts, budget }) {
77
+ const L = [];
78
+ L.push(`# DocGov context pack: ${topic}`);
79
+ L.push('');
80
+ L.push('Authoritative documents for this topic, most authoritative first. A lower-authority');
81
+ L.push('document may not contradict a higher one. If something here is wrong, fix the document,');
82
+ L.push('do not work around it.');
83
+ L.push('');
84
+
85
+ if (invariants.length) {
86
+ L.push('## Invariants in force');
87
+ L.push('');
88
+ for (const i of invariants) L.push(`- **${i.id}** ${i.statement} _(${i.source})_`);
89
+ L.push('');
90
+ }
91
+
92
+ if (contracts.length) {
93
+ L.push('## Machine contracts (authoritative over prose)');
94
+ L.push('');
95
+ for (const c of contracts) L.push(`- \`${c.path}\` (${c.type})`);
96
+ L.push('');
97
+ }
98
+
99
+ let used = L.join('\n').length;
100
+ const truncated = [];
101
+ for (const { doc, why } of selected) {
102
+ const header = `## ${doc.title}\n\n\`${doc.path}\` · ${AUTHORITY[doc.authority]?.label || doc.authority} · ${doc.type} · included because it ${why}\n\n`;
103
+ const bodyText = doc.body.trim();
104
+ if (used + header.length + bodyText.length < budget) {
105
+ L.push(header.trimEnd());
106
+ L.push('');
107
+ L.push(bodyText);
108
+ L.push('');
109
+ used += header.length + bodyText.length;
110
+ } else {
111
+ truncated.push(doc);
112
+ }
113
+ }
114
+
115
+ if (truncated.length) {
116
+ L.push('## Not included in full (read on demand)');
117
+ L.push('');
118
+ for (const doc of truncated) {
119
+ const heads = doc.sections.slice(0, 8).map((s) => s.title).join(', ');
120
+ L.push(`- \`${doc.path}\` — ${AUTHORITY[doc.authority]?.label || doc.authority}${heads ? `: ${heads}` : ''}`);
121
+ }
122
+ L.push('');
123
+ }
124
+
125
+ L.push('---');
126
+ L.push(`${selected.length} document(s) considered, ${selected.length - truncated.length} included in full, ${invariants.length} invariant(s).`);
127
+ return L.join('\n');
128
+ }
129
+
130
+ /** The agent lens (PRD §11): is this document safe for another agent to act on? */
131
+ export function agentReadiness(doc) {
132
+ const issues = [];
133
+ if (!doc.registered) issues.push('no docgov.id — an agent cannot cite or update it reliably');
134
+ if (doc.type === 'unknown') issues.push('unclassified — an agent cannot tell what authority it carries');
135
+ if (doc.status === 'draft') issues.push('marked draft but contains no warning in the body');
136
+ if (!/\n/.test(doc.body.trim())) issues.push('effectively empty');
137
+ const def = typeDef(doc.type);
138
+ const missing = doc.missingSections();
139
+ if (missing.length) issues.push(`missing required sections: ${missing.join(', ')}`);
140
+ if (/\b(TBD|TODO|FIXME|\?\?\?)\b/.test(doc.body)) issues.push('contains TBD/TODO an agent may read as fact');
141
+ if (doc.lines > 1200) issues.push('too long to fit a focused agent context without truncation');
142
+ if (!doc.relationships || Object.keys(doc.relationships).length === 0) issues.push('no relationships — invisible to impact analysis');
143
+ return { path: doc.path, ready: issues.length === 0, issues };
144
+ }
@@ -0,0 +1,132 @@
1
+ import path from 'node:path';
2
+ import * as fm from './frontmatter.js';
3
+ import { TYPES, AUTHORITY, typeDef } from './taxonomy.js';
4
+ import { read, sha, slug, titleCase, toPosix } from './util.js';
5
+
6
+ /**
7
+ * A governed document. Built from disk once and passed around; no module
8
+ * re-reads a file after this point, so the whole engine sees one consistent
9
+ * snapshot per run.
10
+ */
11
+ export class Document {
12
+ /**
13
+ * @param {string} root
14
+ * @param {string} rel
15
+ * @param {string} [source]
16
+ * @param {object} [externalMeta] registration from config, for files that must not carry
17
+ * frontmatter. GitHub renders YAML frontmatter in Markdown as a table, so README,
18
+ * CONTRIBUTING, SECURITY and CHANGELOG are governed from the registry instead.
19
+ */
20
+ constructor(root, rel, source, externalMeta) {
21
+ this.root = root;
22
+ this.path = toPosix(rel);
23
+ this.source = source ?? read(path.join(root, rel));
24
+ this.error = null;
25
+ let parsed;
26
+ try { parsed = fm.parse(this.source); }
27
+ catch (e) { this.error = e.message; parsed = { data: {}, body: this.source, hasFrontmatter: true }; }
28
+ this.frontmatter = parsed.data;
29
+ this.body = parsed.body;
30
+ this.hasFrontmatter = parsed.hasFrontmatter;
31
+ this.meta = this.frontmatter.docgov || externalMeta || {};
32
+ this.externallyRegistered = !this.frontmatter.docgov && Boolean(externalMeta);
33
+ this.registered = Boolean(this.meta.id);
34
+ this.lines = this.source.split('\n').length;
35
+ this.bodyLines = this.body.split('\n').length;
36
+ this.hash = sha(this.source);
37
+ this.sections = fm.sections(this.body);
38
+ this.title = this.frontmatter.title || fm.firstHeading(this.body) || titleCase(path.basename(rel, path.extname(rel)));
39
+ }
40
+
41
+ get id() { return this.meta.id || slug(this.path.replace(/\.mdx?$/, '').replace(/\//g, '-')); }
42
+ get type() { return this.meta.type && TYPES[this.meta.type] ? this.meta.type : (this.meta.type || 'unknown'); }
43
+ get def() { return typeDef(this.type); }
44
+ get authority() { return this.meta.authority || this.def.authority; }
45
+ get tier() { return (AUTHORITY[this.authority] || AUTHORITY.historical).tier; }
46
+ get visibility() { return this.meta.visibility || this.def.visibility || 'internal'; }
47
+ get status() { return this.meta.status || 'active'; }
48
+ get domain() { return this.meta.domain || null; }
49
+ get owner() { return this.meta.owner || null; }
50
+ get lens() { return this.meta.lens || this.def.lens || 'developer'; }
51
+ get generationMode() { return (this.meta.generation || {}).mode || (this.def.generated ? 'generated' : 'human-maintained'); }
52
+ get isGenerated() { return this.generationMode === 'generated'; }
53
+
54
+ /** @returns {Record<string,string[]>} */
55
+ get relationships() {
56
+ const r = this.meta.relationships || {};
57
+ const out = {};
58
+ for (const [k, v] of Object.entries(r)) out[k] = Array.isArray(v) ? v.map(String) : (v == null ? [] : [String(v)]);
59
+ return out;
60
+ }
61
+
62
+ /** Section titles present, normalized for comparison against required sections. */
63
+ sectionTitles() { return this.sections.map((s) => normalizeHeading(s.title)); }
64
+
65
+ missingSections() {
66
+ const have = this.sectionTitles();
67
+ return (this.def.sections || []).filter((req) => {
68
+ const n = normalizeHeading(req);
69
+ return !have.some((h) => h === n || h.startsWith(n) || n.startsWith(h));
70
+ });
71
+ }
72
+
73
+ /** Markdown links, split into internal file refs and external URLs. */
74
+ links() {
75
+ const out = { internal: [], external: [], anchors: [] };
76
+ const re = /\[(?:[^\]\\]|\\.)*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
77
+ let m;
78
+ const body = this.body.replace(/```[\s\S]*?```/g, '').replace(/`[^`\n]*`/g, '');
79
+ while ((m = re.exec(body))) {
80
+ const target = m[1];
81
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//')) out.external.push(target);
82
+ else if (target.startsWith('#')) out.anchors.push(target);
83
+ else out.internal.push(target.split('#')[0]);
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /** Words for tf-idf similarity. Code fences and frontmatter excluded. */
89
+ tokens() {
90
+ if (this._tokens) return this._tokens;
91
+ const text = this.body.replace(/```[\s\S]*?```/g, ' ').toLowerCase();
92
+ this._tokens = text.match(/[a-z][a-z0-9_-]{2,}/g) || [];
93
+ return this._tokens;
94
+ }
95
+
96
+ toRegistryEntry() {
97
+ const e = { path: this.path, type: this.type, authority: this.authority, visibility: this.visibility };
98
+ if (this.externallyRegistered) e.registered_in = 'config';
99
+ if (this.title) e.title = this.title;
100
+ if (this.domain) e.domain = this.domain;
101
+ if (this.owner) e.owner = this.owner;
102
+ if (this.status !== 'active') e.status = this.status;
103
+ if (this.isGenerated) e.generated = true;
104
+ e.hash = this.hash;
105
+ e.lines = this.lines;
106
+ const rel = this.relationships;
107
+ if (Object.keys(rel).length) e.relationships = rel;
108
+ return e;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Normalize a heading for comparison: drop an ordinal prefix ("1.", "2.1", "Step 3 —",
114
+ * "IV. Context") and any punctuation. Numbered headings are common enough that not
115
+ * handling them would make the required-sections gate fire on documents that pass.
116
+ *
117
+ * Roman numerals are only stripped when a separator follows them. Without that guard,
118
+ * `[ivxlc]+` eats the first letter of ordinary words — "Verdict" becomes "erdict",
119
+ * "Install" becomes "nstall", "Context" becomes "ontext" — and every required section
120
+ * starting with one of those letters silently stops matching.
121
+ */
122
+ export function normalizeHeading(title) {
123
+ let t = String(title).trim();
124
+ t = t.replace(/^(?:step|phase|part|section)\s+(?:\d+|[ivxlc]+)\b[\s).:\-\u2013\u2014]*/i, '');
125
+ t = t.replace(/^\d+(?:\.\d+)*[\s).:\-\u2013\u2014]+/, '');
126
+ t = t.replace(/^[ivxlc]+[.)][\s\-\u2013\u2014]*/i, '');
127
+ return t.toLowerCase().replace(/[^a-z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim();
128
+ }
129
+
130
+ export function loadDocuments(root, relPaths) {
131
+ return relPaths.map((rel) => new Document(root, rel));
132
+ }
package/core/drift.js ADDED
@@ -0,0 +1,225 @@
1
+ import { changedFiles, lastCommitDate, commitsSince, isRepo, diffFor } from './git.js';
2
+ import { codeNodesFor } from './graph.js';
3
+ import { matchAny } from './util.js';
4
+ import { AUTHORITY } from './taxonomy.js';
5
+ import { parseFrom as parseInvariants } from './invariants.js';
6
+ import { MD_RE, CONTRACT_RE, isMappable } from './paths.js';
7
+
8
+ /**
9
+ * Drift engine (PRD §22, §23, §30).
10
+ *
11
+ * Everything here is deterministic: it answers "which documents are now
12
+ * unverified?" from the diff and the graph. It never claims to know that a
13
+ * document's *prose* contradicts the code — that judgement is handed to the
14
+ * drift-reviewer agent, on the narrowed list this module produces
15
+ * (FEASIBILITY §3.4). Keeping that line sharp is the product's credibility.
16
+ */
17
+
18
+ export const SEVERITY = ['critical', 'high', 'medium', 'low'];
19
+
20
+
21
+ /**
22
+ * @param {{root:string, cfg:object, docs:import('./document.js').Document[], graph:import('./graph.js').Graph, base?:string}} ctx
23
+ */
24
+ export function analyze({ root, cfg, docs, graph, base = 'HEAD' }) {
25
+ const findings = [];
26
+ if (!isRepo(root)) {
27
+ return { findings, changed: [], usable: false,
28
+ note: 'not a git repository — drift analysis needs git history' };
29
+ }
30
+
31
+ const changed = changedFiles(root, base);
32
+ const changedPaths = changed.map((c) => c.path);
33
+ const changedDocs = new Set(changed.filter((c) => MD_RE.test(c.path)).map((c) => c.path));
34
+ const byPath = new Map(docs.map((d) => [d.path, d]));
35
+
36
+ // ---- Forward drift: implementation moved, its documentation did not.
37
+ // Every changed non-documentation file is a candidate. Whether it matters is decided by
38
+ // the graph — if no document claims it, the lookup below finds nothing and costs nothing.
39
+ const codeChanges = changed.filter((c) => isMappable(c.path) && c.status !== 'D');
40
+ const impactedByDoc = new Map();
41
+ for (const c of codeChanges) {
42
+ for (const node of codeNodesFor(graph, c.path)) {
43
+ for (const e of graph.in(node.id, 'documents')) {
44
+ const docNode = graph.nodes.get(e.from);
45
+ if (!docNode) continue;
46
+ if (!impactedByDoc.has(docNode.path)) impactedByDoc.set(docNode.path, { docNode, files: [] });
47
+ impactedByDoc.get(docNode.path).files.push(c.path);
48
+ }
49
+ }
50
+ }
51
+ for (const [docPath, { docNode, files }] of impactedByDoc) {
52
+ if (changedDocs.has(docPath)) continue; // moved together: no drift
53
+ const doc = byPath.get(docPath);
54
+ findings.push({
55
+ id: null, kind: 'forward', severity: severityFor(docNode.authority, files.length),
56
+ document: docPath, documentId: docNode.id, documentAuthority: docNode.authority,
57
+ implementation: files.slice(0, 6), implementationCount: files.length,
58
+ // The invariants this document states are the part of it that can be objectively
59
+ // falsified by a code change, so the reviewer agent gets them up front.
60
+ invariants: doc ? parseInvariants(doc).map((i) => `${i.id} ${i.statement}`).slice(0, 5) : [],
61
+ why: `${files.length} file(s) this document claims to describe changed; the document did not`,
62
+ action: `Review ${docPath} against the implementation change, then record the review`,
63
+ reviewable: true,
64
+ });
65
+ }
66
+
67
+ // ---- Reverse drift: specification moved, implementation did not.
68
+ for (const c of changed) {
69
+ if (!MD_RE.test(c.path)) continue;
70
+ const doc = byPath.get(c.path);
71
+ if (!doc) continue;
72
+ const rank = AUTHORITY[doc.authority]?.rank ?? 9;
73
+ if (rank > 2) continue; // only intent documents can be ahead of code
74
+ const globs = [].concat(doc.meta.documents || [], cfg.domains?.[doc.domain]?.paths || []).map(String);
75
+ if (!globs.length) continue;
76
+ const codeTouched = changedPaths.some((p) => isMappable(p) && matchAny(p, globs));
77
+ const testsTouched = changedPaths.some((p) => /(^|\/)(test|tests|spec|__tests__)\//.test(p) || /\.(test|spec)\./.test(p));
78
+ if (codeTouched) continue;
79
+ findings.push({
80
+ id: null, kind: 'reverse', severity: doc.authority === 'constitution' ? 'high' : 'medium',
81
+ document: c.path, documentId: doc.id, documentAuthority: doc.authority,
82
+ implementation: globs, implementationCount: 0,
83
+ why: `specification changed but no implementation change was detected in ${globs.join(', ')}`,
84
+ action: testsTouched
85
+ ? 'Tests changed but implementation did not — confirm the change is complete'
86
+ : 'Implement the specification change, or mark it as planned-only',
87
+ reviewable: true,
88
+ });
89
+ }
90
+
91
+ // ---- Contract drift: machine contract moved, derived documentation did not.
92
+ for (const c of changed) {
93
+ if (!CONTRACT_RE.test(c.path) || c.status === 'D') continue;
94
+ const contractNode = [...graph.nodes.values()].find((n) => n.path === c.path);
95
+ const derived = contractNode
96
+ ? graph.in(contractNode.id, 'generated_from').concat(graph.in(contractNode.id, 'derived_from'))
97
+ : [];
98
+ const stale = derived.map((e) => graph.nodes.get(e.from)).filter((n) => n && !changedDocs.has(n.path));
99
+ findings.push({
100
+ id: null, kind: 'contract', severity: stale.length ? 'high' : 'medium',
101
+ document: stale.length ? stale.map((n) => n.path).join(', ') : '(no derived documentation registered)',
102
+ documentId: stale[0]?.id ?? null, documentAuthority: 'generated',
103
+ implementation: [c.path], implementationCount: 1,
104
+ why: stale.length
105
+ ? 'machine contract changed; documentation derived from it did not'
106
+ : 'machine contract changed and nothing is registered as derived from it',
107
+ action: stale.length ? 'Regenerate derived documentation' : `Register a document with generated_from: [${contractIdOf(c.path)}]`,
108
+ reviewable: true,
109
+ });
110
+ }
111
+
112
+ // ---- Dependency drift: a document's declared dependency moved ahead of it.
113
+ for (const d of docs) {
114
+ for (const dep of d.relationships.depends_on || []) {
115
+ const depNode = graph.nodes.get(dep);
116
+ if (!depNode || !changedDocs.has(depNode.path) || changedDocs.has(d.path)) continue;
117
+ findings.push({
118
+ id: null, kind: 'dependency', severity: 'low',
119
+ document: d.path, documentId: d.id, documentAuthority: d.authority,
120
+ implementation: [depNode.path], implementationCount: 1,
121
+ why: `depends_on ${dep}, which changed in this diff`,
122
+ action: `Check ${d.path} still agrees with ${depNode.path}`,
123
+ reviewable: true,
124
+ });
125
+ }
126
+ }
127
+
128
+ assignIds(findings);
129
+ findings.sort((a, b) => SEVERITY.indexOf(a.severity) - SEVERITY.indexOf(b.severity) || a.document.localeCompare(b.document));
130
+ return { findings, changed, usable: true };
131
+ }
132
+
133
+ function contractIdOf(p) { return p.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '').toLowerCase(); }
134
+
135
+ function severityFor(authority, fileCount) {
136
+ const rank = AUTHORITY[authority]?.rank ?? 9;
137
+ if (rank <= 1) return fileCount >= 3 ? 'critical' : 'high';
138
+ if (rank <= 2) return fileCount >= 5 ? 'high' : 'medium';
139
+ if (rank === 5) return 'high'; // generated docs falling behind is mechanical and always fixable
140
+ return fileCount >= 5 ? 'medium' : 'low';
141
+ }
142
+
143
+ /** Stable ids so suppressions survive re-runs. */
144
+ function assignIds(findings) {
145
+ for (const f of findings) {
146
+ const basis = `${f.kind}:${f.document}:${(f.implementation || []).join(',')}`;
147
+ let h = 0;
148
+ for (let i = 0; i < basis.length; i++) h = (h * 31 + basis.charCodeAt(i)) >>> 0;
149
+ f.id = `DRIFT-${String(h % 100000).padStart(5, '0')}`;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Semantic staleness (PRD §30). Not "last edited > 90 days" — a correct document
155
+ * can sit untouched for years. Risk accrues from things that changed *around* it.
156
+ */
157
+ export function staleness({ root, cfg, docs, graph, since = '180 days ago' }) {
158
+ const out = [];
159
+ if (!isRepo(root)) return out;
160
+ for (const d of docs) {
161
+ const signals = [];
162
+ let risk = 0;
163
+ const docCommits = commitsSince(root, d.path, since).length;
164
+ const docLast = lastCommitDate(root, d.path);
165
+
166
+ const globs = [].concat(d.meta.documents || [], cfg.domains?.[d.domain]?.paths || []).map(String);
167
+ let codeCommits = 0;
168
+ for (const g of globs) {
169
+ codeCommits += commitsSince(root, globToPathspec(g), since).length;
170
+ }
171
+ if (codeCommits > 0 && docCommits === 0) {
172
+ risk += Math.min(55, 10 + codeCommits * 5);
173
+ signals.push(`${codeCommits} implementation commits since last documentation change`);
174
+ }
175
+
176
+ for (const dep of d.relationships.depends_on || []) {
177
+ const n = graph.nodes.get(dep);
178
+ if (!n) continue;
179
+ const depLast = lastCommitDate(root, n.path);
180
+ if (depLast && docLast && depLast > docLast) { risk += 12; signals.push(`dependency ${dep} is newer`); }
181
+ }
182
+ for (const src of d.relationships.generated_from || []) {
183
+ const n = graph.nodes.get(src);
184
+ if (!n) continue;
185
+ const srcLast = lastCommitDate(root, n.path);
186
+ if (srcLast && docLast && srcLast > docLast) { risk += 25; signals.push(`source contract ${src} is newer`); }
187
+ }
188
+ if (d.status === 'draft') { risk += 10; signals.push('still marked draft'); }
189
+ const cadence = d.meta.review?.cadence;
190
+ if (cadence && docLast) {
191
+ const days = parseCadence(cadence);
192
+ if (days && daysSince(docLast) > days) {
193
+ risk += 20;
194
+ signals.push(`review cadence ${cadence} elapsed (${daysSince(docLast)} days since last change)`);
195
+ }
196
+ }
197
+ if (risk > 0) out.push({ path: d.path, id: d.id, risk: Math.min(100, risk), signals, lastChanged: docLast });
198
+ }
199
+ return out.sort((a, b) => b.risk - a.risk);
200
+ }
201
+
202
+ function globToPathspec(g) { return g.replace(/\*\*\/?$/, '').replace(/\/$/, '') || '.'; }
203
+ function parseCadence(c) {
204
+ const m = /^(\d+)\s*([dwmy])/i.exec(String(c));
205
+ if (!m) return null;
206
+ const n = parseInt(m[1], 10);
207
+ return { d: n, w: n * 7, m: n * 30, y: n * 365 }[m[2].toLowerCase()];
208
+ }
209
+ function daysSince(isoDate) {
210
+ return Math.floor((Date.now() - new Date(isoDate).getTime()) / 86400000);
211
+ }
212
+
213
+ /**
214
+ * Narrow the review surface for the drift-reviewer agent and hand it the exact
215
+ * diff hunks it needs — so the model reads kilobytes, not the repository.
216
+ */
217
+ export function reviewPackets({ root, findings, base = 'HEAD', limit = 12 }) {
218
+ return findings.filter((f) => f.reviewable).slice(0, limit).map((f) => ({
219
+ id: f.id, kind: f.kind, severity: f.severity, document: f.document,
220
+ why: f.why,
221
+ diffs: (f.implementation || []).slice(0, 3).map((p) => ({ path: p, diff: truncate(diffFor(root, p, base), 3000) })),
222
+ }));
223
+ }
224
+
225
+ function truncate(s, n) { return s.length <= n ? s : `${s.slice(0, n)}\n... (${s.length - n} more characters)`; }