knodin 0.5.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/LICENSE +21 -0
- package/README.md +590 -0
- package/dist/bin/cli.js +1704 -0
- package/dist/src/agent-integration.js +250 -0
- package/dist/src/artifact-refresh.js +81 -0
- package/dist/src/cli-args.js +267 -0
- package/dist/src/cli-model.js +324 -0
- package/dist/src/compact-structural.js +96 -0
- package/dist/src/competitive-constraints.js +20 -0
- package/dist/src/competitive-manifest.js +330 -0
- package/dist/src/competitive-measurement.js +183 -0
- package/dist/src/competitive-runner.js +453 -0
- package/dist/src/competitive-sandbox.js +108 -0
- package/dist/src/context-export.js +422 -0
- package/dist/src/context.js +102 -0
- package/dist/src/docs-sections.js +141 -0
- package/dist/src/doctor.js +380 -0
- package/dist/src/engine/ann-hnsw.js +271 -0
- package/dist/src/engine/embeddings.js +193 -0
- package/dist/src/engine/file-walker.js +43 -0
- package/dist/src/engine/index.js +13030 -0
- package/dist/src/engine/perf.js +115 -0
- package/dist/src/engine/prune.js +112 -0
- package/dist/src/engine/source-policy.js +69 -0
- package/dist/src/engine/sqlite.js +71 -0
- package/dist/src/engine/symbol-delete.js +58 -0
- package/dist/src/failure-diagnosis.js +590 -0
- package/dist/src/fleet.js +7 -0
- package/dist/src/git-executable.js +31 -0
- package/dist/src/graph-query-health.js +115 -0
- package/dist/src/index-activity.js +125 -0
- package/dist/src/init-progress-worker.js +107 -0
- package/dist/src/init-progress.js +155 -0
- package/dist/src/init.js +985 -0
- package/dist/src/lifecycle-health.js +213 -0
- package/dist/src/lsp-readonly.js +217 -0
- package/dist/src/output-compression.js +629 -0
- package/dist/src/output-telemetry.js +359 -0
- package/dist/src/pr-triage.js +638 -0
- package/dist/src/relationship-adapters.js +370 -0
- package/dist/src/release-attestation.js +533 -0
- package/dist/src/repair-progress-worker.js +121 -0
- package/dist/src/repair-progress.js +262 -0
- package/dist/src/repository-init-process.js +173 -0
- package/dist/src/repository-management.js +1089 -0
- package/dist/src/response-budget.js +184 -0
- package/dist/src/server.js +53 -0
- package/dist/src/system-config.js +615 -0
- package/dist/src/terminal-help.js +83 -0
- package/dist/src/tools/knodin-tools.js +1438 -0
- package/dist/src/tools/reckon-tools.js +5 -0
- package/dist/src/update-policy.js +944 -0
- package/dist/src/update-trust.js +503 -0
- package/dist/src/version.js +13 -0
- package/dist/src/visualization.js +162 -0
- package/dist/src/wait-for-fresh.js +98 -0
- package/dist/src/worktree-lifecycle.js +231 -0
- package/docs/CLI.md +39 -0
- package/docs/COMMAND-OUTPUT-COMPRESSION.md +194 -0
- package/docs/DEAD-CODE-AND-IMPACT.md +27 -0
- package/docs/DOCTOR-AND-UPDATES.md +84 -0
- package/docs/INDEXING-POLICY-AND-PROVENANCE.md +37 -0
- package/docs/INSTALLATION.md +208 -0
- package/docs/MCP.md +100 -0
- package/docs/PT-ACCESS-RECOMMENDATION.md +91 -0
- package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +81 -0
- package/docs/SIGNED-UPDATES.md +146 -0
- package/docs/SYSTEMS-AND-RELATIONSHIPS.md +45 -0
- package/docs/TELEMETRY.md +42 -0
- package/docs/releases/0.3.0.md +46 -0
- package/docs/releases/0.4.0.md +68 -0
- package/docs/releases/0.4.1.md +28 -0
- package/docs/releases/0.4.2.md +27 -0
- package/docs/releases/0.4.3.md +23 -0
- package/docs/releases/0.5.0.md +29 -0
- package/package.json +110 -0
- package/schemas/release-attestation-v1.schema.json +210 -0
- package/tree-sitter-prisma.wasm +0 -0
- package/tree-sitter-sql.wasm +0 -0
- package/tree-sitter-xml.wasm +0 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { RE2 } from "re2-wasm";
|
|
5
|
+
import { measurePerfPhaseSync } from "./engine/perf.js";
|
|
6
|
+
const DEFAULT_EXCLUDES = [".git/**", ".reckon/**", "node_modules/**", "dist/**", "build/**"];
|
|
7
|
+
const GLOB_CACHE_LIMIT = 256;
|
|
8
|
+
const globCache = new Map();
|
|
9
|
+
function normalizeRelative(value) {
|
|
10
|
+
return value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
11
|
+
}
|
|
12
|
+
function globRegex(glob) {
|
|
13
|
+
const normalized = normalizeRelative(glob);
|
|
14
|
+
const cached = globCache.get(normalized);
|
|
15
|
+
if (cached)
|
|
16
|
+
return cached;
|
|
17
|
+
let source = "^";
|
|
18
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
19
|
+
const char = normalized[i];
|
|
20
|
+
if (char === "*") {
|
|
21
|
+
if (normalized[i + 1] === "*") {
|
|
22
|
+
i++;
|
|
23
|
+
if (normalized[i + 1] === "/") {
|
|
24
|
+
i++;
|
|
25
|
+
source += "(?:.*/)?";
|
|
26
|
+
}
|
|
27
|
+
else
|
|
28
|
+
source += ".*";
|
|
29
|
+
}
|
|
30
|
+
else
|
|
31
|
+
source += "[^/]*";
|
|
32
|
+
}
|
|
33
|
+
else if (char === "?")
|
|
34
|
+
source += "[^/]";
|
|
35
|
+
else
|
|
36
|
+
source += char.replace(/[\\^$+?.()|{}[\]]/g, "\\$&");
|
|
37
|
+
}
|
|
38
|
+
const compiled = new RegExp(`${source}$`);
|
|
39
|
+
if (globCache.size >= GLOB_CACHE_LIMIT) {
|
|
40
|
+
const oldest = globCache.keys().next().value;
|
|
41
|
+
if (oldest !== undefined)
|
|
42
|
+
globCache.delete(oldest);
|
|
43
|
+
}
|
|
44
|
+
globCache.set(normalized, compiled);
|
|
45
|
+
return compiled;
|
|
46
|
+
}
|
|
47
|
+
function matches(file, patterns) {
|
|
48
|
+
return patterns.some((pattern) => globRegex(normalizeRelative(pattern)).test(file));
|
|
49
|
+
}
|
|
50
|
+
function compilePatterns(patterns) {
|
|
51
|
+
return patterns.map((pattern) => globRegex(normalizeRelative(pattern)));
|
|
52
|
+
}
|
|
53
|
+
function matchesCompiled(file, patterns) {
|
|
54
|
+
return patterns.some((pattern) => pattern.test(file));
|
|
55
|
+
}
|
|
56
|
+
function walk(root, current = root, result = []) {
|
|
57
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
58
|
+
const absolute = path.join(current, entry.name);
|
|
59
|
+
const relative = normalizeRelative(path.relative(root, absolute));
|
|
60
|
+
if (matches(relative, DEFAULT_EXCLUDES) || matches(`${relative}/`, DEFAULT_EXCLUDES))
|
|
61
|
+
continue;
|
|
62
|
+
if (entry.isDirectory())
|
|
63
|
+
walk(root, absolute, result);
|
|
64
|
+
else if (entry.isFile())
|
|
65
|
+
result.push(relative);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
function policyFor(file, policies) {
|
|
70
|
+
for (const { pattern, policy } of policies)
|
|
71
|
+
if (pattern.test(file))
|
|
72
|
+
return policy;
|
|
73
|
+
return "full";
|
|
74
|
+
}
|
|
75
|
+
function structureOnly(source, lineNumbers = false) {
|
|
76
|
+
return source
|
|
77
|
+
.split(/\r?\n/)
|
|
78
|
+
.map((line, index) => ({ line, index }))
|
|
79
|
+
.filter(({ line }) => /^\s*(export\s+)?(async\s+)?(class|interface|type|enum|function|const|let|var|def|module|namespace)\b/.test(line) || /^\s*(import|export)\b/.test(line))
|
|
80
|
+
.map(({ line, index }) => (lineNumbers ? `${index + 1}: ${line}` : line))
|
|
81
|
+
.join("\n");
|
|
82
|
+
}
|
|
83
|
+
function summarize(source, lineNumbers = false) {
|
|
84
|
+
const lines = source.split(/\r?\n/);
|
|
85
|
+
const structure = structureOnly(source, lineNumbers);
|
|
86
|
+
return [`[summary: ${lines.length} lines, ${Buffer.byteLength(source)} bytes]`, structure]
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join("\n");
|
|
89
|
+
}
|
|
90
|
+
function numbered(source) {
|
|
91
|
+
return source
|
|
92
|
+
.split(/\r?\n/)
|
|
93
|
+
.map((line, index) => `${index + 1}: ${line}`)
|
|
94
|
+
.join("\n");
|
|
95
|
+
}
|
|
96
|
+
function xmlEscape(value) {
|
|
97
|
+
return value
|
|
98
|
+
.replaceAll("&", "&")
|
|
99
|
+
.replaceAll("<", "<")
|
|
100
|
+
.replaceAll(">", ">")
|
|
101
|
+
.replaceAll('"', """);
|
|
102
|
+
}
|
|
103
|
+
function serialize(format, files, tree, git) {
|
|
104
|
+
if (format === "json")
|
|
105
|
+
return JSON.stringify({ tree, files, git }, null, 2);
|
|
106
|
+
if (format === "xml") {
|
|
107
|
+
const treeXml = tree
|
|
108
|
+
? `<tree>${tree.map((item) => `<path>${xmlEscape(item)}</path>`).join("")}</tree>`
|
|
109
|
+
: "";
|
|
110
|
+
const filesXml = files
|
|
111
|
+
.map((file) => `<file path="${xmlEscape(file.path)}" policy="${file.policy}"><![CDATA[${file.content.replaceAll("]]>", "]]]]><![CDATA[>")}]]></file>`)
|
|
112
|
+
.join("");
|
|
113
|
+
const gitXml = git
|
|
114
|
+
? `<git>${git.diff === undefined ? "" : `<diff><![CDATA[${git.diff.replaceAll("]]>", "]]]]><![CDATA[>")}]]></diff>`}${git.log === undefined ? "" : `<log><![CDATA[${git.log.replaceAll("]]>", "]]]]><![CDATA[>")}]]></log>`}</git>`
|
|
115
|
+
: "";
|
|
116
|
+
return `<context>${treeXml}<files>${filesXml}</files>${gitXml}</context>`;
|
|
117
|
+
}
|
|
118
|
+
const fenced = (content, language = "") => {
|
|
119
|
+
const longest = Math.max(0, ...[...content.matchAll(/`+/g)].map((match) => match[0].length));
|
|
120
|
+
const fence = "`".repeat(Math.max(3, longest + 1));
|
|
121
|
+
return [`${fence}${language}`, content, fence];
|
|
122
|
+
};
|
|
123
|
+
const chunks = ["# knodin context export"];
|
|
124
|
+
if (tree)
|
|
125
|
+
chunks.push("## Tree", ...fenced(tree.join("\n"), "text"));
|
|
126
|
+
for (const file of files)
|
|
127
|
+
chunks.push(`## ${file.path} (${file.policy})`, ...fenced(file.content));
|
|
128
|
+
if (git?.diff !== undefined)
|
|
129
|
+
chunks.push("## Git diff", ...fenced(git.diff, "diff"));
|
|
130
|
+
if (git?.log !== undefined)
|
|
131
|
+
chunks.push("## Git log", ...fenced(git.log, "text"));
|
|
132
|
+
return chunks.join("\n");
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Exact serialized-byte contribution for one additional file. Serializing a
|
|
136
|
+
* single candidate (plus a constant sentinel for non-first entries) keeps pack
|
|
137
|
+
* selection linear in total source bytes instead of repeatedly serializing the
|
|
138
|
+
* whole growing artifact.
|
|
139
|
+
*/
|
|
140
|
+
function serializedFileByteDelta(format, file, hasFiles) {
|
|
141
|
+
if (!hasFiles)
|
|
142
|
+
return Buffer.byteLength(serialize(format, [file])) - Buffer.byteLength(serialize(format, []));
|
|
143
|
+
const sentinel = { path: "", policy: "full", content: "" };
|
|
144
|
+
return (Buffer.byteLength(serialize(format, [sentinel, file])) -
|
|
145
|
+
Buffer.byteLength(serialize(format, [sentinel])));
|
|
146
|
+
}
|
|
147
|
+
function gitSections(repo, request) {
|
|
148
|
+
if (!request)
|
|
149
|
+
return undefined;
|
|
150
|
+
const run = (args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
|
|
151
|
+
const result = {};
|
|
152
|
+
if (request.diffScope) {
|
|
153
|
+
const revision = (value) => {
|
|
154
|
+
const hasControl = [...value].some((character) => {
|
|
155
|
+
const code = character.charCodeAt(0);
|
|
156
|
+
return code < 32 || code === 127;
|
|
157
|
+
});
|
|
158
|
+
if (!value || value.startsWith("-") || hasControl)
|
|
159
|
+
throw new Error(`knodin pack: invalid git revision: ${value}`);
|
|
160
|
+
return value;
|
|
161
|
+
};
|
|
162
|
+
const args = request.diffScope === "unstaged"
|
|
163
|
+
? ["diff", "--"]
|
|
164
|
+
: request.diffScope === "staged"
|
|
165
|
+
? ["diff", "--cached", "--"]
|
|
166
|
+
: request.diffScope === "compare"
|
|
167
|
+
? ["diff", revision(request.from ?? "HEAD~1"), revision(request.to ?? "HEAD"), "--"]
|
|
168
|
+
: ["diff", revision(request.from ?? "HEAD"), "--"];
|
|
169
|
+
result.diff = run(args);
|
|
170
|
+
}
|
|
171
|
+
if (request.log !== undefined)
|
|
172
|
+
result.log = run([
|
|
173
|
+
"log",
|
|
174
|
+
`-${Math.max(0, Math.min(100, Math.floor(request.log)))}`,
|
|
175
|
+
"--oneline",
|
|
176
|
+
]);
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
function safeOutput(repo, output) {
|
|
180
|
+
const absolute = path.resolve(repo, output);
|
|
181
|
+
if (absolute !== repo && !absolute.startsWith(`${repo}${path.sep}`))
|
|
182
|
+
throw new Error("knodin pack: outputPath must stay inside the repository");
|
|
183
|
+
if (fs.existsSync(absolute)) {
|
|
184
|
+
const realTarget = fs.realpathSync(absolute);
|
|
185
|
+
if (realTarget !== repo && !realTarget.startsWith(`${repo}${path.sep}`))
|
|
186
|
+
throw new Error("knodin pack: outputPath may not follow a symlink outside the repository");
|
|
187
|
+
}
|
|
188
|
+
let parent = path.dirname(absolute);
|
|
189
|
+
while (!fs.existsSync(parent) && parent !== repo)
|
|
190
|
+
parent = path.dirname(parent);
|
|
191
|
+
const realParent = fs.realpathSync(parent);
|
|
192
|
+
if (realParent !== repo && !realParent.startsWith(`${repo}${path.sep}`))
|
|
193
|
+
throw new Error("knodin pack: outputPath may not traverse a symlink outside the repository");
|
|
194
|
+
return absolute;
|
|
195
|
+
}
|
|
196
|
+
export function exportContext(repoPath, request = {}) {
|
|
197
|
+
const repo = fs.realpathSync(repoPath);
|
|
198
|
+
const format = request.format ?? "markdown";
|
|
199
|
+
if (!["markdown", "json", "xml"].includes(format))
|
|
200
|
+
throw new Error("knodin pack: format must be markdown, json, or xml");
|
|
201
|
+
if (request.byteBudget !== undefined &&
|
|
202
|
+
(!Number.isFinite(request.byteBudget) || request.byteBudget < 256))
|
|
203
|
+
throw new Error("knodin pack: byteBudget must be at least 256");
|
|
204
|
+
if (request.tokenBudget !== undefined &&
|
|
205
|
+
(!Number.isFinite(request.tokenBudget) || request.tokenBudget < 64))
|
|
206
|
+
throw new Error("knodin pack: tokenBudget must be at least 64");
|
|
207
|
+
if (request.git?.diffScope !== undefined &&
|
|
208
|
+
!["unstaged", "staged", "all", "compare"].includes(request.git.diffScope))
|
|
209
|
+
throw new Error("knodin pack: invalid git diff scope");
|
|
210
|
+
if (request.git?.log !== undefined &&
|
|
211
|
+
(!Number.isInteger(request.git.log) || request.git.log < 0 || request.git.log > 100))
|
|
212
|
+
throw new Error("knodin pack: git log must be an integer from 0 to 100");
|
|
213
|
+
if (Object.values(request.policies ?? {}).some((policy) => !["full", "summary", "structure-only"].includes(policy)))
|
|
214
|
+
throw new Error("knodin pack: invalid file policy");
|
|
215
|
+
const byteLimit = Math.floor(request.byteBudget ?? 65_536);
|
|
216
|
+
const tokenLimit = Math.floor(request.tokenBudget ?? 16_384);
|
|
217
|
+
const hardLimit = Math.min(byteLimit, tokenLimit * 4);
|
|
218
|
+
const include = request.include?.length ? request.include : ["**"];
|
|
219
|
+
const exclude = [...DEFAULT_EXCLUDES, ...(request.exclude ?? [])];
|
|
220
|
+
const includePatterns = compilePatterns(include);
|
|
221
|
+
const excludePatterns = compilePatterns(exclude);
|
|
222
|
+
const policyRules = request.policies ?? {};
|
|
223
|
+
const policies = Object.keys(policyRules)
|
|
224
|
+
.sort()
|
|
225
|
+
.map((pattern) => ({
|
|
226
|
+
pattern: globRegex(normalizeRelative(pattern)),
|
|
227
|
+
policy: policyRules[pattern],
|
|
228
|
+
}));
|
|
229
|
+
const omitted = new Set([...(request.alreadyPresent ?? []), ...(request.chatFiles ?? [])].map(normalizeRelative));
|
|
230
|
+
if (request.outputPath)
|
|
231
|
+
omitted.add(normalizeRelative(request.outputPath));
|
|
232
|
+
const candidates = measurePerfPhaseSync("pack_walk", () => walk(repo))
|
|
233
|
+
.filter((file) => matchesCompiled(file, includePatterns) &&
|
|
234
|
+
!matchesCompiled(file, excludePatterns) &&
|
|
235
|
+
!omitted.has(file))
|
|
236
|
+
.sort();
|
|
237
|
+
let tree = request.includeTree ? [...candidates] : undefined;
|
|
238
|
+
let git = gitSections(repo, request.git);
|
|
239
|
+
const files = [];
|
|
240
|
+
const measuredSerialize = () => measurePerfPhaseSync("pack_serialize", () => serialize(format, files, tree, git));
|
|
241
|
+
let artifact = measuredSerialize();
|
|
242
|
+
if (Buffer.byteLength(artifact) > hardLimit) {
|
|
243
|
+
git = undefined;
|
|
244
|
+
artifact = measuredSerialize();
|
|
245
|
+
}
|
|
246
|
+
if (Buffer.byteLength(artifact) > hardLimit) {
|
|
247
|
+
tree = undefined;
|
|
248
|
+
artifact = measuredSerialize();
|
|
249
|
+
}
|
|
250
|
+
let serializedSize = Buffer.byteLength(artifact);
|
|
251
|
+
for (const file of candidates) {
|
|
252
|
+
const policy = policyFor(file, policies);
|
|
253
|
+
let content = fs.readFileSync(path.join(repo, file), "utf8");
|
|
254
|
+
if (policy === "summary")
|
|
255
|
+
content = summarize(content, request.lineNumbers);
|
|
256
|
+
else if (policy === "structure-only")
|
|
257
|
+
content = structureOnly(content, request.lineNumbers);
|
|
258
|
+
else if (request.lineNumbers)
|
|
259
|
+
content = numbered(content);
|
|
260
|
+
const packed = { path: file, policy, content };
|
|
261
|
+
const delta = measurePerfPhaseSync("pack_serialize", () => serializedFileByteDelta(format, packed, files.length > 0));
|
|
262
|
+
if (serializedSize + delta > hardLimit)
|
|
263
|
+
continue;
|
|
264
|
+
files.push(packed);
|
|
265
|
+
serializedSize += delta;
|
|
266
|
+
}
|
|
267
|
+
artifact = measuredSerialize();
|
|
268
|
+
const serializedBytes = Buffer.byteLength(artifact);
|
|
269
|
+
const outputAbsolute = request.outputPath ? safeOutput(repo, request.outputPath) : undefined;
|
|
270
|
+
const result = {
|
|
271
|
+
format,
|
|
272
|
+
artifact,
|
|
273
|
+
files: files.map(({ path: filePath, policy }) => ({ path: filePath, policy })),
|
|
274
|
+
telemetry: {
|
|
275
|
+
byteLimit,
|
|
276
|
+
tokenLimit,
|
|
277
|
+
serializedBytes,
|
|
278
|
+
responseBytes: 0,
|
|
279
|
+
estimatedTokens: Math.ceil(serializedBytes / 4),
|
|
280
|
+
tokenizer: "estimate:deterministic-utf8-bytes-divided-by-4",
|
|
281
|
+
includedFiles: files.length,
|
|
282
|
+
candidateFiles: candidates.length,
|
|
283
|
+
truncated: files.length < candidates.length,
|
|
284
|
+
},
|
|
285
|
+
...(outputAbsolute
|
|
286
|
+
? { outputPath: normalizeRelative(path.relative(repo, outputAbsolute)) }
|
|
287
|
+
: {}),
|
|
288
|
+
};
|
|
289
|
+
const updateResponseBytes = () => {
|
|
290
|
+
for (let index = 0; index < 4; index++) {
|
|
291
|
+
const actual = Buffer.byteLength(JSON.stringify(result));
|
|
292
|
+
if (actual === result.telemetry.responseBytes)
|
|
293
|
+
break;
|
|
294
|
+
result.telemetry.responseBytes = actual;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
updateResponseBytes();
|
|
298
|
+
while (result.telemetry.responseBytes > hardLimit && files.length > 0) {
|
|
299
|
+
files.pop();
|
|
300
|
+
result.files.pop();
|
|
301
|
+
result.artifact = measuredSerialize();
|
|
302
|
+
result.telemetry.serializedBytes = Buffer.byteLength(result.artifact);
|
|
303
|
+
result.telemetry.estimatedTokens = Math.ceil(result.telemetry.serializedBytes / 4);
|
|
304
|
+
result.telemetry.includedFiles = files.length;
|
|
305
|
+
result.telemetry.truncated = true;
|
|
306
|
+
updateResponseBytes();
|
|
307
|
+
}
|
|
308
|
+
if (result.telemetry.responseBytes > hardLimit)
|
|
309
|
+
throw new Error("knodin pack: budget is too small for the requested format and telemetry");
|
|
310
|
+
if (outputAbsolute) {
|
|
311
|
+
fs.mkdirSync(path.dirname(outputAbsolute), { recursive: true });
|
|
312
|
+
fs.writeFileSync(outputAbsolute, result.artifact);
|
|
313
|
+
}
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
316
|
+
function safeArtifact(repoPath, artifactPath) {
|
|
317
|
+
const repo = fs.realpathSync(repoPath);
|
|
318
|
+
const absolute = path.resolve(repo, artifactPath);
|
|
319
|
+
if (absolute !== repo && !absolute.startsWith(`${repo}${path.sep}`))
|
|
320
|
+
throw new Error("knodin pack: artifactPath must stay inside the repository");
|
|
321
|
+
const real = fs.realpathSync(absolute);
|
|
322
|
+
if (real !== repo && !real.startsWith(`${repo}${path.sep}`))
|
|
323
|
+
throw new Error("knodin pack: artifactPath may not follow a symlink outside the repository");
|
|
324
|
+
const stat = fs.statSync(real);
|
|
325
|
+
if (!stat.isFile() || stat.size > 16 * 1024 * 1024)
|
|
326
|
+
throw new Error("knodin pack: artifactPath must be a packed file no larger than 16 MiB");
|
|
327
|
+
return real;
|
|
328
|
+
}
|
|
329
|
+
const ARTIFACT_CACHE_ENTRY_LIMIT = 2;
|
|
330
|
+
const ARTIFACT_CACHE_FILE_LIMIT = 4 * 1024 * 1024;
|
|
331
|
+
const artifactLineCache = new Map();
|
|
332
|
+
function artifactLines(repoPath, artifactPath) {
|
|
333
|
+
const real = safeArtifact(repoPath, artifactPath);
|
|
334
|
+
const stat = fs.statSync(real);
|
|
335
|
+
const cached = artifactLineCache.get(real);
|
|
336
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
337
|
+
artifactLineCache.delete(real);
|
|
338
|
+
artifactLineCache.set(real, cached);
|
|
339
|
+
return cached.lines;
|
|
340
|
+
}
|
|
341
|
+
if (cached)
|
|
342
|
+
artifactLineCache.delete(real);
|
|
343
|
+
const lines = fs.readFileSync(real, "utf8").split(/\r?\n/);
|
|
344
|
+
if (stat.size <= ARTIFACT_CACHE_FILE_LIMIT) {
|
|
345
|
+
while (artifactLineCache.size >= ARTIFACT_CACHE_ENTRY_LIMIT) {
|
|
346
|
+
const oldest = artifactLineCache.keys().next().value;
|
|
347
|
+
if (oldest === undefined)
|
|
348
|
+
break;
|
|
349
|
+
artifactLineCache.delete(oldest);
|
|
350
|
+
}
|
|
351
|
+
artifactLineCache.set(real, { mtimeMs: stat.mtimeMs, size: stat.size, lines });
|
|
352
|
+
}
|
|
353
|
+
return lines;
|
|
354
|
+
}
|
|
355
|
+
function truncateUtf8(value, byteLimit) {
|
|
356
|
+
if (Buffer.byteLength(value) <= byteLimit)
|
|
357
|
+
return value;
|
|
358
|
+
let low = 0;
|
|
359
|
+
let high = value.length;
|
|
360
|
+
while (low < high) {
|
|
361
|
+
const midpoint = Math.ceil((low + high) / 2);
|
|
362
|
+
if (Buffer.byteLength(value.slice(0, midpoint)) <= byteLimit)
|
|
363
|
+
low = midpoint;
|
|
364
|
+
else
|
|
365
|
+
high = midpoint - 1;
|
|
366
|
+
}
|
|
367
|
+
return value.slice(0, low);
|
|
368
|
+
}
|
|
369
|
+
export function readPackedArtifact(repoPath, artifactPath, startLine = 1, endLine = startLine + 199, byteBudget = 16_384) {
|
|
370
|
+
if (!Number.isFinite(byteBudget) || !Number.isInteger(byteBudget) || byteBudget < 256)
|
|
371
|
+
throw new Error("knodin pack read: byteBudget must be an integer >= 256");
|
|
372
|
+
if (!Number.isInteger(startLine) ||
|
|
373
|
+
!Number.isInteger(endLine) ||
|
|
374
|
+
startLine < 1 ||
|
|
375
|
+
endLine < startLine ||
|
|
376
|
+
endLine - startLine > 999)
|
|
377
|
+
throw new Error("knodin pack read: invalid or unbounded line range");
|
|
378
|
+
const lines = measurePerfPhaseSync("artifact_read", () => artifactLines(repoPath, artifactPath).slice(startLine - 1, endLine));
|
|
379
|
+
let content = lines.join("\n");
|
|
380
|
+
const limit = Math.max(256, Math.min(65_536, Math.floor(byteBudget)));
|
|
381
|
+
content = truncateUtf8(content, limit);
|
|
382
|
+
return {
|
|
383
|
+
artifactPath: normalizeRelative(artifactPath),
|
|
384
|
+
startLine,
|
|
385
|
+
endLine: startLine + content.split(/\r?\n/).length - 1,
|
|
386
|
+
content,
|
|
387
|
+
truncated: lines.join("\n") !== content,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
export function grepPackedArtifact(repoPath, artifactPath, pattern, flags = "", limit = 100) {
|
|
391
|
+
if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
392
|
+
throw new Error("knodin pack grep: limit must be an integer from 1 to 1000");
|
|
393
|
+
if (pattern.length === 0 || pattern.length > 1000 || !/^[gimsuy]*$/.test(flags))
|
|
394
|
+
throw new Error("knodin pack grep: invalid regex or flags");
|
|
395
|
+
let regex;
|
|
396
|
+
try {
|
|
397
|
+
const safeFlags = flags.replace("g", "").includes("u")
|
|
398
|
+
? flags.replace("g", "")
|
|
399
|
+
: `${flags.replace("g", "")}u`;
|
|
400
|
+
regex = new RE2(pattern, safeFlags);
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
throw new Error("knodin pack grep: invalid or unsupported linear-time regex");
|
|
404
|
+
}
|
|
405
|
+
const matches = [];
|
|
406
|
+
measurePerfPhaseSync("artifact_grep", () => {
|
|
407
|
+
for (const [index, text] of artifactLines(repoPath, artifactPath).entries()) {
|
|
408
|
+
regex.lastIndex = 0;
|
|
409
|
+
if (regex.test(text))
|
|
410
|
+
matches.push({ line: index + 1, text });
|
|
411
|
+
if (matches.length >= Math.max(1, Math.min(1000, Math.floor(limit))))
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
return {
|
|
416
|
+
artifactPath: normalizeRelative(artifactPath),
|
|
417
|
+
pattern,
|
|
418
|
+
flags,
|
|
419
|
+
matches,
|
|
420
|
+
truncated: matches.length >= limit,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logic for the `knodin context` operation — the "call this FIRST" ultra-
|
|
3
|
+
* compact orientation. Lives in its own neutral module, mirroring
|
|
4
|
+
* `pr-triage.ts`'s role for the `prs` operation: `bin/cli.ts` and
|
|
5
|
+
* `src/tools/knodin-tools.ts` both import from here independently, rather
|
|
6
|
+
* than the CLI reaching into the MCP-gateway module the way it previously did
|
|
7
|
+
* (code review finding — R23-R28 reuse/altitude pass).
|
|
8
|
+
*/
|
|
9
|
+
import { measurePerfPhase } from "./engine/perf.js";
|
|
10
|
+
/** Keyword heuristic mapping a free-text `task` to a suggested next knodin
|
|
11
|
+
* operation for the `context` op. This is explicitly a hint, not a claim of
|
|
12
|
+
* understanding intent — a wrong guess must never stop the caller from reaching
|
|
13
|
+
* for any operation directly (R26). Order matters: review-shaped tasks win over
|
|
14
|
+
* map-shaped ones, and a bare single-symbol token routes to `explain`. */
|
|
15
|
+
export function suggestNextOperation(task) {
|
|
16
|
+
const t = task.toLowerCase();
|
|
17
|
+
if (/\b(bug|review|diff|pr|pull request|regression|risk|approve|merge)\b/.test(t)) {
|
|
18
|
+
return "review";
|
|
19
|
+
}
|
|
20
|
+
if (/\b(batch outline|file outline|outline files?|project overview|directory overview)\b/.test(t)) {
|
|
21
|
+
return "query";
|
|
22
|
+
}
|
|
23
|
+
if (/\b(how does|how do|architecture|architectural|structure|structured|design|subsystem|boundaries|overview)\b/.test(t)) {
|
|
24
|
+
return "map";
|
|
25
|
+
}
|
|
26
|
+
// A bare identifier-shaped token (a single symbol name) → explain it directly.
|
|
27
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(task.trim())) {
|
|
28
|
+
return "explain";
|
|
29
|
+
}
|
|
30
|
+
// Otherwise no exact symbol is known → fuzzy search to find a starting point.
|
|
31
|
+
return "search";
|
|
32
|
+
}
|
|
33
|
+
function suggestedStructuralPattern(task) {
|
|
34
|
+
const normalized = task.toLowerCase();
|
|
35
|
+
if (/\bbatch outline\b/.test(normalized))
|
|
36
|
+
return "batch_outline";
|
|
37
|
+
if (/\b(project|directory) overview\b/.test(normalized))
|
|
38
|
+
return "project_overview";
|
|
39
|
+
if (/\bfile outline\b|\boutline (?:the )?file\b/.test(normalized))
|
|
40
|
+
return "file_summary";
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* R26 `context` operation — the "call this FIRST" ultra-compact orientation.
|
|
45
|
+
* Composes EXISTING public engine methods only (`map`, `query stats`,
|
|
46
|
+
* `query flows`, `review`) so it never touches `src/engine/index.ts` and stays
|
|
47
|
+
* parallel-safe against the query-dispatch work landing there. Takes an
|
|
48
|
+
* `engine: KnodinEngine` parameter rather than constructing its own, so the
|
|
49
|
+
* `knodin-tools.ts` module-level `createEngine()` singleton is never
|
|
50
|
+
* duplicated or side-effected by this module's existence.
|
|
51
|
+
*/
|
|
52
|
+
export async function buildKnodinContext(eng, task, repo, base, changedFiles) {
|
|
53
|
+
return measurePerfPhase("context_composition", async () => {
|
|
54
|
+
// Establish one full graph snapshot first: stats requires that same standard
|
|
55
|
+
// map, so asking for a separate minimal map would run community analytics
|
|
56
|
+
// twice. Once initialized, the remaining read-only facets can safely share
|
|
57
|
+
// the generation and freshness lease.
|
|
58
|
+
const CONTEXT_TOP = 5;
|
|
59
|
+
const mapResult = await eng.map(repo, "standard");
|
|
60
|
+
const shouldReview = changedFiles === undefined || changedFiles.length > 0;
|
|
61
|
+
const [statsResult, flowsResult, review] = await Promise.all([
|
|
62
|
+
eng.query("stats", "", repo),
|
|
63
|
+
eng.query("flows", "", repo, undefined, CONTEXT_TOP),
|
|
64
|
+
shouldReview ? eng.review(base ?? "HEAD~1", repo, "minimal") : Promise.resolve(undefined),
|
|
65
|
+
]);
|
|
66
|
+
// Risk score: eng.review() always re-derives the diff from `git diff <base>`;
|
|
67
|
+
// it has no seam for an explicit file list, so `changedFiles`, when supplied,
|
|
68
|
+
// is only a caller hint — an explicit empty array means "no changes, skip the
|
|
69
|
+
// diff". When there IS a diff but it touches no indexed symbols, review
|
|
70
|
+
// returns changedSymbolCount 0 and a meaningless riskScore 0; omit the field
|
|
71
|
+
// in that case rather than report a misleading 0 (R26 acceptance criterion).
|
|
72
|
+
let riskScore;
|
|
73
|
+
if (review && review.changedSymbolCount > 0)
|
|
74
|
+
riskScore = review.riskScore;
|
|
75
|
+
// Truncate aggressively — this is a ~100-300 token orientation, not a dump of
|
|
76
|
+
// map()'s full payload. Sort defensively so "top" holds even if an engine
|
|
77
|
+
// method's ordering changes; flows already arrive most-critical first (capped
|
|
78
|
+
// at CONTEXT_TOP via the query limit above).
|
|
79
|
+
return {
|
|
80
|
+
repoPath: repo,
|
|
81
|
+
task,
|
|
82
|
+
stats: statsResult.totals,
|
|
83
|
+
communities: [...mapResult.communities]
|
|
84
|
+
.sort((a, b) => b.size - a.size)
|
|
85
|
+
.slice(0, CONTEXT_TOP)
|
|
86
|
+
.map((c) => ({ name: c.name, size: c.size })),
|
|
87
|
+
hubs: [...mapResult.hubs]
|
|
88
|
+
.sort((a, b) => b.degree - a.degree)
|
|
89
|
+
.slice(0, CONTEXT_TOP)
|
|
90
|
+
.map((h) => ({ symbol: h.symbol, degree: h.degree })),
|
|
91
|
+
flows: flowsResult.results
|
|
92
|
+
.slice(0, CONTEXT_TOP)
|
|
93
|
+
.map((f) => ({ symbol: f.symbol, criticality: f.criticality })),
|
|
94
|
+
...(riskScore !== undefined ? { riskScore } : {}),
|
|
95
|
+
suggestedOperation: suggestNextOperation(task),
|
|
96
|
+
...(suggestedStructuralPattern(task)
|
|
97
|
+
? { suggestedQueryPattern: suggestedStructuralPattern(task) }
|
|
98
|
+
: {}),
|
|
99
|
+
suggestionNote: "Heuristic from keywords in `task`; a hint only — any operation can be called directly regardless.",
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
export const DOCS_SECTIONS = {
|
|
5
|
+
quickstart: `# Quickstart Guide
|
|
6
|
+
|
|
7
|
+
knodin — source-evidenced local code intelligence with known bounds. It builds a semantic and syntactic knowledge graph of your repository and exposes five primary operations through its MCP tool gateway or CLI:
|
|
8
|
+
|
|
9
|
+
1. **context**: Orient on a repository. Returns high-level statistics, key subsystems/hubs/flows, a heuristic next-operation recommendation, and a diff-based risk score if changes are detected.
|
|
10
|
+
2. **explain**: Understand a specific symbol (function, class, or file). Returns edit-ready source code, inbound/outbound call paths, and blast radius (transitive callers).
|
|
11
|
+
3. **review**: Analyze local modifications. Displays risk-scored changes, affected execution flows, and missing test coverage before you commit or create a PR.
|
|
12
|
+
4. **map**: Visualize subsystem boundaries. Partitions the code into cohesive modular communities using Louvain community detection.
|
|
13
|
+
5. **search**: Fuzzy semantic and keyword lookup to find symbols when you do not know their exact name.
|
|
14
|
+
|
|
15
|
+
### Command Usage (CLI)
|
|
16
|
+
\`\`\`bash
|
|
17
|
+
knodin context "explore auth"
|
|
18
|
+
knodin explain login
|
|
19
|
+
knodin map
|
|
20
|
+
\`\`\``,
|
|
21
|
+
"query-patterns": `# Structured Query Patterns
|
|
22
|
+
|
|
23
|
+
The \`query\` operation lets you ask precise, structured questions about your code using a variety of built-in graph patterns:
|
|
24
|
+
|
|
25
|
+
- **callers_of <target>**: Who invokes this symbol (transitive/direct).
|
|
26
|
+
- **callees_of <target>**: What other symbols does this symbol invoke.
|
|
27
|
+
- **tests_for <target>**: Find test files that cover this symbol.
|
|
28
|
+
- **file_summary <target>**: Lists all symbols defined in a file.
|
|
29
|
+
- **shortest_path <from> <to>**: Find the call-graph chain connecting two symbols.
|
|
30
|
+
- **impact <symbol>**: Directional, depth-bounded symbol reach with stable selectors, relation/confidence/test filters, edge evidence, and heuristic summaries. Use \`impactMode: "file"\` explicitly for a distinctly labeled changed-file blast radius.
|
|
31
|
+
- **dead_code**: Surface exported or unexported symbols with zero references.
|
|
32
|
+
- **large_functions / large_files**: Spot complex "god" functions and files exceeding size thresholds.
|
|
33
|
+
- **traverse <symbol>**: Perform a bounded BFS neighborhood walk. Use \`direction\` (\`upstream|downstream|both\`), \`relationKinds\`, and optional \`includeDataFlow\`; every discovered hop carries its exact kind, direction, confidence, provenance, source line, and files. Call arguments are labeled heuristic source evidence, never runtime proof.
|
|
34
|
+
- **knowledge_gaps**: Report repo-health weaknesses like thin communities (<3 symbols) and untested hubs/bridges.
|
|
35
|
+
- **surprising_connections**: Score and rank coupling that crosses community, language, or test boundaries.
|
|
36
|
+
- **suggested_questions**: prioritizes human-readable review prompts based on untested hotspots and high-surprise edges.
|
|
37
|
+
- **architecture_overview**: Formats community cohesion and coupling plus independently selectable \`architectureFacets\`: packages, layers, boundaries, hotspots, entryPoints, and languages. \`path\` scopes every facet consistently.`,
|
|
38
|
+
federation: `# Cross-Repository Composition
|
|
39
|
+
|
|
40
|
+
Federation is knodin's internal query-composition mechanism, not the primary
|
|
41
|
+
user-facing model. Use \`knodin repos\` for operational checkout management and
|
|
42
|
+
\`knodin system\` with commit-ready \`reckon.yaml\` stable identities for
|
|
43
|
+
cooperating components. Sibling directory placement and shared search matches
|
|
44
|
+
never establish system membership.
|
|
45
|
+
|
|
46
|
+
Legacy \`.reckon/federation.json\` remains a compatibility input and is not
|
|
47
|
+
silently discarded. Migrate it to \`reckon.yaml\` plus personal XDG checkout
|
|
48
|
+
paths. The \`federated_repos\` query remains available for inspecting the
|
|
49
|
+
engine's currently composed repository paths; it does not prove those
|
|
50
|
+
repositories form one system.`,
|
|
51
|
+
"rename-safety": `# Rename Safety and Refactoring
|
|
52
|
+
|
|
53
|
+
knodin provides an AST-backed, type-safe refactoring pipeline for renaming symbols.
|
|
54
|
+
|
|
55
|
+
### Two-Step Pipeline
|
|
56
|
+
1. **Dry-Run (default)**: Returns every edit site (definitions, references, imports) and generates a unified diff. Refuses to make changes if there's an ambiguity or conflict.
|
|
57
|
+
2. **Apply (\`apply: true\` or \`--apply\`)**: Atomically writes line-scoped word-boundary edits to disk and triggers an automatic reindexing of touched files.
|
|
58
|
+
|
|
59
|
+
### Safety Guards
|
|
60
|
+
The engine refuses to write to disk and rolls back any changes if:
|
|
61
|
+
- **Ambiguity**: The old symbol name resolves to more than one definition file.
|
|
62
|
+
- **Collision**: The new name already exists as a symbol in any of the edit-site files.
|
|
63
|
+
- **Out of Scope**: The edit targets files outside the repository.
|
|
64
|
+
- **Type Errors**: If verification is enabled (default), it verifies the workspace compiles successfully using \`tsc\` (or equivalent) after applying the edit, and automatically rolls back if compilation fails.`,
|
|
65
|
+
"language-support": `# Language and Metadata Support
|
|
66
|
+
|
|
67
|
+
knodin is multi-language, not language-universal.
|
|
68
|
+
|
|
69
|
+
### Native syntax
|
|
70
|
+
TypeScript (\`.ts/.tsx/.mts/.cts\`), JavaScript (\`.js/.jsx/.mjs/.cjs\`), Python, Java, C#, Salesforce Apex, SQL/PLSQL, Prisma, and XML-backed formats.
|
|
71
|
+
|
|
72
|
+
### Domain-specific structure
|
|
73
|
+
Salesforce LWC, Aura, Visualforce, Experience Cloud, and selected Salesforce DX metadata/automations; Terraform/HCL; Dockerfiles; dbt manifests; and Workday Studio XML.
|
|
74
|
+
|
|
75
|
+
### Imported graph
|
|
76
|
+
LSIF can import symbols and relationships produced by a compatible language server. This is not native parsing.
|
|
77
|
+
|
|
78
|
+
Go, Rust, PHP, Ruby, Kotlin, Swift, Perl, PowerShell, Bash, and MuleSoft/RAML are known native-parser gaps. Coverage differs by language, and static dead-code candidates must be corroborated when runtime or platform configuration can invoke code dynamically. Salesforce candidates should be checked against deployed-org and platform dependency data before deletion.`,
|
|
79
|
+
troubleshooting: `# Troubleshooting and Recovery
|
|
80
|
+
|
|
81
|
+
### Index health and surgical repair
|
|
82
|
+
|
|
83
|
+
Run \`knodin status\` (or MCP \`operation: "status"\`) to inspect the local schema/model/version, file and symbol coverage, orphaned or missing records, and the last successful reconciliation. Its repair steps are actionable. Run \`knodin repair\` (or MCP \`operation: "repair"\`) to rebuild only missing/damaged rows and verify health; healthy indexed files are not deleted or rebuilt.
|
|
84
|
+
|
|
85
|
+
The MCP \`telemetry\` operation reads process-local metadata-only measurements: actual response bytes and \`gpt-tokenizer@3.4.0:o200k_base\` tokens, executed baselines where available, negative or positive savings, latency, RSS, schema cost, truncation, and detail mode. It never contains source or file paths and does not change existing operation response shapes. Telemetry is not persisted by default. Set MCP \`persistTelemetry: true\` to append metadata-only JSONL to the repo-root \`.reckon-telemetry.jsonl\`; knodin never sends it over the network. Use \`telemetryAction: "report"\` or \`knodin telemetry report\` for a static repository-local HTML report.
|
|
86
|
+
|
|
87
|
+
Common issues and how to resolve them when using knodin:
|
|
88
|
+
|
|
89
|
+
### Corrupt Embedding Row
|
|
90
|
+
If you see warnings like \`Skipping corrupt embedding... dimension mismatch\`, your index database contains malformed embeddings.
|
|
91
|
+
- **Fix**: Force a clean re-index of the repository to rebuild the vector store.
|
|
92
|
+
\`\`\`bash
|
|
93
|
+
# Prefer surgical local repair; it preserves healthy state
|
|
94
|
+
knodin repair
|
|
95
|
+
\`\`\`
|
|
96
|
+
|
|
97
|
+
### Missing 'gh' CLI Dependency
|
|
98
|
+
The \`prs\` triage operation requires the official GitHub CLI (\`gh\`) to be installed, in your PATH, and authenticated.
|
|
99
|
+
- **Fix**: Run \`gh auth login\` to authenticate locally.
|
|
100
|
+
|
|
101
|
+
### Legacy federation configuration
|
|
102
|
+
If legacy federation configuration fails, validate
|
|
103
|
+
\`.reckon/federation.json\`, then migrate stable identities to \`reckon.yaml\`
|
|
104
|
+
and local paths to XDG configuration. Do not infer membership from siblings.`,
|
|
105
|
+
};
|
|
106
|
+
const DOC_TOPIC_FILES = {
|
|
107
|
+
installation: "INSTALLATION.md",
|
|
108
|
+
mcp: "MCP.md",
|
|
109
|
+
repositories: "REPOSITORIES-AND-WORKTREES.md",
|
|
110
|
+
systems: "SYSTEMS-AND-RELATIONSHIPS.md",
|
|
111
|
+
provenance: "INDEXING-POLICY-AND-PROVENANCE.md",
|
|
112
|
+
"dead-code": "DEAD-CODE-AND-IMPACT.md",
|
|
113
|
+
doctor: "DOCTOR-AND-UPDATES.md",
|
|
114
|
+
compression: "COMMAND-OUTPUT-COMPRESSION.md",
|
|
115
|
+
};
|
|
116
|
+
function packageRoot() {
|
|
117
|
+
let current = path.dirname(fileURLToPath(import.meta.url));
|
|
118
|
+
while (!fs.existsSync(path.join(current, "package.json")) &&
|
|
119
|
+
current !== path.parse(current).root) {
|
|
120
|
+
current = path.dirname(current);
|
|
121
|
+
}
|
|
122
|
+
return current;
|
|
123
|
+
}
|
|
124
|
+
export function listDocTopics() {
|
|
125
|
+
return [...new Set([...Object.keys(DOCS_SECTIONS), ...Object.keys(DOC_TOPIC_FILES)])].sort((left, right) => left.localeCompare(right));
|
|
126
|
+
}
|
|
127
|
+
/** Read canonical long-form guides directly so CLI and MCP cannot drift from Markdown. */
|
|
128
|
+
export function getDocSection(topic) {
|
|
129
|
+
const file = DOC_TOPIC_FILES[topic];
|
|
130
|
+
if (file) {
|
|
131
|
+
try {
|
|
132
|
+
return fs.readFileSync(path.join(packageRoot(), "docs", file), "utf-8");
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error.code === "ENOENT")
|
|
136
|
+
return undefined;
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return DOCS_SECTIONS[topic];
|
|
141
|
+
}
|