@mfittko/repo-wiki 0.2.1

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 (190) hide show
  1. package/.llmwiki/schema.md +107 -0
  2. package/AGENTS.md +42 -0
  3. package/CHANGELOG.md +91 -0
  4. package/LICENSE +21 -0
  5. package/README.md +254 -0
  6. package/dist/bin/repo-wiki.d.ts +2 -0
  7. package/dist/bin/repo-wiki.js +7 -0
  8. package/dist/bin/repo-wiki.js.map +1 -0
  9. package/dist/src/cli.d.ts +1 -0
  10. package/dist/src/cli.js +404 -0
  11. package/dist/src/cli.js.map +1 -0
  12. package/dist/src/compiler.d.ts +55 -0
  13. package/dist/src/compiler.js +2046 -0
  14. package/dist/src/compiler.js.map +1 -0
  15. package/dist/src/config.d.ts +63 -0
  16. package/dist/src/config.js +86 -0
  17. package/dist/src/config.js.map +1 -0
  18. package/dist/src/context-assembler.d.ts +68 -0
  19. package/dist/src/context-assembler.js +378 -0
  20. package/dist/src/context-assembler.js.map +1 -0
  21. package/dist/src/data-model-signals.d.ts +1 -0
  22. package/dist/src/data-model-signals.js +13 -0
  23. package/dist/src/data-model-signals.js.map +1 -0
  24. package/dist/src/docs-ingestor.d.ts +138 -0
  25. package/dist/src/docs-ingestor.js +844 -0
  26. package/dist/src/docs-ingestor.js.map +1 -0
  27. package/dist/src/docs-linter.d.ts +14 -0
  28. package/dist/src/docs-linter.js +164 -0
  29. package/dist/src/docs-linter.js.map +1 -0
  30. package/dist/src/docs-validation.d.ts +36 -0
  31. package/dist/src/docs-validation.js +297 -0
  32. package/dist/src/docs-validation.js.map +1 -0
  33. package/dist/src/extractors.d.ts +50 -0
  34. package/dist/src/extractors.js +2275 -0
  35. package/dist/src/extractors.js.map +1 -0
  36. package/dist/src/frontmatter.d.ts +46 -0
  37. package/dist/src/frontmatter.js +377 -0
  38. package/dist/src/frontmatter.js.map +1 -0
  39. package/dist/src/index.d.ts +26 -0
  40. package/dist/src/index.js +18 -0
  41. package/dist/src/index.js.map +1 -0
  42. package/dist/src/init.d.ts +12 -0
  43. package/dist/src/init.js +121 -0
  44. package/dist/src/init.js.map +1 -0
  45. package/dist/src/language.d.ts +2 -0
  46. package/dist/src/language.js +62 -0
  47. package/dist/src/language.js.map +1 -0
  48. package/dist/src/linter.d.ts +33 -0
  49. package/dist/src/linter.js +398 -0
  50. package/dist/src/linter.js.map +1 -0
  51. package/dist/src/llm-provider.d.ts +267 -0
  52. package/dist/src/llm-provider.js +474 -0
  53. package/dist/src/llm-provider.js.map +1 -0
  54. package/dist/src/page-ownership.d.ts +38 -0
  55. package/dist/src/page-ownership.js +96 -0
  56. package/dist/src/page-ownership.js.map +1 -0
  57. package/dist/src/planner.d.ts +55 -0
  58. package/dist/src/planner.js +422 -0
  59. package/dist/src/planner.js.map +1 -0
  60. package/dist/src/prompts.d.ts +103 -0
  61. package/dist/src/prompts.js +344 -0
  62. package/dist/src/prompts.js.map +1 -0
  63. package/dist/src/publisher.d.ts +68 -0
  64. package/dist/src/publisher.js +662 -0
  65. package/dist/src/publisher.js.map +1 -0
  66. package/dist/src/repository-analysis.d.ts +88 -0
  67. package/dist/src/repository-analysis.js +485 -0
  68. package/dist/src/repository-analysis.js.map +1 -0
  69. package/dist/src/scanner.d.ts +122 -0
  70. package/dist/src/scanner.js +309 -0
  71. package/dist/src/scanner.js.map +1 -0
  72. package/dist/src/search.d.ts +71 -0
  73. package/dist/src/search.js +410 -0
  74. package/dist/src/search.js.map +1 -0
  75. package/dist/src/secret-patterns.d.ts +3 -0
  76. package/dist/src/secret-patterns.js +14 -0
  77. package/dist/src/secret-patterns.js.map +1 -0
  78. package/dist/src/utils/args.d.ts +2 -0
  79. package/dist/src/utils/args.js +19 -0
  80. package/dist/src/utils/args.js.map +1 -0
  81. package/dist/src/utils/dotenv.d.ts +7 -0
  82. package/dist/src/utils/dotenv.js +73 -0
  83. package/dist/src/utils/dotenv.js.map +1 -0
  84. package/dist/src/utils/fs.d.ts +22 -0
  85. package/dist/src/utils/fs.js +83 -0
  86. package/dist/src/utils/fs.js.map +1 -0
  87. package/dist/src/utils/git.d.ts +13 -0
  88. package/dist/src/utils/git.js +39 -0
  89. package/dist/src/utils/git.js.map +1 -0
  90. package/dist/src/wiki-graph.d.ts +74 -0
  91. package/dist/src/wiki-graph.js +335 -0
  92. package/dist/src/wiki-graph.js.map +1 -0
  93. package/dist/src/wiki-patch.d.ts +152 -0
  94. package/dist/src/wiki-patch.js +489 -0
  95. package/dist/src/wiki-patch.js.map +1 -0
  96. package/dist/src/wiki-query.d.ts +63 -0
  97. package/dist/src/wiki-query.js +255 -0
  98. package/dist/src/wiki-query.js.map +1 -0
  99. package/dist/test/cli.test.d.ts +1 -0
  100. package/dist/test/cli.test.js +514 -0
  101. package/dist/test/cli.test.js.map +1 -0
  102. package/dist/test/compiler-eval.test.d.ts +1 -0
  103. package/dist/test/compiler-eval.test.js +234 -0
  104. package/dist/test/compiler-eval.test.js.map +1 -0
  105. package/dist/test/compiler.test.d.ts +1 -0
  106. package/dist/test/compiler.test.js +2537 -0
  107. package/dist/test/compiler.test.js.map +1 -0
  108. package/dist/test/context-assembler.test.d.ts +1 -0
  109. package/dist/test/context-assembler.test.js +379 -0
  110. package/dist/test/context-assembler.test.js.map +1 -0
  111. package/dist/test/docs-linter.test.d.ts +1 -0
  112. package/dist/test/docs-linter.test.js +900 -0
  113. package/dist/test/docs-linter.test.js.map +1 -0
  114. package/dist/test/dotenv.test.d.ts +1 -0
  115. package/dist/test/dotenv.test.js +77 -0
  116. package/dist/test/dotenv.test.js.map +1 -0
  117. package/dist/test/extractors-go.test.d.ts +1 -0
  118. package/dist/test/extractors-go.test.js +393 -0
  119. package/dist/test/extractors-go.test.js.map +1 -0
  120. package/dist/test/extractors-rust.test.d.ts +1 -0
  121. package/dist/test/extractors-rust.test.js +219 -0
  122. package/dist/test/extractors-rust.test.js.map +1 -0
  123. package/dist/test/extractors-utils.test.d.ts +1 -0
  124. package/dist/test/extractors-utils.test.js +786 -0
  125. package/dist/test/extractors-utils.test.js.map +1 -0
  126. package/dist/test/fixtures/compiler-e2e/basic-node-service/repo/infra/deploy.d.ts +1 -0
  127. package/dist/test/fixtures/compiler-e2e/basic-node-service/repo/infra/deploy.js +4 -0
  128. package/dist/test/fixtures/compiler-e2e/basic-node-service/repo/infra/deploy.js.map +1 -0
  129. package/dist/test/frontmatter.test.d.ts +1 -0
  130. package/dist/test/frontmatter.test.js +287 -0
  131. package/dist/test/frontmatter.test.js.map +1 -0
  132. package/dist/test/init-planner.test.d.ts +1 -0
  133. package/dist/test/init-planner.test.js +688 -0
  134. package/dist/test/init-planner.test.js.map +1 -0
  135. package/dist/test/linter.test.d.ts +1 -0
  136. package/dist/test/linter.test.js +426 -0
  137. package/dist/test/linter.test.js.map +1 -0
  138. package/dist/test/llm-provider.test.d.ts +1 -0
  139. package/dist/test/llm-provider.test.js +783 -0
  140. package/dist/test/llm-provider.test.js.map +1 -0
  141. package/dist/test/page-ownership.test.d.ts +1 -0
  142. package/dist/test/page-ownership.test.js +247 -0
  143. package/dist/test/page-ownership.test.js.map +1 -0
  144. package/dist/test/publisher.test.d.ts +1 -0
  145. package/dist/test/publisher.test.js +1297 -0
  146. package/dist/test/publisher.test.js.map +1 -0
  147. package/dist/test/repository-analysis.test.d.ts +1 -0
  148. package/dist/test/repository-analysis.test.js +182 -0
  149. package/dist/test/repository-analysis.test.js.map +1 -0
  150. package/dist/test/run-compiled-tests.d.ts +1 -0
  151. package/dist/test/run-compiled-tests.js +48 -0
  152. package/dist/test/run-compiled-tests.js.map +1 -0
  153. package/dist/test/scanner.test.d.ts +1 -0
  154. package/dist/test/scanner.test.js +551 -0
  155. package/dist/test/scanner.test.js.map +1 -0
  156. package/dist/test/search.test.d.ts +1 -0
  157. package/dist/test/search.test.js +92 -0
  158. package/dist/test/search.test.js.map +1 -0
  159. package/dist/test/update-changelog.test.d.ts +1 -0
  160. package/dist/test/update-changelog.test.js +125 -0
  161. package/dist/test/update-changelog.test.js.map +1 -0
  162. package/dist/test/wiki-graph.test.d.ts +1 -0
  163. package/dist/test/wiki-graph.test.js +164 -0
  164. package/dist/test/wiki-graph.test.js.map +1 -0
  165. package/dist/test/wiki-patch.test.d.ts +1 -0
  166. package/dist/test/wiki-patch.test.js +610 -0
  167. package/dist/test/wiki-patch.test.js.map +1 -0
  168. package/dist/test/wiki-query.test.d.ts +1 -0
  169. package/dist/test/wiki-query.test.js +163 -0
  170. package/dist/test/wiki-query.test.js.map +1 -0
  171. package/docs/PLAN.md +993 -0
  172. package/docs/WHY.md +61 -0
  173. package/docs/plans/agent-integration.md +85 -0
  174. package/docs/plans/ci-publishing.md +111 -0
  175. package/docs/plans/doc-validation.md +92 -0
  176. package/docs/plans/github-action.md +113 -0
  177. package/docs/plans/incremental-mode.md +98 -0
  178. package/docs/plans/karpathy-llm-wiki-alignment.md +84 -0
  179. package/docs/plans/llm-compiler.md +160 -0
  180. package/docs/plans/production-scanner.md +104 -0
  181. package/docs/plans/query-and-file-back.md +103 -0
  182. package/docs/plans/search-index.md +118 -0
  183. package/docs/plans/trust-hardening.md +74 -0
  184. package/docs/plans/wiki-graph.md +183 -0
  185. package/docs/plans/wiki-health.md +76 -0
  186. package/package.json +83 -0
  187. package/prompts/compiler.md +16 -0
  188. package/prompts/lint.md +18 -0
  189. package/prompts/page-templates.md +25 -0
  190. package/skills/repo-wiki-cli/SKILL.md +139 -0
