@agentskit/doc-bridge 1.7.45 → 1.10.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.
- package/CHANGELOG.md +488 -0
- package/CONTRIBUTING.md +23 -0
- package/README.md +87 -40
- package/SECURITY.md +7 -0
- package/action.yml +1 -1
- package/bin/ak-docs.js +2 -2
- package/bin/ak-verify.js +13 -7
- package/dist/cli/program.d.ts +3 -1
- package/dist/cli/program.js +15871 -6218
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +95 -6
- package/dist/config/index.js.map +1 -1
- package/dist/{index-BUL0q7s8.d.ts → index-7wYGbllW.d.ts} +134 -1
- package/dist/index.d.ts +9880 -1062
- package/dist/index.js +16143 -6127
- package/dist/index.js.map +1 -1
- package/docs/MARKETPLACE.md +1 -1
- package/docs/PRD-documentation-efficiency-study.md +406 -0
- package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
- package/docs/adr/0002-documentation-audit-boundary.md +22 -0
- package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
- package/docs/adr/0004-controlled-study-runner.md +25 -0
- package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
- package/docs/adr/0006-registry-semantic-grounding.md +20 -0
- package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
- package/docs/adr/0008-study-verification-boundary.md +21 -0
- package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
- package/docs/agent-corpus/INDEX.md +12 -1
- package/docs/agent-corpus/OVERVIEW.md +25 -0
- package/docs/agent-corpus/chat.md +5 -1
- package/docs/agent-corpus/cli.md +16 -0
- package/docs/agent-corpus/conformance.md +12 -0
- package/docs/agent-corpus/doc-bridge.md +47 -0
- package/docs/agent-corpus/doctor.md +8 -0
- package/docs/agent-corpus/gates.md +4 -0
- package/docs/agent-corpus/mcp.md +13 -0
- package/docs/agent-corpus/memory.md +4 -0
- package/docs/agent-corpus/query.md +33 -0
- package/docs/bench/README.md +122 -0
- package/docs/bench/retrieval-baseline-v1.json +28 -0
- package/docs/bench/retrieval-suite-v1.json +1033 -0
- package/docs/chat-and-rag.md +3 -2
- package/docs/for-agents.md +9 -1
- package/docs/getting-started.md +4 -11
- package/docs/guides/gate-ci.md +11 -1
- package/docs/guides/install-and-run.md +9 -65
- package/docs/index.md +22 -1
- package/docs/knowledge-engine-runbook.md +38 -3
- package/docs/landing/assets/context-payload-reduction.svg +21 -0
- package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
- package/docs/landing/index.html +119 -5
- package/docs/loop-workflow.md +117 -0
- package/docs/mcp.md +6 -1
- package/docs/parity/public-claims-v1.json +145 -0
- package/docs/playbook/doc-bridge-pattern.md +1 -1
- package/docs/query.md +90 -2
- package/docs/recipes/index-pipeline.md +1 -1
- package/docs/schemas/agent-handoff-v1.md +15 -0
- package/docs/schemas/doc-bridge-index-v1.md +65 -0
- package/docs/spec/benchmark-v1.md +33 -1
- package/docs/spec/cli.md +30 -10
- package/docs/spec/config-v1.md +157 -8
- package/docs/spec/documentation-audit-v1.md +61 -0
- package/docs/spec/enrichment-overlay-v1.md +241 -0
- package/docs/spec/graph-signals-v1.md +92 -0
- package/docs/spec/incremental-scan-v1.md +102 -0
- package/docs/spec/markdown-analyzer-v1.md +73 -0
- package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
- package/docs/spec/measured-enrichment-v1.md +229 -0
- package/docs/spec/public-parity-v1.md +119 -0
- package/docs/spec/registry-agents.md +6 -0
- package/docs/spec/render-v1.md +122 -0
- package/docs/spec/retrieval-index-v1.md +164 -0
- package/docs/spec/study-metrics-v1.md +58 -0
- package/docs/spec/study-protocol-v1.md +46 -0
- package/docs/spec/study-provider-cli-v1.md +116 -0
- package/docs/spec/study-runner-v1.md +35 -0
- package/docs/spec/study-task-suite-v1.md +41 -0
- package/docs/spec/study-verification-v1.md +40 -0
- package/docs/study/README.md +84 -0
- package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
- package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
- package/docs/study/ab-baseline-analysis-v1.md +21 -0
- package/docs/study/ab-baseline-plan-v1.json +33 -0
- package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
- package/docs/study/ab-baseline-result-v1.json +79 -0
- package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
- package/docs/study/historical-evidence-v1.json +252 -0
- package/docs/study/observation-ledger-v1.json +30632 -0
- package/docs/study/phase3-task-coverage-v1.json +34 -0
- package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
- package/docs/study/phase4-public-pilot-result-v1.json +52 -0
- package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
- package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
- package/docs/study/pilot-round-2026-08-31.json +46 -0
- package/docs/study/protocol-v1.json +90 -0
- package/docs/study/publication-gate-v1.md +45 -0
- package/docs/study/quality-scorecard-cycle-plan.md +545 -0
- package/docs/study/quality-scorecard-v1.json +38 -0
- package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
- package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
- package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
- package/docs/study/round-2-expanded-validation-v1.md +58 -0
- package/docs/study/round-3-evidence-contract-v1.json +75 -0
- package/docs/study/round-3-evidence-contract-v1.md +57 -0
- package/docs/study/round-4-confirmation-v1.json +75 -0
- package/docs/study/round-4-confirmation-v1.md +55 -0
- package/docs/study/run-plan-v1.json +33 -0
- package/docs/study/semantic-adjudication-cycle-8.md +20 -0
- package/docs/study/task-suite-v1.json +96 -0
- package/docs/study/token-efficiency-plan-v1.md +337 -0
- package/docs/study/token-efficiency-protocol-v2.json +62 -0
- package/docs/study/verification-binding-v1.json +27 -0
- package/docs/validation-cycle-plan.md +14 -0
- package/docs/verification-harness.md +11 -6
- package/ecosystem-claims.json +2 -2
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +4 -4
- package/mcpb/manifest.json +9 -1
- package/package.json +34 -15
- package/scripts/check-ecosystem-upstream.mjs +34 -6
- package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +192 -24
- package/src/audit/documentation.ts +513 -0
- package/src/bench/baseline.ts +198 -0
- package/src/bench/overlay-delta.ts +139 -0
- package/src/bench/retrieval.ts +319 -0
- package/src/budget/compile.ts +91 -0
- package/src/budget/sections.ts +70 -0
- package/src/cli/program.ts +684 -74
- package/src/cli/usage.ts +71 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/config/schema.ts +126 -2
- package/src/conformance/documentation-standard-v1.ts +8 -6
- package/src/discovery/areas.ts +197 -0
- package/src/discovery/documentation.ts +211 -5
- package/src/discovery/identity.ts +24 -0
- package/src/discovery/incremental.ts +314 -0
- package/src/discovery/inputs.ts +110 -0
- package/src/discovery/markdown.ts +481 -0
- package/src/discovery/repository.ts +485 -101
- package/src/doctor/run-doctor.ts +244 -12
- package/src/enrich/approvals.ts +190 -0
- package/src/enrich/cache.ts +93 -0
- package/src/enrich/context-pack.ts +272 -0
- package/src/enrich/overlay.ts +255 -0
- package/src/enrich/review.ts +106 -0
- package/src/enrich/stage.ts +374 -0
- package/src/enrich/stats.ts +100 -0
- package/src/enrich/validate.ts +410 -0
- package/src/federation/llms.ts +2 -4
- package/src/findings/report.ts +103 -0
- package/src/graph/build.ts +356 -0
- package/src/graph/memory.ts +208 -0
- package/src/index-builder/build-handoffs.ts +22 -11
- package/src/index-builder/build-index.ts +132 -3
- package/src/index-builder/llms-txt.ts +48 -8
- package/src/index-builder/project-corpus.ts +111 -0
- package/src/index.ts +630 -2
- package/src/lib/fuzzy-match.ts +235 -0
- package/src/mcp/knowledge.ts +554 -0
- package/src/mcp/server.ts +113 -18
- package/src/metrics/benchmark.ts +21 -0
- package/src/parity/check.ts +309 -0
- package/src/parity/claims.ts +259 -0
- package/src/parity/resolve.ts +160 -0
- package/src/query/handoff.ts +326 -0
- package/src/query/load-index.ts +53 -1
- package/src/query/query.ts +92 -59
- package/src/query/search.ts +289 -92
- package/src/query/text.ts +155 -0
- package/src/reconciliation/reconcile.ts +101 -10
- package/src/render/data.ts +356 -0
- package/src/render/engine.ts +398 -0
- package/src/render/generated.ts +77 -0
- package/src/render/render.ts +209 -0
- package/src/render/template-source.ts +52 -0
- package/src/render/templates.ts +289 -0
- package/src/report/html.ts +2 -2
- package/src/retrieval/bm25.ts +161 -0
- package/src/retrieval/project.ts +495 -0
- package/src/retrieval/rank.ts +383 -0
- package/src/retrieval/weights.ts +39 -0
- package/src/retriever/doc-bridge-retriever.ts +100 -15
- package/src/rules/engine.ts +30 -10
- package/src/schemas/agent-handoff.ts +56 -0
- package/src/schemas/budget.ts +37 -0
- package/src/schemas/doc-bridge-index.ts +53 -2
- package/src/schemas/enrichment.ts +369 -0
- package/src/schemas/json-schemas.ts +39 -2
- package/src/schemas/knowledge.ts +14 -1
- package/src/schemas/retrieval-index.ts +152 -0
- package/src/shims/graphology.d.ts +91 -0
- package/src/study/adjudication.ts +196 -0
- package/src/study/execution.ts +350 -0
- package/src/study/expectations.ts +219 -0
- package/src/study/metrics.ts +467 -0
- package/src/study/protocol.ts +271 -0
- package/src/study/provider-cli.ts +115 -0
- package/src/study/provider-telemetry.ts +47 -0
- package/src/study/quality-scorecard.ts +164 -0
- package/src/study/runner.ts +461 -0
- package/src/study/task-suite.ts +321 -0
- package/src/study/verification.ts +134 -0
- package/src/validate.ts +1 -4
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +36 -11
- 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
|
+
}
|