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
@@ -0,0 +1,126 @@
1
+ import { matchAny } from './util.js';
2
+
3
+ /**
4
+ * Invariants as first-class objects (PRD §24).
5
+ *
6
+ * This is the highest-value, cheapest feature in the product: a deterministic
7
+ * path lookup that tells an agent "the code you are about to edit is bound by
8
+ * these three rules" before it writes a line. No model call; the whole hook,
9
+ * node startup included, measures around 95 ms on a small repository.
10
+ */
11
+
12
+ const ID = /^([A-Z][A-Z0-9]*-[A-Z][A-Z0-9]*-\d{3,4})\b[:.\s-]*(.*)$/;
13
+
14
+ /**
15
+ * Parse invariants out of any document. Two accepted forms:
16
+ * - a list item or heading starting with an ID: `INV-LIC-001 License belongs to one org.`
17
+ * - an explicit frontmatter `invariants:` block
18
+ * @param {import('./document.js').Document} doc
19
+ */
20
+ export function parseFrom(doc) {
21
+ const out = [];
22
+ const declared = doc.meta.invariants;
23
+ if (Array.isArray(declared)) {
24
+ for (const entry of declared) {
25
+ if (typeof entry === 'string') {
26
+ const m = ID.exec(entry.trim());
27
+ if (m) out.push({ id: m[1], statement: m[2].trim(), source: doc.path, paths: [], docId: doc.id });
28
+ } else if (entry && entry.id) {
29
+ out.push({ id: String(entry.id), statement: String(entry.statement || entry.text || '').trim(),
30
+ source: doc.path, paths: toList(entry.paths), docId: doc.id, severity: entry.severity || 'high' });
31
+ }
32
+ }
33
+ }
34
+
35
+ const lines = doc.body.split('\n');
36
+ let inFence = false;
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const l = lines[i];
39
+ if (/^\s*(```|~~~)/.test(l)) { inFence = !inFence; continue; }
40
+ if (inFence) continue;
41
+ // unwrap emphasis around a leading ID: "- **INV-LIC-002**: ..." -> "INV-LIC-002: ..."
42
+ const stripped = l
43
+ .replace(/^\s*(?:[-*+]\s+|#{1,6}\s+|\d+\.\s+)?/, '')
44
+ .replace(/^(\*\*|__|\*|_|`)(.+?)\1/, '$2')
45
+ .trim();
46
+ const m = ID.exec(stripped);
47
+ if (!m) continue;
48
+ if (out.some((o) => o.id === m[1])) continue;
49
+ let statement = m[2].trim();
50
+ // A bare ID on its own line takes the next non-empty line as its statement.
51
+ if (!statement) {
52
+ for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
53
+ const n = lines[j].trim();
54
+ if (n) { statement = n.replace(/^[-*+]\s+/, ''); break; }
55
+ }
56
+ }
57
+ out.push({ id: m[1], statement, source: doc.path, paths: [], docId: doc.id, severity: 'high', line: i + 1 });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Build the global invariant set, attaching the code paths each one governs.
64
+ * Paths come from the owning document's `documents:` globs or from the matching
65
+ * config domain, so an invariant is reachable from a changed file.
66
+ */
67
+ export function collect(docs, cfg) {
68
+ const all = [];
69
+ for (const d of docs) {
70
+ const docPaths = toList(d.meta.documents);
71
+ const domainPaths = d.domain ? toList(cfg.domains?.[d.domain]?.paths) : [];
72
+ for (const inv of parseFrom(d)) {
73
+ inv.paths = inv.paths.length ? inv.paths : [...docPaths, ...domainPaths];
74
+ inv.domain = d.domain || domainFromId(inv.id);
75
+ all.push(inv);
76
+ }
77
+ }
78
+ const byId = new Map();
79
+ const duplicates = [];
80
+ for (const inv of all) {
81
+ if (byId.has(inv.id)) duplicates.push({ id: inv.id, sources: [byId.get(inv.id).source, inv.source] });
82
+ else byId.set(inv.id, inv);
83
+ }
84
+ return { invariants: [...byId.values()], duplicates };
85
+ }
86
+
87
+ function domainFromId(id) {
88
+ const parts = id.split('-');
89
+ return parts.length >= 3 ? parts[1].toLowerCase() : null;
90
+ }
91
+
92
+ function toList(v) { return v == null ? [] : (Array.isArray(v) ? v.map(String) : [String(v)]); }
93
+
94
+ /**
95
+ * Invariants that apply to a set of changed files. This is what the PreToolUse
96
+ * hook injects as additionalContext.
97
+ * @param {{invariants:object[]}} set
98
+ * @param {string[]} changedPaths
99
+ * @param {object} cfg
100
+ */
101
+ export function applicable(set, changedPaths, cfg) {
102
+ const out = [];
103
+ for (const inv of set.invariants) {
104
+ const globs = inv.paths.length ? inv.paths : domainGlobs(cfg, inv.domain);
105
+ if (!globs.length) continue;
106
+ const hit = changedPaths.find((p) => matchAny(p, globs));
107
+ if (hit) out.push({ ...inv, matched: hit });
108
+ }
109
+ return out;
110
+ }
111
+
112
+ function domainGlobs(cfg, domain) {
113
+ if (!domain) return [];
114
+ return toList(cfg.domains?.[domain]?.paths);
115
+ }
116
+
117
+ export function render(list) {
118
+ if (!list.length) return '';
119
+ const lines = ['Invariants that constrain the code you are editing:'];
120
+ for (const inv of list) {
121
+ lines.push(` ${inv.id} ${inv.statement}`);
122
+ lines.push(` source: ${inv.source}${inv.line ? `:${inv.line}` : ''} (matched ${inv.matched})`);
123
+ }
124
+ lines.push('Breaking one of these requires changing its source document in the same change.');
125
+ return lines.join('\n');
126
+ }
@@ -0,0 +1,167 @@
1
+ import path from 'node:path';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { walk, exists, read, matchAny, matchGlob, toPosix } from './util.js';
4
+ import { Document } from './document.js';
5
+ import { TYPES } from './taxonomy.js';
6
+ import { classify } from './classify.js';
7
+
8
+ /** Repository signals DocGov reads to infer what documentation *should* exist (PRD §16). */
9
+ const STACK_PROBES = [
10
+ { id: 'kubernetes', files: ['k8s/**', 'kubernetes/**', '**/*.deployment.yaml', 'helm/**', 'charts/**'], expects: ['operations.deployment', 'operations.infrastructure'] },
11
+ { id: 'terraform', files: ['**/*.tf', 'terraform/**', 'infra/**/*.tf'], expects: ['operations.infrastructure', 'operations.disaster-recovery'] },
12
+ { id: 'docker', files: ['Dockerfile', '**/Dockerfile', 'docker-compose*.yml', 'compose.yaml'], expects: ['operations.deployment', 'engineering.development'] },
13
+ { id: 'openapi', files: ['openapi/**', '**/openapi.{yaml,yml,json}', '**/swagger.{yaml,yml,json}', 'api/**/*.{yaml,yml}'], expects: ['user.reference', 'architecture.integration'] },
14
+ { id: 'graphql', files: ['**/*.graphql', '**/schema.gql'], expects: ['user.reference'] },
15
+ { id: 'protobuf', files: ['**/*.proto'], expects: ['user.reference', 'architecture.integration'] },
16
+ { id: 'db-migrations', files: ['migrations/**', 'db/migrate/**', '**/migrations/*.sql', 'prisma/schema.prisma', 'alembic/**'], expects: ['architecture.data', 'release.migration'] },
17
+ { id: 'prometheus', files: ['**/prometheus*.y*ml', '**/grafana/**', '**/*dashboard*.json'], expects: ['operations.observability'] },
18
+ { id: 'ci', files: ['.github/workflows/**', '.gitlab-ci.yml', 'Jenkinsfile', '.circleci/**'], expects: ['operations.deployment', 'engineering.testing'] },
19
+ { id: 'tests', files: ['test/**', 'tests/**', '**/*.test.*', '**/*_test.go', '**/*_spec.rb', 'spec/**'], expects: ['engineering.testing'] },
20
+ { id: 'auth', files: ['**/auth/**', '**/authentication/**', '**/session*.{ts,js,py,go,rb}'], expects: ['security.architecture', 'security.authorization', 'security.threat-model'] },
21
+ { id: 'iac-secrets', files: ['**/*.env.example', '**/secrets*.y*ml', '**/vault/**'], expects: ['security.data-classification', 'operations.configuration'] },
22
+ { id: 'mobile', files: ['ios/**', 'android/**', '**/*.xcodeproj/**', 'pubspec.yaml'], expects: ['operations.deployment', 'user.getting-started'] },
23
+ { id: 'monorepo', files: ['pnpm-workspace.yaml', 'turbo.json', 'lerna.json', 'nx.json', 'Cargo.toml'], expects: ['engineering.conventions', 'architecture.overview'] },
24
+ ];
25
+
26
+ const MANIFESTS = ['package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml', 'requirements.txt',
27
+ 'Gemfile', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'composer.json', 'mix.exs', 'Package.swift'];
28
+
29
+ const AGENT_INSTRUCTION_FILES = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.cursorrules', '.windsurfrules',
30
+ '.claude/rules', '.github/copilot-instructions.md'];
31
+
32
+ const MD = /\.mdx?$/i;
33
+
34
+ /**
35
+ * Full repository sweep. One filesystem walk, everything derived from it.
36
+ * @param {string} root
37
+ * @param {object} cfg
38
+ */
39
+ export function inventory(root, cfg) {
40
+ const all = walk(root);
41
+ const include = cfg.documentation.include || ['**/*.md'];
42
+ const exclude = cfg.documentation.exclude || [];
43
+
44
+ const mdPaths = all.filter((p) => MD.test(p) && matchAny(p, include) && !matchAny(p, exclude));
45
+ const registrations = cfg.documentation?.registrations || {};
46
+ const documents = mdPaths.map((p) => new Document(root, p, undefined, registrations[p]));
47
+
48
+ const contracts = [];
49
+ for (const glob of (cfg.contracts?.openapi || [])) {
50
+ for (const p of all) if (matchGlob(p, glob)) contracts.push({ path: p, kind: 'openapi' });
51
+ }
52
+ for (const p of all) {
53
+ if (/\.proto$/.test(p)) contracts.push({ path: p, kind: 'protobuf' });
54
+ else if (/\.graphql$|\.gql$/.test(p)) contracts.push({ path: p, kind: 'graphql' });
55
+ else if (/(^|\/)schemas?\/.*\.json$/.test(p)) contracts.push({ path: p, kind: 'json-schema' });
56
+ else if (/prisma\/schema\.prisma$/.test(p)) contracts.push({ path: p, kind: 'prisma' });
57
+ }
58
+
59
+ const stack = [];
60
+ for (const probe of STACK_PROBES) {
61
+ const hits = all.filter((p) => matchAny(p, probe.files));
62
+ if (hits.length) stack.push({ id: probe.id, evidence: hits.slice(0, 4), count: hits.length, expects: probe.expects });
63
+ }
64
+
65
+ const manifests = MANIFESTS.filter((m) => exists(path.join(root, m)));
66
+ const agentInstructions = AGENT_INSTRUCTION_FILES.filter((f) => exists(path.join(root, f)));
67
+
68
+ const codePaths = all.filter((p) => /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|kt|swift|cs|php|ex|scala|c|cc|cpp|h|hpp)$/.test(p));
69
+
70
+ return {
71
+ root, all, documents, contracts, stack, manifests, agentInstructions, codePaths,
72
+ counts: {
73
+ files: all.length, documents: documents.length, contracts: contracts.length,
74
+ code: codePaths.length, markdownLines: documents.reduce((a, d) => a + d.lines, 0),
75
+ },
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Documentation the repository's stack implies but which no document covers (PRD §16).
81
+ *
82
+ * Uses the *inferred* class, not only the declared one: before `docgov review` runs nothing is
83
+ * annotated, and reporting an existing README as a missing README would make the first
84
+ * health score anyone ever sees wrong.
85
+ */
86
+ export function coverageGaps(inv) {
87
+ const present = new Set();
88
+ for (const d of inv.documents) {
89
+ if (d.type !== 'unknown') { present.add(d.type); continue; }
90
+ const c = classify(d);
91
+ if (c.type !== 'unknown' && !c.needsReview) present.add(c.type);
92
+ }
93
+ const gaps = [];
94
+ const seen = new Set();
95
+ for (const s of inv.stack) {
96
+ for (const type of s.expects) {
97
+ if (present.has(type) || seen.has(type)) continue;
98
+ seen.add(type);
99
+ gaps.push({ type, label: (TYPES[type] || {}).label || type, because: s.id, evidence: s.evidence[0] });
100
+ }
101
+ }
102
+ // Baseline documents every repository should have regardless of stack.
103
+ for (const type of ['user.readme', 'architecture.overview', 'engineering.development']) {
104
+ if (!present.has(type) && !seen.has(type)) {
105
+ seen.add(type);
106
+ gaps.push({ type, label: TYPES[type].label, because: 'baseline', evidence: null });
107
+ }
108
+ }
109
+ return gaps;
110
+ }
111
+
112
+ /** Detected third-party capabilities DocGov should delegate to (PRD §17). */
113
+ export function capabilities(root, pluginNames = []) {
114
+ const hasBin = (bin) => {
115
+ try { execFileSync('/bin/sh', ['-c', `command -v ${bin}`], { stdio: 'ignore' }); return true; }
116
+ catch { return false; }
117
+ };
118
+ return discoverCapabilities(root, hasBin, pluginNames);
119
+ }
120
+
121
+ /**
122
+ * Capability registry. Normalizes whatever is installed into capability names so
123
+ * skills ask "who can draw a diagram?" rather than "is mermaid-skill installed?".
124
+ * @param {string} root
125
+ * @param {(bin:string)=>boolean} hasBin
126
+ * @param {string[]} [pluginNames]
127
+ */
128
+ export function discoverCapabilities(root, hasBin, pluginNames = []) {
129
+ const caps = {};
130
+ const add = (cap, provider) => { (caps[cap] ||= []).push(provider); };
131
+
132
+ const bins = [
133
+ ['lychee', 'links.external'], ['markdownlint-cli2', 'markdown.style'], ['markdownlint', 'markdown.style'],
134
+ ['vale', 'prose.style'], ['spectral', 'openapi.lint'], ['redocly', 'openapi.lint'],
135
+ ['oasdiff', 'openapi.diff'], ['gh', 'github'], ['git', 'git'], ['jq', 'json'],
136
+ ['mmdc', 'diagram.mermaid'], ['plantuml', 'diagram.plantuml'], ['typedoc', 'reference.typescript'],
137
+ ['sphinx-build', 'reference.python'], ['cargo', 'reference.rust'], ['openapi-generator', 'reference.openapi'],
138
+ ];
139
+ for (const [bin, cap] of bins) if (hasBin(bin)) add(cap, `bin:${bin}`);
140
+
141
+ const configProbes = [
142
+ ['.vale.ini', 'prose.style', 'vale'], ['.markdownlint.json', 'markdown.style', 'markdownlint'],
143
+ ['.markdownlint-cli2.jsonc', 'markdown.style', 'markdownlint'],
144
+ ['.spectral.yaml', 'openapi.lint', 'spectral'], ['lychee.toml', 'links.external', 'lychee'],
145
+ ['mkdocs.yml', 'site.mkdocs', 'mkdocs'], ['docusaurus.config.js', 'site.docusaurus', 'docusaurus'],
146
+ ['docs/.vitepress/config.ts', 'site.vitepress', 'vitepress'], ['mint.json', 'site.mintlify', 'mintlify'],
147
+ ];
148
+ for (const [f, cap, provider] of configProbes) if (exists(path.join(root, f))) add(cap, `config:${provider}`);
149
+
150
+ // Claude Code surface: MCP servers and skills visible from the repo.
151
+ const mcpFile = path.join(root, '.mcp.json');
152
+ if (exists(mcpFile)) {
153
+ try {
154
+ const servers = Object.keys(JSON.parse(read(mcpFile)).mcpServers || {});
155
+ for (const s of servers) add('mcp', `mcp:${s}`);
156
+ } catch { /* malformed .mcp.json is the user's problem, not a DocGov failure */ }
157
+ }
158
+ for (const dir of [path.join(root, '.claude', 'skills'), path.join(root, '.claude', 'agents')]) {
159
+ if (!exists(dir)) continue;
160
+ for (const name of walk(dir, { maxDepth: 1, includeDirs: true })) {
161
+ if (!name.includes('/')) add(dir.endsWith('agents') ? 'agent' : 'skill', `local:${name}`);
162
+ }
163
+ }
164
+ for (const p of pluginNames) add('plugin', `plugin:${p}`);
165
+
166
+ return caps;
167
+ }
package/core/links.js ADDED
@@ -0,0 +1,80 @@
1
+ import path from 'node:path';
2
+ import { exists, toPosix } from './util.js';
3
+ import { resolveLink } from './graph.js';
4
+
5
+ /**
6
+ * Internal link integrity and repair.
7
+ *
8
+ * External link rot is lychee's job when lychee is installed (PRD §42). Internal
9
+ * links are DocGov's job because they *are* the graph.
10
+ */
11
+
12
+ /**
13
+ * @param {import('./document.js').Document[]} docs
14
+ * @param {string} root
15
+ * @param {Set<string>} [allFiles] repo-relative paths that exist
16
+ */
17
+ export function brokenLinks(docs, root, allFiles) {
18
+ const out = [];
19
+ for (const d of docs) {
20
+ for (const target of d.links().internal) {
21
+ if (target === '' || target.startsWith('mailto:')) continue;
22
+ const resolved = resolveLink(d.path, target);
23
+ const ok = allFiles ? allFiles.has(resolved) : exists(path.join(root, resolved));
24
+ if (!ok) out.push({ path: d.path, target, resolved });
25
+ }
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /** Anchors referenced within a document that have no matching heading. */
31
+ export function brokenAnchors(docs) {
32
+ const out = [];
33
+ for (const d of docs) {
34
+ const slugs = new Set(headingSlugs(d.body));
35
+ for (const a of d.links().anchors) {
36
+ const s = a.slice(1).toLowerCase();
37
+ if (s && !slugs.has(s)) out.push({ path: d.path, anchor: a });
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ export function headingSlugs(body) {
44
+ const out = [];
45
+ for (const m of body.matchAll(/^#{1,6}[ \t]+(.+?)[ \t]*$/gm)) {
46
+ out.push(m[1].toLowerCase().replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-'));
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /**
52
+ * Rewrite every internal link that pointed at a moved file.
53
+ * @param {string} source document text
54
+ * @param {string} fromDocPath the document's path BEFORE the move set applied
55
+ * @param {string} toDocPath the document's path AFTER
56
+ * @param {Map<string,string>} moves old repo-relative path -> new repo-relative path
57
+ */
58
+ export function rewriteLinks(source, fromDocPath, toDocPath, moves) {
59
+ return source.replace(/(\[(?:[^\]\\]|\\.)*\]\()([^)\s]+)((?:\s+"[^"]*")?\))/g, (full, open, target, close) => {
60
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//') || target.startsWith('#')) return full;
61
+ const [file, hash] = target.split(/(?=#)/);
62
+ const absOld = resolveLink(fromDocPath, file);
63
+ const absNew = moves.get(absOld) || absOld;
64
+ const rel = path.posix.relative(path.posix.dirname(toDocPath), absNew) || path.posix.basename(absNew);
65
+ const out = rel.startsWith('.') ? rel : (absNew.includes('/') || toDocPath.includes('/') ? rel : rel);
66
+ return `${open}${toPosix(out)}${hash || ''}${close}`;
67
+ });
68
+ }
69
+
70
+ /** Count of inbound internal links per document, for discoverability scoring. */
71
+ export function inboundCounts(docs) {
72
+ const counts = new Map(docs.map((d) => [d.path, 0]));
73
+ for (const d of docs) {
74
+ for (const t of d.links().internal) {
75
+ const r = resolveLink(d.path, t);
76
+ if (counts.has(r)) counts.set(r, counts.get(r) + 1);
77
+ }
78
+ }
79
+ return counts;
80
+ }
@@ -0,0 +1,158 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import * as git from './git.js';
4
+ import * as fm from './frontmatter.js';
5
+ import { rewriteLinks } from './links.js';
6
+ import { frontmatterFor } from './templates.js';
7
+ import { read, write, exists, DocGovError, EXIT } from './util.js';
8
+ import { classify } from './classify.js';
9
+ import { PLAN_DATA_PATH } from './onboard.js';
10
+
11
+ /**
12
+ * Transactional migration (PRD §41).
13
+ *
14
+ * Two guarantees, both enforced here rather than promised in prose:
15
+ * 1. nothing runs unless git can revert it;
16
+ * 2. every internal link is repaired in the same operation as the move, so the
17
+ * repository is never left in a half-migrated state.
18
+ *
19
+ * Only mechanical actions run. SPLIT, MERGE and EXTRACT rewrite prose, so they
20
+ * belong to an agent with a human in the loop — not to a file mover.
21
+ */
22
+
23
+ const MECHANICAL = new Set(['MOVE', 'ANNOTATE', 'ARCHIVE', 'CREATE']);
24
+
25
+ /**
26
+ * @param {{root:string, cfg:object, docs:any[], planData:object, dryRun?:boolean,
27
+ * include?:string[], branch?:string|null, useGit?:boolean}} args
28
+ */
29
+ export function migrate({ root, cfg, docs, planData, dryRun = false, include = [], branch = null, useGit = true }) {
30
+ const kinds = new Set([...MECHANICAL, ...include.map((k) => k.toUpperCase())]);
31
+ const actions = planData.actions.filter((a) => kinds.has(a.kind));
32
+ const ops = [];
33
+
34
+ if (useGit && !dryRun) {
35
+ if (!git.isRepo(root)) throw new DocGovError(
36
+ 'migrate needs a git repository — without it a migration is not revertible. Run `git init` first, or pass --no-git to accept that risk.',
37
+ EXIT.CONFIG);
38
+ if (!git.isClean(root)) throw new DocGovError(
39
+ ['the working tree has uncommitted changes, so a migration would not be separable from them:',
40
+ ...git.dirtyPaths(root).slice(0, 10).map((p) => ` ${p}`),
41
+ 'Commit or stash these first — `git add -A && git commit -m "chore: adopt DocGov"` is usually what is wanted.',
42
+ ].join('\n'),
43
+ EXIT.CONFIG);
44
+ }
45
+
46
+ // ---- plan the file operations
47
+ const moves = new Map(); // old rel -> new rel
48
+ for (const a of actions) {
49
+ if ((a.kind === 'MOVE' || a.kind === 'ARCHIVE') && a.to && a.to !== a.path) moves.set(a.path, a.to);
50
+ }
51
+ // A destination collision would silently destroy a document. Refuse instead.
52
+ const dests = new Map();
53
+ for (const [from, to] of moves) {
54
+ if (dests.has(to)) throw new DocGovError(
55
+ `migration would put two documents at ${to} (${dests.get(to)} and ${from}). Edit ${PLAN_DATA_PATH} to disambiguate.`);
56
+ if (exists(path.join(root, to)) && !moves.has(to)) throw new DocGovError(
57
+ `migration destination ${to} already exists and is not itself being moved.`);
58
+ dests.set(to, from);
59
+ }
60
+
61
+ const annotate = new Map();
62
+ for (const a of actions) if (a.kind === 'ANNOTATE') annotate.set(a.path, a);
63
+
64
+ const byPath = new Map(docs.map((d) => [d.path, d]));
65
+
66
+ // ---- rewrite every document's content once: links + frontmatter together
67
+ for (const d of docs) {
68
+ const finalPath = moves.get(d.path) || d.path;
69
+ let content = d.source;
70
+
71
+ const linked = rewriteLinks(content, d.path, finalPath, moves);
72
+ const linksChanged = linked !== content;
73
+ content = linked;
74
+
75
+ let metaChanged = false;
76
+ const ann = annotate.get(d.path);
77
+ if (ann) {
78
+ const c = classify(d);
79
+ const type = ann.type && ann.type !== 'unknown' ? ann.type : c.type;
80
+ if (type !== 'unknown') {
81
+ const meta = frontmatterFor({
82
+ type, id: d.meta.id || suggestId(finalPath), title: d.title, cfg,
83
+ domain: d.domain, visibility: d.meta.visibility, owner: d.owner || ann.owner,
84
+ });
85
+ content = fm.patchDocgov(content, meta);
86
+ metaChanged = true;
87
+ }
88
+ }
89
+
90
+ if (finalPath !== d.path) {
91
+ ops.push({ op: 'move', from: d.path, to: finalPath, rewroteLinks: linksChanged, annotated: metaChanged });
92
+ } else if (linksChanged || metaChanged) {
93
+ ops.push({ op: 'edit', path: d.path, rewroteLinks: linksChanged, annotated: metaChanged });
94
+ }
95
+
96
+ if (!dryRun) {
97
+ if (finalPath !== d.path) {
98
+ if (useGit && git.isRepo(root)) git.move(root, d.path, finalPath);
99
+ else {
100
+ fs.mkdirSync(path.dirname(path.join(root, finalPath)), { recursive: true });
101
+ fs.renameSync(path.join(root, d.path), path.join(root, finalPath));
102
+ }
103
+ }
104
+ if (content !== d.source) write(path.join(root, finalPath), content);
105
+ }
106
+ }
107
+
108
+ // ---- namespace directories for the chosen layout, so empty namespaces exist on purpose
109
+ for (const a of actions.filter((x) => x.kind === 'CREATE')) {
110
+ const dir = a.to.endsWith('/') ? a.to : path.dirname(a.to);
111
+ ops.push({ op: 'mkdir', path: dir, reason: a.reason, type: a.type });
112
+ if (!dryRun) fs.mkdirSync(path.join(root, dir), { recursive: true });
113
+ }
114
+
115
+ const deferred = planData.actions.filter((a) => !kinds.has(a.kind));
116
+
117
+ return {
118
+ dryRun, ops, deferred,
119
+ moved: ops.filter((o) => o.op === 'move').length,
120
+ edited: ops.filter((o) => o.op === 'edit').length,
121
+ linksRepaired: ops.filter((o) => o.rewroteLinks).length,
122
+ annotated: ops.filter((o) => o.annotated).length,
123
+ branch: branch || null,
124
+ };
125
+ }
126
+
127
+ function suggestId(relPath) {
128
+ return relPath.replace(/\.mdx?$/, '').replace(/^(docs|documentation)\//, '')
129
+ .replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '').toLowerCase().slice(0, 60);
130
+ }
131
+
132
+ /** Verify a migration left the repository coherent. Run immediately after. */
133
+ export function verify({ root, docs, inv }) {
134
+ const problems = [];
135
+ const files = new Set(inv.all);
136
+ for (const d of docs) {
137
+ for (const t of d.links().internal) {
138
+ const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(d.path), t));
139
+ if (!files.has(resolved) && !exists(path.join(root, resolved))) {
140
+ problems.push({ kind: 'broken-link', path: d.path, target: t });
141
+ }
142
+ }
143
+ }
144
+ const ids = new Map();
145
+ for (const d of docs) {
146
+ if (!d.meta.id) continue;
147
+ if (ids.has(d.meta.id)) problems.push({ kind: 'duplicate-id', path: d.path, other: ids.get(d.meta.id), id: d.meta.id });
148
+ else ids.set(d.meta.id, d.path);
149
+ }
150
+ return problems;
151
+ }
152
+
153
+ /** Abort: undo a migration that has not been committed. */
154
+ export function abort(root) {
155
+ if (!git.isRepo(root)) throw new DocGovError('cannot abort without git');
156
+ git.resetHard(root, 'HEAD');
157
+ return true;
158
+ }