@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,2046 @@
1
+ import path from 'node:path';
2
+ import { promises as fs } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { hasDataModelSignals } from './data-model-signals.js';
5
+ import { assembleAllPageContexts, assemblePageContext } from './context-assembler.js';
6
+ import { ensureDir, readJson, writeJson, writeText } from './utils/fs.js';
7
+ import { buildRouteSurfaceIndex, cleanDocumentedPathTarget, collectKnownEnvironmentVariables, collectManifestDirectories, dedupeRouteValidationFindings, normalizeRepoPath, resolveDocumentedPathFromManifest, validateRouteClaims } from './docs-validation.js';
8
+ import { classifyDocumentedCommands, extractRouteClaims, mergePackageScripts } from './docs-ingestor.js';
9
+ import { extractFrontmatterBlock } from './frontmatter.js';
10
+ import { detectPageState, extractHumanNotes, preserveHumanNotes } from './page-ownership.js';
11
+ import { buildRequest, createProvider, createProviderFromResolvedConfig, LLMProviderError, resolveArchitectureOverrides, resolveProviderConfig } from './llm-provider.js';
12
+ import { synthesizeWikiPage, WikiPatchError } from './wiki-patch.js';
13
+ import { buildSearchIndex } from './search.js';
14
+ export async function compileWiki({ scanDir, planFile, wikiDir, config = null, _provider = null }) {
15
+ const manifest = await readJson(path.join(scanDir, 'manifest.json'));
16
+ const plan = await readJson(planFile);
17
+ const pageContexts = assembleAllPageContexts({ manifest, plan });
18
+ await ensureDir(wikiDir);
19
+ const KNOWN_MODES = ['deterministic', 'llm'];
20
+ const rawMode = resolveCompilerMode(config?.compiler);
21
+ const compilerMode = KNOWN_MODES.includes(rawMode) ? rawMode : 'deterministic';
22
+ if (!KNOWN_MODES.includes(rawMode)) {
23
+ console.warn(`compileWiki: unknown compiler.mode "${rawMode}"; falling back to "deterministic".`);
24
+ }
25
+ const isLLMMode = compilerMode === 'llm';
26
+ const llmCfg = config?.compiler ?? {};
27
+ const resolvedLLMCfg = isLLMMode ? resolveProviderConfig(llmCfg) : null;
28
+ const validationRetries = resolvedLLMCfg?.validationRetries ?? 0;
29
+ const llmErrors = [];
30
+ const pages = new Map();
31
+ pages.set('Home.md', renderHome(manifest, plan));
32
+ pages.set('_Sidebar.md', renderSidebar(manifest, plan));
33
+ pages.set('Index.md', renderIndex(manifest, plan));
34
+ pages.set('Log.md', renderLog(manifest, plan));
35
+ pages.set('Agent-Context-Pack.md', renderAgentContextPack(manifest, plan));
36
+ pages.set('Repository-Overview.md', renderRepositoryOverview(manifest, plan));
37
+ pages.set('Architecture.md', renderArchitecture(manifest, plan));
38
+ pages.set('Build-Test-and-Run.md', renderBuildTestAndRun(manifest));
39
+ pages.set('Open-Questions.md', renderOpenQuestions(manifest, plan));
40
+ pages.set('Documentation-Debt-Report.md', renderDocumentationDebtReport(manifest));
41
+ pages.set('Dependency-Map.md', renderDependencyMap(manifest));
42
+ pages.set('Testing-Strategy.md', renderTestingStrategy(manifest));
43
+ pages.set('Configuration-and-Environment.md', renderConfiguration(manifest));
44
+ pages.set('Security-and-Secrets.md', renderSecurity(manifest));
45
+ pages.set('Operational-Runbook.md', renderRunbook(manifest));
46
+ if (manifest.totals.runtime_hints?.['http-route']) {
47
+ pages.set('API-HTTP-Routes.md', renderHttpRoutes(manifest));
48
+ }
49
+ if (shouldRenderDataModelPage(manifest, plan)) {
50
+ pages.set('Data-Model-and-Migrations.md', renderDataModel(manifest));
51
+ }
52
+ const sourceToTestsIndex = buildSourceToTestsIndex(manifest);
53
+ // Track which module pages are owned by the LLM synthesis path (success or failure).
54
+ // These are excluded from the deterministic fallback below.
55
+ const llmHandledModules = new Set();
56
+ // Track successfully LLM-synthesized module pages for summary reporting.
57
+ const llmGeneratedPages = new Set();
58
+ let skipped = 0;
59
+ const skippedByState = {};
60
+ // Architecture.md handling decision for this compile run (reported in summary).
61
+ let archDecision = null;
62
+ if (isLLMMode) {
63
+ // In LLM mode, synthesize module pages through the provider boundary.
64
+ // Foundation and cross-cutting pages continue to use deterministic renderers
65
+ // (phased archetype rollout – module pages first).
66
+ const llmCandidates = [];
67
+ for (const module of plan.modules || []) {
68
+ const modulePage = `${module.slug}.md`;
69
+ llmHandledModules.add(modulePage);
70
+ const filePath = path.join(wikiDir, modulePage);
71
+ let existingForPrompt;
72
+ try {
73
+ existingForPrompt = await fs.readFile(filePath, 'utf8');
74
+ }
75
+ catch (error) {
76
+ if (!isNodeError(error) || error.code !== 'ENOENT') {
77
+ throw error;
78
+ }
79
+ }
80
+ if (existingForPrompt !== undefined) {
81
+ const state = detectPageState(existingForPrompt);
82
+ if (state === 'human-owned' || state === 'unmanaged') {
83
+ skipped++;
84
+ skippedByState[state] = (skippedByState[state] || 0) + 1;
85
+ continue;
86
+ }
87
+ }
88
+ llmCandidates.push({ module, modulePage, existingForPrompt });
89
+ }
90
+ const llmProvider = llmCandidates.length > 0 ? (_provider ?? createProvider(resolvedLLMCfg)) : null;
91
+ for (const { module, modulePage, existingForPrompt } of llmCandidates) {
92
+ // Assemble the page context using the standard context assembler.
93
+ const syntheticPage = { path: modulePage, phase: 'modules', moduleName: module.name };
94
+ const pageCtx = assemblePageContext({ manifest, plan, page: syntheticPage });
95
+ const promptCtx = buildModulePromptContext(pageCtx, manifest, module, existingForPrompt);
96
+ // Build LLM request from the assembled context and provider settings.
97
+ const request = buildRequest('module', promptCtx, {
98
+ maxOutputTokens: resolvedLLMCfg.maxOutputTokens,
99
+ systemPrompt: resolvedLLMCfg.systemPrompt,
100
+ temperature: resolvedLLMCfg.temperature,
101
+ reasoningEffort: resolvedLLMCfg.reasoningEffort,
102
+ });
103
+ // Synthesize with validation. On success, add to the pages Map so the
104
+ // shared write loop handles human-notes preservation and page-state checks.
105
+ // On failure, record the error. LLM mode fails fast before the write loop
106
+ // below, so invalid LLM output cannot trigger partial wiki writes.
107
+ try {
108
+ const patch = await synthesizeWikiPage(llmProvider, request, { maxRetries: validationRetries });
109
+ const normalized = normalizeLLMGeneratedContent(patch.content, manifest, module);
110
+ pages.set(modulePage, normalized);
111
+ llmGeneratedPages.add(modulePage);
112
+ }
113
+ catch (err) {
114
+ if (err instanceof WikiPatchError) {
115
+ llmErrors.push({ file: modulePage, error: err.message, issues: err.issues });
116
+ }
117
+ else if (err instanceof LLMProviderError) {
118
+ llmErrors.push({ file: modulePage, error: err.message });
119
+ }
120
+ else {
121
+ throw err;
122
+ }
123
+ // Page NOT added to the Map. If any LLM page fails, compilation throws
124
+ // before writing any page, leaving the existing wiki intact.
125
+ }
126
+ }
127
+ }
128
+ if (isLLMMode) {
129
+ // Synthesize Architecture.md through the LLM provider boundary.
130
+ // The deterministic renderArchitecture() output already in the pages map
131
+ // is replaced on success; on failure the error is recorded and compilation
132
+ // throws before any page is written, leaving the existing wiki intact.
133
+ const archOverrides = resolveArchitectureOverrides(llmCfg);
134
+ const archFilePath = path.join(wikiDir, 'Architecture.md');
135
+ let existingArchContent;
136
+ try {
137
+ existingArchContent = await fs.readFile(archFilePath, 'utf8');
138
+ }
139
+ catch (error) {
140
+ if (!isNodeError(error) || error.code !== 'ENOENT') {
141
+ throw error;
142
+ }
143
+ }
144
+ const archPageState = existingArchContent !== undefined ? detectPageState(existingArchContent) : null;
145
+ if (archPageState === 'human-owned' || archPageState === 'unmanaged') {
146
+ // Remove from the pages map so the write loop does not process this page
147
+ // at all (consistent with how LLM-handled module pages work). The existing
148
+ // file is left intact; the write loop never sees it.
149
+ pages.delete('Architecture.md');
150
+ archDecision = 'skipped';
151
+ skipped++;
152
+ skippedByState[archPageState] = (skippedByState[archPageState] || 0) + 1;
153
+ }
154
+ else {
155
+ // Gate the LLM architecture call using a fingerprint of the normalized
156
+ // architecture-input payload. If the fingerprint matches the one stored in
157
+ // the existing Architecture.md, the architecture inputs have not changed and
158
+ // we can skip the LLM call entirely (byte-stable, zero model cost).
159
+ const currentFingerprint = computeArchInputsFingerprint(manifest, plan);
160
+ const storedFingerprint = existingArchContent ? extractArchFingerprint(existingArchContent) : null;
161
+ const existingSourceCommit = existingArchContent ? extractFrontmatterString(existingArchContent, 'source_commit') : null;
162
+ if (storedFingerprint !== null && storedFingerprint === currentFingerprint && existingSourceCommit === manifest.commit) {
163
+ // Architecture inputs unchanged – skip LLM call, keep existing file byte-stable.
164
+ pages.delete('Architecture.md');
165
+ archDecision = 'skipped';
166
+ }
167
+ else {
168
+ // Resolve the architecture provider. If the architecture model override
169
+ // specifies a different model from the global resolved config, create a
170
+ // dedicated provider for the architecture page.
171
+ let archProvider;
172
+ if (_provider !== null) {
173
+ archProvider = _provider;
174
+ }
175
+ else {
176
+ archProvider = createProviderFromResolvedConfig({
177
+ ...resolvedLLMCfg,
178
+ model: archOverrides.model ?? resolvedLLMCfg.model,
179
+ timeoutMs: archOverrides.timeoutMs ?? resolvedLLMCfg.timeoutMs,
180
+ });
181
+ }
182
+ const syntheticArchPage = { path: 'Architecture.md', phase: 'foundation' };
183
+ const archPageCtx = assemblePageContext({ manifest, plan, page: syntheticArchPage });
184
+ const archPromptCtx = buildArchitecturePromptContext(archPageCtx, manifest, existingArchContent);
185
+ const archRequest = buildRequest('architecture', archPromptCtx, {
186
+ maxOutputTokens: archOverrides.maxOutputTokens ?? resolvedLLMCfg.maxOutputTokens,
187
+ temperature: resolvedLLMCfg.temperature,
188
+ reasoningEffort: archOverrides.reasoningEffort ?? resolvedLLMCfg.reasoningEffort,
189
+ });
190
+ try {
191
+ const patch = await synthesizeWikiPage(archProvider, archRequest, { maxRetries: validationRetries });
192
+ const normalized = normalizeLLMArchitectureContent(patch.content, manifest, archRequest.sourcePaths, currentFingerprint);
193
+ pages.set('Architecture.md', normalized);
194
+ llmGeneratedPages.add('Architecture.md');
195
+ archDecision = 'full-regenerated';
196
+ }
197
+ catch (err) {
198
+ if (err instanceof WikiPatchError) {
199
+ llmErrors.push({ file: 'Architecture.md', error: err.message, issues: err.issues });
200
+ }
201
+ else if (err instanceof LLMProviderError) {
202
+ llmErrors.push({ file: 'Architecture.md', error: err.message });
203
+ }
204
+ else {
205
+ throw err;
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+ // Deterministic module pages for modules not handled (or not eligible) for LLM synthesis.
212
+ for (const module of plan.modules || []) {
213
+ const modulePage = `${module.slug}.md`;
214
+ if (!llmHandledModules.has(modulePage) && !pages.has(modulePage)) {
215
+ pages.set(modulePage, renderModulePage(manifest, module, sourceToTestsIndex));
216
+ }
217
+ }
218
+ if (llmErrors.length > 0) {
219
+ throw new Error(`LLM compilation failed for ${llmErrors.length} page(s): ${llmErrors.map(formatLLMError).join('; ')}`);
220
+ }
221
+ for (const [file, initialContent] of pages) {
222
+ let newContent = initialContent;
223
+ const filePath = path.join(wikiDir, file);
224
+ let existingContent = null;
225
+ try {
226
+ existingContent = await fs.readFile(filePath, 'utf8');
227
+ }
228
+ catch (error) {
229
+ if (!isNodeError(error) || error.code !== 'ENOENT') {
230
+ throw error;
231
+ }
232
+ // File does not exist yet – this is a fresh page.
233
+ }
234
+ if (existingContent !== null) {
235
+ const state = detectPageState(existingContent);
236
+ // Human-owned and unmanaged pages are never overwritten implicitly.
237
+ // Adoption of pre-existing hand-written pages must be explicit.
238
+ if (state === 'human-owned' || state === 'unmanaged') {
239
+ if (file === 'Architecture.md') {
240
+ archDecision = 'skipped';
241
+ }
242
+ skipped++;
243
+ skippedByState[state] = (skippedByState[state] || 0) + 1;
244
+ continue;
245
+ }
246
+ // Architecture.md byte-stable skip (deterministic mode only).
247
+ // In LLM mode, the decision was already made before the LLM call.
248
+ if (file === 'Architecture.md' && !isLLMMode) {
249
+ const decision = computeArchDecision(newContent, existingContent);
250
+ archDecision = decision;
251
+ if (decision === 'skipped') {
252
+ continue; // Preserve existing file byte-for-byte.
253
+ }
254
+ if (decision === 'section-patched') {
255
+ const patched = patchArchitectureSections(existingContent, newContent);
256
+ if (patched && architectureUntouchedContent(patched) === architectureUntouchedContent(newContent)) {
257
+ newContent = patched;
258
+ }
259
+ else {
260
+ archDecision = 'full-regenerated';
261
+ }
262
+ }
263
+ }
264
+ // Preserve any human notes that exist in the current page.
265
+ const notes = extractHumanNotes(existingContent);
266
+ if (notes.length > 0) {
267
+ let withNotes = preserveHumanNotes(newContent, notes);
268
+ if (notes.trim().length > 0) {
269
+ // Update page_state to "mixed" since human notes are present.
270
+ // If the content already has page_state: "generated", replace it.
271
+ // If it has no page_state field at all (e.g. LLM output), inject it.
272
+ withNotes = setPageStateMixed(withNotes);
273
+ }
274
+ await writeText(filePath, withNotes);
275
+ continue;
276
+ }
277
+ }
278
+ await writeText(filePath, newContent);
279
+ // Track first write of Architecture.md (no existing file).
280
+ if (file === 'Architecture.md' && archDecision === null) {
281
+ archDecision = 'full-regenerated';
282
+ }
283
+ }
284
+ // Keep the graph artifact rooted in the local .llmwiki workspace rather than the configurable wikiDir.
285
+ // This preserves the fixed `.llmwiki/graph.json` contract even when callers override `--wiki`.
286
+ await writeJson(path.join(path.dirname(scanDir), 'graph.json'), await buildWikiGraph(manifest, plan, wikiDir));
287
+ // Keep the search artifact rooted in the local .llmwiki workspace rather than the configurable wikiDir.
288
+ // This preserves the fixed `.llmwiki/search/` contract even when callers override `--wiki`.
289
+ const search = await buildSearchIndex({
290
+ wikiDir,
291
+ outDir: path.join(path.dirname(scanDir), 'search')
292
+ });
293
+ return {
294
+ contexts: pageContexts,
295
+ search,
296
+ summary: {
297
+ wikiDir,
298
+ compiler_mode: compilerMode,
299
+ pages: pages.size,
300
+ deterministic_pages: pages.size - llmGeneratedPages.size,
301
+ llm_pages: llmGeneratedPages.size,
302
+ skipped,
303
+ skipped_by_state: skippedByState,
304
+ commit: manifest.commit,
305
+ contexts: pageContexts.length,
306
+ architecture_decision: archDecision ?? 'full-regenerated',
307
+ search: search.summary
308
+ }
309
+ };
310
+ }
311
+ async function buildWikiGraph(manifest, plan, wikiDir) {
312
+ const plannedPagePaths = uniqueSorted((plan?.pages || []).map((page) => String(page?.path || '')).filter(Boolean));
313
+ const sourceToPages = Array.isArray(plan?.affected_page_graph?.source_to_pages) ? plan.affected_page_graph.source_to_pages : [];
314
+ const modulePagePathByName = new Map();
315
+ const modulePagePathBySlug = new Map();
316
+ for (const page of plan?.pages || []) {
317
+ const pagePath = String(page?.path || '');
318
+ if (!pagePath) {
319
+ continue;
320
+ }
321
+ const moduleName = String(page?.moduleName || '').trim();
322
+ if (moduleName) {
323
+ modulePagePathByName.set(moduleName, pagePath);
324
+ }
325
+ const extension = path.extname(pagePath);
326
+ const slug = extension ? pagePath.slice(0, -extension.length) : pagePath;
327
+ if (slug) {
328
+ modulePagePathBySlug.set(slug, pagePath);
329
+ }
330
+ }
331
+ const moduleById = new Map();
332
+ for (const module of plan?.modules || []) {
333
+ const slug = String(module?.slug || '').trim();
334
+ if (!slug) {
335
+ continue;
336
+ }
337
+ const moduleId = `module:${slug}`;
338
+ const moduleName = String(module?.name || '').trim();
339
+ const modulePath = modulePagePathByName.get(moduleName) || modulePagePathBySlug.get(slug) || `${slug}.md`;
340
+ let entry = moduleById.get(moduleId);
341
+ if (!entry) {
342
+ entry = { id: moduleId, path: modulePath, files: new Set() };
343
+ moduleById.set(moduleId, entry);
344
+ }
345
+ for (const file of module?.files || []) {
346
+ const sourcePath = String(file || '').trim();
347
+ if (sourcePath) {
348
+ entry.files.add(sourcePath);
349
+ }
350
+ }
351
+ }
352
+ const documentationPaths = new Set([
353
+ ...(manifest?.documentation?.files || []).map((file) => String(file?.path || '')).filter(Boolean),
354
+ ...(manifest?.files || []).filter((file) => file?.category === 'docs').map((file) => String(file?.path || '')).filter(Boolean),
355
+ ]);
356
+ const sourceNodeIdForPath = (sourcePath) => (documentationPaths.has(sourcePath) || isDocumentationPath(sourcePath)
357
+ ? `documentation:${sourcePath}`
358
+ : `source:${sourcePath}`);
359
+ const pageContentByPath = new Map();
360
+ const pageBodyByPath = new Map();
361
+ const pageStateByPath = new Map();
362
+ for (const pagePath of plannedPagePaths) {
363
+ try {
364
+ const content = await fs.readFile(path.join(wikiDir, pagePath), 'utf8');
365
+ pageContentByPath.set(pagePath, content);
366
+ pageBodyByPath.set(pagePath, stripLeadingFrontmatter(content));
367
+ pageStateByPath.set(pagePath, detectPageState(content));
368
+ }
369
+ catch (error) {
370
+ if (!isNodeError(error) || error.code !== 'ENOENT') {
371
+ throw error;
372
+ }
373
+ pageStateByPath.set(pagePath, 'generated');
374
+ }
375
+ }
376
+ const sourcePathSet = new Set(sourceToPages.map((entry) => String(entry?.source || '')).filter(Boolean));
377
+ const edgeSet = new Set();
378
+ for (const entry of sourceToPages) {
379
+ const sourcePath = String(entry?.source || '');
380
+ if (!sourcePath) {
381
+ continue;
382
+ }
383
+ const fromNodeId = sourceNodeIdForPath(sourcePath);
384
+ for (const page of entry?.pages || []) {
385
+ const pagePath = String(page?.page || '');
386
+ if (!pagePath) {
387
+ continue;
388
+ }
389
+ edgeSet.add(`affects\u0000${fromNodeId}\u0000page:${pagePath}`);
390
+ }
391
+ }
392
+ const pageTargetByReference = new Map();
393
+ for (const pagePath of plannedPagePaths) {
394
+ pageTargetByReference.set(canonicalWikiPageReference(pagePath), pagePath);
395
+ }
396
+ for (const [fromPagePath, pageBody] of pageBodyByPath.entries()) {
397
+ for (const targetReference of extractLocalWikiLinks(pageBody)) {
398
+ const toPagePath = pageTargetByReference.get(targetReference);
399
+ if (toPagePath) {
400
+ edgeSet.add(`wiki_link\u0000page:${fromPagePath}\u0000page:${toPagePath}`);
401
+ }
402
+ }
403
+ }
404
+ for (const [pagePath, pageContent] of pageContentByPath.entries()) {
405
+ const sourcePaths = extractFrontmatterSourcePaths(pageContent);
406
+ for (const sourcePath of sourcePaths) {
407
+ sourcePathSet.add(sourcePath);
408
+ edgeSet.add(`provenance\u0000page:${pagePath}\u0000${sourceNodeIdForPath(sourcePath)}`);
409
+ }
410
+ }
411
+ for (const moduleNode of moduleById.values()) {
412
+ if (pageStateByPath.has(moduleNode.path)) {
413
+ edgeSet.add(`owns\u0000${moduleNode.id}\u0000page:${moduleNode.path}`);
414
+ }
415
+ for (const sourcePath of moduleNode.files) {
416
+ sourcePathSet.add(sourcePath);
417
+ edgeSet.add(`owns\u0000${moduleNode.id}\u0000${sourceNodeIdForPath(sourcePath)}`);
418
+ }
419
+ }
420
+ const sourcePaths = uniqueSorted([...sourcePathSet]);
421
+ const pageNodes = plannedPagePaths.map((pagePath) => ({
422
+ id: `page:${pagePath}`,
423
+ kind: 'page',
424
+ path: pagePath,
425
+ page_state: pageStateByPath.get(pagePath) || 'generated',
426
+ }));
427
+ const moduleNodes = [...moduleById.values()]
428
+ .map((moduleNode) => ({
429
+ id: moduleNode.id,
430
+ kind: 'module',
431
+ path: moduleNode.path,
432
+ }))
433
+ .sort(compareWikiGraphNodes);
434
+ const sourceNodes = sourcePaths.map((sourcePath) => ({
435
+ id: sourceNodeIdForPath(sourcePath),
436
+ kind: (documentationPaths.has(sourcePath) || isDocumentationPath(sourcePath) ? 'documentation' : 'source'),
437
+ path: sourcePath,
438
+ }));
439
+ const nodes = [...pageNodes, ...moduleNodes, ...sourceNodes].sort(compareWikiGraphNodes);
440
+ const edges = [...edgeSet].map((entry) => {
441
+ const [type, from, to] = entry.split('\u0000');
442
+ return {
443
+ type: type,
444
+ from,
445
+ to,
446
+ };
447
+ }).sort((left, right) => left.type.localeCompare(right.type) || left.from.localeCompare(right.from) || left.to.localeCompare(right.to));
448
+ return {
449
+ schema_version: 1,
450
+ nodes,
451
+ edges,
452
+ };
453
+ }
454
+ function compareWikiGraphNodes(left, right) {
455
+ return left.path.localeCompare(right.path)
456
+ || left.kind.localeCompare(right.kind)
457
+ || left.id.localeCompare(right.id);
458
+ }
459
+ function stripLeadingFrontmatter(content) {
460
+ return extractFrontmatterBlock(content)?.body ?? content;
461
+ }
462
+ function canonicalWikiPageReference(value) {
463
+ return String(value || '').trim().replace(/^\.\//, '').replace(/#.*$/, '').replace(/\.md$/i, '');
464
+ }
465
+ function extractLocalWikiLinks(content) {
466
+ const links = new Set();
467
+ const lines = content.replace(/\r\n?/g, '\n').split('\n');
468
+ const visibleLines = [];
469
+ let fenceMarker = null;
470
+ let inHtmlComment = false;
471
+ let activeListItemIndent = null;
472
+ for (const rawLine of lines) {
473
+ const line = stripMarkdownBlockquotePrefixes(rawLine);
474
+ const fenceMatch = parseMarkdownFenceLine(line);
475
+ if (fenceMatch) {
476
+ if (!fenceMarker) {
477
+ fenceMarker = fenceMatch.marker;
478
+ continue;
479
+ }
480
+ if (fenceMatch.marker.char === fenceMarker.char && fenceMatch.marker.length >= fenceMarker.length && !fenceMatch.trailing.trim()) {
481
+ fenceMarker = null;
482
+ continue;
483
+ }
484
+ }
485
+ if (fenceMarker) {
486
+ continue;
487
+ }
488
+ const visibleLine = stripWikiLinkExtractionNoise(line, inHtmlComment);
489
+ inHtmlComment = visibleLine.inHtmlComment;
490
+ if (isIndentedMarkdownCodeBlockLine(line, activeListItemIndent)) {
491
+ if (!visibleLine.text.trim()) {
492
+ activeListItemIndent = null;
493
+ }
494
+ continue;
495
+ }
496
+ visibleLines.push(visibleLine.text);
497
+ activeListItemIndent = nextMarkdownListItemIndent(visibleLine.text, activeListItemIndent);
498
+ }
499
+ const referenceTargets = extractMarkdownReferenceDefinitions(visibleLines);
500
+ for (const line of visibleLines) {
501
+ for (const href of extractMarkdownLinkTargets(line, referenceTargets)) {
502
+ const target = cleanDocumentedPathTarget(href);
503
+ if (!target || /^(?:https?:|mailto:|ftp:|\/\/|#)/i.test(target) || /^(?:javascript:|data:|vbscript:|blob:|about:)/i.test(target)) {
504
+ continue;
505
+ }
506
+ const normalized = target.replace(/^\.\//, '');
507
+ if (!normalized || normalized.includes('/')) {
508
+ continue;
509
+ }
510
+ const extension = path.extname(normalized).toLowerCase();
511
+ if (extension && extension !== '.md') {
512
+ continue;
513
+ }
514
+ const canonical = canonicalWikiPageReference(normalized);
515
+ if (canonical) {
516
+ links.add(canonical);
517
+ }
518
+ }
519
+ }
520
+ return [...links];
521
+ }
522
+ function parseMarkdownFenceLine(line) {
523
+ const match = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line);
524
+ if (!match) {
525
+ return null;
526
+ }
527
+ return {
528
+ marker: {
529
+ char: match[2][0],
530
+ length: match[2].length,
531
+ },
532
+ trailing: match[3] || '',
533
+ };
534
+ }
535
+ function stripMarkdownBlockquotePrefixes(line) {
536
+ let result = line;
537
+ while (true) {
538
+ const match = /^( {0,3})> ?/.exec(result);
539
+ if (!match) {
540
+ return result;
541
+ }
542
+ result = result.slice(match[0].length);
543
+ }
544
+ }
545
+ function leadingMarkdownIndentWidth(line) {
546
+ let width = 0;
547
+ for (const char of line) {
548
+ if (char === ' ') {
549
+ width += 1;
550
+ continue;
551
+ }
552
+ if (char === '\t') {
553
+ width += 4;
554
+ continue;
555
+ }
556
+ break;
557
+ }
558
+ return width;
559
+ }
560
+ function nextMarkdownListItemIndent(line, activeListItemIndent) {
561
+ if (!line.trim()) {
562
+ return null;
563
+ }
564
+ const listItem = /^(\s*)(?:[*+-]|\d+[.)])\s+/.exec(line);
565
+ if (listItem) {
566
+ return leadingMarkdownIndentWidth(listItem[1]);
567
+ }
568
+ const indentWidth = leadingMarkdownIndentWidth(line);
569
+ if (activeListItemIndent !== null && indentWidth > activeListItemIndent) {
570
+ return activeListItemIndent;
571
+ }
572
+ return null;
573
+ }
574
+ function isIndentedMarkdownCodeBlockLine(line, activeListItemIndent) {
575
+ const indentMatch = /^([ \t]+)(.*)$/.exec(line);
576
+ if (!indentMatch) {
577
+ return false;
578
+ }
579
+ const indentWidth = leadingMarkdownIndentWidth(indentMatch[1]);
580
+ if (indentWidth < 4) {
581
+ return false;
582
+ }
583
+ if (/^(?:[*+-]|\d+[.)])\s/.test(indentMatch[2])) {
584
+ return false;
585
+ }
586
+ if (activeListItemIndent !== null && indentWidth > activeListItemIndent) {
587
+ return false;
588
+ }
589
+ return true;
590
+ }
591
+ function isEscapedMarkdownCharacter(line, index) {
592
+ let backslashCount = 0;
593
+ for (let cursor = index - 1; cursor >= 0 && line[cursor] === '\\'; cursor -= 1) {
594
+ backslashCount += 1;
595
+ }
596
+ return backslashCount % 2 === 1;
597
+ }
598
+ function normalizeMarkdownReferenceLabel(value) {
599
+ return value.trim().replace(/\s+/g, ' ').toLowerCase();
600
+ }
601
+ function findClosingMarkdownBracket(line, openBracket) {
602
+ let depth = 0;
603
+ for (let index = openBracket + 1; index < line.length; index += 1) {
604
+ const char = line[index];
605
+ if (isEscapedMarkdownCharacter(line, index)) {
606
+ continue;
607
+ }
608
+ if (char === '[') {
609
+ depth += 1;
610
+ continue;
611
+ }
612
+ if (char === ']') {
613
+ if (depth === 0) {
614
+ return index;
615
+ }
616
+ depth -= 1;
617
+ }
618
+ }
619
+ return -1;
620
+ }
621
+ function stripYamlInlineComment(value) {
622
+ let result = '';
623
+ let quote = '';
624
+ for (let index = 0; index < value.length; index += 1) {
625
+ const char = value[index];
626
+ if ((char === '"' || char === "'") && !isEscapedMarkdownCharacter(value, index)) {
627
+ quote = quote === char ? '' : (quote || char);
628
+ result += char;
629
+ continue;
630
+ }
631
+ if (!quote && char === '#' && (index === 0 || /\s/.test(value[index - 1] || ''))) {
632
+ break;
633
+ }
634
+ result += char;
635
+ }
636
+ return result.trimEnd();
637
+ }
638
+ function normalizeYamlPathScalar(value) {
639
+ return unquoteYamlScalar(stripYamlInlineComment(value).trim());
640
+ }
641
+ function extractMarkdownReferenceDefinitions(lines) {
642
+ const definitions = new Map();
643
+ for (const line of lines) {
644
+ const match = /^\s*\[([^\]]+)\]:\s*(.+)$/.exec(line);
645
+ if (!match) {
646
+ continue;
647
+ }
648
+ const label = normalizeMarkdownReferenceLabel(match[1]);
649
+ const target = extractMarkdownReferenceDefinitionTarget(match[2]);
650
+ if (label && target && !definitions.has(label)) {
651
+ definitions.set(label, target);
652
+ }
653
+ }
654
+ return definitions;
655
+ }
656
+ function extractMarkdownReferenceDefinitionTarget(value) {
657
+ const trimmed = value.trim();
658
+ if (!trimmed) {
659
+ return '';
660
+ }
661
+ if (trimmed.startsWith('<')) {
662
+ const closeAngle = trimmed.indexOf('>');
663
+ return closeAngle === -1 ? '' : trimmed.slice(1, closeAngle).trim();
664
+ }
665
+ const match = /^([^\s]+)/.exec(trimmed);
666
+ return match ? match[1].trim() : '';
667
+ }
668
+ function stripWikiLinkExtractionNoise(line, initialInHtmlComment = false) {
669
+ let text = '';
670
+ let index = 0;
671
+ let inHtmlComment = initialInHtmlComment;
672
+ while (index < line.length) {
673
+ if (inHtmlComment) {
674
+ const commentEnd = line.indexOf('-->', index);
675
+ if (commentEnd === -1) {
676
+ return { text, inHtmlComment: true };
677
+ }
678
+ inHtmlComment = false;
679
+ index = commentEnd + 3;
680
+ continue;
681
+ }
682
+ const commentStart = line.indexOf('<!--', index);
683
+ const codeStart = line.indexOf('`', index);
684
+ const nextStop = [commentStart, codeStart].filter((value) => value !== -1).sort((left, right) => left - right)[0] ?? -1;
685
+ if (nextStop === -1) {
686
+ text += line.slice(index);
687
+ break;
688
+ }
689
+ text += line.slice(index, nextStop);
690
+ if (nextStop === commentStart) {
691
+ inHtmlComment = true;
692
+ index = commentStart + 4;
693
+ continue;
694
+ }
695
+ let tickCount = 1;
696
+ while (line[nextStop + tickCount] === '`') {
697
+ tickCount += 1;
698
+ }
699
+ const closingTicks = '`'.repeat(tickCount);
700
+ const closingIndex = line.indexOf(closingTicks, nextStop + tickCount);
701
+ if (closingIndex === -1) {
702
+ text += line.slice(nextStop);
703
+ break;
704
+ }
705
+ index = closingIndex + tickCount;
706
+ }
707
+ return { text, inHtmlComment };
708
+ }
709
+ function consumeOptionalMarkdownLinkTitle(line, startIndex) {
710
+ let cursor = startIndex;
711
+ while (line[cursor] && /\s/.test(line[cursor]))
712
+ cursor += 1;
713
+ if (line[cursor] === ')') {
714
+ return cursor;
715
+ }
716
+ const opener = line[cursor];
717
+ if (!opener || !['"', "'", '('].includes(opener)) {
718
+ return -1;
719
+ }
720
+ const closer = opener === '(' ? ')' : opener;
721
+ cursor += 1;
722
+ while (cursor < line.length) {
723
+ const char = line[cursor];
724
+ if (char === closer && line[cursor - 1] !== '\\') {
725
+ cursor += 1;
726
+ while (line[cursor] && /\s/.test(line[cursor]))
727
+ cursor += 1;
728
+ return line[cursor] === ')' ? cursor : -1;
729
+ }
730
+ cursor += 1;
731
+ }
732
+ return -1;
733
+ }
734
+ function extractMarkdownLinkTargets(line, referenceTargets = new Map()) {
735
+ const targets = [];
736
+ const isReferenceDefinitionLine = /^\s*\[[^\]]+\]:/.test(line);
737
+ if (isReferenceDefinitionLine) {
738
+ return targets;
739
+ }
740
+ for (let index = 0; index < line.length; index += 1) {
741
+ const openBracket = line.indexOf('[', index);
742
+ if (openBracket === -1)
743
+ break;
744
+ if (openBracket > 0 && line[openBracket - 1] === '!') {
745
+ const closeImageAlt = findClosingMarkdownBracket(line, openBracket);
746
+ if (closeImageAlt !== -1 && line[closeImageAlt + 1] === '[') {
747
+ const closeImageReference = findClosingMarkdownBracket(line, closeImageAlt + 1);
748
+ index = closeImageReference === -1 ? closeImageAlt : closeImageReference;
749
+ }
750
+ else {
751
+ index = closeImageAlt === -1 ? openBracket : closeImageAlt;
752
+ }
753
+ continue;
754
+ }
755
+ if (isEscapedMarkdownCharacter(line, openBracket)) {
756
+ index = openBracket;
757
+ continue;
758
+ }
759
+ const closeBracket = findClosingMarkdownBracket(line, openBracket);
760
+ if (closeBracket === -1 || isEscapedMarkdownCharacter(line, closeBracket)) {
761
+ index = openBracket;
762
+ continue;
763
+ }
764
+ const nextChar = line[closeBracket + 1];
765
+ if (nextChar !== '(') {
766
+ if (!isReferenceDefinitionLine) {
767
+ if (nextChar === '[') {
768
+ const closeReference = line.indexOf(']', closeBracket + 2);
769
+ if (closeReference !== -1 && !isEscapedMarkdownCharacter(line, closeReference)) {
770
+ const rawReference = line.slice(closeBracket + 2, closeReference).trim();
771
+ const label = normalizeMarkdownReferenceLabel(rawReference || line.slice(openBracket + 1, closeBracket));
772
+ const target = referenceTargets.get(label);
773
+ if (target) {
774
+ targets.push(target);
775
+ }
776
+ index = closeReference;
777
+ continue;
778
+ }
779
+ }
780
+ const label = normalizeMarkdownReferenceLabel(line.slice(openBracket + 1, closeBracket));
781
+ const target = referenceTargets.get(label);
782
+ if (target) {
783
+ targets.push(target);
784
+ index = closeBracket;
785
+ continue;
786
+ }
787
+ }
788
+ index = openBracket;
789
+ continue;
790
+ }
791
+ let cursor = closeBracket + 2;
792
+ let target = '';
793
+ if (line[cursor] === '<') {
794
+ cursor += 1;
795
+ const closeAngle = line.indexOf('>', cursor);
796
+ if (closeAngle === -1) {
797
+ index = cursor;
798
+ continue;
799
+ }
800
+ target = line.slice(cursor, closeAngle);
801
+ cursor = consumeOptionalMarkdownLinkTitle(line, closeAngle + 1);
802
+ if (cursor === -1) {
803
+ index = closeAngle + 1;
804
+ continue;
805
+ }
806
+ targets.push(target);
807
+ index = cursor;
808
+ continue;
809
+ }
810
+ let depth = 0;
811
+ let quote = '';
812
+ for (; cursor < line.length; cursor += 1) {
813
+ const char = line[cursor];
814
+ if ((char === '"' || char === "'") && !quote) {
815
+ quote = char;
816
+ continue;
817
+ }
818
+ if (quote) {
819
+ if (char === quote && line[cursor - 1] !== '\\') {
820
+ quote = '';
821
+ }
822
+ continue;
823
+ }
824
+ if (char === '(') {
825
+ depth += 1;
826
+ continue;
827
+ }
828
+ if (char === ')') {
829
+ if (depth === 0) {
830
+ target = line.slice(closeBracket + 2, cursor).trim();
831
+ targets.push(target);
832
+ index = cursor;
833
+ break;
834
+ }
835
+ depth -= 1;
836
+ }
837
+ }
838
+ }
839
+ return targets;
840
+ }
841
+ function extractFrontmatterSourcePaths(content) {
842
+ const block = extractFrontmatterBlock(content);
843
+ if (!block) {
844
+ return [];
845
+ }
846
+ const lines = block.yaml.replace(/\r\n?/g, '\n').split('\n');
847
+ const values = [];
848
+ for (let index = 0; index < lines.length; index++) {
849
+ const match = /^source_paths:\s*(.*)$/.exec(lines[index]);
850
+ if (!match) {
851
+ continue;
852
+ }
853
+ const value = stripYamlInlineComment(match[1]).trim();
854
+ if (value.startsWith('[') && value.endsWith(']')) {
855
+ values.push(...parseInlineYamlSequence(value));
856
+ continue;
857
+ }
858
+ if (value !== '') {
859
+ values.push(normalizeYamlPathScalar(value));
860
+ continue;
861
+ }
862
+ const sourcePathsIndent = lines[index].match(/^\s*/)?.[0].length ?? 0;
863
+ for (let listIndex = index + 1; listIndex < lines.length; listIndex++) {
864
+ const line = lines[listIndex];
865
+ const trimmedLine = line.trim();
866
+ if (!trimmedLine || /^#/.test(trimmedLine)) {
867
+ continue;
868
+ }
869
+ const listEntry = /^\s*-\s*(.*)$/.exec(line);
870
+ if (listEntry) {
871
+ values.push(normalizeYamlPathScalar(listEntry[1].trim()));
872
+ index = listIndex;
873
+ continue;
874
+ }
875
+ const lineIndent = line.match(/^\s*/)?.[0].length ?? 0;
876
+ if (lineIndent > sourcePathsIndent) {
877
+ continue;
878
+ }
879
+ break;
880
+ }
881
+ }
882
+ return uniqueSorted(values
883
+ .map((entry) => canonicalRepoRelativePath(normalizeYamlPathScalar(entry).trim()))
884
+ .filter(Boolean));
885
+ }
886
+ function parseInlineYamlSequence(value) {
887
+ try {
888
+ const parsed = JSON.parse(value);
889
+ if (Array.isArray(parsed)) {
890
+ return parsed.filter((entry) => typeof entry === 'string');
891
+ }
892
+ }
893
+ catch {
894
+ // Fall through to permissive YAML-style parsing.
895
+ }
896
+ const trimmed = value.trim();
897
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
898
+ return [];
899
+ }
900
+ const entries = [];
901
+ let current = '';
902
+ let quote = '';
903
+ for (const char of trimmed.slice(1, -1)) {
904
+ if (quote) {
905
+ current += char;
906
+ if (char === quote) {
907
+ quote = '';
908
+ }
909
+ continue;
910
+ }
911
+ if (char === '"' || char === "'") {
912
+ quote = char;
913
+ current += char;
914
+ continue;
915
+ }
916
+ if (char === ',') {
917
+ if (current.trim()) {
918
+ entries.push(unquoteYamlScalar(current.trim()));
919
+ }
920
+ current = '';
921
+ continue;
922
+ }
923
+ current += char;
924
+ }
925
+ if (current.trim()) {
926
+ entries.push(unquoteYamlScalar(current.trim()));
927
+ }
928
+ return entries;
929
+ }
930
+ function canonicalRepoRelativePath(value) {
931
+ const normalized = path.posix.normalize(normalizeRepoPath(String(value || '').trim()).replace(/^\.\//, ''));
932
+ if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
933
+ return '';
934
+ }
935
+ return normalized.replace(/^\.\//, '');
936
+ }
937
+ function unquoteYamlScalar(value) {
938
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
939
+ try {
940
+ return String(JSON.parse(value));
941
+ }
942
+ catch {
943
+ return value.slice(1, -1);
944
+ }
945
+ }
946
+ if (value.startsWith('\'') && value.endsWith('\'') && value.length >= 2) {
947
+ return value.slice(1, -1).replace(/''/g, '\'');
948
+ }
949
+ return value;
950
+ }
951
+ /**
952
+ * Build a PromptContext for a module page from an assembled PageContext and plan module entry.
953
+ * Maps the context-assembler output format to the prompt-template input format.
954
+ */
955
+ function buildModulePromptContext(pageCtx, manifest, module, existingContent) {
956
+ return {
957
+ pageName: String(pageCtx.page.path).replace(/\.md$/, ''),
958
+ pageTitle: module.name,
959
+ repoRemote: manifest.remote,
960
+ repoCommit: manifest.commit,
961
+ sourceCards: pageCtx.source_inputs.map((si) => ({
962
+ path: si.path,
963
+ category: si.category,
964
+ language: si.language,
965
+ symbols: si.symbols,
966
+ imports: si.imports,
967
+ reasons: si.reasons,
968
+ runtime_hints: si.runtime_hints,
969
+ environment_variables: si.environment_variables,
970
+ routes: si.routes,
971
+ migrations: si.migrations,
972
+ models: si.models,
973
+ excerpt: si.excerpt,
974
+ })),
975
+ docCards: pageCtx.documentation_inputs.map((di) => ({
976
+ path: di.path,
977
+ status: di.status,
978
+ claims: di.claims,
979
+ excerpt: di.excerpt,
980
+ })),
981
+ existingContent,
982
+ docsOnlyModule: isDocsOnlyModule(module),
983
+ moduleInfo: {
984
+ name: module.name,
985
+ slug: module.slug,
986
+ files: module.files,
987
+ categories: module.categories,
988
+ languages: module.languages,
989
+ important_reasons: module.important_reasons,
990
+ },
991
+ };
992
+ }
993
+ /**
994
+ * Build a PromptContext for the Architecture page from an assembled PageContext.
995
+ * Maps the context-assembler output format to the prompt-template input format.
996
+ */
997
+ function buildArchitecturePromptContext(pageCtx, manifest, existingContent) {
998
+ return {
999
+ pageName: 'Architecture',
1000
+ pageTitle: 'Architecture',
1001
+ repoRemote: manifest.remote,
1002
+ repoCommit: manifest.commit,
1003
+ sourceCards: pageCtx.source_inputs.map((si) => ({
1004
+ path: si.path,
1005
+ category: si.category,
1006
+ language: si.language,
1007
+ symbols: si.symbols,
1008
+ imports: si.imports,
1009
+ reasons: si.reasons,
1010
+ runtime_hints: si.runtime_hints,
1011
+ environment_variables: si.environment_variables,
1012
+ routes: si.routes,
1013
+ migrations: si.migrations,
1014
+ models: si.models,
1015
+ excerpt: si.excerpt,
1016
+ })),
1017
+ docCards: pageCtx.documentation_inputs.map((di) => ({
1018
+ path: di.path,
1019
+ status: di.status,
1020
+ claims: di.claims,
1021
+ excerpt: di.excerpt,
1022
+ })),
1023
+ existingContent,
1024
+ };
1025
+ }
1026
+ /**
1027
+ * Normalize LLM-generated Architecture page content by enforcing canonical
1028
+ * provenance frontmatter fields (source_repo, source_commit, page_state,
1029
+ * source_paths) using the prompt context source paths that were actually
1030
+ * provided to the model.
1031
+ */
1032
+ function normalizeLLMArchitectureContent(content, manifest, requestSourcePaths = [], archFingerprint) {
1033
+ if (!content.startsWith('---\n')) {
1034
+ return content;
1035
+ }
1036
+ const closing = content.indexOf('\n---', 4);
1037
+ if (closing === -1) {
1038
+ return content;
1039
+ }
1040
+ const frontmatterRaw = content.slice(4, closing);
1041
+ const body = content.slice(closing);
1042
+ const sourcePaths = uniqueSorted((requestSourcePaths || []).filter((value) => typeof value === 'string' && value.trim())).slice(0, 20);
1043
+ const lines = removeNormalizedFrontmatterFields(frontmatterRaw.split('\n'), /* removeConservativeEvidenceFields= */ false);
1044
+ const withoutNormalized = lines.filter((line) => line.trim().length > 0);
1045
+ const normalizedLines = [
1046
+ `source_repo: ${JSON.stringify(manifest.remote)}`,
1047
+ `source_commit: ${JSON.stringify(manifest.commit)}`,
1048
+ 'page_state: "generated"',
1049
+ `source_paths: ${JSON.stringify(sourcePaths)}`,
1050
+ ...(archFingerprint ? [`${ARCH_FINGERPRINT_FIELD}: "${archFingerprint}"`] : []),
1051
+ ...withoutNormalized
1052
+ ];
1053
+ return `---\n${normalizedLines.join('\n')}${body}`;
1054
+ }
1055
+ /**
1056
+ * Frontmatter field used to store the architecture inputs fingerprint for LLM gating.
1057
+ * The value is a 16-hex-char SHA-256 prefix of the normalized architecture-input payload.
1058
+ */
1059
+ const ARCH_FINGERPRINT_FIELD = 'arch_inputs_fingerprint';
1060
+ /**
1061
+ * Normalize volatile fields in Architecture.md content for structural comparison.
1062
+ * Strips compiled_at timestamp and the short commit hash from the mermaid structural map.
1063
+ */
1064
+ function normalizeArchForComparison(content) {
1065
+ return content
1066
+ .replace(/^(compiled_at: )"[^"]*"$/m, '$1""')
1067
+ .replace(/^page_state: "[^"]*"$/m, 'page_state: "generated"')
1068
+ .replace(/<!-- HUMAN_NOTES_START -->[\s\S]*?<!-- HUMAN_NOTES_END -->/g, '<!-- HUMAN_NOTES_START -->\n<!-- HUMAN_NOTES_END -->')
1069
+ .replace(/\n{3,}/g, '\n\n')
1070
+ .replace(/\bRepository at [^\]]+\]/g, 'Repository at ]');
1071
+ }
1072
+ /**
1073
+ * Extract the ordered list of module names from the `## Module groups` section.
1074
+ * HUMAN_NOTES content is ignored so user-authored `###` headings do not affect
1075
+ * architecture change detection.
1076
+ */
1077
+ function extractArchitectureModuleNames(content) {
1078
+ const names = [];
1079
+ const normalizedBody = splitFrontmatterAndBody(content).body
1080
+ .replace(/<!-- HUMAN_NOTES_START -->[\s\S]*?<!-- HUMAN_NOTES_END -->/g, '<!-- HUMAN_NOTES_START -->\n<!-- HUMAN_NOTES_END -->');
1081
+ const moduleGroups = extractSection(normalizedBody, 'Module groups') || normalizedBody;
1082
+ const re = /^### (.+)$/gm;
1083
+ let match;
1084
+ while ((match = re.exec(moduleGroups)) !== null) {
1085
+ names.push(match[1].trim());
1086
+ }
1087
+ return names;
1088
+ }
1089
+ /**
1090
+ * Compute the Architecture.md handling decision by comparing new vs existing content.
1091
+ *
1092
+ * Returns:
1093
+ * - 'skipped' if content is effectively unchanged after normalizing volatile fields.
1094
+ * - 'section-patched' if only module group sections changed within the same module list.
1095
+ * - 'full-regenerated' if the module list or broader structure changed.
1096
+ */
1097
+ export function computeArchDecision(newContent, existingContent) {
1098
+ if (!existingContent) {
1099
+ return 'full-regenerated';
1100
+ }
1101
+ const normalizedNew = normalizeArchForComparison(newContent);
1102
+ const normalizedExisting = normalizeArchForComparison(existingContent);
1103
+ if (normalizedNew === normalizedExisting) {
1104
+ return 'skipped';
1105
+ }
1106
+ // Check if the module list is unchanged – prerequisite for safe section patching.
1107
+ const newModules = extractArchitectureModuleNames(newContent);
1108
+ const existingModules = extractArchitectureModuleNames(existingContent);
1109
+ const sameModuleList = (newModules.length > 0 &&
1110
+ newModules.length === existingModules.length &&
1111
+ newModules.every((name, i) => name === existingModules[i]));
1112
+ return sameModuleList ? 'section-patched' : 'full-regenerated';
1113
+ }
1114
+ function splitFrontmatterAndBody(content) {
1115
+ if (!content.startsWith('---\n')) {
1116
+ return { frontmatter: '', body: content };
1117
+ }
1118
+ const end = content.indexOf('\n---\n', 4);
1119
+ if (end === -1) {
1120
+ return { frontmatter: '', body: content };
1121
+ }
1122
+ return { frontmatter: content.slice(0, end + 5), body: content.slice(end + 5) };
1123
+ }
1124
+ function sectionBounds(body, heading) {
1125
+ const marker = `## ${heading}\n`;
1126
+ const start = body.indexOf(marker);
1127
+ if (start === -1) {
1128
+ return null;
1129
+ }
1130
+ const next = body.indexOf('\n## ', start + marker.length);
1131
+ return { start, end: next === -1 ? body.length : next + 1 };
1132
+ }
1133
+ function extractSection(body, heading) {
1134
+ const bounds = sectionBounds(body, heading);
1135
+ return bounds ? body.slice(bounds.start, bounds.end) : null;
1136
+ }
1137
+ function replaceSection(body, heading, replacement) {
1138
+ const bounds = sectionBounds(body, heading);
1139
+ if (!bounds) {
1140
+ return body;
1141
+ }
1142
+ return `${body.slice(0, bounds.start)}${replacement.trimEnd()}\n\n${body.slice(bounds.end)}`;
1143
+ }
1144
+ function patchArchitectureSections(existingContent, newContent) {
1145
+ const existingParts = splitFrontmatterAndBody(existingContent);
1146
+ const newParts = splitFrontmatterAndBody(newContent);
1147
+ const newStructuralMap = extractSection(newParts.body, 'Structural map');
1148
+ const newModuleGroups = extractSection(newParts.body, 'Module groups');
1149
+ const newSignals = extractSection(newParts.body, 'Architecture signals');
1150
+ if (!newStructuralMap || !newModuleGroups || !newSignals) {
1151
+ return null;
1152
+ }
1153
+ let patchedBody = existingParts.body;
1154
+ if (!extractSection(patchedBody, 'Structural map') || !extractSection(patchedBody, 'Module groups') || !extractSection(patchedBody, 'Architecture signals')) {
1155
+ return null;
1156
+ }
1157
+ patchedBody = replaceSection(patchedBody, 'Structural map', newStructuralMap);
1158
+ patchedBody = replaceSection(patchedBody, 'Module groups', newModuleGroups);
1159
+ patchedBody = replaceSection(patchedBody, 'Architecture signals', newSignals);
1160
+ return `${newParts.frontmatter}${patchedBody}`;
1161
+ }
1162
+ function architectureUntouchedContent(content) {
1163
+ let normalized = normalizeArchForComparison(content)
1164
+ .replace(/<!-- HUMAN_NOTES_START -->[\s\S]*?<!-- HUMAN_NOTES_END -->/g, '');
1165
+ const { frontmatter, body } = splitFrontmatterAndBody(normalized);
1166
+ let remainder = body;
1167
+ for (const heading of ['Structural map', 'Module groups', 'Architecture signals']) {
1168
+ const section = extractSection(remainder, heading);
1169
+ if (section) {
1170
+ remainder = remainder.replace(section, '');
1171
+ }
1172
+ }
1173
+ return `${frontmatter}${remainder}`.replace(/\n{3,}/g, '\n\n').trim();
1174
+ }
1175
+ /**
1176
+ * Compute a short fingerprint of architecture inputs for LLM mode gating.
1177
+ * The fingerprint is derived from a normalized architecture-input payload built from the
1178
+ * current manifest/plan slices that influence architecture synthesis.
1179
+ */
1180
+ function computeArchInputsFingerprint(manifest, plan) {
1181
+ const modules = (plan?.modules || []).map((module) => ({
1182
+ name: module.name,
1183
+ slug: module.slug,
1184
+ files: [...(module.files || [])].sort(),
1185
+ important_reasons: [...(module.important_reasons || [])].sort()
1186
+ }));
1187
+ const dependencyEdges = (manifest?.analysis?.dependency_graph?.edges || [])
1188
+ .filter((edge) => typeof edge?.from === 'string' && typeof edge?.to === 'string')
1189
+ .map((edge) => ({ from: edge.from, to: edge.to, specifier: edge.specifier || null }))
1190
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
1191
+ const routeSignals = (manifest?.files || [])
1192
+ .flatMap((file) => (file.route_surfaces || []).map((route) => ({
1193
+ path: file.path,
1194
+ framework: route.framework || null,
1195
+ methods: [...(route.methods || [])].sort(),
1196
+ route_path: route.path || null,
1197
+ handler: route.handler || null
1198
+ })))
1199
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
1200
+ const envSignals = (manifest?.files || [])
1201
+ .filter((file) => (file.environment_variables || []).length > 0)
1202
+ .map((file) => ({ path: file.path, env: [...(file.environment_variables || [])].sort() }))
1203
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
1204
+ const dataSignals = (manifest?.files || [])
1205
+ .filter((file) => (file.migration_surfaces || []).length > 0 || (file.model_surfaces || []).length > 0)
1206
+ .map((file) => ({
1207
+ path: file.path,
1208
+ migrations: (file.migration_surfaces || []).map((entry) => ({ kind: entry.kind || null, id: entry.id || null, name: entry.name || null })),
1209
+ models: (file.model_surfaces || []).map((entry) => ({ name: entry.name || null, kind: entry.kind || null, framework: entry.framework || null }))
1210
+ }))
1211
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
1212
+ const securitySignals = (manifest?.files || [])
1213
+ .filter((file) => (file.reasons || []).some((reason) => ['auth', 'billing-or-payment'].includes(reason)))
1214
+ .map((file) => ({ path: file.path, reasons: [...(file.reasons || [])].sort() }))
1215
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
1216
+ const infrastructureSignals = (manifest?.files || [])
1217
+ .filter((file) => file.category === 'infra' || (file.runtime_hints || []).includes('deployment'))
1218
+ .map((file) => ({ path: file.path, category: file.category || null, runtime_hints: [...(file.runtime_hints || [])].sort() }))
1219
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
1220
+ const payload = { modules, dependencyEdges, routeSignals, envSignals, dataSignals, securitySignals, infrastructureSignals };
1221
+ return createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16);
1222
+ }
1223
+ /**
1224
+ * Extract the stored arch_inputs_fingerprint from an Architecture.md frontmatter.
1225
+ * Returns null when the field is absent (e.g. first run or deterministic render).
1226
+ */
1227
+ function extractArchFingerprint(content) {
1228
+ const match = new RegExp(`^${ARCH_FINGERPRINT_FIELD}: "([^"]+)"$`, 'm').exec(content);
1229
+ return match ? match[1] : null;
1230
+ }
1231
+ function extractFrontmatterString(content, key) {
1232
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1233
+ const match = new RegExp(`^${escaped}: \"([^\"]+)\"$`, 'm').exec(content);
1234
+ return match ? match[1] : null;
1235
+ }
1236
+ function isDocsOnlyModule(module) {
1237
+ const files = Array.isArray(module?.files) ? module.files : [];
1238
+ return files.length > 0 && files.every((entry) => isDocumentationPath(entry));
1239
+ }
1240
+ function formatLLMError(entry) {
1241
+ const issueSummary = Array.isArray(entry.issues) && entry.issues.length > 0
1242
+ ? ` (${entry.issues.map((issue) => `${issue.code}: ${issue.message}`).join(', ')})`
1243
+ : '';
1244
+ return `${entry.file}: ${entry.error}${issueSummary}`;
1245
+ }
1246
+ function resolveCompilerMode(compilerConfig) {
1247
+ const envMode = typeof process.env.LLMWIKI_COMPILER_MODE === 'string' && process.env.LLMWIKI_COMPILER_MODE.trim()
1248
+ ? process.env.LLMWIKI_COMPILER_MODE.trim()
1249
+ : undefined;
1250
+ if (envMode) {
1251
+ return envMode;
1252
+ }
1253
+ try {
1254
+ return resolveProviderConfig(compilerConfig ?? {}).mode || 'deterministic';
1255
+ }
1256
+ catch {
1257
+ return typeof compilerConfig?.mode === 'string' ? compilerConfig.mode : 'deterministic';
1258
+ }
1259
+ }
1260
+ function normalizeLLMGeneratedContent(content, manifest, module) {
1261
+ if (!content.startsWith('---\n')) {
1262
+ return content;
1263
+ }
1264
+ const closing = content.indexOf('\n---', 4);
1265
+ if (closing === -1) {
1266
+ return content;
1267
+ }
1268
+ const frontmatterRaw = content.slice(4, closing);
1269
+ const sourcePaths = Array.isArray(module?.files) && module.files.length > 0 ? module.files.slice(0, 20) : collectPrimarySourcePaths(manifest).slice(0, 20);
1270
+ const docsOnlyModule = sourcePaths.length > 0 && sourcePaths.every((entry) => isDocumentationPath(entry));
1271
+ const body = normalizeLLMGeneratedBody(content.slice(closing), docsOnlyModule);
1272
+ const lines = removeNormalizedFrontmatterFields(frontmatterRaw.split('\n'), docsOnlyModule);
1273
+ const withoutNormalized = lines.filter((line) => line.trim().length > 0);
1274
+ const normalizedLines = [
1275
+ `source_repo: ${JSON.stringify(manifest.remote)}`,
1276
+ `source_commit: ${JSON.stringify(manifest.commit)}`,
1277
+ 'page_state: "generated"',
1278
+ `source_paths: ${JSON.stringify(sourcePaths)}`,
1279
+ ...(docsOnlyModule ? ['claim_status: "review-needed"', 'confidence: "low"'] : []),
1280
+ ...withoutNormalized
1281
+ ];
1282
+ return `---\n${normalizedLines.join('\n')}${body}`;
1283
+ }
1284
+ function normalizeLLMGeneratedBody(body, docsOnlyModule) {
1285
+ if (!docsOnlyModule || hasSecondaryDocumentationLabel(body)) {
1286
+ return body;
1287
+ }
1288
+ const evidenceNote = [
1289
+ '',
1290
+ '> Evidence note: This module page is generated from markdown documentation only. Markdown documentation is secondary evidence; operational and current-behavior claims must be validated against source code, tests, CI workflows, runtime configuration, or schemas before being treated as authoritative.',
1291
+ ''
1292
+ ].join('\n');
1293
+ const titleMatch = /^(\n---[^\n]*\n\s*# [^\n]+\n?)/.exec(body);
1294
+ if (titleMatch) {
1295
+ return `${titleMatch[1]}${evidenceNote}${body.slice(titleMatch[1].length)}`;
1296
+ }
1297
+ return `${body}${evidenceNote}`;
1298
+ }
1299
+ function hasSecondaryDocumentationLabel(content) {
1300
+ return /(secondary evidence|secondary documentation|unvalidated documentation|markdown documentation is ingested as secondary evidence)/i.test(content);
1301
+ }
1302
+ function removeNormalizedFrontmatterFields(lines, removeConservativeEvidenceFields = false) {
1303
+ const normalizedFields = new Set(['source_repo', 'source_commit', 'page_state', 'source_paths', ARCH_FINGERPRINT_FIELD]);
1304
+ if (removeConservativeEvidenceFields) {
1305
+ normalizedFields.add('claim_status');
1306
+ normalizedFields.add('confidence');
1307
+ }
1308
+ const result = [];
1309
+ for (let index = 0; index < lines.length;) {
1310
+ const line = lines[index];
1311
+ const match = /^([A-Za-z0-9_-]+):(?:\s|$)/.exec(line);
1312
+ if (match && normalizedFields.has(match[1])) {
1313
+ index++;
1314
+ while (index < lines.length && (/^\s+\S/.test(lines[index]) || lines[index].trim().length === 0)) {
1315
+ index++;
1316
+ }
1317
+ continue;
1318
+ }
1319
+ result.push(line);
1320
+ index++;
1321
+ }
1322
+ return result;
1323
+ }
1324
+ function isNodeError(error) {
1325
+ return error instanceof Error && 'code' in error;
1326
+ }
1327
+ /**
1328
+ * Update the page_state frontmatter field to "mixed".
1329
+ * - If `page_state: "generated"` is present, replaces it.
1330
+ * - If no `page_state:` field is present, injects one into the frontmatter block.
1331
+ * - If `page_state: "mixed"` or any other value is already present, leaves it unchanged.
1332
+ */
1333
+ function setPageStateMixed(content) {
1334
+ if (/^page_state: "generated"/m.test(content)) {
1335
+ return content.replace(/^page_state: "generated"/m, 'page_state: "mixed"');
1336
+ }
1337
+ if (/^page_state:/m.test(content)) {
1338
+ // Already set to something other than "generated"; leave it.
1339
+ return content;
1340
+ }
1341
+ // No page_state field – inject it as the first field in the frontmatter block.
1342
+ // The pattern anchors to the absolute start of the document to avoid matching
1343
+ // any `---\n` sequences that may appear in the content body.
1344
+ return content.replace(/^---\n/, '---\npage_state: "mixed"\n');
1345
+ }
1346
+ function frontmatter(manifest, extra = {}) {
1347
+ const kind = typeof extra.kind === 'string' ? extra.kind : undefined;
1348
+ const fields = {
1349
+ source_repo: manifest.remote,
1350
+ source_commit: manifest.commit,
1351
+ compiled_at: new Date().toISOString(),
1352
+ ...extra,
1353
+ confidence: extra.confidence || confidenceForKind(kind),
1354
+ page_state: 'generated'
1355
+ };
1356
+ const lines = ['---'];
1357
+ for (const [key, value] of Object.entries(fields)) {
1358
+ lines.push(`${key}: ${JSON.stringify(value)}`);
1359
+ }
1360
+ lines.push('---', '');
1361
+ return lines.join('\n');
1362
+ }
1363
+ function wikiLink(page) {
1364
+ return `[${page.replace(/\.md$/, '').replaceAll('-', ' ')}](${page.replace(/\.md$/, '')})`;
1365
+ }
1366
+ function renderHome(manifest, plan) {
1367
+ return `${frontmatter(manifest, { kind: 'home' })}# Repository Knowledge Base\n\n## Start here\n\n- ${wikiLink('Agent-Context-Pack.md')}\n- ${wikiLink('Repository-Overview.md')}\n- ${wikiLink('Architecture.md')}\n- ${wikiLink('Build-Test-and-Run.md')}\n- ${wikiLink('Index.md')}\n\n## Important rule\n\nSource code at the pinned commit is authoritative. Tests, CI, and generated schemas are high-authority evidence. Markdown documentation is ingested as configurable secondary evidence and must be validated before it changes generated claims.\n\n## Generated module pages\n\n${(plan.modules || []).slice(0, 20).map((module) => `- [${module.name}](${module.slug})`).join('\n') || '- No module pages generated.'}\n`;
1368
+ }
1369
+ function renderSidebar(manifest, plan) {
1370
+ const moduleLinks = (plan.modules || []).slice(0, 25).map((module) => ` - [${module.name}](${module.slug})`).join('\n');
1371
+ return `${frontmatter(manifest, { kind: 'sidebar' })}# Navigation\n\n- [Home](Home)\n- [Agent Context Pack](Agent-Context-Pack)\n- [Repository Overview](Repository-Overview)\n- [Architecture](Architecture)\n- [Build, Test, and Run](Build-Test-and-Run)\n- [Index](Index)\n- [Log](Log)\n\n## Modules\n\n${moduleLinks || '- No module pages generated.'}\n\n## Cross-cutting\n\n- [Dependency Map](Dependency-Map)\n- [Testing Strategy](Testing-Strategy)\n- [Configuration and Environment](Configuration-and-Environment)\n- [Security and Secrets](Security-and-Secrets)\n- [Operational Runbook](Operational-Runbook)\n- [Documentation Debt Report](Documentation-Debt-Report)
1372
+ - [Open Questions](Open-Questions)\n`;
1373
+ }
1374
+ function renderIndex(manifest, plan) {
1375
+ return `${frontmatter(manifest, { kind: 'index' })}# Index\n\n## Foundation\n\n${plan.pages.filter((page) => page.phase === 'foundation').map((page) => `- ${wikiLink(page.path)} - ${page.purpose}`).join('\n')}\n\n## Modules\n\n${plan.pages.filter((page) => page.phase === 'modules').map((page) => `- ${wikiLink(page.path)} - ${page.purpose}`).join('\n') || '- No module pages generated.'}\n\n## Cross-cutting\n\n${plan.pages.filter((page) => page.phase === 'cross-cutting').map((page) => `- ${wikiLink(page.path)} - ${page.purpose}`).join('\n')}\n\n## Source inventory summary\n\n\`\`\`json\n${JSON.stringify(manifest.totals, null, 2)}\n\`\`\`\n`;
1376
+ }
1377
+ function renderLog(manifest, plan) {
1378
+ return `${frontmatter(manifest, { kind: 'log' })}# Wiki Compilation Log\n\n## ${new Date().toISOString().slice(0, 10)} | ${manifest.mode} | ${manifest.commit}\n\nGenerated initial wiki scaffold.\n\n- Files scanned: ${manifest.files.length}\n- Planned pages: ${plan.pages.length}\n- Module groups: ${plan.modules.length}\n\n`;
1379
+ }
1380
+ function renderAgentContextPack(manifest, plan) {
1381
+ const topModules = (plan.modules || []).slice(0, 10);
1382
+ const compiledAt = new Date().toISOString();
1383
+ return `${frontmatter(manifest, {
1384
+ kind: 'agent_context_pack',
1385
+ claim_status: 'grounded',
1386
+ source_paths: collectPrimarySourcePaths(manifest).slice(0, 50)
1387
+ })}# Agent Context Pack\n\nThis page is the compact entry point for coding agents and developers.\n\n## Repository snapshot\n\n- Source: \`${manifest.remote}\`\n- Commit: \`${manifest.commit}\`\n- Last compiled: \`${compiledAt}\`\n- Files scanned: ${manifest.files.length}\n\n## Read first\n\n1. ${wikiLink('Architecture.md')}\n2. ${wikiLink('Build-Test-and-Run.md')}\n3. ${wikiLink('Index.md')}\n4. Relevant module page from the routing table below\n\n## Task routing\n\n| Task | Read these pages first |\n|---|---|\n${topModules.map((module) => `| Work in ${module.name} | [${module.name}](${module.slug}), ${wikiLink('Testing-Strategy.md')}, ${wikiLink('Dependency-Map.md')} |`).join('\n') || '| General change | Architecture, Build Test and Run, Index |'}\n\n## Verification policy\n\nRun the repository's own test, lint, and type-check commands when available. If commands are not detected, inspect package manifests and CI workflows before changing behavior.\n\n## Confidence rule\n\nThe wiki is generated from source cards and documentation cards. Treat code, tests, CI, and config as authoritative when there is disagreement. Treat markdown documentation as useful but potentially stale unless marked validated.\n`;
1388
+ }
1389
+ function renderRepositoryOverview(manifest, plan) {
1390
+ return `${frontmatter(manifest, {
1391
+ kind: 'repository_overview',
1392
+ claim_status: 'grounded',
1393
+ source_paths: collectPrimarySourcePaths(manifest).slice(0, 50)
1394
+ })}# Repository Overview\n\n## Languages\n\n${tableFromObject(manifest.totals.languages, ['Language', 'Files'])}\n\n## File categories\n\n${tableFromObject(manifest.totals.categories, ['Category', 'Files'])}\n\n## Main knowledge units\n\n${(plan.modules || []).map((module) => `- [${module.name}](${module.slug}) - ${module.files.length} files`).join('\n')}\n`;
1395
+ }
1396
+ function renderArchitecture(manifest, plan) {
1397
+ const dependencySummary = manifest.analysis?.dependency_graph?.summary || {};
1398
+ const routeFiles = (manifest.files || []).filter((file) => (file.route_surfaces?.length ?? 0) > 0).length;
1399
+ const configFiles = (manifest.files || []).filter((file) => (file.environment_variables?.length ?? 0) > 0).length;
1400
+ const dataModelFiles = (manifest.files || []).filter((file) => (file.migration_surfaces?.length ?? 0) > 0 || (file.model_surfaces?.length ?? 0) > 0).length;
1401
+ const securityFiles = (manifest.files || []).filter((file) => (file.reasons || []).some((reason) => ['auth', 'billing-or-payment'].includes(reason))).length;
1402
+ const infrastructureFiles = (manifest.files || []).filter((file) => file.category === 'infra' || (file.runtime_hints || []).includes('deployment')).length;
1403
+ return `${frontmatter(manifest, {
1404
+ kind: 'architecture',
1405
+ claim_status: 'grounded',
1406
+ source_paths: collectPrimarySourcePaths(manifest).slice(0, 50)
1407
+ })}# Architecture
1408
+
1409
+ This page is a first-pass architecture summary based on repository structure. The production compiler should replace this with an LLM-reviewed synthesis that uses source cards and targeted code excerpts.
1410
+
1411
+ ## Structural map
1412
+
1413
+ \`\`\`mermaid
1414
+ flowchart TD
1415
+ Repo[Repository at ${shortCommit(manifest.commit)}]
1416
+ ${(plan.modules || []).slice(0, 12).map((module, index) => ` Repo --> M${index}[${escapeMermaid(module.name)}]`).join('\n')}
1417
+ \`\`\`
1418
+
1419
+ ## Module groups
1420
+
1421
+ ${(plan.modules || []).map((module) => `### ${module.name}
1422
+
1423
+ - Files: ${module.files.length}
1424
+ - Dominant categories: ${Object.keys(module.categories).join(', ') || 'unknown'}
1425
+ - Dominant languages: ${Object.keys(module.languages).join(', ') || 'unknown'}
1426
+ - Important reasons: ${module.important_reasons.join(', ') || 'none detected'}
1427
+ `).join('\n')}
1428
+
1429
+ ## Architecture signals
1430
+
1431
+ - Module groups: ${(plan.modules || []).length}
1432
+ - Dependency edges: ${dependencySummary.edges ?? 0}
1433
+ - Route-bearing files: ${routeFiles}
1434
+ - Config-bearing files: ${configFiles}
1435
+ - Data-model files: ${dataModelFiles}
1436
+ - Security-sensitive files: ${securityFiles}
1437
+ - Infrastructure files: ${infrastructureFiles}
1438
+ `;
1439
+ }
1440
+ function renderBuildTestAndRun(manifest) {
1441
+ const packageFiles = manifest.files.filter((file) => file.path.endsWith('package.json'));
1442
+ const ciFiles = manifest.files.filter((file) => file.category === 'ci');
1443
+ const packageScripts = (manifest.analysis?.package_scripts || []).filter((entry) => Object.keys(entry.scripts || {}).length > 0);
1444
+ const scriptRows = packageScripts.flatMap((entry) => Object.entries(entry.scripts || {}).map(([name, command]) => [
1445
+ sourcePathLink(manifest, entry.path, findNamedSourceRange(entry.script_sources, name)),
1446
+ entry.name ? code(entry.name) : 'unknown',
1447
+ code(name),
1448
+ code(redactSensitiveText(String(command)))
1449
+ ]));
1450
+ const scriptsSection = scriptRows.length
1451
+ ? `## Package scripts\n\n- Package manifests with scripts: ${packageScripts.length}\n- Scripts detected: ${scriptRows.length}\n\n${markdownTable(['Manifest', 'Package', 'Script', 'Command'], scriptRows)}\n`
1452
+ : `## Package scripts\n\nNo package scripts were extracted from manifest analysis. Inspect package manifests, task runners, and CI workflows directly when confirming canonical commands.\n`;
1453
+ const ciCommandSources = manifest.analysis?.ci_workflow_command_sources || [];
1454
+ const ciCommandsSection = ciCommandSources.length
1455
+ ? `## CI workflow commands\n\n- Commands detected: ${ciCommandSources.length}\n\n${markdownTable(['Source', 'Command'], ciCommandSources.map((entry) => [sourcePathLink(manifest, entry.path, entry), code(redactSensitiveText(entry.command))]))}\n`
1456
+ : manifest.analysis?.ci_workflow_commands?.length
1457
+ ? `## CI workflow commands\n\n- Commands detected: ${manifest.analysis.ci_workflow_commands.length}\n\n${manifest.analysis.ci_workflow_commands.map((command) => `- ${code(redactSensitiveText(command))}`).join('\n')}\n`
1458
+ : `## CI workflow commands\n\nNo workflow commands were extracted from CI analysis.\n`;
1459
+ return `${frontmatter(manifest, {
1460
+ kind: 'build_test_run',
1461
+ claim_status: 'grounded',
1462
+ source_paths: uniqueSorted([...packageFiles.map((file) => file.path), ...ciFiles.map((file) => file.path)]).slice(0, 50)
1463
+ })}# Build, Test, and Run\n\n## Detected package manifests\n\n${packageFiles.map((file) => `- ${sourcePathLink(manifest, file.path)}`).join('\n') || '- No package manifests detected.'}\n\n## Detected CI files\n\n${ciFiles.map((file) => `- ${sourcePathLink(manifest, file.path)}`).join('\n') || '- No CI files detected.'}\n\n${scriptsSection}\n${ciCommandsSection}\n## Manual verification guidance\n\nTreat extracted scripts as a starting point. Verify the canonical build, test, and run paths against CI workflows, container entrypoints, and deployment configs when they exist.\n`;
1464
+ }
1465
+ function renderOpenQuestions(manifest, plan) {
1466
+ const docs = manifest.documentation?.files || [];
1467
+ const reviewQueue = buildDocumentationReviewQueue(docs);
1468
+ const reviewRows = reviewQueue.map((entry) => `- \`${entry.path}\` - ${entry.reasons.join(', ')}.`);
1469
+ return `${frontmatter(manifest, {
1470
+ kind: 'open_questions',
1471
+ claim_status: 'review-needed',
1472
+ confidence: 'low',
1473
+ source_paths: uniqueSorted(reviewQueue.map((entry) => entry.path)).slice(0, 50)
1474
+ })}# Open Questions\n\n- What pages should be human-owned versus generated?\n- Which source paths should be excluded from wiki compilation?\n- Which modules require deeper AST-level extraction?\n- Which package manager and CI commands should be treated as canonical?\n- How should large files and generated files be summarized?\n- What confidence threshold should block publishing?\n\n## Documentation review queue\n\nDocumentation cards listed below are secondary evidence and require review. Do not promote these items as authoritative wiki claims until validated against source, tests, CI, config, or generated schemas.\n\n${reviewRows.join('\n') || '- No stale, contradicted, or unvalidated documentation findings detected.'}\n\n## Bootstrap gaps\n\n- This first-pass compiler uses repository structure, not an LLM synthesis pass.\n- Existing human wiki reconciliation is not implemented yet.\n- GitHub Wiki publishing is a placeholder.\n`;
1475
+ }
1476
+ function renderDocumentationDebtReport(manifest) {
1477
+ const docs = manifest.documentation?.files || [];
1478
+ const summary = manifest.documentation?.summary || {};
1479
+ const rows = docs.slice(0, 100).map((doc) => `| \`${doc.path}\` | ${doc.status} | ${doc.authority} | ${doc.age_days} | ${doc.claims?.length || 0} | ${doc.validation?.commands?.length || 0} | ${doc.validation?.env_vars?.length || 0} |`);
1480
+ // Build merged package scripts from manifest analysis for command validation
1481
+ const allPackageScripts = mergePackageScripts(manifest);
1482
+ // Classify all documented commands against known package scripts and CI
1483
+ // workflow commands extracted into the scan manifest.
1484
+ const allDocCommands = docs.flatMap((doc) => doc.validation?.commands || []);
1485
+ const uniqueDocCommands = [...new Set(allDocCommands)];
1486
+ const ciCommands = manifest.analysis?.ci_workflow_commands || [];
1487
+ const makeTargets = manifest.analysis?.make_targets || [];
1488
+ const taskRunnerTargetSources = manifest.analysis?.task_runner_target_sources || [];
1489
+ const taskRunnerTargetsByRunner = {
1490
+ just: [...new Set(taskRunnerTargetSources.filter((entry) => entry.runner === 'just').map((entry) => entry.target))],
1491
+ taskfile: [...new Set(taskRunnerTargetSources.filter((entry) => entry.runner === 'taskfile').map((entry) => entry.target))]
1492
+ };
1493
+ const classified = classifyDocumentedCommands(uniqueDocCommands, allPackageScripts, ciCommands, {
1494
+ makeTargets,
1495
+ taskRunnerTargetsByRunner
1496
+ });
1497
+ const validatedCmds = classified.filter((c) => c.status === 'validated');
1498
+ const missingCmds = classified.filter((c) => c.status === 'missing');
1499
+ const unvalidatedCmds = classified.filter((c) => c.status === 'unvalidated');
1500
+ const manifestFiles = new Set((manifest.files || []).map((file) => normalizeRepoPath(file.path)));
1501
+ const manifestDirectories = collectManifestDirectories(manifestFiles);
1502
+ const filePathFindings = docs.flatMap((doc) => (doc.file_paths || []).map((reference) => {
1503
+ const resolved = resolveDocumentedPathFromManifest(reference.path, doc.path, manifestFiles, manifestDirectories, reference.source);
1504
+ return {
1505
+ doc: doc.path,
1506
+ line: reference.line,
1507
+ source: reference.source,
1508
+ reference_path: reference.path,
1509
+ resolved_path: resolved.path,
1510
+ valid: resolved.valid
1511
+ };
1512
+ }));
1513
+ const validFilePaths = filePathFindings.filter((finding) => finding.valid);
1514
+ const brokenFilePaths = filePathFindings.filter((finding) => !finding.valid);
1515
+ const knownEnvVars = collectKnownEnvironmentVariables(manifest);
1516
+ const envFindings = docs.flatMap((doc) => (doc.validation?.env_vars || []).map((name) => ({ doc: doc.path, name, valid: knownEnvVars.has(name) })));
1517
+ const validatedEnvVars = envFindings.filter((finding) => finding.valid);
1518
+ const unvalidatedEnvVars = envFindings.filter((finding) => !finding.valid);
1519
+ const routeIndex = buildRouteSurfaceIndex(manifest);
1520
+ const routeFindings = docs.flatMap((doc) => {
1521
+ const routeClaims = doc.validation?.route_claims || extractRouteClaims((doc.claims || []).map((claim) => claim.text || '').join('\n'));
1522
+ const findings = validateRouteClaims(routeClaims, routeIndex).map((finding) => ({ ...finding, doc: doc.path }));
1523
+ return dedupeRouteValidationFindings(findings, doc.path);
1524
+ });
1525
+ const validatedRouteClaims = routeFindings.filter((finding) => finding.valid);
1526
+ const unvalidatedRouteClaims = routeFindings.filter((finding) => !finding.valid);
1527
+ const staleFindings = docs.filter((doc) => doc.stale).map((doc) => `- \`${doc.path}\` - age ${doc.age_days} days, status ${doc.status}`);
1528
+ const contradictedFindings = docs.filter((doc) => doc.validation?.contradictions?.length).map((doc) => `- \`${doc.path}\` - ${doc.validation.contradictions.length} contradiction-review signals`);
1529
+ const adrDocs = docs.filter((doc) => doc.adr?.detected);
1530
+ const supersededAdrDocs = adrDocs.filter((doc) => doc.adr?.superseded);
1531
+ const oldUnknownAdrDocs = adrDocs.filter((doc) => doc.stale && !doc.adr?.has_status_metadata);
1532
+ const unvalidatedFindings = [
1533
+ ...docs.filter((doc) => doc.claims?.length && doc.status === 'unvalidated').map((doc) => `- \`${doc.path}\` - documentation claims have no validation signal.`),
1534
+ ...missingCmds.map((finding) => {
1535
+ if (finding.source === 'package_scripts')
1536
+ return `- \`${redactSensitiveText(finding.command)}\` - package script not found.`;
1537
+ if (finding.source === 'makefile')
1538
+ return `- \`${redactSensitiveText(finding.command)}\` - Makefile target not found.`;
1539
+ if (finding.source === 'task_runner')
1540
+ return `- \`${redactSensitiveText(finding.command)}\` - task-runner target not found.`;
1541
+ return `- \`${redactSensitiveText(finding.command)}\` - command reference not found.`;
1542
+ }),
1543
+ ...unvalidatedCmds.map((finding) => `- \`${redactSensitiveText(finding.command)}\` - command source unknown.`),
1544
+ ...unvalidatedEnvVars.map((finding) => `- \`${finding.doc}\` mentions \`${finding.name}\` without scanner/config validation.`),
1545
+ ...unvalidatedRouteClaims.map((finding) => {
1546
+ const location = finding.locations?.length ? finding.locations.map((line) => `${finding.doc}:${line}`).join(', ') : `${finding.doc}:${finding.claim.line}`;
1547
+ return `- \`${location}\` - ${finding.reason}`;
1548
+ })
1549
+ ];
1550
+ const brokenReferenceFindings = brokenFilePaths.map((finding) => `- \`${finding.doc}:${finding.line}\` references \`${finding.reference_path}\` (missing).`);
1551
+ const adrFindings = [
1552
+ ...supersededAdrDocs.map((doc) => `- \`${doc.path}\` - superseded ADR${doc.adr?.superseded_by ? ` (superseded by ${doc.adr.superseded_by})` : ''}.`),
1553
+ ...oldUnknownAdrDocs.map((doc) => `- \`${doc.path}\` - stale ADR missing explicit status metadata.`)
1554
+ ];
1555
+ const commandRows = classified.map((c) => {
1556
+ const badge = c.status === 'validated' ? '✅ validated' : c.status === 'missing' ? '❌ missing' : '❓ unvalidated';
1557
+ const sourceLabels = {
1558
+ package_scripts: 'package.json',
1559
+ ci_workflow: 'CI workflow',
1560
+ makefile: 'Makefile',
1561
+ task_runner: 'Task runner'
1562
+ };
1563
+ const source = sourceLabels[c.source] || 'unknown';
1564
+ return tableRow([code(redactSensitiveText(c.command)), badge, source]);
1565
+ });
1566
+ const filePathRows = filePathFindings.slice(0, 200).map((finding) => {
1567
+ const badge = finding.valid ? '✅ valid' : '❌ missing';
1568
+ return tableRow([
1569
+ code(`${finding.doc}:${finding.line}`),
1570
+ code(finding.reference_path),
1571
+ badge,
1572
+ finding.valid ? code(finding.resolved_path) : 'not found'
1573
+ ]);
1574
+ });
1575
+ const envRows = envFindings.slice(0, 200).map((finding) => {
1576
+ const badge = finding.valid ? '✅ validated' : '❓ unvalidated';
1577
+ return tableRow([code(finding.doc), code(finding.name), badge]);
1578
+ });
1579
+ const routeRows = routeFindings.slice(0, 200).map((finding) => {
1580
+ const badge = finding.valid ? '✅ validated' : '❓ unvalidated';
1581
+ const method = finding.claim.method || 'ANY';
1582
+ const routePath = finding.claim.path || '(none)';
1583
+ const location = finding.locations?.length ? finding.locations.map((line) => `${finding.doc}:${line}`).join(', ') : `${finding.doc}:${finding.claim.line}`;
1584
+ const evidence = finding.valid
1585
+ ? formatRouteEvidence(manifest, finding.evidence || [])
1586
+ : finding.reason;
1587
+ return tableRow([code(location), code(`${method} ${routePath}`), badge, evidence]);
1588
+ });
1589
+ const adrRows = adrDocs.slice(0, 200).map((doc) => {
1590
+ const status = doc.adr?.status || 'unknown';
1591
+ const supersession = doc.adr?.superseded_by || doc.adr?.replaces || '-';
1592
+ const review = doc.adr?.superseded
1593
+ ? '⚠ superseded'
1594
+ : doc.stale && !doc.adr?.has_status_metadata
1595
+ ? '⚠ old without status metadata'
1596
+ : '✅ current/explicit';
1597
+ return tableRow([code(doc.path), code(status), code(supersession), String(doc.age_days ?? '-'), review]);
1598
+ });
1599
+ return `${frontmatter(manifest, {
1600
+ kind: 'documentation_debt_report',
1601
+ documentation_authority: manifest.documentation?.authority || 'secondary',
1602
+ claim_status: 'review-needed',
1603
+ confidence: 'low',
1604
+ source_paths: uniqueSorted(docs.map((doc) => doc.path)).slice(0, 50)
1605
+ })}# Documentation Debt Report
1606
+
1607
+ Markdown documentation is ingested as secondary evidence. It is useful for intent, terminology, onboarding, and architectural rationale, but material claims should be validated against code, tests, configuration, generated schemas, or CI before the wiki presents them as current behavior.
1608
+
1609
+ ## Configuration
1610
+
1611
+ \`\`\`json
1612
+ ${JSON.stringify(manifest.config?.documentation || {}, null, 2)}
1613
+ \`\`\`
1614
+
1615
+ ## Summary
1616
+
1617
+ - Documentation ingestion enabled: ${manifest.documentation?.enabled !== false}
1618
+ - Documentation files scanned: ${summary.files || 0}
1619
+ - Claims extracted: ${summary.claims || 0}
1620
+ - Stale documents: ${summary.stale || 0}
1621
+ - Commands found in docs: ${summary.commands || 0}
1622
+ - Environment variable mentions: ${summary.env_vars || 0}
1623
+ - File path references: ${summary.file_paths || 0}
1624
+
1625
+ ## Documentation status table
1626
+
1627
+ | File | Status | Authority | Age days | Claims | Commands | Env vars |
1628
+ |---|---|---:|---:|---:|---:|---:|
1629
+ ${rows.join('\n') || '| No documentation files scanned | | | | | | |'}
1630
+
1631
+ ## Command validation
1632
+
1633
+ Commands extracted from documentation code blocks, validated against \`package.json\` scripts and CI workflow commands captured in the scan manifest.
1634
+
1635
+ - Validated: ${validatedCmds.length}
1636
+ - Missing (package script / Makefile / task-runner target): ${missingCmds.length}
1637
+ - Unvalidated (source unknown): ${unvalidatedCmds.length}
1638
+
1639
+ ${commandRows.length > 0 ? `| Command | Status | Source |\n|---|---|---|\n${commandRows.join('\n')}` : '- No commands extracted from documentation.'}
1640
+
1641
+ ## File path validation
1642
+
1643
+ Repository file and directory references extracted from markdown links and inline code spans. Generated-output roots such as \`dist/\`, \`coverage/\`, and \`.llmwiki/\` are excluded from extraction.
1644
+
1645
+ - Valid: ${validFilePaths.length}
1646
+ - Missing: ${brokenFilePaths.length}
1647
+
1648
+ ${filePathRows.length > 0 ? `| Documentation location | Reference | Status | Resolved path |\n|---|---|---|---|\n${filePathRows.join('\n')}${filePathFindings.length > filePathRows.length ? `\n\n_Showing first ${filePathRows.length} of ${filePathFindings.length} file path findings._` : ''}` : '- No file path references extracted from documentation.'}
1649
+
1650
+ ## Environment variable validation
1651
+
1652
+ Environment variable names extracted from documentation are validated against scanner-detected source usage and configured environment-variable names. Values are never copied into generated markdown.
1653
+
1654
+ - Validated: ${validatedEnvVars.length}
1655
+ - Unvalidated: ${unvalidatedEnvVars.length}
1656
+
1657
+ ${envRows.length > 0 ? `| Documentation file | Variable | Status |\n|---|---|---|\n${envRows.join('\n')}${envFindings.length > envRows.length ? `\n\n_Showing first ${envRows.length} of ${envFindings.length} environment variable findings._` : ''}` : '- No environment variable mentions extracted from documentation.'}
1658
+
1659
+ ## Route/API claim validation
1660
+
1661
+ Route and API claims from documentation prose are validated against scanner-extracted route surfaces when available.
1662
+
1663
+ - Validated: ${validatedRouteClaims.length}
1664
+ - Unvalidated: ${unvalidatedRouteClaims.length}
1665
+
1666
+ ${routeRows.length > 0 ? `| Claim location | Route claim | Status | Evidence / reason |\n|---|---|---|---|\n${routeRows.join('\n')}${routeFindings.length > routeRows.length ? `\n\n_Showing first ${routeRows.length} of ${routeFindings.length} route claim findings._` : ''}` : '- No route/API claims extracted from documentation.'}
1667
+
1668
+ ## ADR validation
1669
+
1670
+ Conservative ADR detection uses deterministic path hints (\`ADR/**\`, \`docs/adr/**\`, \`docs/adrs/**\`) and explicit markers (e.g. \`Status:\`, \`Superseded by:\`, \`Replaces:\`, or ADR heading/title markers).
1671
+
1672
+ - ADR files detected: ${adrDocs.length}
1673
+ - Superseded ADRs: ${supersededAdrDocs.length}
1674
+ - Old ADRs missing status metadata: ${oldUnknownAdrDocs.length}
1675
+
1676
+ ${adrRows.length > 0 ? `| ADR file | Status | Superseded by / Replaces | Age days | Review signal |\n|---|---|---|---:|---|\n${adrRows.join('\n')}${adrDocs.length > adrRows.length ? `\n\n_Showing first ${adrRows.length} of ${adrDocs.length} ADR findings._` : ''}` : '- No ADR-like documentation files detected.'}
1677
+
1678
+ ## Findings by category
1679
+
1680
+ ### Stale
1681
+
1682
+ ${staleFindings.join('\n') || '- None detected.'}
1683
+
1684
+ ### Contradicted
1685
+
1686
+ ${contradictedFindings.join('\n') || '- None detected.'}
1687
+
1688
+ ### Unvalidated
1689
+
1690
+ ${unvalidatedFindings.join('\n') || '- None detected.'}
1691
+
1692
+ ### Broken-reference
1693
+
1694
+ ${brokenReferenceFindings.join('\n') || '- None detected.'}
1695
+
1696
+ ### ADR-specific
1697
+
1698
+ ${adrFindings.join('\n') || '- None detected.'}
1699
+
1700
+ ## Compiler policy
1701
+
1702
+ - Do not suppress documentation by default.
1703
+ - Never treat docs as more authoritative than code at the pinned commit.
1704
+ - Promote documentation-derived claims only when validated or clearly labeled.
1705
+ - Include unvalidated operational claims in this report and in ${wikiLink('Open-Questions.md')}.
1706
+ - Fail publishing when project policy marks stale or contradicted docs as error-level.
1707
+ `;
1708
+ }
1709
+ function renderDependencyMap(manifest) {
1710
+ const dependencyEdges = manifest.analysis?.dependency_graph?.edges || [];
1711
+ if (dependencyEdges.length > 0) {
1712
+ const rows = dependencyEdges.slice(0, 200).map((edge) => [sourcePathLink(manifest, edge.from), sourcePathLink(manifest, edge.to), code(edge.specifier)]);
1713
+ const summary = manifest.analysis?.dependency_graph?.summary || {};
1714
+ return `${frontmatter(manifest, {
1715
+ kind: 'dependency_map',
1716
+ claim_status: 'grounded',
1717
+ source_paths: uniqueSorted(dependencyEdges.flatMap((edge) => [edge.from, edge.to]).filter((value) => !String(value).startsWith('package:'))).slice(0, 50)
1718
+ })}# Dependency Map\n\n## Resolved internal dependency edges\n\n- Edges detected: ${summary.edges ?? dependencyEdges.length}\n- Importing files: ${summary.importers ?? uniqueCount(dependencyEdges.map((edge) => edge.from))}\n- Imported files: ${summary.imported_files ?? uniqueCount(dependencyEdges.map((edge) => edge.to))}\n\n${markdownTable(['From', 'To', 'Specifier'], rows)}\n`;
1719
+ }
1720
+ const importRows = manifest.files
1721
+ .filter((file) => file.imports?.length)
1722
+ .slice(0, 100)
1723
+ .map((file) => `| ${sourcePathLink(manifest, file.path)} | ${file.imports.map((imp) => `\`${imp}\``).join(', ')} |`);
1724
+ return `${frontmatter(manifest, {
1725
+ kind: 'dependency_map',
1726
+ claim_status: 'grounded',
1727
+ source_paths: uniqueSorted(manifest.files.filter((file) => file.imports?.length).map((file) => file.path)).slice(0, 50)
1728
+ })}# Dependency Map\n\n| Source file | Imports |\n|---|---|\n${importRows.join('\n') || '| None detected | |'}\n`;
1729
+ }
1730
+ function renderTestingStrategy(manifest) {
1731
+ const tests = manifest.files.filter((file) => file.category === 'test');
1732
+ const mappings = manifest.analysis?.test_to_source?.mappings || [];
1733
+ const mappingSection = mappings.length
1734
+ ? `## Test-to-source mappings\n\n- Mapped tests: ${manifest.analysis?.test_to_source?.summary?.mapped_tests ?? mappings.length}\n- Source files covered: ${manifest.analysis?.test_to_source?.summary?.source_files ?? uniqueCount(mappings.flatMap((mapping) => mapping.sources))}\n\n${markdownTable(['Test', 'Source files', 'Heuristics'], mappings.map((mapping) => [sourcePathLink(manifest, mapping.test), formatSourcePathList(manifest, mapping.sources), mapping.heuristics.join(', ') || 'unknown']))}\n`
1735
+ : `## Next refinement\n\nThe compiler will add direct test-to-source mappings when manifest analysis includes them.\n`;
1736
+ return `${frontmatter(manifest, {
1737
+ kind: 'testing_strategy',
1738
+ claim_status: 'grounded',
1739
+ source_paths: uniqueSorted([...tests.map((file) => file.path), ...mappings.flatMap((mapping) => [mapping.test, ...mapping.sources])]).slice(0, 50)
1740
+ })}# Testing Strategy\n\n## Detected test files\n\n${tests.map((file) => `- ${sourcePathLink(manifest, file.path)}`).join('\n') || '- No tests detected by the sketch scanner.'}\n\n${mappingSection}`;
1741
+ }
1742
+ function renderConfiguration(manifest) {
1743
+ const configFiles = manifest.files.filter((file) => file.runtime_hints?.includes('environment-variable') || /(^|\/)(\.env|config|settings)/i.test(file.path));
1744
+ const envRows = collectEnvironmentRows(manifest.files);
1745
+ const envNames = uniqueSorted(envRows.flatMap((row) => row.variables));
1746
+ const envSection = envRows.length
1747
+ ? `## Explicit environment variables\n\n- Unique variable names detected: ${envNames.length}\n- Variable names: ${formatCodeList(envNames)}\n\n${markdownTable(['Source file', 'Variables'], envRows.map((row) => [sourcePathLink(manifest, row.path), formatCodeList(row.variables)]))}\n`
1748
+ : `## Explicit environment variables\n\nNo explicit environment variable names were extracted from source cards.\n`;
1749
+ return `${frontmatter(manifest, {
1750
+ kind: 'configuration',
1751
+ claim_status: 'grounded',
1752
+ source_paths: sourcePathsOrPrimary(manifest, uniqueSorted([...configFiles.map((file) => file.path), ...envRows.map((row) => row.path)])).slice(0, 50)
1753
+ })}# Configuration and Environment\n\n## Detected configuration-related files\n\n${configFiles.map((file) => `- ${sourcePathLink(manifest, file.path)}`).join('\n') || '- No configuration surfaces detected by the sketch scanner.'}\n\n${envSection}\n## Secret handling\n\nGenerated wiki pages must describe variable names and configuration concepts, not copy secret values.\n`;
1754
+ }
1755
+ function renderSecurity(manifest) {
1756
+ const securityFiles = manifest.files.filter((file) => file.reasons?.some((reason) => ['auth', 'billing-or-payment', 'configuration'].includes(reason)));
1757
+ return `${frontmatter(manifest, {
1758
+ kind: 'security',
1759
+ claim_status: 'grounded',
1760
+ source_paths: sourcePathsOrPrimary(manifest, uniqueSorted(securityFiles.map((file) => file.path))).slice(0, 50)
1761
+ })}# Security and Secrets\n\n## Security-sensitive source areas\n\n${securityFiles.map((file) => `- ${sourcePathLink(manifest, file.path)} - ${file.reasons.join(', ')}`).join('\n') || '- No obvious security-sensitive areas detected by the sketch scanner.'}\n\n## Policy\n\n- Do not copy secrets or private tokens into wiki pages.\n- Cite source paths instead of embedding sensitive source content.\n- Require human review before publishing changes to authentication, authorization, billing, or deployment documentation.\n`;
1762
+ }
1763
+ function renderRunbook(manifest) {
1764
+ const infra = manifest.files.filter((file) => file.category === 'infra' || file.runtime_hints?.includes('deployment'));
1765
+ return `${frontmatter(manifest, {
1766
+ kind: 'runbook',
1767
+ claim_status: 'grounded',
1768
+ source_paths: sourcePathsOrPrimary(manifest, uniqueSorted(infra.map((file) => file.path))).slice(0, 50)
1769
+ })}# Operational Runbook\n\n## Deployment and operations files\n\n${infra.map((file) => `- ${sourcePathLink(manifest, file.path)}`).join('\n') || '- No deployment or operations files detected by the sketch scanner.'}\n\n## Next refinement\n\nThe production compiler should extract deployment commands, rollback notes, service dependencies, queue names, cron jobs, and operational dashboards when those are represented in source.\n`;
1770
+ }
1771
+ function renderHttpRoutes(manifest) {
1772
+ const routeFiles = manifest.files.filter((file) => file.runtime_hints?.includes('http-route') || file.reasons?.includes('api-surface'));
1773
+ const routes = collectRoutes(manifest.files);
1774
+ const routeSection = routes.length
1775
+ ? `## Detected routes\n\n- Route surfaces detected: ${routes.length}\n\n${markdownTable(['Source file', 'Framework', 'Target', 'Methods', 'Path', 'Handler'], routes.map((route) => [sourcePathLink(manifest, route.file), route.framework, code(route.target), route.methods.join(', ') || 'ANY', code(route.path), code(route.handler)]))}\n`
1776
+ : `## Detected route-related files\n\n${routeFiles.map((file) => `- ${sourcePathLink(manifest, file.path)}`).join('\n') || '- No HTTP routes detected.'}\n`;
1777
+ return `${frontmatter(manifest, {
1778
+ kind: 'api_http_routes',
1779
+ claim_status: 'grounded',
1780
+ source_paths: uniqueSorted([...routeFiles.map((file) => file.path), ...routes.map((route) => route.file)]).slice(0, 50)
1781
+ })}# API: HTTP Routes\n\n${routeSection}\n## Next refinement\n\nAdd framework-specific extractors for Express, Fastify, NestJS, Next.js route handlers, Hono, Koa, tRPC, OpenAPI, and GraphQL.\n`;
1782
+ }
1783
+ function renderDataModel(manifest) {
1784
+ const dataFiles = manifest.files.filter((file) => file.category === 'data' || file.reasons?.includes('data-model'));
1785
+ return `${frontmatter(manifest, {
1786
+ kind: 'data_model',
1787
+ claim_status: 'grounded',
1788
+ source_paths: uniqueSorted(dataFiles.map((file) => file.path)).slice(0, 50)
1789
+ })}# Data Model and Migrations\n\n## Detected data-related files\n\n${dataFiles.map((file) => `- ${sourcePathLink(manifest, file.path)}`).join('\n') || '- No data files detected.'}\n`;
1790
+ }
1791
+ function renderModulePage(manifest, module, sourceToTestsIndex) {
1792
+ const sampleFiles = module.files.slice(0, 80).map((file) => `- ${sourcePathLink(manifest, file)}`).join('\n');
1793
+ const relatedTests = lookupRelatedTests(module.files, sourceToTestsIndex);
1794
+ const relatedTestsSection = relatedTests.length
1795
+ ? `## Related tests\n\n${relatedTests.map((testPath) => `- ${sourcePathLink(manifest, testPath)}`).join('\n')}\n\n`
1796
+ : '';
1797
+ return `${frontmatter(manifest, {
1798
+ kind: 'module',
1799
+ module: module.name,
1800
+ claim_status: 'grounded',
1801
+ confidence: 'high',
1802
+ source_paths: module.files.slice(0, 20)
1803
+ })}# ${module.name}\n\n## Purpose\n\nGenerated first-pass page for files grouped under ${module.name}. This should be refined by the LLM compiler using source cards and targeted source excerpts.\n\n## Signals\n\n- Files: ${module.files.length}\n- Categories: ${Object.keys(module.categories).join(', ') || 'unknown'}\n- Languages: ${Object.keys(module.languages).join(', ') || 'unknown'}\n- Runtime hints: ${Object.keys(module.runtime_hints).join(', ') || 'none'}\n- Reasons: ${module.important_reasons.join(', ') || 'none'}\n\n## Source files\n\n${sampleFiles || '- None'}\n\n${relatedTestsSection}## Related pages\n\n- ${wikiLink('Dependency-Map.md')}\n- ${wikiLink('Testing-Strategy.md')}\n- ${wikiLink('Open-Questions.md')}\n\n<!-- HUMAN_NOTES_START -->\n<!-- HUMAN_NOTES_END -->\n`;
1804
+ }
1805
+ function buildSourceToTestsIndex(manifest) {
1806
+ const index = new Map();
1807
+ for (const mapping of manifest.analysis?.test_to_source?.mappings || []) {
1808
+ for (const source of mapping.sources) {
1809
+ if (!index.has(source)) {
1810
+ index.set(source, new Set());
1811
+ }
1812
+ index.get(source).add(mapping.test);
1813
+ }
1814
+ }
1815
+ return index;
1816
+ }
1817
+ function lookupRelatedTests(sourceFiles, index) {
1818
+ const tests = new Set();
1819
+ for (const source of sourceFiles) {
1820
+ const related = index.get(source);
1821
+ if (related) {
1822
+ for (const test of related) {
1823
+ tests.add(test);
1824
+ }
1825
+ }
1826
+ }
1827
+ return [...tests].sort();
1828
+ }
1829
+ function tableFromObject(object, headers) {
1830
+ const rows = Object.entries(object || {}).sort((a, b) => Number(b[1]) - Number(a[1]));
1831
+ if (!rows.length) {
1832
+ return 'No entries detected.';
1833
+ }
1834
+ return [`| ${headers[0]} | ${headers[1]} |`, '|---|---:|', ...rows.map(([key, value]) => `| ${sanitizeTableCell(key)} | ${sanitizeTableCell(value)} |`)].join('\n');
1835
+ }
1836
+ function markdownTable(headers, rows) {
1837
+ return [
1838
+ tableRow(headers),
1839
+ `| ${headers.map(() => '---').join(' | ')} |`,
1840
+ ...rows.map((row) => tableRow(row))
1841
+ ].join('\n');
1842
+ }
1843
+ function tableRow(cells) {
1844
+ return `| ${cells.map((cell) => sanitizeTableCell(cell)).join(' | ')} |`;
1845
+ }
1846
+ function collectEnvironmentRows(files) {
1847
+ return files
1848
+ .filter((file) => (file.environment_variables || []).length > 0)
1849
+ .map((file) => ({ path: file.path, variables: uniqueSorted(file.environment_variables) }))
1850
+ .sort((left, right) => left.path.localeCompare(right.path));
1851
+ }
1852
+ function collectRoutes(files) {
1853
+ return files
1854
+ .flatMap((file) => (file.route_surfaces || []).map((route) => ({
1855
+ file: file.path,
1856
+ framework: route.framework || 'unknown',
1857
+ target: route.target || 'unknown',
1858
+ methods: route.methods || [],
1859
+ path: route.path || 'unknown',
1860
+ handler: route.handler || 'unknown'
1861
+ })))
1862
+ .sort((left, right) => {
1863
+ if (left.file !== right.file) {
1864
+ return left.file.localeCompare(right.file);
1865
+ }
1866
+ if (left.path !== right.path) {
1867
+ return left.path.localeCompare(right.path);
1868
+ }
1869
+ return left.target.localeCompare(right.target);
1870
+ });
1871
+ }
1872
+ function formatRouteEvidence(manifest, evidence) {
1873
+ if (!evidence?.length)
1874
+ return 'scanner route match';
1875
+ return evidence.slice(0, 3).map((item) => {
1876
+ const source = sourcePathLink(manifest, item.source_path);
1877
+ const details = [item.framework, item.method, code(item.path)].filter(Boolean).join(' ');
1878
+ return `${source} (${details})`;
1879
+ }).join('; ');
1880
+ }
1881
+ function formatCodeList(values) {
1882
+ return values.map((value) => code(value)).join(', ');
1883
+ }
1884
+ function formatSourcePathList(manifest, values) {
1885
+ return values.map((value) => sourcePathLink(manifest, value)).join(', ');
1886
+ }
1887
+ function sourcePathLink(manifest, filePath, sourceRange) {
1888
+ const browserUrl = githubSourceUrl(manifest, filePath, sourceRange);
1889
+ if (!browserUrl) {
1890
+ return code(filePath);
1891
+ }
1892
+ return `[${escapeMarkdownLinkText(filePath)}](${browserUrl})`;
1893
+ }
1894
+ function githubSourceUrl(manifest, filePath, sourceRange) {
1895
+ const repoUrl = githubRepositoryUrl(manifest?.remote);
1896
+ const commit = manifest?.commit;
1897
+ if (!repoUrl || !commit || !filePath) {
1898
+ return null;
1899
+ }
1900
+ return `${repoUrl}/blob/${encodeURIComponent(String(commit))}/${encodePathSegments(filePath)}${formatGitHubLineAnchor(sourceRange)}`;
1901
+ }
1902
+ function githubRepositoryUrl(remote) {
1903
+ if (!remote) {
1904
+ return null;
1905
+ }
1906
+ const normalized = String(remote).trim();
1907
+ const httpsMatch = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(normalized);
1908
+ if (httpsMatch) {
1909
+ return `https://github.com/${httpsMatch[1]}/${httpsMatch[2]}`;
1910
+ }
1911
+ const sshMatch = /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/.exec(normalized);
1912
+ if (sshMatch) {
1913
+ return `https://github.com/${sshMatch[1]}/${sshMatch[2]}`;
1914
+ }
1915
+ const sshUrlMatch = /^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(normalized);
1916
+ if (sshUrlMatch) {
1917
+ return `https://github.com/${sshUrlMatch[1]}/${sshUrlMatch[2]}`;
1918
+ }
1919
+ return null;
1920
+ }
1921
+ function encodePathSegments(filePath) {
1922
+ return String(filePath).split('/').map((segment) => encodeURIComponent(segment)).join('/');
1923
+ }
1924
+ function formatGitHubLineAnchor(sourceRange) {
1925
+ const line = sanitizeLineNumber(sourceRange?.line);
1926
+ if (!line) {
1927
+ return '';
1928
+ }
1929
+ const endLine = sanitizeLineNumber(sourceRange?.end_line);
1930
+ if (!endLine || endLine === line) {
1931
+ return `#L${line}`;
1932
+ }
1933
+ if (endLine > line) {
1934
+ return `#L${line}-L${endLine}`;
1935
+ }
1936
+ return `#L${line}`;
1937
+ }
1938
+ function sanitizeLineNumber(value) {
1939
+ const numeric = typeof value === 'number' ? Math.floor(value) : Number.NaN;
1940
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
1941
+ }
1942
+ function escapeMarkdownLinkText(value) {
1943
+ return String(value).replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]');
1944
+ }
1945
+ function findNamedSourceRange(sources, name) {
1946
+ return (sources || []).find((source) => source.name === name);
1947
+ }
1948
+ function uniqueSorted(values) {
1949
+ return [...new Set(values || [])].sort((left, right) => String(left).localeCompare(String(right)));
1950
+ }
1951
+ function collectPrimarySourcePaths(manifest) {
1952
+ return uniqueSorted((manifest.files || [])
1953
+ .filter((file) => file?.path && file.category !== 'docs')
1954
+ .map((file) => file.path));
1955
+ }
1956
+ function isDocumentationPath(entry) {
1957
+ const normalized = String(entry).trim().replace(/\\/g, '/').toLowerCase();
1958
+ const segments = normalized.split('/').filter(Boolean);
1959
+ const basename = segments.at(-1) || '';
1960
+ const extension = path.extname(basename);
1961
+ const firstSegment = segments[0] || '';
1962
+ if (['.md', '.mdx', '.markdown'].includes(extension)) {
1963
+ return true;
1964
+ }
1965
+ if (['readme', 'changelog'].includes(basename)) {
1966
+ return true;
1967
+ }
1968
+ return extension === '.json' && firstSegment === '.llmwiki' && segments.includes('docs');
1969
+ }
1970
+ function sourcePathsOrPrimary(manifest, paths) {
1971
+ return paths.length > 0 ? paths : collectPrimarySourcePaths(manifest);
1972
+ }
1973
+ function uniqueCount(values) {
1974
+ return new Set(values || []).size;
1975
+ }
1976
+ function confidenceForKind(pageKind) {
1977
+ switch (pageKind) {
1978
+ case 'module':
1979
+ case 'build_test_run':
1980
+ case 'testing_strategy':
1981
+ case 'dependency_map':
1982
+ case 'configuration':
1983
+ case 'api_http_routes':
1984
+ case 'data_model':
1985
+ return 'high';
1986
+ case 'documentation_debt_report':
1987
+ case 'open_questions':
1988
+ case 'log':
1989
+ return 'low';
1990
+ default:
1991
+ return 'medium';
1992
+ }
1993
+ }
1994
+ function buildDocumentationReviewQueue(docs) {
1995
+ const queue = new Map();
1996
+ for (const doc of docs || []) {
1997
+ const reasons = new Set();
1998
+ const contradictionCount = doc.validation?.contradictions?.length || 0;
1999
+ if (doc.stale)
2000
+ reasons.add(`stale (${doc.age_days} days old)`);
2001
+ if (contradictionCount > 0)
2002
+ reasons.add(formatContradictionReason(contradictionCount));
2003
+ if (doc.status === 'unvalidated')
2004
+ reasons.add('unvalidated status');
2005
+ if ((doc.claims?.length || 0) > 0 && !['validated', 'unvalidated'].includes(doc.status))
2006
+ reasons.add('claims need validation');
2007
+ if (!reasons.size)
2008
+ continue;
2009
+ if (!queue.has(doc.path))
2010
+ queue.set(doc.path, new Set());
2011
+ for (const reason of reasons) {
2012
+ queue.get(doc.path).add(reason);
2013
+ }
2014
+ }
2015
+ return [...queue.entries()]
2016
+ .map(([path, reasons]) => ({ path, reasons: [...reasons].sort() }))
2017
+ .sort((left, right) => left.path.localeCompare(right.path));
2018
+ }
2019
+ function formatContradictionReason(count) {
2020
+ return `contradicted (${count} ${pluralize(count, 'signal', 'signals')})`;
2021
+ }
2022
+ function pluralize(count, singularForm, pluralForm) {
2023
+ return count === 1 ? singularForm : pluralForm;
2024
+ }
2025
+ function code(value) {
2026
+ return `\`${String(value).replace(/`/g, '\\`')}\``;
2027
+ }
2028
+ function sanitizeTableCell(value) {
2029
+ return String(value ?? '').replace(/[\r\n]+/g, ' ').replace(/\|/g, '\\|').trim();
2030
+ }
2031
+ function redactSensitiveText(value) {
2032
+ return String(value ?? '')
2033
+ .replace(/(authorization:\s*bearer\s+)[^\s"']+/ig, '$1[REDACTED]')
2034
+ .replace(/((?:--?token|--?password|--?api[-_]?key|--?secret)(?:=|\s+))[^\s"']+/ig, '$1[REDACTED]')
2035
+ .replace(/((?:token|password|api[_-]?key|secret)=)[^\s&]+/ig, '$1[REDACTED]');
2036
+ }
2037
+ function shortCommit(commit) {
2038
+ return String(commit || 'unknown').slice(0, 8);
2039
+ }
2040
+ function escapeMermaid(value) {
2041
+ return String(value).replace(/[\[\]{}]/g, '').replace(/"/g, "'");
2042
+ }
2043
+ function shouldRenderDataModelPage(manifest, plan) {
2044
+ return hasDataModelSignals(manifest) || (plan.pages || []).some((page) => page.path === 'Data-Model-and-Migrations.md');
2045
+ }
2046
+ //# sourceMappingURL=compiler.js.map