@agentskit/doc-bridge 1.7.45 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +465 -0
  2. package/CONTRIBUTING.md +23 -0
  3. package/README.md +87 -40
  4. package/SECURITY.md +7 -0
  5. package/action.yml +1 -1
  6. package/bin/ak-docs.js +2 -2
  7. package/bin/ak-verify.js +13 -7
  8. package/dist/cli/program.d.ts +3 -1
  9. package/dist/cli/program.js +15861 -6223
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +86 -6
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/{index-BUL0q7s8.d.ts → index-Beor6Yhi.d.ts} +133 -1
  15. package/dist/index.d.ts +9871 -1062
  16. package/dist/index.js +16126 -6125
  17. package/dist/index.js.map +1 -1
  18. package/docs/MARKETPLACE.md +1 -1
  19. package/docs/PRD-documentation-efficiency-study.md +406 -0
  20. package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
  21. package/docs/adr/0002-documentation-audit-boundary.md +22 -0
  22. package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
  23. package/docs/adr/0004-controlled-study-runner.md +25 -0
  24. package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
  25. package/docs/adr/0006-registry-semantic-grounding.md +20 -0
  26. package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
  27. package/docs/adr/0008-study-verification-boundary.md +21 -0
  28. package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
  29. package/docs/agent-corpus/INDEX.md +12 -1
  30. package/docs/agent-corpus/OVERVIEW.md +25 -0
  31. package/docs/agent-corpus/chat.md +5 -1
  32. package/docs/agent-corpus/cli.md +16 -0
  33. package/docs/agent-corpus/conformance.md +12 -0
  34. package/docs/agent-corpus/doc-bridge.md +47 -0
  35. package/docs/agent-corpus/doctor.md +8 -0
  36. package/docs/agent-corpus/gates.md +4 -0
  37. package/docs/agent-corpus/mcp.md +13 -0
  38. package/docs/agent-corpus/memory.md +4 -0
  39. package/docs/agent-corpus/query.md +33 -0
  40. package/docs/bench/README.md +122 -0
  41. package/docs/bench/retrieval-baseline-v1.json +28 -0
  42. package/docs/bench/retrieval-suite-v1.json +1033 -0
  43. package/docs/chat-and-rag.md +3 -2
  44. package/docs/for-agents.md +9 -1
  45. package/docs/getting-started.md +4 -11
  46. package/docs/guides/gate-ci.md +11 -1
  47. package/docs/guides/install-and-run.md +9 -65
  48. package/docs/index.md +22 -1
  49. package/docs/knowledge-engine-runbook.md +38 -3
  50. package/docs/landing/assets/context-payload-reduction.svg +21 -0
  51. package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
  52. package/docs/landing/index.html +119 -5
  53. package/docs/loop-workflow.md +117 -0
  54. package/docs/mcp.md +6 -1
  55. package/docs/parity/public-claims-v1.json +145 -0
  56. package/docs/playbook/doc-bridge-pattern.md +1 -1
  57. package/docs/query.md +90 -2
  58. package/docs/recipes/index-pipeline.md +1 -1
  59. package/docs/schemas/agent-handoff-v1.md +15 -0
  60. package/docs/schemas/doc-bridge-index-v1.md +65 -0
  61. package/docs/spec/benchmark-v1.md +33 -1
  62. package/docs/spec/cli.md +30 -10
  63. package/docs/spec/config-v1.md +147 -8
  64. package/docs/spec/documentation-audit-v1.md +61 -0
  65. package/docs/spec/enrichment-overlay-v1.md +241 -0
  66. package/docs/spec/graph-signals-v1.md +92 -0
  67. package/docs/spec/incremental-scan-v1.md +102 -0
  68. package/docs/spec/markdown-analyzer-v1.md +73 -0
  69. package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
  70. package/docs/spec/measured-enrichment-v1.md +229 -0
  71. package/docs/spec/public-parity-v1.md +119 -0
  72. package/docs/spec/registry-agents.md +6 -0
  73. package/docs/spec/render-v1.md +122 -0
  74. package/docs/spec/retrieval-index-v1.md +164 -0
  75. package/docs/spec/study-metrics-v1.md +58 -0
  76. package/docs/spec/study-protocol-v1.md +46 -0
  77. package/docs/spec/study-provider-cli-v1.md +116 -0
  78. package/docs/spec/study-runner-v1.md +35 -0
  79. package/docs/spec/study-task-suite-v1.md +41 -0
  80. package/docs/spec/study-verification-v1.md +40 -0
  81. package/docs/study/README.md +84 -0
  82. package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
  83. package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
  84. package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
  85. package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
  86. package/docs/study/ab-baseline-analysis-v1.md +21 -0
  87. package/docs/study/ab-baseline-plan-v1.json +33 -0
  88. package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
  89. package/docs/study/ab-baseline-result-v1.json +79 -0
  90. package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
  91. package/docs/study/historical-evidence-v1.json +252 -0
  92. package/docs/study/observation-ledger-v1.json +30632 -0
  93. package/docs/study/phase3-task-coverage-v1.json +34 -0
  94. package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
  95. package/docs/study/phase4-public-pilot-result-v1.json +52 -0
  96. package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
  97. package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
  98. package/docs/study/pilot-round-2026-08-31.json +46 -0
  99. package/docs/study/protocol-v1.json +90 -0
  100. package/docs/study/publication-gate-v1.md +45 -0
  101. package/docs/study/quality-scorecard-cycle-plan.md +545 -0
  102. package/docs/study/quality-scorecard-v1.json +38 -0
  103. package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
  104. package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
  105. package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
  106. package/docs/study/round-2-expanded-validation-v1.md +58 -0
  107. package/docs/study/round-3-evidence-contract-v1.json +75 -0
  108. package/docs/study/round-3-evidence-contract-v1.md +57 -0
  109. package/docs/study/round-4-confirmation-v1.json +75 -0
  110. package/docs/study/round-4-confirmation-v1.md +55 -0
  111. package/docs/study/run-plan-v1.json +33 -0
  112. package/docs/study/semantic-adjudication-cycle-8.md +20 -0
  113. package/docs/study/task-suite-v1.json +96 -0
  114. package/docs/study/token-efficiency-plan-v1.md +337 -0
  115. package/docs/study/token-efficiency-protocol-v2.json +62 -0
  116. package/docs/study/verification-binding-v1.json +27 -0
  117. package/docs/validation-cycle-plan.md +14 -0
  118. package/docs/verification-harness.md +11 -6
  119. package/ecosystem-claims.json +2 -2
  120. package/ecosystem-upstream.json +2 -2
  121. package/ecosystem.json +4 -4
  122. package/mcpb/manifest.json +9 -1
  123. package/package.json +25 -6
  124. package/scripts/check-ecosystem-upstream.mjs +34 -6
  125. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
  126. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  127. package/src/agents/registry-adapter.ts +192 -24
  128. package/src/audit/documentation.ts +513 -0
  129. package/src/bench/baseline.ts +198 -0
  130. package/src/bench/overlay-delta.ts +139 -0
  131. package/src/bench/retrieval.ts +319 -0
  132. package/src/budget/compile.ts +91 -0
  133. package/src/budget/sections.ts +70 -0
  134. package/src/cli/program.ts +684 -74
  135. package/src/cli/usage.ts +71 -0
  136. package/src/config/defaults.ts +1 -0
  137. package/src/config/index.ts +4 -0
  138. package/src/config/schema.ts +117 -2
  139. package/src/conformance/documentation-standard-v1.ts +8 -6
  140. package/src/discovery/areas.ts +182 -0
  141. package/src/discovery/documentation.ts +211 -5
  142. package/src/discovery/identity.ts +24 -0
  143. package/src/discovery/incremental.ts +314 -0
  144. package/src/discovery/inputs.ts +110 -0
  145. package/src/discovery/markdown.ts +481 -0
  146. package/src/discovery/repository.ts +484 -101
  147. package/src/doctor/run-doctor.ts +244 -12
  148. package/src/enrich/approvals.ts +190 -0
  149. package/src/enrich/cache.ts +93 -0
  150. package/src/enrich/context-pack.ts +272 -0
  151. package/src/enrich/overlay.ts +255 -0
  152. package/src/enrich/review.ts +106 -0
  153. package/src/enrich/stage.ts +374 -0
  154. package/src/enrich/stats.ts +100 -0
  155. package/src/enrich/validate.ts +410 -0
  156. package/src/federation/llms.ts +2 -4
  157. package/src/findings/report.ts +103 -0
  158. package/src/graph/build.ts +356 -0
  159. package/src/graph/memory.ts +208 -0
  160. package/src/index-builder/build-handoffs.ts +22 -11
  161. package/src/index-builder/build-index.ts +132 -3
  162. package/src/index-builder/llms-txt.ts +48 -8
  163. package/src/index-builder/project-corpus.ts +111 -0
  164. package/src/index.ts +630 -2
  165. package/src/lib/fuzzy-match.ts +235 -0
  166. package/src/mcp/knowledge.ts +554 -0
  167. package/src/mcp/server.ts +113 -18
  168. package/src/metrics/benchmark.ts +21 -0
  169. package/src/parity/check.ts +309 -0
  170. package/src/parity/claims.ts +259 -0
  171. package/src/parity/resolve.ts +160 -0
  172. package/src/query/handoff.ts +326 -0
  173. package/src/query/load-index.ts +53 -1
  174. package/src/query/query.ts +92 -59
  175. package/src/query/search.ts +289 -92
  176. package/src/query/text.ts +155 -0
  177. package/src/reconciliation/reconcile.ts +101 -10
  178. package/src/render/data.ts +356 -0
  179. package/src/render/engine.ts +398 -0
  180. package/src/render/generated.ts +77 -0
  181. package/src/render/render.ts +209 -0
  182. package/src/render/template-source.ts +52 -0
  183. package/src/render/templates.ts +289 -0
  184. package/src/report/html.ts +2 -2
  185. package/src/retrieval/bm25.ts +161 -0
  186. package/src/retrieval/project.ts +495 -0
  187. package/src/retrieval/rank.ts +383 -0
  188. package/src/retrieval/weights.ts +39 -0
  189. package/src/retriever/doc-bridge-retriever.ts +100 -15
  190. package/src/rules/engine.ts +30 -10
  191. package/src/schemas/agent-handoff.ts +56 -0
  192. package/src/schemas/budget.ts +37 -0
  193. package/src/schemas/doc-bridge-index.ts +53 -2
  194. package/src/schemas/enrichment.ts +369 -0
  195. package/src/schemas/json-schemas.ts +39 -2
  196. package/src/schemas/knowledge.ts +14 -1
  197. package/src/schemas/retrieval-index.ts +152 -0
  198. package/src/shims/graphology.d.ts +91 -0
  199. package/src/study/adjudication.ts +196 -0
  200. package/src/study/execution.ts +350 -0
  201. package/src/study/expectations.ts +219 -0
  202. package/src/study/metrics.ts +467 -0
  203. package/src/study/protocol.ts +271 -0
  204. package/src/study/provider-cli.ts +115 -0
  205. package/src/study/provider-telemetry.ts +47 -0
  206. package/src/study/quality-scorecard.ts +164 -0
  207. package/src/study/runner.ts +461 -0
  208. package/src/study/task-suite.ts +321 -0
  209. package/src/study/verification.ts +134 -0
  210. package/src/validate.ts +1 -4
  211. package/src/version.ts +1 -1
  212. package/src/workflow/engine.ts +36 -11
  213. package/scripts/verification-harness.mjs +0 -482