@@ -0,0 +1,900 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, writeFile, mkdir, rm, readFile, utimes } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { scanRepository } from '../src/scanner.js';
7
+ import { lintDocs } from '../src/docs-linter.js';
8
+ import { classifyDocumentedCommands, extractCiCommandSources, extractCiCommands, extractDocumentedFilePaths, extractJustfileTargetSources, extractRouteClaims, extractTaskfileTargetSources } from '../src/docs-ingestor.js';
9
+ import { compileWiki } from '../src/compiler.js';
10
+ import { candidateRepoPaths, normalizeRoutePath } from '../src/docs-validation.js';
11
+ test('documentation ingestion produces documentation cards and lint issues', async () => {
12
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-docs-'));
13
+ try {
14
+ await mkdir(path.join(dir, 'docs'), { recursive: true });
15
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
16
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
17
+ documentation: {
18
+ ingest: true,
19
+ include: ['README.md', 'docs/**/*.md'],
20
+ exclude: [],
21
+ stale_after_days: 9999
22
+ }
23
+ }), 'utf8');
24
+ await writeFile(path.join(dir, 'README.md'), '# Demo\n\nRun npm test with MY_API_TOKEN. See [old docs](docs/old.md).\n\n```bash\nnpm test\n```\n', 'utf8');
25
+ await writeFile(path.join(dir, 'docs', 'old.md'), '# Old\n\nThis is deprecated and should be reviewed.\n', 'utf8');
26
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { test: 'node --test' } }), 'utf8');
27
+ const scanDir = path.join(dir, '.llmwiki', 'run');
28
+ const scan = await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
29
+ assert.equal(scan.manifest.documentation.files.length, 2);
30
+ const readmeCard = scan.manifest.documentation.files.find((doc) => doc.path === 'README.md');
31
+ assert.ok(readmeCard.validation.env_vars.includes('MY_API_TOKEN'));
32
+ assert.ok(readmeCard.links.includes('docs/old.md'));
33
+ assert.ok(readmeCard.file_paths.some((reference) => reference.path === 'docs/old.md'));
34
+ const lint = await lintDocs({ scanDir, repoPath: dir });
35
+ assert.ok(lint.summary.warnings + lint.summary.errors >= 1);
36
+ }
37
+ finally {
38
+ await rm(dir, { recursive: true, force: true });
39
+ }
40
+ });
41
+ test('extractDocumentedFilePaths extracts deterministic markdown link and inline code path references', () => {
42
+ const refs = extractDocumentedFilePaths('# Paths\n\nSee [plan](docs/PLAN.md), [titled](docs/TITLE.md "Title"), [paren](docs/guide(arch).md), [angle](<docs/another(arch).md>), `src/cli.ts`, `../README.md`, `dist/`, `1..2`, and `npm run build`.\n\n```bash\ncat missing.md\n```\n\n~~~bash\ncat also-missing.md\n~~~\n');
43
+ assert.deepEqual(refs, [
44
+ { path: 'docs/PLAN.md', line: 3, source: 'link' },
45
+ { path: 'docs/TITLE.md', line: 3, source: 'link' },
46
+ { path: 'docs/guide(arch).md', line: 3, source: 'link' },
47
+ { path: 'docs/another(arch).md', line: 3, source: 'link' },
48
+ { path: 'src/cli.ts', line: 3, source: 'inline_code' },
49
+ { path: '../README.md', line: 3, source: 'inline_code' }
50
+ ]);
51
+ });
52
+ test('markdown links with parentheses are ingested without truncated fallback links', async () => {
53
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-link-parens-'));
54
+ try {
55
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
56
+ await mkdir(path.join(dir, 'docs'), { recursive: true });
57
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
58
+ documentation: {
59
+ ingest: true,
60
+ include: ['README.md', 'docs/**/*.md'],
61
+ exclude: [],
62
+ stale_after_days: 9999
63
+ }
64
+ }), 'utf8');
65
+ await writeFile(path.join(dir, 'README.md'), '# Demo\n\nSee [guide](docs/guide(arch).md).\n', 'utf8');
66
+ await writeFile(path.join(dir, 'docs', 'guide(arch).md'), '# Guide\n', 'utf8');
67
+ const scanDir = path.join(dir, '.llmwiki', 'run');
68
+ const scan = await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
69
+ const readmeCard = scan.manifest.documentation.files.find((doc) => doc.path === 'README.md');
70
+ assert.ok(readmeCard.links.includes('docs/guide(arch).md'));
71
+ assert.ok(!readmeCard.links.includes('docs/guide(arch'));
72
+ const lint = await lintDocs({ scanDir, repoPath: dir });
73
+ assert.equal(lint.issues.filter((i) => i.code === 'broken-documentation-link').length, 0);
74
+ assert.equal(lint.issues.filter((i) => i.code === 'broken-documented-file-path').length, 0);
75
+ }
76
+ finally {
77
+ await rm(dir, { recursive: true, force: true });
78
+ }
79
+ });
80
+ test('candidateRepoPaths normalizes Windows separators before resolving relative paths', () => {
81
+ assert.deepEqual(candidateRepoPaths('..\\README.md', 'docs/guides/intro.md'), ['../README.md', 'docs/README.md']);
82
+ });
83
+ test('extractRouteClaims captures prose, lists, tables, and fenced route mentions including ALL', () => {
84
+ const claims = extractRouteClaims([
85
+ '# Routes',
86
+ '',
87
+ 'The API serves GET /health and POST /users endpoints.',
88
+ '- ALL /maintenance',
89
+ '| Method | Path |',
90
+ '| --- | --- |',
91
+ '| GET | /table-health |',
92
+ '```http',
93
+ 'POST /from-fence',
94
+ '```'
95
+ ].join('\n'));
96
+ assert.deepEqual(claims, [
97
+ { line: 3, text: 'The API serves GET /health and POST /users endpoints.', snippet: 'The API serves GET /health and POST /users endpoints.', path: '/health', method: 'GET' },
98
+ { line: 3, text: 'The API serves GET /health and POST /users endpoints.', snippet: 'The API serves GET /health and POST /users endpoints.', path: '/users', method: 'POST' },
99
+ { line: 4, text: '- ALL /maintenance', snippet: '- ALL /maintenance', path: '/maintenance', method: 'ALL' },
100
+ { line: 7, text: '| GET | /table-health |', snippet: '| GET | /table-health |', path: '/table-health', method: 'GET' },
101
+ { line: 9, text: 'POST /from-fence', snippet: 'POST /from-fence', path: '/from-fence', method: 'POST' }
102
+ ]);
103
+ });
104
+ test('extractRouteClaims normalizes punctuation, query/fragment suffixes, and duplicate slashes', () => {
105
+ const claims = extractRouteClaims([
106
+ 'Use GET /health.',
107
+ 'Use GET /api/users?active=true',
108
+ 'Use GET /api/users#list',
109
+ 'Use GET /api//users',
110
+ 'Use GET /api/users, POST /api/items.'
111
+ ].join('\n'));
112
+ assert.deepEqual(claims, [
113
+ { line: 1, text: 'Use GET /health.', snippet: 'Use GET /health.', path: '/health', method: 'GET' },
114
+ { line: 2, text: 'Use GET /api/users?active=true', snippet: 'Use GET /api/users?active=true', path: '/api/users', method: 'GET' },
115
+ { line: 3, text: 'Use GET /api/users#list', snippet: 'Use GET /api/users#list', path: '/api/users', method: 'GET' },
116
+ { line: 4, text: 'Use GET /api//users', snippet: 'Use GET /api//users', path: '/api/users', method: 'GET' },
117
+ { line: 5, text: 'Use GET /api/users, POST /api/items.', snippet: 'Use GET /api/users, POST /api/items.', path: '/api/users', method: 'GET' },
118
+ { line: 5, text: 'Use GET /api/users, POST /api/items.', snippet: 'Use GET /api/users, POST /api/items.', path: '/api/items', method: 'POST' }
119
+ ]);
120
+ });
121
+ test('extractRouteClaims rejects degenerate slash-only route claims', () => {
122
+ const claims = extractRouteClaims([
123
+ 'Use GET /, GET //, POST ////, and GET /health.',
124
+ '| Method | Path |',
125
+ '| --- | --- |',
126
+ '| GET | // |'
127
+ ].join('\n'));
128
+ assert.deepEqual(claims, [
129
+ { line: 1, text: 'Use GET /, GET //, POST ////, and GET /health.', snippet: 'Use GET /, GET //, POST ////, and GET /health.', path: '/', method: 'GET' },
130
+ { line: 1, text: 'Use GET /, GET //, POST ////, and GET /health.', snippet: 'Use GET /, GET //, POST ////, and GET /health.', path: '/health', method: 'GET' }
131
+ ]);
132
+ });
133
+ test('normalizeRoutePath aligns scanner and documented route path variants', () => {
134
+ assert.equal(normalizeRoutePath('`/api/users`.'), '/api/users');
135
+ assert.equal(normalizeRoutePath('/api/users?active=true'), '/api/users');
136
+ assert.equal(normalizeRoutePath('/api/users#list'), '/api/users');
137
+ assert.equal(normalizeRoutePath('/api//users'), '/api/users');
138
+ assert.equal(normalizeRoutePath('/api/users/'), '/api/users');
139
+ });
140
+ test('classifyDocumentedCommands validates known package scripts, flags missing scripts, and marks unknowns', () => {
141
+ const packageScripts = { test: 'node --test', build: 'tsc', lint: 'eslint .' };
142
+ // Known npm run script → validated
143
+ const knownRun = classifyDocumentedCommands(['npm run build'], packageScripts, []);
144
+ assert.equal(knownRun.length, 1);
145
+ assert.equal(knownRun[0].status, 'validated');
146
+ assert.equal(knownRun[0].source, 'package_scripts');
147
+ assert.equal(knownRun[0].script_name, 'build');
148
+ // Missing npm run script → missing
149
+ const missingRun = classifyDocumentedCommands(['npm run deploy'], packageScripts, []);
150
+ assert.equal(missingRun.length, 1);
151
+ assert.equal(missingRun[0].status, 'missing');
152
+ assert.equal(missingRun[0].source, 'package_scripts');
153
+ assert.equal(missingRun[0].script_name, 'deploy');
154
+ // npm run options and quoted script names should not be treated as script names
155
+ const optionRun = classifyDocumentedCommands(['npm run --silent "build"'], packageScripts, []);
156
+ assert.equal(optionRun[0].status, 'validated');
157
+ assert.equal(optionRun[0].script_name, 'build');
158
+ // npm test lifecycle → validated when test script exists
159
+ const npmTest = classifyDocumentedCommands(['npm test'], packageScripts, []);
160
+ assert.equal(npmTest[0].status, 'validated');
161
+ assert.equal(npmTest[0].script_name, 'test');
162
+ // Chained commands are classified independently so later missing scripts are not hidden
163
+ const chained = classifyDocumentedCommands(['npm run build && npm run deploy'], packageScripts, []);
164
+ assert.equal(chained.length, 2);
165
+ assert.equal(chained[0].script_name, 'build');
166
+ assert.equal(chained[0].status, 'validated');
167
+ assert.equal(chained[1].script_name, 'deploy');
168
+ assert.equal(chained[1].status, 'missing');
169
+ // Unrecognised command without CI match → unvalidated
170
+ const unknown = classifyDocumentedCommands(['docker compose up'], packageScripts, []);
171
+ assert.equal(unknown[0].status, 'unvalidated');
172
+ assert.equal(unknown[0].source, 'unknown');
173
+ // Workspace selectors are conservatively unvalidated unless an exact CI match exists.
174
+ const workspace = classifyDocumentedCommands(['npm --workspace packages/app run build'], {}, []);
175
+ assert.equal(workspace[0].status, 'unvalidated');
176
+ assert.equal(workspace[0].source, 'unknown');
177
+ const makeKnown = classifyDocumentedCommands(['make build'], packageScripts, [], { makeTargets: ['build'] });
178
+ assert.equal(makeKnown[0].status, 'validated');
179
+ assert.equal(makeKnown[0].source, 'makefile');
180
+ assert.equal(makeKnown[0].target_name, 'build');
181
+ const makeMissing = classifyDocumentedCommands(['make deploy'], packageScripts, [], { makeTargets: ['build'] });
182
+ assert.equal(makeMissing[0].status, 'missing');
183
+ assert.equal(makeMissing[0].source, 'makefile');
184
+ assert.equal(makeMissing[0].target_name, 'deploy');
185
+ const taskKnown = classifyDocumentedCommands(['just build'], packageScripts, [], {
186
+ taskRunnerTargetsByRunner: { just: ['build'] }
187
+ });
188
+ assert.equal(taskKnown[0].status, 'validated');
189
+ assert.equal(taskKnown[0].source, 'task_runner');
190
+ assert.equal(taskKnown[0].target_name, 'build');
191
+ const taskfileKnown = classifyDocumentedCommands(['task build'], packageScripts, [], {
192
+ taskRunnerTargetsByRunner: { taskfile: ['build'] }
193
+ });
194
+ assert.equal(taskfileKnown[0].status, 'validated');
195
+ assert.equal(taskfileKnown[0].source, 'task_runner');
196
+ assert.equal(taskfileKnown[0].target_name, 'build');
197
+ const crossRunnerMissing = classifyDocumentedCommands(['task build', 'just release'], packageScripts, [], {
198
+ taskRunnerTargetsByRunner: { just: ['build'], taskfile: ['release'] }
199
+ });
200
+ assert.equal(crossRunnerMissing[0].status, 'missing');
201
+ assert.equal(crossRunnerMissing[1].status, 'missing');
202
+ // Separators inside quotes are not split into fake commands.
203
+ const quoted = classifyDocumentedCommands(['npm run "build;prod" && npm run missing'], { 'build;prod': 'tsc' }, []);
204
+ assert.equal(quoted.length, 2);
205
+ assert.equal(quoted[0].script_name, 'build;prod');
206
+ assert.equal(quoted[0].status, 'validated');
207
+ assert.equal(quoted[1].script_name, 'missing');
208
+ assert.equal(quoted[1].status, 'missing');
209
+ });
210
+ test('task-runner target extractors accept underscore-prefixed names', () => {
211
+ assert.deepEqual(extractJustfileTargetSources('_docs:\n @echo docs\n'), [
212
+ { target: '_docs', runner: 'just', line: 1 }
213
+ ]);
214
+ assert.deepEqual(extractTaskfileTargetSources('version: "3"\ntasks:\n _publish:\n cmds:\n - echo publish\n'), [
215
+ { target: '_publish', runner: 'taskfile', line: 3 }
216
+ ]);
217
+ });
218
+ test('classifyDocumentedCommands validates CI workflow commands', () => {
219
+ const packageScripts = {};
220
+ const ciCommands = ['npm run check', 'npm run coverage'];
221
+ const ciValidated = classifyDocumentedCommands(['npm run check'], packageScripts, ciCommands);
222
+ assert.equal(ciValidated[0].status, 'validated');
223
+ assert.equal(ciValidated[0].source, 'ci_workflow');
224
+ // A non-npm command that appears verbatim in CI is validated against CI
225
+ const dockerCmd = classifyDocumentedCommands(['docker build .'], packageScripts, ['docker build .']);
226
+ assert.equal(dockerCmd[0].status, 'validated');
227
+ assert.equal(dockerCmd[0].source, 'ci_workflow');
228
+ });
229
+ test('extractCiCommands parses run: and command: fields from workflow YAML', () => {
230
+ const yaml = `
231
+ jobs:
232
+ test:
233
+ steps:
234
+ - run: npm ci
235
+ - name: Lint
236
+ run: npm run lint:code
237
+ - name: Scripted
238
+ run: bash scripts/check.sh || python tools/check.py; ./local-check
239
+ - name: Templated echo
240
+ run: echo \${{ matrix.foo }}
241
+ - name: Matrix step
242
+ run: \${{ matrix.task.command }}
243
+ - name: Block script
244
+ run: |-
245
+ if [ -f package.json ]; then
246
+ npm run block
247
+ else
248
+ bash scripts/block.sh && python tools/block.py
249
+ fi
250
+ echo \${{ matrix.skip }}
251
+ - name: Folded script
252
+ run: >
253
+ ./folded-check
254
+ matrix:
255
+ task:
256
+ - name: Check
257
+ command: npm run check
258
+ - name: Pack
259
+ command: npm run pack:check
260
+ `;
261
+ const cmds = extractCiCommands(yaml);
262
+ assert.ok(cmds.includes('npm ci'));
263
+ assert.ok(cmds.includes('npm run lint:code'));
264
+ assert.ok(cmds.includes('bash scripts/check.sh'));
265
+ assert.ok(cmds.includes('python tools/check.py'));
266
+ assert.ok(cmds.includes('./local-check'));
267
+ assert.ok(cmds.includes('npm run block'));
268
+ assert.ok(cmds.includes('bash scripts/block.sh'));
269
+ assert.ok(cmds.includes('python tools/block.py'));
270
+ assert.ok(cmds.includes('./folded-check'));
271
+ assert.ok(!cmds.some((c) => ['if [ -f package.json ]', 'then', 'else', 'fi'].includes(c)));
272
+ assert.ok(cmds.includes('npm run check'));
273
+ assert.ok(cmds.includes('npm run pack:check'));
274
+ // Template expressions anywhere in the command should be excluded
275
+ assert.ok(!cmds.some((c) => c.includes('${{')));
276
+ });
277
+ test('extractCiCommandSources captures end_line for multiline run blocks', () => {
278
+ const yaml = `
279
+ jobs:
280
+ test:
281
+ steps:
282
+ - run: |-
283
+ npm run build \\
284
+ && npm run test
285
+ `;
286
+ const sources = extractCiCommandSources(yaml);
287
+ assert.deepEqual(sources, [
288
+ { command: 'npm run build', line: 6, end_line: 7 },
289
+ { command: 'npm run test', line: 6, end_line: 7 }
290
+ ]);
291
+ });
292
+ test('extractCiCommandSources handles multiple multiline blocks and keeps single-line commands un-ranged', () => {
293
+ const yaml = `
294
+ jobs:
295
+ test:
296
+ steps:
297
+ - run: |-
298
+ npm run lint \\
299
+ && npm run test
300
+ - run: npm run pack:check
301
+ - run: |-
302
+ npm run build \\
303
+ && npm run coverage
304
+ `;
305
+ const sources = extractCiCommandSources(yaml);
306
+ assert.deepEqual(sources, [
307
+ { command: 'npm run lint', line: 6, end_line: 7 },
308
+ { command: 'npm run test', line: 6, end_line: 7 },
309
+ { command: 'npm run pack:check', line: 8 },
310
+ { command: 'npm run build', line: 10, end_line: 11 },
311
+ { command: 'npm run coverage', line: 10, end_line: 11 }
312
+ ]);
313
+ });
314
+ test('extractCiCommandSources preserves literal trailing backslashes on non-continuation lines', () => {
315
+ const yaml = `
316
+ jobs:
317
+ test:
318
+ steps:
319
+ - run: |-
320
+ printf path\\\\
321
+ `;
322
+ const sources = extractCiCommandSources(yaml);
323
+ assert.deepEqual(sources, [
324
+ { command: 'printf path\\\\', line: 6 }
325
+ ]);
326
+ });
327
+ test('lintDocs reports missing-package-script for commands not in package.json', async () => {
328
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-cmd-'));
329
+ try {
330
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
331
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
332
+ documentation: {
333
+ ingest: true,
334
+ include: ['README.md'],
335
+ exclude: [],
336
+ stale_after_days: 9999
337
+ }
338
+ }), 'utf8');
339
+ // Document a script that does NOT exist in package.json after a valid script in a chained command
340
+ await writeFile(path.join(dir, 'README.md'), '# Demo\n\nRun the deploy script:\n\n```bash\nnpm run build && npm run deploy\n```\n', 'utf8');
341
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { test: 'node --test', build: 'tsc' } }), 'utf8');
342
+ const scanDir = path.join(dir, '.llmwiki', 'run');
343
+ await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
344
+ const lint = await lintDocs({ scanDir, repoPath: dir });
345
+ const missingIssues = lint.issues.filter((i) => i.code === 'missing-package-script');
346
+ assert.ok(missingIssues.length >= 1, 'expected at least one missing-package-script issue');
347
+ assert.ok(missingIssues[0].message.includes('deploy'), 'issue message should name the missing script');
348
+ }
349
+ finally {
350
+ await rm(dir, { recursive: true, force: true });
351
+ }
352
+ });
353
+ test('lintDocs and Documentation Debt Report validate exact commands from CI workflows', async () => {
354
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-ci-'));
355
+ try {
356
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
357
+ await mkdir(path.join(dir, '.github', 'workflows', 'bad.yaml'), { recursive: true });
358
+ await writeFile(path.join(dir, '.github', 'workflows', 'notes.txt'), 'ignored', 'utf8');
359
+ await writeFile(path.join(dir, '.github', 'workflows', 'good.yml'), 'jobs:\n test:\n steps:\n - run: npm run ci-only\n - run: docker build .\n', 'utf8');
360
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
361
+ documentation: {
362
+ ingest: true,
363
+ include: ['README.md'],
364
+ exclude: [],
365
+ stale_after_days: 9999
366
+ }
367
+ }), 'utf8');
368
+ await writeFile(path.join(dir, 'README.md'), '# Demo\n\n```bash\nnpm run ci-only\ndocker login --password supersecretvalue\ndocker build .\n```\n', 'utf8');
369
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: {} }), 'utf8');
370
+ const scanDir = path.join(dir, '.llmwiki', 'run');
371
+ const scan = await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
372
+ assert.ok(scan.manifest.analysis.ci_workflow_commands.includes('npm run ci-only'));
373
+ const lint = await lintDocs({ scanDir, repoPath: dir });
374
+ assert.equal(lint.issues.filter((i) => i.code === 'missing-package-script').length, 0);
375
+ const wikiDir = path.join(dir, '.llmwiki', 'wiki');
376
+ const planFile = path.join(dir, '.llmwiki', 'plan.json');
377
+ await writeFile(planFile, JSON.stringify({ pages: [], modules: [] }), 'utf8');
378
+ await compileWiki({ scanDir, planFile, wikiDir });
379
+ const report = await readFile(path.join(wikiDir, 'Documentation-Debt-Report.md'), 'utf8');
380
+ assert.match(report, /\| `npm run ci-only` \| ✅ validated \| CI workflow \|/);
381
+ assert.match(report, /`docker login --password \[REDACTED\]`/);
382
+ assert.doesNotMatch(report, /supersecretvalue/);
383
+ }
384
+ finally {
385
+ await rm(dir, { recursive: true, force: true });
386
+ }
387
+ });
388
+ test('scanRepository ignores similarly named non-target files when validating Make/task-runner commands', async () => {
389
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-false-targets-'));
390
+ try {
391
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
392
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
393
+ documentation: {
394
+ ingest: true,
395
+ include: ['README.md'],
396
+ exclude: [],
397
+ stale_after_days: 9999
398
+ }
399
+ }), 'utf8');
400
+ await writeFile(path.join(dir, 'README.md'), '# Demo\n\n```bash\nmake build\njust docs\ntask publish\n```\n', 'utf8');
401
+ await writeFile(path.join(dir, 'CMakefile'), 'build:\n\t@echo cmake\n', 'utf8');
402
+ await writeFile(path.join(dir, 'adjustfile'), 'docs:\n @echo docs\n', 'utf8');
403
+ await writeFile(path.join(dir, 'mytaskfile.yml'), 'version: "3"\ntasks:\n publish:\n cmds:\n - echo publish\n', 'utf8');
404
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: {} }), 'utf8');
405
+ const scanDir = path.join(dir, '.llmwiki', 'run');
406
+ const scan = await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
407
+ assert.deepEqual(scan.manifest.analysis.make_targets || [], []);
408
+ assert.deepEqual(scan.manifest.analysis.task_runner_targets || [], []);
409
+ const lint = await lintDocs({ scanDir, repoPath: dir });
410
+ assert.equal(lint.issues.filter((i) => i.code === 'missing-make-target').length, 1);
411
+ assert.equal(lint.issues.filter((i) => i.code === 'missing-task-runner-target').length, 2);
412
+ }
413
+ finally {
414
+ await rm(dir, { recursive: true, force: true });
415
+ }
416
+ });
417
+ test('lintDocs and Documentation Debt Report validate Makefile and task-runner targets', async () => {
418
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-make-task-'));
419
+ try {
420
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
421
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
422
+ documentation: {
423
+ ingest: true,
424
+ include: ['README.md'],
425
+ exclude: [],
426
+ stale_after_days: 9999
427
+ }
428
+ }), 'utf8');
429
+ await writeFile(path.join(dir, 'README.md'), '# Demo\n\n```bash\nmake build\nmake deploy\njust docs\njust release\njust _docs\ntask publish\ntask docs\ntask _publish\n```\n', 'utf8');
430
+ await writeFile(path.join(dir, 'Makefile'), 'build:\n\t@echo build\n', 'utf8');
431
+ await writeFile(path.join(dir, 'justfile'), 'docs:\n @echo docs\n_docs:\n @echo underscore docs\n', 'utf8');
432
+ await writeFile(path.join(dir, 'Taskfile.yml'), 'version: "3"\ntasks:\n publish:\n cmds:\n - echo publish\n _publish:\n cmds:\n - echo underscore publish\n', 'utf8');
433
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: {} }), 'utf8');
434
+ const scanDir = path.join(dir, '.llmwiki', 'run');
435
+ await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
436
+ const lint = await lintDocs({ scanDir, repoPath: dir });
437
+ assert.equal(lint.issues.filter((i) => i.code === 'missing-make-target').length, 1);
438
+ assert.equal(lint.issues.filter((i) => i.code === 'missing-task-runner-target').length, 2);
439
+ const wikiDir = path.join(dir, '.llmwiki', 'wiki');
440
+ const planFile = path.join(dir, '.llmwiki', 'plan.json');
441
+ await writeFile(planFile, JSON.stringify({ pages: [], modules: [] }), 'utf8');
442
+ await compileWiki({ scanDir, planFile, wikiDir });
443
+ const report = await readFile(path.join(wikiDir, 'Documentation-Debt-Report.md'), 'utf8');
444
+ assert.match(report, /\| `make build` \| ✅ validated \| Makefile \|/);
445
+ assert.match(report, /\| `make deploy` \| ❌ missing \| Makefile \|/);
446
+ assert.match(report, /\| `just docs` \| ✅ validated \| Task runner \|/);
447
+ assert.match(report, /\| `just release` \| ❌ missing \| Task runner \|/);
448
+ assert.match(report, /\| `just _docs` \| ✅ validated \| Task runner \|/);
449
+ assert.match(report, /\| `task publish` \| ✅ validated \| Task runner \|/);
450
+ assert.match(report, /\| `task docs` \| ❌ missing \| Task runner \|/);
451
+ assert.match(report, /\| `task _publish` \| ✅ validated \| Task runner \|/);
452
+ }
453
+ finally {
454
+ await rm(dir, { recursive: true, force: true });
455
+ }
456
+ });
457
+ test('lintDocs reports broken documented file paths, broken image links, and unvalidated environment variables', async () => {
458
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-path-env-'));
459
+ try {
460
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
461
+ await mkdir(path.join(dir, 'src'), { recursive: true });
462
+ await mkdir(path.join(dir, 'docs', 'plans'), { recursive: true });
463
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
464
+ documentation: {
465
+ ingest: true,
466
+ include: ['README.md', 'docs/**/*.md'],
467
+ exclude: [],
468
+ stale_after_days: 9999
469
+ }
470
+ }), 'utf8');
471
+ await writeFile(path.join(dir, 'src', 'app.js'), "export const mode = process.env.APP_MODE;\nconst port = process.env.PORT;\nconst baseUrl = optionalEnv(env, 'LLMWIKI_LLM_BASE_URL');\n", 'utf8');
472
+ await writeFile(path.join(dir, '.env.example'), 'EXAMPLE_MODE=on\n', 'utf8');
473
+ await writeFile(path.join(dir, 'docs', 'plans', 'README.md'), '# Plans\n', 'utf8');
474
+ await writeFile(path.join(dir, 'docs', 'guide.md'), '# Guide\n\n[local readme](README.md)\n', 'utf8');
475
+ await writeFile(path.join(dir, 'README.md'), '# Demo\n\nSee `src/app.js`, `docs/plans/`, `docs/missing.md`, ![missing](assets/missing.png), and configure APP_MODE, PORT, EXAMPLE_MODE, MISSING_TOKEN, or LLMWIKI_LLM_BASE_URL.\n', 'utf8');
476
+ const scanDir = path.join(dir, '.llmwiki', 'run');
477
+ await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
478
+ const lint = await lintDocs({ scanDir, repoPath: dir });
479
+ const brokenPathIssues = lint.issues.filter((i) => i.code === 'broken-documented-file-path');
480
+ assert.equal(brokenPathIssues.length, 3);
481
+ assert.ok(brokenPathIssues.some((item) => /README\.md:3 references missing repository path docs\/missing\.md/.test(item.message)));
482
+ assert.ok(brokenPathIssues.some((item) => /README\.md:3 references missing repository path assets\/missing\.png/.test(item.message)));
483
+ assert.ok(brokenPathIssues.some((item) => /docs\/guide\.md:3 references missing repository path README\.md/.test(item.message)));
484
+ const envIssues = lint.issues.filter((i) => i.code === 'unvalidated-env-var');
485
+ assert.equal(envIssues.length, 1);
486
+ assert.match(envIssues[0].message, /MISSING_TOKEN/);
487
+ }
488
+ finally {
489
+ await rm(dir, { recursive: true, force: true });
490
+ }
491
+ });
492
+ test('lintDocs keeps link validation inside repo and exempts generated-output roots', async () => {
493
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-link-safety-'));
494
+ try {
495
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
496
+ await mkdir(path.join(dir, 'docs'), { recursive: true });
497
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
498
+ documentation: {
499
+ ingest: true,
500
+ include: ['README.md'],
501
+ exclude: [],
502
+ stale_after_days: 9999
503
+ }
504
+ }), 'utf8');
505
+ await writeFile(path.join(dir, 'README.md'), '# Demo\n\nSee [outside](../outside.txt), [dist](dist/), [escaped](dist/../../outside.txt), [missing](docs/missing.md), [angle](<docs/guide.md>), and [titled](docs/guide.md "Guide").\n', 'utf8');
506
+ await writeFile(path.join(dir, 'docs', 'guide.md'), '# Guide\n', 'utf8');
507
+ const scanDir = path.join(dir, '.llmwiki', 'run');
508
+ await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
509
+ const lint = await lintDocs({ scanDir, repoPath: dir });
510
+ const brokenPaths = lint.issues.filter((i) => i.code === 'broken-documented-file-path');
511
+ assert.equal(brokenPaths.length, 3);
512
+ assert.ok(brokenPaths.some((i) => i.message.includes('../outside.txt')));
513
+ assert.ok(brokenPaths.some((i) => i.message.includes('dist/../../outside.txt')));
514
+ assert.ok(brokenPaths.some((i) => i.message.includes('docs/missing.md')));
515
+ assert.ok(!brokenPaths.some((i) => i.message.endsWith('repository path dist/.')));
516
+ assert.ok(!brokenPaths.some((i) => i.message.includes('docs/guide.md')));
517
+ }
518
+ finally {
519
+ await rm(dir, { recursive: true, force: true });
520
+ }
521
+ });
522
+ test('lintDocs only treats config strings as env vars under env-specific keys', async () => {
523
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-config-env-'));
524
+ try {
525
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
526
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
527
+ documentation: {
528
+ ingest: true,
529
+ include: ['README.md'],
530
+ exclude: [],
531
+ stale_after_days: 9999
532
+ },
533
+ compiler: {
534
+ profile: 'FEATURE_FLAG',
535
+ api_key_env: 'REAL_API_KEY'
536
+ }
537
+ }), 'utf8');
538
+ await writeFile(path.join(dir, 'README.md'), '# Demo\n\nConfigure FEATURE_FLAG and REAL_API_KEY.\n', 'utf8');
539
+ const scanDir = path.join(dir, '.llmwiki', 'run');
540
+ await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
541
+ const lint = await lintDocs({ scanDir, repoPath: dir });
542
+ const envIssues = lint.issues.filter((i) => i.code === 'unvalidated-env-var');
543
+ assert.equal(envIssues.length, 1);
544
+ assert.match(envIssues[0].message, /FEATURE_FLAG/);
545
+ }
546
+ finally {
547
+ await rm(dir, { recursive: true, force: true });
548
+ }
549
+ });
550
+ test('Documentation Debt Report includes route source evidence and deduplicated route findings', async () => {
551
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-debt-path-env-'));
552
+ try {
553
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
554
+ await mkdir(path.join(dir, 'src'), { recursive: true });
555
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
556
+ documentation: {
557
+ ingest: true,
558
+ include: ['README.md'],
559
+ exclude: [],
560
+ stale_after_days: 9999
561
+ }
562
+ }), 'utf8');
563
+ await writeFile(path.join(dir, 'src', 'app.js'), "const app = express();\napp.get('/health', handler);\nexport const mode = process.env.APP_MODE;\n", 'utf8');
564
+ await writeFile(path.join(dir, 'README.md'), '# Demo\n\nSee `src/app.js` and `docs/missing.md`. Configure APP_MODE and MISSING_TOKEN.\nUse GET /health API endpoint.\nUse GET /health API endpoint.\nUse POST /missing API endpoint.\n', 'utf8');
565
+ const scanDir = path.join(dir, '.llmwiki', 'run');
566
+ await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
567
+ const wikiDir = path.join(dir, '.llmwiki', 'wiki');
568
+ const planFile = path.join(dir, '.llmwiki', 'plan.json');
569
+ await writeFile(planFile, JSON.stringify({ pages: [], modules: [] }), 'utf8');
570
+ await compileWiki({ scanDir, planFile, wikiDir });
571
+ const report = await readFile(path.join(wikiDir, 'Documentation-Debt-Report.md'), 'utf8');
572
+ assert.match(report, /## File path validation/);
573
+ assert.match(report, /\| `README\.md:3` \| `src\/app\.js` \| ✅ valid \| `src\/app\.js` \|/);
574
+ assert.match(report, /\| `README\.md:3` \| `docs\/missing\.md` \| ❌ missing \| not found \|/);
575
+ assert.match(report, /## Environment variable validation/);
576
+ assert.match(report, /\| `README\.md` \| `APP_MODE` \| ✅ validated \|/);
577
+ assert.match(report, /\| `README\.md` \| `MISSING_TOKEN` \| ❓ unvalidated \|/);
578
+ assert.match(report, /## Route\/API claim validation/);
579
+ assert.match(report, /\| `README\.md:4, README\.md:5` \| `GET \/health` \| ✅ validated \| .*`src\/app\.js`.*express GET `\/health`/);
580
+ assert.match(report, /\| `README\.md:6` \| `POST \/missing` \| ❓ unvalidated \| route claim did not match scanner route surfaces for path \/missing\./);
581
+ assert.match(report, /## Findings by category/);
582
+ assert.match(report, /### Stale/);
583
+ assert.match(report, /### Contradicted/);
584
+ assert.match(report, /### Unvalidated/);
585
+ assert.match(report, /### Broken-reference/);
586
+ }
587
+ finally {
588
+ await rm(dir, { recursive: true, force: true });
589
+ }
590
+ });
591
+ test('lintDocs applies documentation validation strictness levels predictably', async () => {
592
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-doc-strictness-'));
593
+ const scanDir = path.join(dir, '.llmwiki', 'run');
594
+ try {
595
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
596
+ await writeFile(path.join(dir, 'README.md'), '# Existing\n', 'utf8');
597
+ await mkdir(scanDir, { recursive: true });
598
+ await writeFile(path.join(scanDir, 'manifest.json'), JSON.stringify({
599
+ files: [],
600
+ documentation: {
601
+ files: [
602
+ {
603
+ path: 'docs/problem.md',
604
+ stale: true,
605
+ age_days: 365,
606
+ status: 'unvalidated',
607
+ claims: [{ line: 3, text: 'Use GET /missing API endpoint.' }],
608
+ validation: {
609
+ contradictions: [{ text: 'deprecated' }],
610
+ commands: [],
611
+ env_vars: [],
612
+ route_claims: [{ line: 3, text: 'Use GET /missing API endpoint.', path: '/missing', method: 'GET' }]
613
+ },
614
+ file_paths: [],
615
+ links: ['missing.md']
616
+ }
617
+ ]
618
+ }
619
+ }), 'utf8');
620
+ // Baseline findings in this fixture:
621
+ // stale-documentation, contradicted-documentation, unvalidated-documentation-claims,
622
+ // unvalidated-route-claim, and broken-documentation-link.
623
+ for (const [strictness, expected] of [
624
+ ['standard', { errors: 1, warnings: 4 }],
625
+ ['lenient', { errors: 0, warnings: 5 }],
626
+ ['strict', { errors: 5, warnings: 0 }],
627
+ ['off', { errors: 0, warnings: 0 }]
628
+ ]) {
629
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
630
+ documentation: {
631
+ validation_strictness: strictness
632
+ }
633
+ }), 'utf8');
634
+ const lint = await lintDocs({ scanDir, repoPath: dir });
635
+ assert.equal(lint.summary.strictness, strictness);
636
+ assert.equal(lint.summary.errors, expected.errors);
637
+ assert.equal(lint.summary.warnings, expected.warnings);
638
+ }
639
+ }
640
+ finally {
641
+ await rm(dir, { recursive: true, force: true });
642
+ }
643
+ });
644
+ test('scanRepository and lintDocs detect ADR recency/supersession conservatively', async () => {
645
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-adr-docs-'));
646
+ try {
647
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
648
+ await mkdir(path.join(dir, 'ADR'), { recursive: true });
649
+ await mkdir(path.join(dir, 'docs', 'adrs'), { recursive: true });
650
+ await mkdir(path.join(dir, 'docs', 'architecture'), { recursive: true });
651
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
652
+ documentation: {
653
+ ingest: true,
654
+ include: ['README.md', 'docs/**/*.md', 'ADR/**/*.md'],
655
+ exclude: [],
656
+ stale_after_days: 2
657
+ }
658
+ }), 'utf8');
659
+ await writeFile(path.join(dir, 'README.md'), '# Demo\n', 'utf8');
660
+ await writeFile(path.join(dir, 'ADR', '0001-accepted.md'), '# ADR-0001\n\nStatus: Accepted\n', 'utf8');
661
+ await writeFile(path.join(dir, 'docs', 'adrs', '0002-superseded.md'), '---\nstatus: Superseded\nsuperseded_by: ADR-0003\n---\n\n# ADR-0002\n', 'utf8');
662
+ await writeFile(path.join(dir, 'docs', 'adrs', '0000-legacy.md'), '# ADR-0000\n\nLegacy decision text without metadata.\n', 'utf8');
663
+ await writeFile(path.join(dir, 'docs', 'architecture', 'overview.md'), '# Architecture Overview\n\nSystem design notes.\n', 'utf8');
664
+ await writeFile(path.join(dir, 'docs', 'notes.md'), '# Notes\n\nStatus: Current\n\nRelease operations note, not an ADR.\n', 'utf8');
665
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: {} }), 'utf8');
666
+ const oldDate = new Date(Date.now() - (10 * 86_400_000));
667
+ await utimes(path.join(dir, 'docs', 'adrs', '0000-legacy.md'), oldDate, oldDate);
668
+ const scanDir = path.join(dir, '.llmwiki', 'run');
669
+ const scan = await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
670
+ const docs = scan.manifest.documentation.files;
671
+ const accepted = docs.find((doc) => doc.path === 'ADR/0001-accepted.md');
672
+ const superseded = docs.find((doc) => doc.path === 'docs/adrs/0002-superseded.md');
673
+ const legacy = docs.find((doc) => doc.path === 'docs/adrs/0000-legacy.md');
674
+ const architectureOverview = docs.find((doc) => doc.path === 'docs/architecture/overview.md');
675
+ const notes = docs.find((doc) => doc.path === 'docs/notes.md');
676
+ assert.equal(accepted.adr.detected, true);
677
+ assert.equal(accepted.adr.status, 'Accepted');
678
+ assert.equal(accepted.adr.superseded, false);
679
+ assert.equal(superseded.adr.detected, true);
680
+ assert.equal(superseded.adr.superseded, true);
681
+ assert.equal(superseded.adr.superseded_by, 'ADR-0003');
682
+ assert.equal(legacy.adr.detected, true);
683
+ assert.equal(legacy.adr.has_status_metadata, false);
684
+ assert.equal(legacy.stale, true);
685
+ assert.equal(architectureOverview.adr.detected, false);
686
+ assert.equal(notes.adr.detected, false);
687
+ assert.equal(notes.adr.detection_source, 'none');
688
+ const lint = await lintDocs({ scanDir, repoPath: dir });
689
+ const supersededIssues = lint.issues.filter((issue) => issue.code === 'superseded-adr');
690
+ const missingStatusIssues = lint.issues.filter((issue) => issue.code === 'adr-without-status-metadata');
691
+ assert.equal(supersededIssues.length, 1);
692
+ assert.match(supersededIssues[0].message, /0002-superseded\.md/);
693
+ assert.equal(missingStatusIssues.length, 1);
694
+ assert.match(missingStatusIssues[0].message, /0000-legacy\.md/);
695
+ assert.ok(!lint.issues.some((issue) => issue.code === 'superseded-adr' && issue.message.includes('0001-accepted')));
696
+ assert.ok(!lint.issues.some((issue) => issue.message.includes('docs/architecture/overview.md') && issue.code.startsWith('adr-')));
697
+ assert.ok(!lint.issues.some((issue) => issue.message.includes('docs/notes.md') && issue.code.startsWith('adr-')));
698
+ }
699
+ finally {
700
+ await rm(dir, { recursive: true, force: true });
701
+ }
702
+ });
703
+ test('lintDocs validates route claims with clear reasons and suppresses duplicate route issues', async () => {
704
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-route-claims-'));
705
+ const scanDir = path.join(dir, '.llmwiki', 'run');
706
+ try {
707
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
708
+ await mkdir(scanDir, { recursive: true });
709
+ await writeFile(path.join(scanDir, 'manifest.json'), JSON.stringify({
710
+ files: [
711
+ {
712
+ path: 'src/server.ts',
713
+ route_surfaces: [
714
+ { path: '/health', methods: ['GET'] }
715
+ ],
716
+ environment_variables: []
717
+ }
718
+ ],
719
+ documentation: {
720
+ files: [
721
+ {
722
+ path: 'README.md',
723
+ stale: false,
724
+ age_days: 1,
725
+ status: 'partially_validated',
726
+ claims: [{ line: 3, text: 'Use GET /health API endpoint.' }],
727
+ validation: {
728
+ contradictions: [],
729
+ commands: [],
730
+ env_vars: [],
731
+ route_claims: [
732
+ { line: 3, text: 'Use GET /health API endpoint.', path: '/health', method: 'GET' },
733
+ { line: 4, text: 'Use POST /health API endpoint.', path: '/health', method: 'POST' },
734
+ { line: 6, text: 'Use POST /health API endpoint.', path: '/health', method: 'POST' },
735
+ { line: 5, text: 'Use GET /missing?debug=true. API endpoint.', path: '/missing?debug=true.', method: 'GET' }
736
+ ]
737
+ },
738
+ file_paths: [],
739
+ links: []
740
+ }
741
+ ]
742
+ }
743
+ }), 'utf8');
744
+ const lint = await lintDocs({ scanDir, repoPath: dir });
745
+ const routeIssues = lint.issues.filter((item) => item.code === 'unvalidated-route-claim');
746
+ assert.equal(routeIssues.length, 2);
747
+ assert.ok(routeIssues.some((issue) => issue.message.includes('README.md:4,6 route claim method POST for /health did not match')));
748
+ assert.ok(routeIssues.some((issue) => issue.message.includes('did not match scanner route surfaces for path /missing (normalized from /missing?debug=true.)')));
749
+ }
750
+ finally {
751
+ await rm(dir, { recursive: true, force: true });
752
+ }
753
+ });
754
+ test('lintDocs treats ANY/ALL/USE route methods as wildcard matches', async () => {
755
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-route-wildcards-'));
756
+ const scanDir = path.join(dir, '.llmwiki', 'run');
757
+ try {
758
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
759
+ await mkdir(scanDir, { recursive: true });
760
+ await writeFile(path.join(scanDir, 'manifest.json'), JSON.stringify({
761
+ files: [
762
+ {
763
+ path: 'src/server.ts',
764
+ route_surfaces: [
765
+ { path: '/health', methods: ['ALL'] },
766
+ { path: '/middleware', methods: ['USE'] },
767
+ { path: '/mixed', methods: ['GET', 'POST'] }
768
+ ],
769
+ environment_variables: []
770
+ }
771
+ ],
772
+ documentation: {
773
+ files: [
774
+ {
775
+ path: 'README.md',
776
+ stale: false,
777
+ age_days: 1,
778
+ status: 'partially_validated',
779
+ claims: [],
780
+ validation: {
781
+ contradictions: [],
782
+ commands: [],
783
+ env_vars: [],
784
+ route_claims: [
785
+ { line: 3, text: 'Use GET /health API endpoint.', path: '/health', method: 'GET' },
786
+ { line: 4, text: 'Use POST /middleware API endpoint.', path: '/middleware', method: 'POST' },
787
+ { line: 5, text: 'Use ALL /mixed API endpoint.', path: '/mixed', method: 'ALL' },
788
+ { line: 6, text: 'Use GET /missing API endpoint.', path: '/missing', method: 'GET' }
789
+ ]
790
+ },
791
+ file_paths: [],
792
+ links: []
793
+ }
794
+ ]
795
+ }
796
+ }), 'utf8');
797
+ const lint = await lintDocs({ scanDir, repoPath: dir });
798
+ const routeIssues = lint.issues.filter((item) => item.code === 'unvalidated-route-claim');
799
+ assert.equal(routeIssues.length, 1);
800
+ assert.ok(routeIssues[0].message.includes('did not match scanner route surfaces for path /missing'));
801
+ assert.ok(!routeIssues.some((item) => item.message.includes('/health')));
802
+ assert.ok(!routeIssues.some((item) => item.message.includes('/middleware')));
803
+ assert.ok(!routeIssues.some((item) => item.message.includes('/mixed')));
804
+ }
805
+ finally {
806
+ await rm(dir, { recursive: true, force: true });
807
+ }
808
+ });
809
+ test('lintDocs does not report missing-package-script for validated scripts', async () => {
810
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-valid-'));
811
+ try {
812
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
813
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
814
+ documentation: {
815
+ ingest: true,
816
+ include: ['README.md'],
817
+ exclude: [],
818
+ stale_after_days: 9999
819
+ }
820
+ }), 'utf8');
821
+ // Document a script that EXISTS in package.json
822
+ await writeFile(path.join(dir, 'README.md'), '# Demo\n\nBuild the project:\n\n```bash\nnpm run build\n```\n', 'utf8');
823
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { build: 'tsc' } }), 'utf8');
824
+ const scanDir = path.join(dir, '.llmwiki', 'run');
825
+ await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
826
+ const lint = await lintDocs({ scanDir, repoPath: dir });
827
+ const missingIssues = lint.issues.filter((i) => i.code === 'missing-package-script');
828
+ assert.equal(missingIssues.length, 0, 'should not report missing-package-script for a known script');
829
+ }
830
+ finally {
831
+ await rm(dir, { recursive: true, force: true });
832
+ }
833
+ });
834
+ test('lintDocs does not flag wiki page name references as broken paths when the page exists in .llmwiki/wiki', async () => {
835
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-wikiref-'));
836
+ try {
837
+ await mkdir(path.join(dir, '.llmwiki', 'wiki'), { recursive: true });
838
+ await mkdir(path.join(dir, 'docs'), { recursive: true });
839
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
840
+ documentation: { ingest: true, include: ['docs/**/*.md'], exclude: [], stale_after_days: 9999 }
841
+ }), 'utf8');
842
+ // Plan doc references bare wiki page names that live in .llmwiki/wiki/
843
+ await writeFile(path.join(dir, 'docs', 'PLAN.md'), '# Plan\n\nSee `Index.md` and `Log.md`.\n', 'utf8');
844
+ await writeFile(path.join(dir, '.llmwiki', 'wiki', 'Index.md'), '# Index\n', 'utf8');
845
+ await writeFile(path.join(dir, '.llmwiki', 'wiki', 'Log.md'), '# Log\n', 'utf8');
846
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: {} }), 'utf8');
847
+ const scanDir = path.join(dir, '.llmwiki', 'run');
848
+ await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
849
+ const lint = await lintDocs({ scanDir, repoPath: dir });
850
+ const broken = lint.issues.filter((i) => i.code === 'broken-documented-file-path');
851
+ assert.equal(broken.length, 0, 'should not flag wiki page references that exist in .llmwiki/wiki/');
852
+ }
853
+ finally {
854
+ await rm(dir, { recursive: true, force: true });
855
+ }
856
+ });
857
+ test('lintDocs still flags bare .md references that do not exist in repo or .llmwiki/wiki', async () => {
858
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-missingwiki-'));
859
+ try {
860
+ await mkdir(path.join(dir, '.llmwiki', 'wiki'), { recursive: true });
861
+ await mkdir(path.join(dir, 'docs'), { recursive: true });
862
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
863
+ documentation: { ingest: true, include: ['docs/**/*.md'], exclude: [], stale_after_days: 9999 }
864
+ }), 'utf8');
865
+ await writeFile(path.join(dir, 'docs', 'PLAN.md'), '# Plan\n\nSee `TotallyMissingPage.md`.\n', 'utf8');
866
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: {} }), 'utf8');
867
+ const scanDir = path.join(dir, '.llmwiki', 'run');
868
+ await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
869
+ const lint = await lintDocs({ scanDir, repoPath: dir });
870
+ const broken = lint.issues.filter((i) => i.code === 'broken-documented-file-path');
871
+ assert.ok(broken.length >= 1, 'should flag .md references that do not exist anywhere');
872
+ }
873
+ finally {
874
+ await rm(dir, { recursive: true, force: true });
875
+ }
876
+ });
877
+ test('extractDocumentedFilePaths does not treat .git remote URLs as file path candidates', () => {
878
+ const refs = extractDocumentedFilePaths('# Publish\n\nPublish to `OWNER/REPO.wiki.git` using the CLI.\n');
879
+ assert.ok(!refs.some((r) => r.path.endsWith('.git')), '.git remote URL should not be extracted as a file path');
880
+ });
881
+ test('isEnvironmentVariableMention does not flag HUMAN_NOTES or CHANGES_REQUESTED', async () => {
882
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'repo-wiki-envmarker-'));
883
+ try {
884
+ await mkdir(path.join(dir, '.llmwiki'), { recursive: true });
885
+ await writeFile(path.join(dir, '.llmwiki', 'config.json'), JSON.stringify({
886
+ documentation: { ingest: true, include: ['README.md'], exclude: [], stale_after_days: 9999 }
887
+ }), 'utf8');
888
+ await writeFile(path.join(dir, 'README.md'), '# Guide\n\nPreserve `HUMAN_NOTES` sections. Use `CHANGES_REQUESTED` for review states.\n', 'utf8');
889
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: {} }), 'utf8');
890
+ const scanDir = path.join(dir, '.llmwiki', 'run');
891
+ const scan = await scanRepository({ mode: 'bootstrap', repoPath: dir, outDir: scanDir });
892
+ const readmeCard = scan.manifest.documentation.files.find((doc) => doc.path === 'README.md');
893
+ assert.ok(!readmeCard.validation.env_vars.includes('HUMAN_NOTES'), 'HUMAN_NOTES should not be extracted as env var');
894
+ assert.ok(!readmeCard.validation.env_vars.includes('CHANGES_REQUESTED'), 'CHANGES_REQUESTED should not be extracted as env var');
895
+ }
896
+ finally {
897
+ await rm(dir, { recursive: true, force: true });
898
+ }
899
+ });
900
+ //# sourceMappingURL=docs-linter.test.js.map