@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,398 @@
1
+ import {
2
+ createEngine,
3
+ parse,
4
+ standardFilterMetadata,
5
+ standardFilters,
6
+ validateFilters,
7
+ type ASTNode,
8
+ type Expression,
9
+ type FilterContext,
10
+ type IfNode,
11
+ type ForNode,
12
+ type SetNode,
13
+ type VariableNode,
14
+ } from 'knap'
15
+
16
+ /**
17
+ * The template engine behind every Markdown rendering.
18
+ *
19
+ * Templates are knap templates: knap's parser produces the AST, knap's validators reject an
20
+ * unknown filter or a malformed tag before anything is rendered, and knap's own filters run on
21
+ * the values. What is not knap's is the walk over the AST. knap renders asynchronously — its
22
+ * evaluator awaits at every node so filters and variable resolvers may be promises — and the
23
+ * index pipeline that writes `llms.txt`, the conformance profile that re-renders it to check
24
+ * freshness, and the gates that run both are synchronous. Making them asynchronous to render a
25
+ * template would change the signature of every artifact writer for a property the templates do
26
+ * not use: the application computes every variable before rendering, and none of them is a
27
+ * promise.
28
+ *
29
+ * So the evaluator below is a synchronous port of knap's, node for node, including its
30
+ * whitespace rules — a `for` body is trimmed and joined line by line, `-%}` marks a pending trim
31
+ * that the next text node consumes — and a test renders every bundled template through knap's
32
+ * real engine as well and asserts byte-identical output. The port exists to keep the pipeline
33
+ * synchronous, not to diverge from knap; when the pipeline becomes asynchronous, delete it and
34
+ * call `renderTemplateWithKnap`.
35
+ */
36
+
37
+ export type TemplateVariables = Record<string, unknown>
38
+
39
+ export class TemplateError extends Error {
40
+ constructor(
41
+ message: string,
42
+ readonly line?: number,
43
+ readonly column?: number,
44
+ ) {
45
+ super(line === undefined ? message : `${message} (line ${line}, column ${column ?? 1})`)
46
+ this.name = 'TemplateError'
47
+ }
48
+ }
49
+
50
+ export type CompiledTemplate = {
51
+ readonly name: string
52
+ readonly source: string
53
+ readonly ast: readonly ASTNode[]
54
+ }
55
+
56
+ /** Parse and validate a template. A template that references an unknown filter fails here, not mid-render. */
57
+ export const compileTemplate = (source: string, name = 'template'): CompiledTemplate => {
58
+ const parsed = parse(source)
59
+ const errors = [...parsed.errors, ...(parsed.errors.length ? [] : validateFilters(parsed.ast, standardFilterMetadata))]
60
+ const first = errors[0]
61
+ if (first) throw new TemplateError(`${name}: ${first.message}`, first.line, first.column)
62
+ return { name, source, ast: parsed.ast }
63
+ }
64
+
65
+ type RenderState = {
66
+ variables: TemplateVariables
67
+ pendingTrimRight: boolean
68
+ readonly name: string
69
+ }
70
+
71
+ // --- knap's value semantics, ported verbatim so `{{ x }}` prints what knap would print.
72
+
73
+ /*
74
+ * What /[\t ]*\r?\n?$/ did, without the backtracking: a quantified class anchored at the end runs
75
+ * quadratically on a template line of blanks, and a template is repository input.
76
+ */
77
+ const trimTrailingWhitespace = (value: string): string => {
78
+ let end = value.length
79
+ if (end > 0 && value[end - 1] === '\n') {
80
+ end -= 1
81
+ if (end > 0 && value[end - 1] === '\r') end -= 1
82
+ }
83
+ while (end > 0 && (value[end - 1] === ' ' || value[end - 1] === '\t')) end -= 1
84
+ return value.slice(0, end)
85
+ }
86
+ const trimLeadingWhitespace = (value: string): string => value.replace(/^[\t ]*\r?\n?/, '')
87
+
88
+ const isTruthy = (value: unknown): boolean => {
89
+ if (value === undefined || value === null) return false
90
+ if (value === '') return false
91
+ if (value === 0) return false
92
+ if (value === false) return false
93
+ if (Array.isArray(value) && value.length === 0) return false
94
+ return true
95
+ }
96
+
97
+ const valueToString = (value: unknown): string => {
98
+ if (value === undefined || value === null) return ''
99
+ if (Array.isArray(value) && value.length === 1 && typeof value[0] !== 'object') return String(value[0])
100
+ if (typeof value === 'object') return JSON.stringify(value)
101
+ return String(value)
102
+ }
103
+
104
+ const ownProperty = (value: unknown, key: unknown): { exists: boolean; value: unknown } => {
105
+ if (value === null || value === undefined || typeof value === 'function' || (typeof key !== 'string' && typeof key !== 'number')) {
106
+ return { exists: false, value: undefined }
107
+ }
108
+ const descriptor = Object.getOwnPropertyDescriptor(Object(value), key)
109
+ return descriptor && 'value' in descriptor ? { exists: true, value: descriptor.value } : { exists: false, value: undefined }
110
+ }
111
+
112
+ const getNestedValue = (object: unknown, keys: readonly string[]): unknown => {
113
+ if (keys.length === 0 || !object) return undefined
114
+ let value: unknown = object
115
+ for (const key of keys) {
116
+ if (value === undefined || value === null) return undefined
117
+ if (key.includes('[') && key.includes(']')) {
118
+ const match = key.match(/^([^[]*)\[([^\]]+)\]/)
119
+ if (match) {
120
+ const arrayKey = match[1] as string
121
+ const indexText = match[2] as string
122
+ const base = arrayKey ? ownProperty(value, arrayKey).value : value
123
+ if (Array.isArray(base)) value = ownProperty(base, Number.parseInt(indexText, 10)).value
124
+ else if (base && typeof base === 'object') value = ownProperty(base, indexText.replace(/^["']|["']$/g, '')).value
125
+ else return undefined
126
+ continue
127
+ }
128
+ }
129
+ const wrapped = ownProperty(value, `{{${key}}}`).value
130
+ value = wrapped !== undefined ? wrapped : ownProperty(value, key).value
131
+ }
132
+ return value
133
+ }
134
+
135
+ const resolveVariable = (name: string, variables: TemplateVariables, path: readonly string[] | undefined): unknown => {
136
+ const trimmed = name.trim()
137
+ const wrapped = ownProperty(variables, `{{${trimmed}}}`).value
138
+ if (wrapped !== undefined) return wrapped
139
+ const direct = ownProperty(variables, trimmed).value
140
+ if (direct !== undefined) return direct
141
+ if (trimmed.includes('.')) return getNestedValue(variables, path ?? trimmed.split('.'))
142
+ return undefined
143
+ }
144
+
145
+ const evaluateContains = (left: unknown, right: unknown): boolean => {
146
+ if (left === undefined || left === null) return false
147
+ if (right === undefined || right === null) return false
148
+ if (Array.isArray(left)) {
149
+ return left.some((item) => (typeof item === 'string' && typeof right === 'string' ? item.toLowerCase() === right.toLowerCase() : item == right))
150
+ }
151
+ if (typeof left === 'string') {
152
+ const search = typeof right === 'string' ? right : String(right)
153
+ return left.toLowerCase().includes(search.toLowerCase())
154
+ }
155
+ return false
156
+ }
157
+
158
+ const isQuotedString = (value: string): boolean => /^["'][\s\S]*["']$/.test(value) || value.includes('":"') || value.includes("':'")
159
+
160
+ const unwrapGroup = (expression: Expression): Expression => {
161
+ let current = expression
162
+ while (current.type === 'group') current = current.expression
163
+ return current
164
+ }
165
+
166
+ const isPromiseLike = (value: unknown): value is PromiseLike<unknown> =>
167
+ typeof value === 'object' && value !== null && typeof (value as { then?: unknown }).then === 'function'
168
+
169
+ const evaluateExpression = (expression: Expression, state: RenderState): unknown => {
170
+ switch (expression.type) {
171
+ case 'literal':
172
+ return expression.value
173
+ case 'identifier':
174
+ return resolveVariable(expression.name, state.variables, expression.path)
175
+ case 'group':
176
+ return evaluateExpression(expression.expression, state)
177
+ case 'member': {
178
+ const object = evaluateExpression(expression.object, state)
179
+ const property = evaluateExpression(expression.property, state)
180
+ if (object === undefined || object === null) return undefined
181
+ return ownProperty(object, property).value
182
+ }
183
+ case 'unary': {
184
+ const argument = evaluateExpression(expression.argument, state)
185
+ if (expression.operator === 'not') return !isTruthy(argument)
186
+ throw new TemplateError(`${state.name}: unknown unary operator "${expression.operator}"`, expression.line, expression.column)
187
+ }
188
+ case 'binary': {
189
+ if (expression.operator === '??') {
190
+ const left = evaluateExpression(expression.left, state)
191
+ return isTruthy(left) ? left : evaluateExpression(expression.right, state)
192
+ }
193
+ const left = evaluateExpression(expression.left, state)
194
+ const right = evaluateExpression(expression.right, state)
195
+ switch (expression.operator) {
196
+ case '==':
197
+ return left == right
198
+ case '!=':
199
+ return left != right
200
+ case '>':
201
+ return (left as number) > (right as number)
202
+ case '<':
203
+ return (left as number) < (right as number)
204
+ case '>=':
205
+ return (left as number) >= (right as number)
206
+ case '<=':
207
+ return (left as number) <= (right as number)
208
+ case 'contains':
209
+ return evaluateContains(left, right)
210
+ case 'and':
211
+ return isTruthy(left) && isTruthy(right)
212
+ case 'or':
213
+ return isTruthy(left) || isTruthy(right)
214
+ default:
215
+ throw new TemplateError(`${state.name}: unknown binary operator "${expression.operator}"`, expression.line, expression.column)
216
+ }
217
+ }
218
+ case 'filter':
219
+ return evaluateFilter(expression, state)
220
+ default:
221
+ throw new TemplateError(`${state.name}: unknown expression type "${(expression as { type: string }).type}"`)
222
+ }
223
+ }
224
+
225
+ /**
226
+ * A filter call, exactly as knap makes it: the value is serialised to a string, the arguments to
227
+ * one parameter string, and knap's own filter function runs on both. A filter that returns a
228
+ * promise cannot be honoured synchronously and is refused rather than rendered as "[object
229
+ * Promise]"; none of the standard filters does.
230
+ */
231
+ const evaluateFilter = (expression: Extract<Expression, { type: 'filter' }>, state: RenderState): unknown => {
232
+ const value = evaluateExpression(expression.value, state)
233
+ const args: unknown[] = expression.args.map((argument) => {
234
+ const evaluated = evaluateExpression(argument, state)
235
+ return evaluated === undefined && argument.type === 'identifier' ? argument.name : evaluated
236
+ })
237
+ const rawArguments = args.map((argument, index) => {
238
+ const source = unwrapGroup(expression.args[index] as Expression)
239
+ return source.type === 'literal' ? (source.unquotedValue ?? argument) : argument
240
+ })
241
+ const stringValue = valueToString(value)
242
+ const paramString = args.length
243
+ ? args
244
+ .map((argument) => {
245
+ if (typeof argument === 'string') {
246
+ if (isQuotedString(argument)) return argument
247
+ if (/\w\s*=>/.test(argument)) return argument
248
+ if (/^[\w.:+\-*/]+$/.test(argument)) return argument
249
+ return `"${argument}"`
250
+ }
251
+ return String(argument)
252
+ })
253
+ .join(',')
254
+ : undefined
255
+ const filter = Object.prototype.hasOwnProperty.call(standardFilters, expression.name) ? standardFilters[expression.name] : undefined
256
+ if (!filter) return stringValue
257
+ const context: FilterContext = {
258
+ variables: state.variables,
259
+ rawValue: value,
260
+ rawArguments,
261
+ allowRegex: true,
262
+ reportWarning: () => undefined,
263
+ checkValue: () => undefined,
264
+ checkLength: () => undefined,
265
+ }
266
+ let result: unknown
267
+ try {
268
+ result = filter(stringValue, paramString, context)
269
+ } catch (error) {
270
+ throw new TemplateError(`${state.name}: filter "${expression.name}" failed: ${error instanceof Error ? error.message : String(error)}`, expression.line, expression.column)
271
+ }
272
+ if (isPromiseLike(result)) {
273
+ throw new TemplateError(`${state.name}: filter "${expression.name}" is asynchronous and cannot be used in a synchronous rendering`, expression.line, expression.column)
274
+ }
275
+ return result
276
+ }
277
+
278
+ const appendNodeOutput = (output: string, nodeOutput: string, node: ASTNode, state: RenderState): string => {
279
+ let next = output
280
+ if ('trimLeft' in node && node.trimLeft && next.length > 0) next = trimTrailingWhitespace(next)
281
+ if (state.pendingTrimRight && nodeOutput.length > 0) {
282
+ state.pendingTrimRight = false
283
+ return next + trimLeadingWhitespace(nodeOutput)
284
+ }
285
+ return next + nodeOutput
286
+ }
287
+
288
+ const renderNodes = (nodes: readonly ASTNode[], state: RenderState): string => {
289
+ let output = ''
290
+ for (const node of nodes) output = appendNodeOutput(output, renderNode(node, state), node, state)
291
+ return output
292
+ }
293
+
294
+ const renderVariable = (node: VariableNode, state: RenderState): string => {
295
+ const result = valueToString(evaluateExpression(node.expression, state))
296
+ if (node.trimRight) state.pendingTrimRight = true
297
+ return result
298
+ }
299
+
300
+ const renderIf = (node: IfNode, state: RenderState): string => {
301
+ if (isTruthy(evaluateExpression(node.condition, state))) {
302
+ const result = renderNodes(node.consequent, state)
303
+ if (node.trimRight) state.pendingTrimRight = true
304
+ return result
305
+ }
306
+ for (const branch of node.elseifs) {
307
+ if (isTruthy(evaluateExpression(branch.condition, state))) {
308
+ const result = renderNodes(branch.body, state)
309
+ if (node.trimRight) state.pendingTrimRight = true
310
+ return result
311
+ }
312
+ }
313
+ if (node.alternate) {
314
+ const result = renderNodes(node.alternate, state)
315
+ if (node.trimRight) state.pendingTrimRight = true
316
+ return result
317
+ }
318
+ if (node.trimRight) state.pendingTrimRight = true
319
+ return ''
320
+ }
321
+
322
+ const renderFor = (node: ForNode, state: RenderState): string => {
323
+ const iterable = evaluateExpression(node.iterable, state)
324
+ if (iterable === undefined || iterable === null) {
325
+ if (node.trimRight) state.pendingTrimRight = true
326
+ return ''
327
+ }
328
+ let items: unknown = iterable
329
+ if (!Array.isArray(items) && typeof items === 'string') {
330
+ try {
331
+ const parsed: unknown = JSON.parse(items)
332
+ if (Array.isArray(parsed)) items = parsed
333
+ } catch {
334
+ // A string that is not a JSON array is reported below, as knap reports it.
335
+ }
336
+ }
337
+ if (!Array.isArray(items)) {
338
+ throw new TemplateError(`${state.name}: for loop iterable is not an array: ${typeof items}`, node.line, node.column)
339
+ }
340
+ const results: string[] = []
341
+ const length = items.length
342
+ for (let index = 0; index < length; index += 1) {
343
+ const loop = { index: index + 1, index0: index, first: index === 0, last: index === length - 1, length }
344
+ const loopState: RenderState = {
345
+ ...state,
346
+ variables: { ...state.variables, [node.iterator]: items[index], [`${node.iterator}_index`]: index, loop },
347
+ }
348
+ const result = trimLeadingWhitespace(renderNodes(node.body, loopState))
349
+ if (result !== '') results.push(result)
350
+ }
351
+ if (node.trimRight) state.pendingTrimRight = true
352
+ return results
353
+ .map((result, index) => {
354
+ if (index === results.length - 1) return trimTrailingWhitespace(result)
355
+ return result.endsWith('\n') ? result : `${result}\n`
356
+ })
357
+ .join('')
358
+ }
359
+
360
+ const renderSet = (node: SetNode, state: RenderState): string => {
361
+ state.variables[node.variable] = evaluateExpression(node.value, state)
362
+ if (node.trimRight) state.pendingTrimRight = true
363
+ return ''
364
+ }
365
+
366
+ const renderNode = (node: ASTNode, state: RenderState): string => {
367
+ switch (node.type) {
368
+ case 'text': {
369
+ if (!state.pendingTrimRight) return node.value
370
+ state.pendingTrimRight = false
371
+ return trimLeadingWhitespace(node.value)
372
+ }
373
+ case 'variable':
374
+ return renderVariable(node, state)
375
+ case 'if':
376
+ return renderIf(node, state)
377
+ case 'for':
378
+ return renderFor(node, state)
379
+ case 'set':
380
+ return renderSet(node, state)
381
+ default:
382
+ throw new TemplateError(`${state.name}: unknown node type "${(node as { type: string }).type}"`)
383
+ }
384
+ }
385
+
386
+ /** Render a compiled template synchronously. The variables are copied: `{% set %}` never reaches the caller. */
387
+ export const renderCompiledTemplate = (template: CompiledTemplate, variables: TemplateVariables): string =>
388
+ renderNodes(template.ast, { name: template.name, variables: { ...variables }, pendingTrimRight: false })
389
+
390
+ export const renderTemplate = (source: string, variables: TemplateVariables, name = 'template'): string =>
391
+ renderCompiledTemplate(compileTemplate(source, name), variables)
392
+
393
+ /**
394
+ * The same template through knap's own engine. Tests hold the synchronous evaluator to this;
395
+ * a caller that can await may use it directly.
396
+ */
397
+ export const renderTemplateWithKnap = async (source: string, variables: TemplateVariables): Promise<string> =>
398
+ createEngine({ filters: standardFilters }).renderOrThrow(source, { variables })
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /**
4
+ * The marker every generated region carries.
5
+ *
6
+ * `<!-- doc-bridge:generated hash=… -->` … `<!-- /doc-bridge:generated -->` is what the Markdown
7
+ * analyzer already recognises (`src/discovery/markdown.ts`): it skips mentions inside the region,
8
+ * so Doc Bridge never reads its own output back in as evidence about the repository. The hash is
9
+ * over the region's body, which is what lets the documentation audit tell a regenerated region
10
+ * from one a person edited by hand: recompute the hash, compare it with the marker.
11
+ */
12
+
13
+ export const GENERATED_REGION_HASH_LENGTH = 16
14
+
15
+ export const GENERATED_REGION_CLOSE = '<!-- /doc-bridge:generated -->'
16
+
17
+ export const generatedRegionOpen = (hash: string): string => `<!-- doc-bridge:generated hash=${hash} -->`
18
+
19
+ /**
20
+ * The hash of a region body. Line endings are normalised first: an editor that converts a file
21
+ * to CRLF has not changed what the generator wrote.
22
+ */
23
+ export const generatedRegionHash = (body: string): string =>
24
+ createHash('sha256').update(body.replace(/\r\n/g, '\n'), 'utf8').digest('hex').slice(0, GENERATED_REGION_HASH_LENGTH)
25
+
26
+ /**
27
+ * Strip the blank lines around a region body.
28
+ *
29
+ * A loop rather than /\n+$/: a quantified group anchored at the end backtracks quadratically on a
30
+ * body of newlines, and a rendered body comes from a repository's own template.
31
+ */
32
+ export const trimRegionBlankLines = (body: string): string => {
33
+ let start = 0
34
+ while (start < body.length && body[start] === '\n') start += 1
35
+ let end = body.length
36
+ while (end > start && body[end - 1] === '\n') end -= 1
37
+ return body.slice(start, end)
38
+ }
39
+
40
+ /** Wrap a body in markers. The body is stripped of surrounding blank lines so the hash covers exactly the lines between the markers. */
41
+ export const wrapGeneratedRegion = (body: string): string => {
42
+ const inner = trimRegionBlankLines(body)
43
+ return `${generatedRegionOpen(generatedRegionHash(inner))}\n${inner}\n${GENERATED_REGION_CLOSE}\n`
44
+ }
45
+
46
+ export type GeneratedRegionRef = {
47
+ readonly lineStart: number
48
+ readonly lineEnd: number
49
+ readonly hash?: string | undefined
50
+ }
51
+
52
+ export type GeneratedRegionMismatch = {
53
+ readonly lineStart: number
54
+ readonly lineEnd: number
55
+ readonly expected: string
56
+ readonly actual: string
57
+ }
58
+
59
+ /**
60
+ * Which regions of a document no longer hash to what their marker claims.
61
+ *
62
+ * The regions come from the analyzer, which is the one detector of markers; this only recomputes
63
+ * the hash of the lines strictly between the opening and closing marker lines. A region whose
64
+ * marker carries no hash was written by an older generator and cannot be verified, so it is not
65
+ * reported.
66
+ */
67
+ export const verifyGeneratedRegions = (content: string, regions: readonly GeneratedRegionRef[]): GeneratedRegionMismatch[] => {
68
+ const lines = content.replace(/^/, '').split(/\r?\n/)
69
+ const mismatches: GeneratedRegionMismatch[] = []
70
+ for (const region of regions) {
71
+ if (!region.hash) continue
72
+ const body = lines.slice(region.lineStart, Math.max(region.lineStart, region.lineEnd - 1)).join('\n')
73
+ const actual = generatedRegionHash(body)
74
+ if (actual !== region.hash) mismatches.push({ lineStart: region.lineStart, lineEnd: region.lineEnd, expected: region.hash, actual })
75
+ }
76
+ return mismatches
77
+ }
@@ -0,0 +1,209 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join, relative, resolve } from 'node:path'
3
+
4
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
5
+ import { discoverRepository } from '../discovery/repository.js'
6
+ import { buildDocBridgeIndex } from '../index-builder/build-index.js'
7
+ import { renderLlmsTxt } from '../index-builder/llms-txt.js'
8
+ import { toPosix } from '../lib/paths.js'
9
+ import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
10
+ import type { ReconciliationReportV1 } from '../schemas/knowledge.js'
11
+ import { parseDiscoverySnapshot, parseDocBridgeIndex, parseReconciliationReport } from '../validate.js'
12
+ import { loadWorkflowStepOutput } from '../workflow/engine.js'
13
+ import type { TemplateVariables } from './engine.js'
14
+ import {
15
+ areaPagesView,
16
+ changeDigestView,
17
+ overlayReviewView,
18
+ ownershipPagesView,
19
+ pageFileName,
20
+ type OverlayReviewInput,
21
+ type RenderedPage,
22
+ type SnapshotForDigest,
23
+ } from './data.js'
24
+ import { GENERATED_REGION_CLOSE, generatedRegionHash, generatedRegionOpen, trimRegionBlankLines } from './generated.js'
25
+ import { renderNamedTemplate, resolveTemplateSource } from './template-source.js'
26
+ import { RENDER_TEMPLATES, type RenderTemplateName } from './templates.js'
27
+
28
+ /**
29
+ * `ak-docs render`: the canonical artifacts, rendered as Markdown a person can read.
30
+ *
31
+ * Rendering never calls an agent and never reads the Registry: its inputs are the index (built
32
+ * in memory from the working tree, or the artifact `--data` names), the workflow's last
33
+ * reconciliation report and snapshot, and an overlay file. Equal inputs, equal bytes.
34
+ */
35
+
36
+ export type RenderArtifactOptions = {
37
+ readonly root: string
38
+ readonly config: DocBridgeConfigV1
39
+ readonly template: RenderTemplateName
40
+ /** An artifact to render instead of the default source for the template. Relative to `root`. */
41
+ readonly dataPath?: string
42
+ }
43
+
44
+ export type RenderArtifactResult = {
45
+ readonly template: RenderTemplateName
46
+ /** `bundled`, or the project override path. */
47
+ readonly origin: string
48
+ readonly pages: readonly RenderedPage[]
49
+ }
50
+
51
+ /*
52
+ * A template says where its generated region begins and ends with `{{ region.open }}` and
53
+ * `{{ region.close }}`. The hash in the opening marker is over the text between them, which is
54
+ * not known until the template has rendered — so the template prints placeholders, and the
55
+ * markers replace them afterwards. A template that prints neither is wrapped whole.
56
+ */
57
+ const OPEN_PLACEHOLDER = '@@doc-bridge:generated-open@@'
58
+ const CLOSE_PLACEHOLDER = '@@doc-bridge:generated-close@@'
59
+
60
+ export const REGION_VARIABLES = { open: OPEN_PLACEHOLDER, close: CLOSE_PLACEHOLDER } as const
61
+
62
+ const trimRegionBody = trimRegionBlankLines
63
+
64
+ export const applyGeneratedRegions = (output: string, name: string): string => {
65
+ if (!output.includes(OPEN_PLACEHOLDER)) {
66
+ if (output.includes(CLOSE_PLACEHOLDER)) throw new Error(`template "${name}" prints region.close without region.open`)
67
+ const body = trimRegionBody(output)
68
+ return `${generatedRegionOpen(generatedRegionHash(body))}\n${body}\n${GENERATED_REGION_CLOSE}\n`
69
+ }
70
+ let result = output
71
+ for (;;) {
72
+ const open = result.indexOf(OPEN_PLACEHOLDER)
73
+ if (open === -1) break
74
+ const close = result.indexOf(CLOSE_PLACEHOLDER, open)
75
+ if (close === -1) throw new Error(`template "${name}" prints region.open without a matching region.close`)
76
+ const body = trimRegionBody(result.slice(open + OPEN_PLACEHOLDER.length, close))
77
+ const replacement = `${generatedRegionOpen(generatedRegionHash(body))}\n${body}\n${GENERATED_REGION_CLOSE}`
78
+ result = result.slice(0, open) + replacement + result.slice(close + CLOSE_PLACEHOLDER.length)
79
+ }
80
+ if (result.includes(CLOSE_PLACEHOLDER)) throw new Error(`template "${name}" prints region.close without region.open`)
81
+ return result
82
+ }
83
+
84
+ const renderPage = (name: RenderTemplateName, variables: TemplateVariables, config: DocBridgeConfigV1, root: string): string => {
85
+ const output = renderNamedTemplate(name, { ...variables, region: REGION_VARIABLES }, config, root)
86
+ return RENDER_TEMPLATES[name].generatedRegion ? applyGeneratedRegions(output, name) : output
87
+ }
88
+
89
+ const readJson = (root: string, path: string): unknown => JSON.parse(readFileSync(resolve(root, path), 'utf8')) as unknown
90
+
91
+ const stateDirOf = (root: string, config: DocBridgeConfigV1): string => resolve(root, config.workflow?.stateDir ?? '.doc-bridge/workflow')
92
+
93
+ const loadIndex = (options: RenderArtifactOptions): DocBridgeIndexV1 =>
94
+ options.dataPath
95
+ ? parseDocBridgeIndex(readJson(options.root, options.dataPath))
96
+ : buildDocBridgeIndex({ root: options.root, config: options.config, write: false }).index
97
+
98
+ /** The last reconciliation report the workflow wrote, when there is one. Findings are optional on a page; a missing report is not an error. */
99
+ const loadReconciliation = (root: string, config: DocBridgeConfigV1): ReconciliationReportV1 | undefined => {
100
+ try {
101
+ const value = loadWorkflowStepOutput(stateDirOf(root, config), 'reconcile')
102
+ return value ? parseReconciliationReport(value) : undefined
103
+ } catch {
104
+ return undefined
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The snapshot the digest compares against: the one `--data` names, otherwise the last
110
+ * `ak-docs scan` (the workflow's `normalize` output). Rendering does not move that baseline —
111
+ * only a scan does — so the digest can be rendered repeatedly while a change is being reviewed.
112
+ */
113
+ const loadPreviousSnapshot = (options: RenderArtifactOptions): SnapshotForDigest => {
114
+ if (options.dataPath) return parseDiscoverySnapshot(readJson(options.root, options.dataPath))
115
+ let value: unknown
116
+ try {
117
+ value = loadWorkflowStepOutput(stateDirOf(options.root, options.config), 'normalize')
118
+ } catch {
119
+ value = undefined
120
+ }
121
+ if (!value) throw new Error('No previous snapshot to compare against. Run `ak-docs scan` to record one, or pass --data <snapshot.json>.')
122
+ return parseDiscoverySnapshot(value)
123
+ }
124
+
125
+ const DEFAULT_OVERLAY_PATH = '.doc-bridge/enrich/overlay.json'
126
+
127
+ const loadOverlay = (options: RenderArtifactOptions): { readonly overlay: OverlayReviewInput | undefined; readonly source?: string } => {
128
+ const path = options.dataPath ?? (existsSync(resolve(options.root, DEFAULT_OVERLAY_PATH)) ? DEFAULT_OVERLAY_PATH : undefined)
129
+ if (!path) return { overlay: undefined }
130
+ const value = readJson(options.root, path)
131
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${path} is not an overlay object.`)
132
+ const pending = (value as { pending?: unknown }).pending
133
+ if (pending !== undefined && !Array.isArray(pending)) throw new Error(`${path}: "pending" must be an array of proposals.`)
134
+ return { overlay: value as OverlayReviewInput, source: toPosix(path) }
135
+ }
136
+
137
+ export const renderArtifact = (options: RenderArtifactOptions): RenderArtifactResult => {
138
+ const { root, config, template } = options
139
+ const origin = resolveTemplateSource(template, config, root).origin
140
+ const page = (path: string, variables: TemplateVariables): RenderedPage => ({ path, content: renderPage(template, variables, config, root) })
141
+
142
+ switch (template) {
143
+ case 'llms.txt': {
144
+ const index = loadIndex(options)
145
+ return {
146
+ template,
147
+ origin,
148
+ pages: [{ path: toPosix(config.index?.llmsTxt?.outFile ?? 'llms.txt'), content: renderLlmsTxt(config, index.knowledge, index.project?.name ?? 'project', { root }) }],
149
+ }
150
+ }
151
+ case 'area': {
152
+ const index = loadIndex(options)
153
+ const reconciliation = loadReconciliation(root, config)
154
+ return {
155
+ template,
156
+ origin,
157
+ pages: areaPagesView(index, config, { root, ...(reconciliation ? { reconciliation } : {}) }).map((area) => page(pageFileName(area.path), { area })),
158
+ }
159
+ }
160
+ case 'ownership': {
161
+ const index = loadIndex(options)
162
+ return { template, origin, pages: ownershipPagesView(index, config, { root }).map((owner) => page(pageFileName(owner.id), { owner })) }
163
+ }
164
+ case 'change-digest': {
165
+ const previous = loadPreviousSnapshot(options)
166
+ const current = discoverRepository({ root, config })
167
+ return { template, origin, pages: [page('change-digest.md', { digest: changeDigestView(previous, current) })] }
168
+ }
169
+ case 'overlay-review': {
170
+ const { overlay, source } = loadOverlay(options)
171
+ return { template, origin, pages: [page('overlay-review.md', { overlay: overlayReviewView(overlay, source) })] }
172
+ }
173
+ default:
174
+ throw new Error(`Unknown template "${String(template)}".`)
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Write the pages. A single page goes to `target` itself unless `target` is an existing
180
+ * directory; several pages go under `target` as a directory. Returns what was written, relative
181
+ * to `root`, in page order.
182
+ */
183
+ export const writeRenderedPages = (result: RenderArtifactResult, target: string, root: string): string[] => {
184
+ const single = result.pages.length === 1 && !RENDER_TEMPLATES[result.template].multiPage
185
+ const written: string[] = []
186
+ const write = (path: string, content: string): string => {
187
+ mkdirSync(dirname(path), { recursive: true })
188
+ writeFileSync(path, content, 'utf8')
189
+ return toPosix(relative(root, path))
190
+ }
191
+ for (const page of result.pages) {
192
+ if (!single) {
193
+ written.push(write(join(target, page.path), page.content))
194
+ continue
195
+ }
196
+ /*
197
+ * A single page is written to `target` itself, and lands inside it when `target` is already a
198
+ * directory. The write is what asks: a stat first would decide from one moment on disk and
199
+ * act in another, and between the two the path can become — or stop being — a directory.
200
+ */
201
+ try {
202
+ written.push(write(target, page.content))
203
+ } catch (error) {
204
+ if ((error as NodeJS.ErrnoException).code !== 'EISDIR') throw error
205
+ written.push(write(join(target, page.path), page.content))
206
+ }
207
+ }
208
+ return written
209
+ }