@@ -0,0 +1,52 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
5
+ import { toPosix } from '../lib/paths.js'
6
+ import { compileTemplate, renderCompiledTemplate, type CompiledTemplate, type TemplateVariables } from './engine.js'
7
+ import { BUNDLED_TEMPLATES, type RenderTemplateName } from './templates.js'
8
+
9
+ /**
10
+ * Which template renders a name: the project's, when `render.templates` names one, otherwise
11
+ * the bundled one. An override is a file the project owns; it replaces the bundled template
12
+ * entirely and is compiled against the same engine, so a project changes its house style without
13
+ * a code change and a template that does not parse fails before anything is written.
14
+ */
15
+ export type TemplateSource = {
16
+ readonly name: RenderTemplateName
17
+ readonly source: string
18
+ /** `bundled`, or the override path as configured. */
19
+ readonly origin: string
20
+ }
21
+
22
+ export const resolveTemplateSource = (name: RenderTemplateName, config: DocBridgeConfigV1, root?: string): TemplateSource => {
23
+ const override = config.render?.templates?.[name]
24
+ if (!override) return { name, source: BUNDLED_TEMPLATES[name], origin: 'bundled' }
25
+ const path = root ? resolve(root, override) : resolve(override)
26
+ let source: string
27
+ try {
28
+ source = readFileSync(path, 'utf8')
29
+ } catch (error) {
30
+ throw new Error(`render.templates["${name}"] points at ${toPosix(override)}, which could not be read: ${error instanceof Error ? error.message : String(error)}`)
31
+ }
32
+ return { name, source, origin: toPosix(override) }
33
+ }
34
+
35
+ /*
36
+ * Compiled once per distinct source. The index builder and the conformance profile render
37
+ * `llms.txt` on every run, and parsing the same bundled template twice per run buys nothing.
38
+ */
39
+ const compiled = new Map<string, CompiledTemplate>()
40
+
41
+ const compile = (template: TemplateSource): CompiledTemplate => {
42
+ const key = JSON.stringify([template.name, template.origin, template.source])
43
+ let result = compiled.get(key)
44
+ if (!result) {
45
+ result = compileTemplate(template.source, template.origin === 'bundled' ? `template "${template.name}"` : template.origin)
46
+ compiled.set(key, result)
47
+ }
48
+ return result
49
+ }
50
+
51
+ export const renderNamedTemplate = (name: RenderTemplateName, variables: TemplateVariables, config: DocBridgeConfigV1, root?: string): string =>
52
+ renderCompiledTemplate(compile(resolveTemplateSource(name, config, root)), variables)
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The bundled templates.
3
+ *
4
+ * They live in a TypeScript module rather than next to it as `.md` files because the package
5
+ * ships `dist/` built by tsup, which bundles source and nothing else: a template on disk is a
6
+ * template the published package does not have. `ak-docs render <name> --print-template` prints
7
+ * one, which is how a project starts an override under `render.templates`.
8
+ *
9
+ * knap syntax, with one rule the syntax does not advertise: a block tag whose output is empty
10
+ * swallows the line break after it, and a block whose output is not empty swallows the line
11
+ * break at its own start instead. So a list is written as `{% for %}` followed by a standalone
12
+ * `{% if not … %}` block for its empty state — exactly one of the two renders, exactly one line
13
+ * break is consumed, and the blank line before the next heading survives either way. A test
14
+ * renders every template through knap's own engine to keep these shapes honest.
15
+ */
16
+
17
+ export const RENDER_TEMPLATE_NAMES = ['llms.txt', 'area', 'ownership', 'change-digest', 'overlay-review'] as const
18
+
19
+ export type RenderTemplateName = (typeof RENDER_TEMPLATE_NAMES)[number]
20
+
21
+ export type RenderTemplateInfo = {
22
+ readonly name: RenderTemplateName
23
+ readonly description: string
24
+ /** What `--data` may point at, in place of the default source. */
25
+ readonly data: string
26
+ /** Whether the output is one Markdown page per unit rather than a single file. */
27
+ readonly multiPage: boolean
28
+ /** Whether the output carries generated-region markers. `llms.txt` is a whole-file artifact with its own consumers and carries none. */
29
+ readonly generatedRegion: boolean
30
+ }
31
+
32
+ export const RENDER_TEMPLATES: Readonly<Record<RenderTemplateName, RenderTemplateInfo>> = {
33
+ 'llms.txt': {
34
+ name: 'llms.txt',
35
+ description: 'The curated reading order for agents, as `ak-docs index` writes it',
36
+ data: 'a DocBridgeIndex (.doc-bridge/index.json)',
37
+ multiPage: false,
38
+ generatedRegion: false,
39
+ },
40
+ area: {
41
+ name: 'area',
42
+ description: 'One page per code area: purpose, modules, documents, related areas, checks and open findings',
43
+ data: 'a DocBridgeIndex (.doc-bridge/index.json)',
44
+ multiPage: true,
45
+ generatedRegion: true,
46
+ },
47
+ ownership: {
48
+ name: 'ownership',
49
+ description: 'One sidecar per ownership record: where to start, what to read, what to run',
50
+ data: 'a DocBridgeIndex (.doc-bridge/index.json)',
51
+ multiPage: true,
52
+ generatedRegion: true,
53
+ },
54
+ 'change-digest': {
55
+ name: 'change-digest',
56
+ description: 'Entities and documents whose content hash moved since the last scan, and the documents that should have moved with them',
57
+ data: 'the previous discovery snapshot to compare against (default: the last `ak-docs scan`)',
58
+ multiPage: false,
59
+ generatedRegion: true,
60
+ },
61
+ 'overlay-review': {
62
+ name: 'overlay-review',
63
+ description: 'Pending agent proposals with their evidence, for a human to judge',
64
+ data: 'an enrichment overlay (default: .doc-bridge/enrich/overlay.json)',
65
+ multiPage: false,
66
+ generatedRegion: true,
67
+ },
68
+ }
69
+
70
+ export const isRenderTemplateName = (value: string): value is RenderTemplateName =>
71
+ (RENDER_TEMPLATE_NAMES as readonly string[]).includes(value)
72
+
73
+ /*
74
+ * `llms.txt` must stay byte-identical to what the string concatenation produced, including the
75
+ * empty corpus: a preamble, a blank line, the heading, a blank line, the entries and a final
76
+ * newline — which for no entries is `## Knowledge` followed by three line breaks. An empty loop
77
+ * swallows the line break after `{% endfor %}`, so the empty case gets its own block: its three
78
+ * line breaks become two after the closing tag's line is trimmed and one after the block's own
79
+ * trim, which is the one the loop swallowed. The template ends at `{% endif %}` on purpose.
80
+ */
81
+ const LLMS_TXT = `{{ preamble }}
82
+
83
+ ## Knowledge
84
+
85
+ {% for entry in entries %}
86
+ - [{{ entry.title }}]({{ entry.url }}){% if entry.description %}: {{ entry.description }}{% endif %}
87
+ {% endfor %}
88
+ {% if not entries %}
89
+
90
+
91
+ {% endif %}`
92
+
93
+ const AREA = `{{ region.open }}
94
+ # Area \`{{ area.path }}\`
95
+
96
+ {{ area.purpose }}
97
+ {% if area.ownership %}
98
+
99
+ {{ area.ownership }}
100
+ {% endif %}
101
+
102
+ ## Modules
103
+
104
+ {% for module in area.modules %}
105
+ - \`{{ module.path }}\`{% if module.symbols %}: exports {{ module.symbols }}{% endif %}
106
+ {% endfor %}
107
+ {% if not area.modules %}
108
+ No module was observed in this area.
109
+ {% endif %}
110
+
111
+ ## Documents
112
+
113
+ {% for document in area.documents %}
114
+ - [{{ document.title }}]({{ document.path }}): {{ document.relation }}
115
+ {% endfor %}
116
+ {% if not area.documents %}
117
+ No document covers or mentions this area.
118
+ {% endif %}
119
+
120
+ ## Related areas
121
+
122
+ {% for related in area.related %}
123
+ - \`{{ related.path }}\`: {{ related.direction }}, {{ related.strength }} import(s) — {{ related.evidence }}
124
+ {% endfor %}
125
+ {% if not area.related %}
126
+ No import crosses this area's boundary.
127
+ {% endif %}
128
+
129
+ ## Checks
130
+
131
+ {% for check in area.checks %}
132
+ - \`{{ check }}\`
133
+ {% endfor %}
134
+ {% if not area.checks %}
135
+ No check is declared for this area.
136
+ {% endif %}
137
+ {% if area.checksSource %}
138
+
139
+ Source: {{ area.checksSource }}.
140
+ {% endif %}
141
+
142
+ ## Open findings
143
+
144
+ {% for finding in area.findings %}
145
+ - **{{ finding.code }}** ({{ finding.severity }}, {{ finding.status }}): {{ finding.message }} — \`{{ finding.evidence }}\`
146
+ {% endfor %}
147
+ {% if not area.findings %}
148
+ {{ area.findingsEmpty }}
149
+ {% endif %}
150
+ {{ region.close }}
151
+ `
152
+
153
+ const OWNERSHIP = `---
154
+ type: {{ owner.kind }}
155
+ id: {{ owner.id }}
156
+ editRoot: {{ owner.path }}
157
+ {% if owner.humanDoc %}
158
+ humanDoc: {{ owner.humanDoc }}
159
+ {% endif %}
160
+ ---
161
+
162
+ {{ region.open }}
163
+ # {{ owner.id }}
164
+
165
+ {{ owner.purpose }}
166
+
167
+ ## Start here
168
+
169
+ {{ owner.startHere }}
170
+
171
+ ## Read before editing
172
+
173
+ {% for path in owner.readBeforeEditing %}
174
+ - {{ path }}
175
+ {% endfor %}
176
+ {% if not owner.readBeforeEditing %}
177
+ Nothing beyond the start page.
178
+ {% endif %}
179
+
180
+ ## Edit roots
181
+
182
+ {% for path in owner.editRoots %}
183
+ - \`{{ path }}\`
184
+ {% endfor %}
185
+
186
+ ## Checks
187
+
188
+ {% for check in owner.checks %}
189
+ - \`{{ check }}\`
190
+ {% endfor %}
191
+ {% if not owner.checks %}
192
+ No check is declared.
193
+ {% endif %}
194
+ {% if owner.checksSource %}
195
+
196
+ Source: {{ owner.checksSource }}.
197
+ {% endif %}
198
+
199
+ ## Related areas
200
+
201
+ {% for related in owner.related %}
202
+ - \`{{ related.path }}\`: {{ related.direction }}, {{ related.strength }} import(s) — {{ related.evidence }}
203
+ {% endfor %}
204
+ {% if not owner.related %}
205
+ No import crosses this unit's boundary.
206
+ {% endif %}
207
+ {{ region.close }}
208
+ `
209
+
210
+ const CHANGE_DIGEST = `{{ region.open }}
211
+ # Change digest
212
+
213
+ Compared the current tree with the previous snapshot: {{ digest.summary }}.
214
+
215
+ ## Changed
216
+
217
+ {% for entity in digest.changed %}
218
+ - \`{{ entity.path }}\` ({{ entity.kind }}): {{ entity.previousHash }} → {{ entity.currentHash }}
219
+ {% endfor %}
220
+ {% if not digest.changed %}
221
+ No file-backed entity changed.
222
+ {% endif %}
223
+
224
+ ## Added
225
+
226
+ {% for entity in digest.added %}
227
+ - \`{{ entity.path }}\` ({{ entity.kind }}): {{ entity.currentHash }}
228
+ {% endfor %}
229
+ {% if not digest.added %}
230
+ Nothing was added.
231
+ {% endif %}
232
+
233
+ ## Removed
234
+
235
+ {% for entity in digest.removed %}
236
+ - \`{{ entity.path }}\` ({{ entity.kind }}): {{ entity.previousHash }}
237
+ {% endfor %}
238
+ {% if not digest.removed %}
239
+ Nothing was removed.
240
+ {% endif %}
241
+
242
+ ## Documentation to review
243
+
244
+ {% for document in digest.documentsToReview %}
245
+ - \`{{ document.path }}\`: {{ document.because }}
246
+ {% endfor %}
247
+ {% if not digest.documentsToReview %}
248
+ No unchanged document covers or mentions a changed entity.
249
+ {% endif %}
250
+ {{ region.close }}
251
+ `
252
+
253
+ const OVERLAY_REVIEW = `{{ region.open }}
254
+ # Overlay review
255
+
256
+ {% if overlay.present %}
257
+ {{ overlay.summary }}
258
+
259
+ {% for proposal in overlay.pending %}
260
+ ## {{ proposal.kind }}: \`{{ proposal.entity }}\`
261
+
262
+ {{ proposal.reason }}
263
+
264
+ Confidence {{ proposal.confidence }} · proposal \`{{ proposal.proposalId }}\`
265
+
266
+ {% for item in proposal.evidence %}
267
+ - {{ item.link }}
268
+ {% endfor %}
269
+ {% if not proposal.evidence %}
270
+ No evidence was attached to this proposal.
271
+ {% endif %}
272
+
273
+ {% endfor %}
274
+ {% if not overlay.pending %}
275
+ No proposal is pending review.
276
+ {% endif %}
277
+ {% else %}
278
+ No enrichment overlay exists for this repository. Run \`ak-docs enrich\` to produce one, or pass \`--data <overlay.json>\`.
279
+ {% endif %}
280
+ {{ region.close }}
281
+ `
282
+
283
+ export const BUNDLED_TEMPLATES: Readonly<Record<RenderTemplateName, string>> = {
284
+ 'llms.txt': LLMS_TXT,
285
+ area: AREA,
286
+ ownership: OWNERSHIP,
287
+ 'change-digest': CHANGE_DIGEST,
288
+ 'overlay-review': OVERLAY_REVIEW,
289
+ }
@@ -354,7 +354,7 @@ styles += String.raw`.graph-node .node-label,.graph-node text.node-label{fill:va
354
354
  styles += String.raw`.report-tabs{display:flex;gap:0;align-items:flex-end;border-bottom:1px solid var(--line);margin:24px 0 0}.report-tabs .tab{font-size:13px}.breadcrumbs{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:0 0 14px;color:var(--muted);font-size:12px}.breadcrumbs button{border:0;background:none;color:var(--blue);padding:3px 0;font-weight:700}.breadcrumbs button:hover{text-decoration:underline}.breadcrumbs .current{color:var(--ink);font-weight:700}.scope-note{margin:0 0 12px}.report-dashboard{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(280px,1fr);gap:24px}.report-table{width:100%;border-collapse:collapse;font-size:12px}.report-table th,.report-table td{border-top:1px solid var(--line);padding:10px 8px;text-align:left;vertical-align:top}.report-table th{color:var(--muted);font-size:10px;letter-spacing:.08em;text-transform:uppercase}.report-table td:first-child{font-weight:750}.bar-list{display:grid;gap:10px}.bar-item{display:grid;grid-template-columns:minmax(120px,1fr) 2fr auto;gap:10px;align-items:center;font-size:12px}.bar-item i{height:8px;background:var(--line);display:block;overflow:hidden}.bar-item i b{display:block;height:100%;background:var(--green)}.map-wrap{touch-action:none;cursor:grab}.map-wrap.dragging{cursor:grabbing}@media(max-width:700px){.report-tabs{overflow:auto}.report-tabs .tab{white-space:nowrap}.report-dashboard{grid-template-columns:1fr}.bar-item{grid-template-columns:minmax(100px,1fr) 1fr auto}}`
355
355
 
356
356
  styles += String.raw`body[data-report-view="architecture"] #insights,body[data-report-view="architecture"] .filters,body[data-report-view="architecture"] .run,body[data-report-view="architecture"] .metadata{display:none}body[data-report-view="insights"] .workspace,body[data-report-view="insights"] .filters,body[data-report-view="insights"] .run,body[data-report-view="insights"] .metadata{display:none}body[data-report-view="findings"] .workspace,body[data-report-view="findings"] #insights,body[data-report-view="findings"] #report-dashboard,body[data-report-view="findings"] #coverage-title,body[data-report-view="findings"] #coverage-title~*{display:none}body[data-report-view="findings"] .run[aria-labelledby="coverage-title"]{display:none}body[data-report-view="coverage"] .workspace,body[data-report-view="coverage"] #insights,body[data-report-view="coverage"] #report-dashboard,body[data-report-view="coverage"] .filters,body[data-report-view="coverage"] .run[aria-labelledby="run-title"],body[data-report-view="coverage"] .metadata{display:none}body[data-report-view="insights"] #report-dashboard{display:block}body[data-report-view="map"] #report-dashboard{display:none}`
357
- styles += String.raw`body[data-report-view="architecture"] #report-dashboard{display:none!important}.edge{fill:none}`
357
+ styles += String.raw`body[data-report-view="architecture"] #report-dashboard{display:none!important}.edge{fill:none}.edge.proposed{stroke-dasharray:6 4}`
358
358
 
359
359
  styles += String.raw`:root{--ink:#17251f;--muted:#586b63;--paper:#f2f5f1;--panel:#fbfcf9;--line:#c9d5cc;--green:#13734a;--blue:#155f78;--amber:#995d08;--red:#a52e2b}html,body{max-width:100%;overflow-x:hidden}body{font-size:15px;line-height:1.55}.shell{width:100%;max-width:1600px;margin:0 auto;padding:28px clamp(16px,3vw,48px) 56px}.masthead{gap:32px;padding:0 0 24px;align-items:flex-start}.masthead h1{font-size:clamp(2.8rem,5.5vw,5.4rem);line-height:.94;margin:12px 0 16px;max-width:100%;overflow-wrap:anywhere}.lede{max-width:60ch;font-size:16px}.run-meta{min-width:0;max-width:300px}.summary{margin:0;padding:18px 0 20px;grid-template-columns:repeat(5,minmax(0,1fr));gap:0}.metric{min-width:0;padding:0 16px;border-inline-start:1px solid var(--line)}.metric:first-child{padding-inline-start:0;border-inline-start:0}.metric b{font-size:clamp(1.8rem,3vw,3rem);max-width:100%;overflow-wrap:anywhere}.metric span{font-size:10px;letter-spacing:.06em;line-height:1.25}.lens-bar{margin:24px 0 4px;align-items:flex-end}.lens-bar>.subtle{max-width:52ch}.report-tabs{max-width:100%;overflow-x:auto}.tab,.level{min-height:40px;padding:8px 12px}.filters{max-width:100%;align-items:center}.filters label{min-width:0}.filters input,.filters select{max-width:100%;min-width:0}.workspace{min-width:0;grid-template-columns:minmax(0,1fr) minmax(280px,340px);gap:20px}.panel{min-width:0}.map-wrap{min-width:0;max-width:100%;width:100%;overflow:hidden;contain:layout paint}#graph{display:block;width:100%!important;min-width:0!important;max-width:100%;height:clamp(480px,60vh,680px)!important}.side{min-width:0}.detail-title,.path,.list,.finding,.finding-group,.report-table{min-width:0;overflow-wrap:anywhere}.report-dashboard{min-width:0}.report-table{table-layout:fixed}.report-table th,.report-table td{overflow-wrap:anywhere}.bar-item{min-width:0}.bar-item span{min-width:0;overflow-wrap:anywhere}.tab:focus-visible,.level:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--blue);outline-offset:2px}@media(max-width:1100px){.workspace{grid-template-columns:1fr}.side{position:static}.summary{grid-template-columns:repeat(3,minmax(0,1fr));row-gap:16px}.summary .metric:nth-child(4){border-inline-start:0;padding-inline-start:0}}@media(max-width:700px){.shell{padding:12px 16px 32px}.masthead{padding-bottom:14px}.masthead h1{font-size:clamp(2.2rem,11vw,3.4rem);margin:6px 0 10px}.lede{font-size:14px;line-height:1.35}.run-meta{display:none}.summary{padding:10px 0 12px;grid-template-columns:repeat(2,minmax(0,1fr));row-gap:8px}.summary .metric:nth-child(even){border-inline-start:1px solid var(--line);padding-inline-start:12px}.summary .metric:nth-child(odd){border-inline-start:0;padding-inline-start:0}.metric{padding-inline:10px}.metric b{font-size:1.65rem}.lens-bar{display:block;margin-top:14px}.lens-bar>.subtle{display:none}.filters{display:grid;grid-template-columns:1fr;gap:10px}.filters label{display:grid;grid-template-columns:1fr;gap:5px}.filters input,.filters select,.filters button{width:100%;min-height:44px}.panel{padding:16px}.panel-head{display:block}.panel-head>.tabs{margin-top:12px}.finding-groups{grid-template-columns:1fr}.report-dashboard{grid-template-columns:1fr}#graph{height:520px!important}.lens-bar + #lens-caption{margin:8px 0}.lens-bar + #lens-caption + #insights{margin-top:8px}}@media(prefers-color-scheme:dark){:root{--ink:#edf5ef;--muted:#b7c6ba;--paper:#111815;--panel:#1a2821;--line:#405248;--green:#73d19d;--blue:#8bd1e6;--amber:#f0bd70;--red:#ff9389}}`
360
360
 
@@ -419,7 +419,7 @@ document.addEventListener("click",(event)=>{const node=event.target.closest?.("[
419
419
  document.addEventListener("click",(event)=>{const crumb=event.target.closest?.("[data-breadcrumb-level]");if(!crumb)return;event.preventDefault();event.stopImmediatePropagation();state.level=crumb.dataset.breadcrumbLevel;state.selected=crumb.dataset.breadcrumbSelected||null;mapState.scale=1;mapState.x=0;mapState.y=0;render()},true);
420
420
  const diagnosticsFor=(id)=>(findingIndex.get(id)||[]).filter((finding)=>finding.status!=="confirmed");
421
421
  const renderDetails=()=>{const panel=document.querySelector("#details"),entity=state.selected?byId.get(state.selected):null,group=state.selected?groupById.get(state.selected):null;if(group){const members=group.members.map((id)=>byId.get(id)).filter(Boolean),relations=new Set(),ids=groupEntityIds.get(state.selected)||new Set();ids.forEach((id)=>(relationsByEntity.get(id)||[]).forEach((relation)=>relations.add(relation)));const findings=nodeIssues(state.selected),evidence=members.flatMap((member)=>member.evidence||[]).slice(0,8),unit=group.kind==="external"?"dependencies":"packages";panel.innerHTML="<div class=\"eyebrow\">Selected group</div><h2 class=\"detail-title\">"+esc(group.name)+"</h2><span class=\"tag\">"+esc(group.kind)+"</span><p class=\"path\">"+esc(group.path||"Derived from stable package identity and repository structure")+"</p><div class=\"detail-grid\"><div><b>"+members.length+"</b><span>"+unit+"</span></div><div><b>"+relations.size+"</b><span>relations</span></div><div><b>"+findings.length+"</b><span>findings</span></div><div><b>"+evidence.length+"</b><span>evidence items</span></div></div><h3>Members</h3><ul class=\"list\">"+(members.length?members.slice(0,8).map((member)=>"<li>"+esc(label(member))+"</li>").join(""):"<li>No members recorded.</li>")+(members.length>8?"<li class=\"subtle\">+"+(members.length-8)+" more — choose Package level to inspect all.</li>":"")+"</ul>"+(findings.length?"<h3 style=\"margin-top:16px\">Attention</h3><ul class=\"list\">"+findings.slice(0,5).map((finding)=>"<li><span class=\"tag "+esc(finding.severity)+"\">"+esc(finding.severity)+"</span> "+esc(finding.code)+"</li>").join("")+"</ul>":"");return}if(!entity){panel.innerHTML="<p class=\"subtle\">Select a node in the map to inspect its evidence, connectivity, and findings.</p>";return}const relations=relationsByEntity.get(entity.id)||[],incoming=relations.filter((relation)=>relation.to===entity.id),outgoing=relations.filter((relation)=>relation.from===entity.id),findings=diagnosticsFor(entity.id),evidence=[...(entity.evidence||[]),...incoming.flatMap((relation)=>relation.evidence||[]),...outgoing.flatMap((relation)=>relation.evidence||[])].slice(0,8);panel.innerHTML="<div class=\"eyebrow\">Selected entity</div><h2 class=\"detail-title\">"+esc(label(entity))+"</h2><span class=\"tag\">"+esc(entity.kind)+"</span><p class=\"path\">"+esc(entity.path||entity.id)+"</p><div class=\"detail-grid\"><div><b>"+incoming.length+"</b><span>incoming</span></div><div><b>"+outgoing.length+"</b><span>outgoing</span></div><div><b>"+findings.length+"</b><span>findings</span></div><div><b>"+evidence.length+"</b><span>evidence items</span></div></div><h3>Evidence</h3><ul class=\"list\">"+(evidence.length?evidence.map((item)=>"<li>"+esc(item.path+(item.lineStart?":"+item.lineStart:"")+(item.context?" — "+item.context:""))+"</li>").join(""):"<li>No evidence recorded.</li>")+"</ul>"+(findings.length?"<h3 style=\"margin-top:16px\">Attention</h3><ul class=\"list\">"+findings.slice(0,5).map((finding)=>"<li><span class=\"tag "+esc(finding.severity)+"\">"+esc(finding.severity)+"</span> "+esc(finding.code)+"</li>").join("")+"</ul>":"")};
422
- const renderGraph=()=>{const model=graphModel(),svg=document.querySelector("#graph"),dense=model.edges.length>64,edges=dense?[...model.edges].sort((a,b)=>(b.health?1:0)-(a.health?1:0)||b.count-a.count||a.from.localeCompare(b.from)||a.to.localeCompare(b.to)).slice(0,64):model.edges;if(!model.nodes.length){svg.innerHTML="<text x=\"500\" y=\"270\" text-anchor=\"middle\" class=\"subtle\">Select an app or package to expand this view.</text>";return}const ids=new Set(model.nodes.map((node)=>node.id)),incoming=new Map(model.nodes.map((node)=>[node.id,0])),outgoing=new Map(model.nodes.map((node)=>[node.id,[]]));edges.forEach((edge)=>{if(!ids.has(edge.from)||!ids.has(edge.to))return;incoming.set(edge.to,(incoming.get(edge.to)||0)+1);outgoing.get(edge.from).push(edge.to)});const rank=new Map(),work=[];model.nodes.filter((node)=>(incoming.get(node.id)||0)===0).sort((a,b)=>label(a).localeCompare(label(b))||a.id.localeCompare(b.id)).forEach((node)=>{rank.set(node.id,0);work.push(node)});for(let index=0;index<work.length;index++){const node=work[index];for(const next of outgoing.get(node.id)||[]){const nextRank=Math.max(rank.get(next)||0,(rank.get(node.id)||0)+1);rank.set(next,nextRank);if(!work.some((item)=>item.id===next))work.push(byId.get(next)||{id:next,name:next})}}model.nodes.forEach((node)=>{if(!rank.has(node.id))rank.set(node.id,0)});const columns=new Map();model.nodes.forEach((node)=>{const items=columns.get(rank.get(node.id))||[];items.push(node);columns.set(rank.get(node.id),items)});for(const items of columns.values())items.sort((a,b)=>label(a).localeCompare(label(b))||a.id.localeCompare(b.id));if(dense){columns.clear();model.nodes.forEach((node,index)=>{const column=Math.floor(index/8),items=columns.get(column)||[];items.push(node);columns.set(column,items)})}const maxRows=Math.max(1,...[...columns.values()].map((items)=>items.length)),cellWidth=190,cellHeight=82,pad=28,columnCount=Math.max(1,...columns.keys())+1,width=Math.max(620,columnCount*cellWidth+pad*2,Math.min(1000,svg.clientWidth*1.05)),height=Math.max(520,maxRows*cellHeight+pad*2);svg.setAttribute("viewBox","0 0 "+width+" "+height);svg.style.width=dense?Math.max(1000,width)+"px":"100%";svg.style.minWidth="0";svg.style.height="100%";document.querySelector(".map-wrap")?.classList.toggle("dense",dense);const positions=new Map();for(const [column,items] of columns)items.forEach((node,row)=>positions.set(node.id,{x:pad+column*cellWidth+78,y:pad+row*cellHeight+27}));const defs="<defs><marker id=\"arrow\" markerWidth=\"8\" markerHeight=\"8\" refX=\"7\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L0,6 L7,3 z\" fill=\"#789087\"/></marker></defs>",edgeMarkup=edges.map((edge)=>{const from=positions.get(edge.from),to=positions.get(edge.to);if(!from||!to)return "";const midX=(from.x+to.x)/2,midY=(from.y+to.y)/2;return "<g><line class=\"edge "+(edge.health?"alert":"")+"\" x1=\""+from.x+"\" y1=\""+from.y+"\" x2=\""+to.x+"\" y2=\""+to.y+"\" marker-end=\"url(#arrow)\"/><text class=\"edge-label\" x=\""+midX+"\" y=\""+midY+"\">"+esc(edge.count>1?edge.count+"×":"")+"</text></g>"}).join(""),nodes=model.nodes.map((node)=>{const pos=positions.get(node.id),issues=nodeIssues(node.id),degree=degreeMap(edges).get(node.id)||0,selected=state.selected===node.id?" selected":"",issue=issues.length?" issue":"",nodeLabel=label(node),nodeKind=node.kind==="domain"?(node.memberCount||0)+" packages":node.kind;return "<g class=\"graph-node"+selected+issue+"\" transform=\"translate("+(pos.x-78)+","+(pos.y-27)+")\"><title>"+esc(nodeLabel)+" · "+esc(nodeKind)+"</title><rect width=\"156\" height=\"54\" rx=\"8\" role=\"button\" tabindex=\"0\" aria-label=\""+esc(nodeLabel+" "+nodeKind)+"\" data-node=\""+esc(node.id)+"\"></rect><text class=\"node-label\" x=\"10\" y=\"19\">"+esc(short(nodeLabel,22))+"</text><text class=\"node-kind\" x=\"10\" y=\"34\">"+esc(short(nodeKind,22))+"</text><text class=\"node-count\" x=\"144\" y=\"19\" text-anchor=\"end\">"+(degree||"")+"</text></g>"}).join("");svg.innerHTML=defs+edgeMarkup+nodes;routeGraphEdges();applyMapTransform();document.querySelector("#map-note").textContent=(state.level==="overview"?"Bounded domain view. ":state.level+" view. ")+"Directional edges are aggregated from canonical relations; "+model.edges.length+" visible connection groups"+(dense?" (showing "+edges.length+" prioritized of "+model.edges.length+").":".");};
422
+ const renderGraph=()=>{const model=graphModel(),svg=document.querySelector("#graph"),dense=model.edges.length>64,edges=dense?[...model.edges].sort((a,b)=>(b.health?1:0)-(a.health?1:0)||b.count-a.count||a.from.localeCompare(b.from)||a.to.localeCompare(b.to)).slice(0,64):model.edges;if(!model.nodes.length){svg.innerHTML="<text x=\"500\" y=\"270\" text-anchor=\"middle\" class=\"subtle\">Select an app or package to expand this view.</text>";return}const ids=new Set(model.nodes.map((node)=>node.id)),incoming=new Map(model.nodes.map((node)=>[node.id,0])),outgoing=new Map(model.nodes.map((node)=>[node.id,[]]));edges.forEach((edge)=>{if(!ids.has(edge.from)||!ids.has(edge.to))return;incoming.set(edge.to,(incoming.get(edge.to)||0)+1);outgoing.get(edge.from).push(edge.to)});const rank=new Map(),work=[];model.nodes.filter((node)=>(incoming.get(node.id)||0)===0).sort((a,b)=>label(a).localeCompare(label(b))||a.id.localeCompare(b.id)).forEach((node)=>{rank.set(node.id,0);work.push(node)});for(let index=0;index<work.length;index++){const node=work[index];for(const next of outgoing.get(node.id)||[]){const nextRank=Math.max(rank.get(next)||0,(rank.get(node.id)||0)+1);rank.set(next,nextRank);if(!work.some((item)=>item.id===next))work.push(byId.get(next)||{id:next,name:next})}}model.nodes.forEach((node)=>{if(!rank.has(node.id))rank.set(node.id,0)});const columns=new Map();model.nodes.forEach((node)=>{const items=columns.get(rank.get(node.id))||[];items.push(node);columns.set(rank.get(node.id),items)});for(const items of columns.values())items.sort((a,b)=>label(a).localeCompare(label(b))||a.id.localeCompare(b.id));if(dense){columns.clear();model.nodes.forEach((node,index)=>{const column=Math.floor(index/8),items=columns.get(column)||[];items.push(node);columns.set(column,items)})}const maxRows=Math.max(1,...[...columns.values()].map((items)=>items.length)),cellWidth=190,cellHeight=82,pad=28,columnCount=Math.max(1,...columns.keys())+1,width=Math.max(620,columnCount*cellWidth+pad*2,Math.min(1000,svg.clientWidth*1.05)),height=Math.max(520,maxRows*cellHeight+pad*2);svg.setAttribute("viewBox","0 0 "+width+" "+height);svg.style.width=dense?Math.max(1000,width)+"px":"100%";svg.style.minWidth="0";svg.style.height="100%";document.querySelector(".map-wrap")?.classList.toggle("dense",dense);const positions=new Map();for(const [column,items] of columns)items.forEach((node,row)=>positions.set(node.id,{x:pad+column*cellWidth+78,y:pad+row*cellHeight+27}));const defs="<defs><marker id=\"arrow\" markerWidth=\"8\" markerHeight=\"8\" refX=\"7\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L0,6 L7,3 z\" fill=\"#789087\"/></marker></defs>",edgeMarkup=edges.map((edge)=>{const from=positions.get(edge.from),to=positions.get(edge.to);if(!from||!to)return "";const midX=(from.x+to.x)/2,midY=(from.y+to.y)/2;const proposed=[...edge.relationIds].length>0&&[...edge.relationIds].every((id)=>relationById.get(id)?.provenance==="proposed");return "<g><line class=\"edge "+(edge.health?"alert":"")+(proposed?" proposed":"")+"\" x1=\""+from.x+"\" y1=\""+from.y+"\" x2=\""+to.x+"\" y2=\""+to.y+"\" marker-end=\"url(#arrow)\"/><text class=\"edge-label\" x=\""+midX+"\" y=\""+midY+"\">"+esc(edge.count>1?edge.count+"×":"")+"</text></g>"}).join(""),nodes=model.nodes.map((node)=>{const pos=positions.get(node.id),issues=nodeIssues(node.id),degree=degreeMap(edges).get(node.id)||0,selected=state.selected===node.id?" selected":"",issue=issues.length?" issue":"",nodeLabel=label(node),nodeKind=node.kind==="domain"?(node.memberCount||0)+" packages":node.kind;return "<g class=\"graph-node"+selected+issue+"\" transform=\"translate("+(pos.x-78)+","+(pos.y-27)+")\"><title>"+esc(nodeLabel)+" · "+esc(nodeKind)+"</title><rect width=\"156\" height=\"54\" rx=\"8\" role=\"button\" tabindex=\"0\" aria-label=\""+esc(nodeLabel+" "+nodeKind)+"\" data-node=\""+esc(node.id)+"\"></rect><text class=\"node-label\" x=\"10\" y=\"19\">"+esc(short(nodeLabel,22))+"</text><text class=\"node-kind\" x=\"10\" y=\"34\">"+esc(short(nodeKind,22))+"</text><text class=\"node-count\" x=\"144\" y=\"19\" text-anchor=\"end\">"+(degree||"")+"</text></g>"}).join("");svg.innerHTML=defs+edgeMarkup+nodes;routeGraphEdges();applyMapTransform();document.querySelector("#map-note").textContent=(state.level==="overview"?"Bounded domain view. ":state.level+" view. ")+"Directional edges are aggregated from canonical relations; "+model.edges.length+" visible connection groups"+(dense?" (showing "+edges.length+" prioritized of "+model.edges.length+").":".");};
423
423
  const findingMatches=(finding)=>{const query=state.query.toLowerCase();return(!query||[finding.id,finding.code,finding.message,...finding.entityIds,...finding.relationIds].join(" ").toLowerCase().includes(query))&&(!state.status||finding.status===state.status)&&(!state.severity||finding.severity===state.severity)};
424
424
  const findingScope=(finding)=>data.view?.diagnosticGroup?.[finding.id]?.[0]||(()=>{const ids=[...(finding.entityIds||[])];(finding.relationIds||[]).forEach((id)=>{const relation=relationById.get(id);if(relation)ids.push(relation.from,relation.to)});return ids.map(groupFor).sort()[0]||"repository"})();
425
425
  const findingGroups=(findings)=>{const groups=new Map();findings.forEach((finding)=>{const scope=findingScope(finding),key=[scope,finding.code,finding.status,finding.severity].join("|"),group=groups.get(key)||{key,scope,code:finding.code,status:finding.status,severity:finding.severity,findings:[]};group.findings.push(finding);groups.set(key,group)});return[...groups.values()].sort((left,right)=>right.findings.length-left.findings.length||left.code.localeCompare(right.code)||left.key.localeCompare(right.key))};
@@ -0,0 +1,161 @@
1
+ import { searchTokens } from '../query/text.js'
2
+
3
+ /**
4
+ * Field-weighted BM25 (BM25F).
5
+ *
6
+ * Retrieval used to score a record by summing token lengths over one flattened haystack, which
7
+ * made a long document beat a short exact match and gave every field the same authority. BM25
8
+ * fixes both: a term that appears in nearly every record earns almost nothing (IDF), a term in a
9
+ * short title outranks the same term buried in a body (per-field length normalization), and the
10
+ * field a term matched in is part of the result, so ranking can be explained rather than guessed.
11
+ *
12
+ * Ordering is total: score descending, then `ref` ascending. Scores are rounded before comparison
13
+ * so a ranking is reproducible and never depends on floating-point accumulation order.
14
+ */
15
+
16
+ export const BM25_VERSION = 1 as const
17
+
18
+ const DEFAULT_K1 = 1.2
19
+ const DEFAULT_B = 0.75
20
+ const SCORE_PRECISION = 1_000_000
21
+
22
+ /** Per-field multipliers. A field absent from the map is not indexed at all. */
23
+ export type Bm25FieldWeights = Readonly<Record<string, number>>
24
+
25
+ export type Bm25Params = {
26
+ /** Term-frequency saturation. Higher rewards repetition more. */
27
+ readonly k1?: number
28
+ /** Length-normalization strength, 0 to 1. */
29
+ readonly b?: number
30
+ }
31
+
32
+ export type Bm25FieldValue = string | readonly string[] | undefined
33
+
34
+ export type Bm25Input = {
35
+ readonly ref: string
36
+ readonly fields: Readonly<Record<string, Bm25FieldValue>>
37
+ }
38
+
39
+ export type Bm25Hit = {
40
+ readonly ref: string
41
+ readonly score: number
42
+ /** Query terms that matched, per field, sorted. The raw material for an explain view. */
43
+ readonly matched: Readonly<Record<string, readonly string[]>>
44
+ }
45
+
46
+ type FieldPosting = {
47
+ readonly frequency: number
48
+ readonly length: number
49
+ }
50
+
51
+ type IndexedDocument = {
52
+ readonly ref: string
53
+ /** field -> term -> frequency, plus the field's token length. */
54
+ readonly fields: ReadonlyMap<string, { readonly tokens: ReadonlyMap<string, number>; readonly length: number }>
55
+ }
56
+
57
+ export type Bm25Index = {
58
+ readonly documents: readonly IndexedDocument[]
59
+ readonly documentFrequency: ReadonlyMap<string, number>
60
+ readonly averageFieldLength: ReadonlyMap<string, number>
61
+ readonly weights: Bm25FieldWeights
62
+ readonly k1: number
63
+ readonly b: number
64
+ }
65
+
66
+ const fieldText = (value: Bm25FieldValue): string =>
67
+ value === undefined ? '' : Array.isArray(value) ? value.join(' ') : String(value)
68
+
69
+ const round = (value: number): number => Math.round(value * SCORE_PRECISION) / SCORE_PRECISION
70
+
71
+ export const buildBm25Index = (
72
+ inputs: readonly Bm25Input[],
73
+ weights: Bm25FieldWeights,
74
+ params: Bm25Params = {},
75
+ ): Bm25Index => {
76
+ const fieldNames = Object.keys(weights).sort()
77
+ const documents: IndexedDocument[] = []
78
+ const documentFrequency = new Map<string, number>()
79
+ const fieldLengthTotal = new Map<string, number>(fieldNames.map((name) => [name, 0]))
80
+
81
+ for (const input of inputs) {
82
+ const fields = new Map<string, { tokens: ReadonlyMap<string, number>; length: number }>()
83
+ const seen = new Set<string>()
84
+ for (const name of fieldNames) {
85
+ const tokens = searchTokens(fieldText(input.fields[name]))
86
+ const frequencies = new Map<string, number>()
87
+ for (const token of tokens) {
88
+ frequencies.set(token, (frequencies.get(token) ?? 0) + 1)
89
+ seen.add(token)
90
+ }
91
+ fields.set(name, { tokens: frequencies, length: tokens.length })
92
+ fieldLengthTotal.set(name, (fieldLengthTotal.get(name) ?? 0) + tokens.length)
93
+ }
94
+ for (const token of seen) documentFrequency.set(token, (documentFrequency.get(token) ?? 0) + 1)
95
+ documents.push({ ref: input.ref, fields })
96
+ }
97
+
98
+ const count = documents.length || 1
99
+ return {
100
+ documents,
101
+ documentFrequency,
102
+ averageFieldLength: new Map(fieldNames.map((name) => [name, (fieldLengthTotal.get(name) ?? 0) / count])),
103
+ weights,
104
+ k1: params.k1 ?? DEFAULT_K1,
105
+ b: params.b ?? DEFAULT_B,
106
+ }
107
+ }
108
+
109
+ /** Probabilistic IDF, floored at zero so a term in every document can never subtract score. */
110
+ export const bm25Idf = (documentCount: number, documentFrequency: number): number =>
111
+ Math.max(0, Math.log(1 + (documentCount - documentFrequency + 0.5) / (documentFrequency + 0.5)))
112
+
113
+ const posting = (document: IndexedDocument, field: string, term: string): FieldPosting | undefined => {
114
+ const stats = document.fields.get(field)
115
+ if (!stats) return undefined
116
+ const frequency = stats.tokens.get(term)
117
+ return frequency === undefined ? undefined : { frequency, length: stats.length }
118
+ }
119
+
120
+ /**
121
+ * Score every document against the query terms. Documents with no matched term are omitted, so an
122
+ * unrelated query returns nothing rather than a list of weak guesses.
123
+ */
124
+ export const bm25Search = (index: Bm25Index, terms: readonly string[]): Bm25Hit[] => {
125
+ if (!terms.length || !index.documents.length) return []
126
+ const unique = [...new Set(terms)].sort()
127
+ const fieldNames = Object.keys(index.weights).sort()
128
+ const hits: Bm25Hit[] = []
129
+
130
+ for (const document of index.documents) {
131
+ let score = 0
132
+ const matched = new Map<string, string[]>()
133
+
134
+ for (const term of unique) {
135
+ const idf = bm25Idf(index.documents.length, index.documentFrequency.get(term) ?? 0)
136
+ if (idf <= 0) continue
137
+ let weightedFrequency = 0
138
+ for (const field of fieldNames) {
139
+ const found = posting(document, field, term)
140
+ if (!found) continue
141
+ const average = index.averageFieldLength.get(field) ?? 0
142
+ const normalization = average > 0 ? 1 - index.b + index.b * (found.length / average) : 1
143
+ weightedFrequency += ((index.weights[field] ?? 0) * found.frequency) / normalization
144
+ const list = matched.get(field)
145
+ if (list) list.push(term)
146
+ else matched.set(field, [term])
147
+ }
148
+ if (weightedFrequency <= 0) continue
149
+ score += (idf * weightedFrequency) / (index.k1 + weightedFrequency)
150
+ }
151
+
152
+ if (score <= 0) continue
153
+ hits.push({
154
+ ref: document.ref,
155
+ score: round(score),
156
+ matched: Object.fromEntries([...matched.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([field, list]) => [field, [...list].sort()])),
157
+ })
158
+ }
159
+
160
+ return hits.sort((a, b) => (b.score !== a.score ? b.score - a.score : a.ref.localeCompare(b.ref)))
161
+ }