knodin 0.5.1 → 0.7.3
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/README.md +190 -535
- package/dist/bin/cli.js +173 -17
- package/dist/bin/launcher.js +11 -0
- package/dist/src/agent-integration.js +4 -16
- package/dist/src/artifact-refresh.js +1 -1
- package/dist/src/cli-args.js +8 -1
- package/dist/src/cli-model.js +35 -1
- package/dist/src/codeflow-replay.js +80 -0
- package/dist/src/competitive-cold-mcp.js +40 -0
- package/dist/src/competitive-manifest.js +106 -25
- package/dist/src/competitive-runner.js +37 -3
- package/dist/src/competitive-sandbox.js +4 -4
- package/dist/src/context-export.js +1 -1
- package/dist/src/diagnostics.js +449 -0
- package/dist/src/docs-sections.js +5 -5
- package/dist/src/engine/ann-hnsw.js +5 -5
- package/dist/src/engine/embeddings.js +4 -4
- package/dist/src/engine/git-history.js +289 -0
- package/dist/src/engine/index.js +450 -103
- package/dist/src/engine/prune.js +2 -2
- package/dist/src/engine/scip-import.js +408 -0
- package/dist/src/engine/symbol-delete.js +1 -1
- package/dist/src/execution-profile.js +203 -0
- package/dist/src/failure-diagnosis.js +69 -10
- package/dist/src/hook-manager-integration.js +156 -0
- package/dist/src/index-activity.js +1 -1
- package/dist/src/init-progress-worker.js +1 -1
- package/dist/src/init.js +361 -92
- package/dist/src/lifecycle-health.js +51 -14
- package/dist/src/lsp-readonly.js +1 -1
- package/dist/src/output-compression.js +1 -1
- package/dist/src/output-telemetry.js +8 -4
- package/dist/src/pr-triage.js +2 -2
- package/dist/src/progressive-evidence.js +473 -0
- package/dist/src/pure-compression-cli.js +101 -0
- package/dist/src/release-preflight.js +510 -0
- package/dist/src/repair-progress-worker.js +1 -1
- package/dist/src/repository-init-process.js +1 -1
- package/dist/src/repository-management.js +145 -3
- package/dist/src/response-budget.js +11 -1
- package/dist/src/server.js +23 -3
- package/dist/src/structural-fast-path.js +303 -0
- package/dist/src/structural-snapshot.js +33 -0
- package/dist/src/system-config.js +7 -7
- package/dist/src/tools/knodin-tools.js +106 -15
- package/dist/src/update-ceremony.js +158 -0
- package/dist/src/update-policy.js +4 -4
- package/dist/src/wait-for-fresh.js +2 -2
- package/dist/src/worktree-lifecycle.js +2 -2
- package/docs/CLI.md +22 -0
- package/docs/COMMAND-OUTPUT-COMPRESSION.md +32 -16
- package/docs/CONTAINED-EXECUTION.md +77 -0
- package/docs/DIAGNOSTICS.md +45 -0
- package/docs/DOCTOR-AND-UPDATES.md +7 -4
- package/docs/GIT-HISTORY-REVIEW.md +39 -0
- package/docs/INSTALLATION.md +1 -1
- package/docs/MCP.md +15 -0
- package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +31 -1
- package/docs/SCIP-IMPORT.md +57 -0
- package/docs/SIGNED-UPDATES.md +5 -0
- package/docs/SYSTEMS-AND-RELATIONSHIPS.md +2 -2
- package/docs/TELEMETRY.md +6 -2
- package/docs/releases/0.6.0.md +18 -0
- package/docs/releases/0.7.0.md +24 -0
- package/docs/releases/0.7.1.md +21 -0
- package/docs/releases/0.7.2.md +21 -0
- package/docs/releases/0.7.3.md +23 -0
- package/package.json +34 -3
- package/dist/src/tools/reckon-tools.js +0 -5
|
@@ -10,7 +10,140 @@ const MAX_DEPTH = 32;
|
|
|
10
10
|
const MAX_GIT_ERROR_BYTES = 16 * 1024;
|
|
11
11
|
const MAX_TRACKED_PATH_BYTES = 1024 * 1024;
|
|
12
12
|
const MAX_DOCUMENT_CANDIDATES = 20;
|
|
13
|
-
const SKIPPED_DIRECTORIES = new Set([".git", ".
|
|
13
|
+
const SKIPPED_DIRECTORIES = new Set([".git", ".knodin", "node_modules"]);
|
|
14
|
+
export function repositorySignalInspectionLimit(options) {
|
|
15
|
+
const bytes = Math.min(options.byteBudget ?? 65_536, (options.tokenBudget ?? 16_384) * 4);
|
|
16
|
+
return Math.max(1, Math.min(options.itemBudget ?? 100, Math.floor(bytes / 256)));
|
|
17
|
+
}
|
|
18
|
+
const SIGNAL_MARKERS = [
|
|
19
|
+
".circleci/config.yml",
|
|
20
|
+
".github/workflows",
|
|
21
|
+
".gitlab-ci.yml",
|
|
22
|
+
".mcp.json",
|
|
23
|
+
".vscode/mcp.json",
|
|
24
|
+
"AGENTS.md",
|
|
25
|
+
"CLAUDE.md",
|
|
26
|
+
"Jenkinsfile",
|
|
27
|
+
"azure-pipelines.yml",
|
|
28
|
+
"sonar-project.properties",
|
|
29
|
+
];
|
|
30
|
+
const HOOK_CONFIGS = [
|
|
31
|
+
{ path: "lefthook.yml", manager: "lefthook", readPaths: ["lefthook.yml"] },
|
|
32
|
+
{ path: "lefthook.yaml", manager: "lefthook", readPaths: ["lefthook.yaml"] },
|
|
33
|
+
{
|
|
34
|
+
path: ".husky",
|
|
35
|
+
manager: "husky",
|
|
36
|
+
readPaths: [".husky/pre-commit", ".husky/commit-msg", ".husky/pre-push"],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
path: ".pre-commit-config.yaml",
|
|
40
|
+
manager: "pre-commit",
|
|
41
|
+
readPaths: [".pre-commit-config.yaml"],
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
const MAX_HOOK_CONFIG_BYTES = 64 * 1024;
|
|
45
|
+
function parseRemote(name, raw) {
|
|
46
|
+
try {
|
|
47
|
+
const normalized = /^[^@/:]+@[^:]+:.+/.test(raw)
|
|
48
|
+
? raw.replace(/^([^@/:]+)@([^:]+):/, "ssh://$1@$2/")
|
|
49
|
+
: raw;
|
|
50
|
+
const url = new URL(normalized);
|
|
51
|
+
const owner = url.pathname.replace(/^\//, "").split("/")[0];
|
|
52
|
+
if (!url.hostname || !owner)
|
|
53
|
+
return undefined;
|
|
54
|
+
return { name, host: url.hostname.toLowerCase(), owner };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Inspect a fixed, path-only allowlist plus bounded hook configuration. */
|
|
61
|
+
export async function detectRepositorySignals(repository) {
|
|
62
|
+
const markerFiles = [];
|
|
63
|
+
for (const marker of SIGNAL_MARKERS) {
|
|
64
|
+
try {
|
|
65
|
+
await fs.promises.lstat(path.join(repository, marker));
|
|
66
|
+
markerFiles.push(marker);
|
|
67
|
+
}
|
|
68
|
+
catch { }
|
|
69
|
+
}
|
|
70
|
+
let hookManager;
|
|
71
|
+
let aidevTrackReferenced;
|
|
72
|
+
for (const config of HOOK_CONFIGS) {
|
|
73
|
+
try {
|
|
74
|
+
await fs.promises.lstat(path.join(repository, config.path));
|
|
75
|
+
if (!hookManager) {
|
|
76
|
+
hookManager = config.manager;
|
|
77
|
+
aidevTrackReferenced = false;
|
|
78
|
+
}
|
|
79
|
+
let remainingBytes = MAX_HOOK_CONFIG_BYTES;
|
|
80
|
+
for (const readPath of config.readPaths) {
|
|
81
|
+
if (remainingBytes === 0 || aidevTrackReferenced)
|
|
82
|
+
break;
|
|
83
|
+
try {
|
|
84
|
+
const readStat = await fs.promises.lstat(path.join(repository, readPath));
|
|
85
|
+
if (!readStat.isFile())
|
|
86
|
+
continue;
|
|
87
|
+
const handle = await fs.promises.open(path.join(repository, readPath), "r");
|
|
88
|
+
try {
|
|
89
|
+
const buffer = Buffer.alloc(Math.min(readStat.size, remainingBytes));
|
|
90
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
91
|
+
remainingBytes -= bytesRead;
|
|
92
|
+
aidevTrackReferenced = /(?:^|[^\w-])aidev-track(?:$|[^\w-])/m.test(buffer.subarray(0, bytesRead).toString("utf8"));
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await handle.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch { }
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
catch { }
|
|
103
|
+
}
|
|
104
|
+
const remotes = [];
|
|
105
|
+
try {
|
|
106
|
+
const lines = child_process.execFileSync(gitExecutable(), ["remote", "-v"], {
|
|
107
|
+
cwd: repository,
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
110
|
+
maxBuffer: MAX_GIT_ERROR_BYTES,
|
|
111
|
+
});
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
for (const line of lines.split("\n")) {
|
|
114
|
+
const [name, raw] = line.trim().split(/\s+/);
|
|
115
|
+
if (!name || !raw || seen.has(name))
|
|
116
|
+
continue;
|
|
117
|
+
const remote = parseRemote(name, raw);
|
|
118
|
+
if (remote) {
|
|
119
|
+
seen.add(name);
|
|
120
|
+
remotes.push(remote);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch { }
|
|
125
|
+
remotes.sort((a, b) => a.name.localeCompare(b.name));
|
|
126
|
+
const ciProviders = [
|
|
127
|
+
markerFiles.includes(".github/workflows") ? "github-actions" : undefined,
|
|
128
|
+
markerFiles.includes(".gitlab-ci.yml") ? "gitlab-ci" : undefined,
|
|
129
|
+
markerFiles.includes(".circleci/config.yml") ? "circleci" : undefined,
|
|
130
|
+
markerFiles.includes("azure-pipelines.yml") ? "azure-pipelines" : undefined,
|
|
131
|
+
markerFiles.includes("Jenkinsfile") ? "jenkins" : undefined,
|
|
132
|
+
].filter((value) => Boolean(value));
|
|
133
|
+
const agentConfigs = markerFiles.filter((marker) => marker === "AGENTS.md" || marker === "CLAUDE.md");
|
|
134
|
+
return {
|
|
135
|
+
...(hookManager ? { hookManager } : {}),
|
|
136
|
+
...(markerFiles.length ? { markerFiles } : {}),
|
|
137
|
+
...(remotes.length ? { remotes } : {}),
|
|
138
|
+
...(ciProviders.length ? { ciProviders } : {}),
|
|
139
|
+
...(agentConfigs.length ? { agentConfigs } : {}),
|
|
140
|
+
...(aidevTrackReferenced === undefined ? {} : { aidevTrackReferenced }),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** Preserve the pre-C79 byte shape unless the caller explicitly opts in. */
|
|
144
|
+
export function withRepositorySignals(record, enabled, signals) {
|
|
145
|
+
return enabled ? { ...record, signals } : record;
|
|
146
|
+
}
|
|
14
147
|
function gitTopLevel(directory) {
|
|
15
148
|
return child_process
|
|
16
149
|
.execFileSync(gitExecutable(), ["rev-parse", "--show-toplevel"], {
|
|
@@ -175,6 +308,9 @@ function parseRepositoryOption(args, index, cwd, command, state) {
|
|
|
175
308
|
case "--json":
|
|
176
309
|
state.json = true;
|
|
177
310
|
return index;
|
|
311
|
+
case "--signals":
|
|
312
|
+
state.signals = true;
|
|
313
|
+
return index;
|
|
178
314
|
case "--allow-partial":
|
|
179
315
|
state.allowPartial = true;
|
|
180
316
|
return index;
|
|
@@ -232,6 +368,7 @@ export function parseRepositoryCommandArgs(args, cwd) {
|
|
|
232
368
|
include: [],
|
|
233
369
|
exclude: [],
|
|
234
370
|
allowPartial: false,
|
|
371
|
+
signals: false,
|
|
235
372
|
};
|
|
236
373
|
let index = 1;
|
|
237
374
|
while (index < args.length) {
|
|
@@ -250,17 +387,22 @@ export function parseRepositoryCommandArgs(args, cwd) {
|
|
|
250
387
|
throw new Error(`knodin repos ${command}: --dry-run applies only to init`);
|
|
251
388
|
if (state.manifestPath && command !== "init")
|
|
252
389
|
throw new Error(`knodin repos ${command}: --manifest applies only to init`);
|
|
390
|
+
if (state.signals && command !== "discover")
|
|
391
|
+
throw new Error(`knodin repos ${command}: --signals applies only to discover`);
|
|
253
392
|
if (command !== "search" &&
|
|
254
393
|
command !== "init" &&
|
|
255
394
|
(state.include.length > 0 || state.exclude.length > 0))
|
|
256
395
|
throw new Error(`knodin repos ${command}: repository selectors apply only to init or search`);
|
|
257
396
|
if (command !== "search" &&
|
|
397
|
+
!(command === "discover" && state.signals) &&
|
|
258
398
|
(state.allowPartial ||
|
|
259
399
|
state.itemBudget ||
|
|
260
400
|
state.byteBudget ||
|
|
261
401
|
state.tokenBudget ||
|
|
262
402
|
state.cursor))
|
|
263
403
|
throw new Error(`knodin repos ${command}: search options apply only to search`);
|
|
404
|
+
if (command === "discover" && state.cursor)
|
|
405
|
+
throw new Error("knodin repos discover: --cursor applies only to search");
|
|
264
406
|
return { command, ...state };
|
|
265
407
|
}
|
|
266
408
|
/**
|
|
@@ -650,7 +792,7 @@ function inventoryFreshness(lastIndexedHead, head) {
|
|
|
650
792
|
export async function inventoryRepository(record, options) {
|
|
651
793
|
const id = repositoryIdentity(record.path);
|
|
652
794
|
const mainWorktreeId = repositoryIdentity(record.mainWorktree);
|
|
653
|
-
const databaseExists = fs.existsSync(path.join(record.path, ".
|
|
795
|
+
const databaseExists = fs.existsSync(path.join(record.path, ".knodin", "db.sqlite"));
|
|
654
796
|
const head = gitValue(record.path, ["rev-parse", "HEAD"]);
|
|
655
797
|
let tracked;
|
|
656
798
|
try {
|
|
@@ -946,7 +1088,7 @@ async function initializeOneRepository(repository, options) {
|
|
|
946
1088
|
if (!options.dryRun && options.isolatedInitialize) {
|
|
947
1089
|
return await options.isolatedInitialize(repository);
|
|
948
1090
|
}
|
|
949
|
-
const databaseExists = fs.existsSync(path.join(repository, ".
|
|
1091
|
+
const databaseExists = fs.existsSync(path.join(repository, ".knodin", "db.sqlite"));
|
|
950
1092
|
const integration = readRepositoryIntegrationConfig(repository);
|
|
951
1093
|
const scope = integration?.scope ?? "personal";
|
|
952
1094
|
const agents = [...new Set([...(options.agents ?? []), ...(integration?.agents ?? [])])];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const encoder = new TextEncoder();
|
|
2
2
|
export const MIN_RESPONSE_BUDGET_BYTES = 256;
|
|
3
|
+
const MAX_REPORTED_TOTAL_PATHS = 128;
|
|
3
4
|
function bytes(value) {
|
|
4
5
|
return encoder.encode(JSON.stringify(value)).byteLength;
|
|
5
6
|
}
|
|
@@ -67,8 +68,14 @@ export function applyResponseBudget(value, operation, request, defaults) {
|
|
|
67
68
|
: { result: payload };
|
|
68
69
|
const totals = {};
|
|
69
70
|
let truncated = false;
|
|
70
|
-
|
|
71
|
+
const initialArrays = collectArrays(root);
|
|
72
|
+
const reportedTotals = [...initialArrays]
|
|
73
|
+
.sort((left, right) => left.path.split("/").length - right.path.split("/").length ||
|
|
74
|
+
left.path.localeCompare(right.path))
|
|
75
|
+
.slice(0, MAX_REPORTED_TOTAL_PATHS);
|
|
76
|
+
for (const entry of reportedTotals)
|
|
71
77
|
totals[entry.path] = entry.value.length;
|
|
78
|
+
for (const entry of initialArrays) {
|
|
72
79
|
if (entry.value.length > itemLimit) {
|
|
73
80
|
entry.value.splice(itemLimit);
|
|
74
81
|
truncated = true;
|
|
@@ -82,6 +89,9 @@ export function applyResponseBudget(value, operation, request, defaults) {
|
|
|
82
89
|
estimatedTokens: 0,
|
|
83
90
|
truncated: false,
|
|
84
91
|
totals,
|
|
92
|
+
...(initialArrays.length > reportedTotals.length
|
|
93
|
+
? { omittedTotalPaths: initialArrays.length - reportedTotals.length }
|
|
94
|
+
: {}),
|
|
85
95
|
};
|
|
86
96
|
root.responseBudget = metadata;
|
|
87
97
|
// Preserve collection contracts and identifiers where possible: large source,
|
package/dist/src/server.js
CHANGED
|
@@ -11,22 +11,42 @@ import { fileURLToPath } from "node:url";
|
|
|
11
11
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
12
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
13
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
14
|
+
import { recordDiagnosticFailure } from "./diagnostics.js";
|
|
14
15
|
import { getKnodinTools, handleKnodinTool } from "./tools/knodin-tools.js";
|
|
15
16
|
import { KNODIN_VERSION } from "./version.js";
|
|
17
|
+
export function recordMcpDiagnosticFailure(args, error, defaultRepo = process.cwd()) {
|
|
18
|
+
const effectiveRepo = typeof args?.repoPath === "string" ? args.repoPath : defaultRepo;
|
|
19
|
+
const diagnostic = recordDiagnosticFailure(effectiveRepo, {
|
|
20
|
+
surface: "mcp",
|
|
21
|
+
operation: typeof args?.operation === "string" ? args.operation : "unknown",
|
|
22
|
+
phase: "dispatch",
|
|
23
|
+
error,
|
|
24
|
+
});
|
|
25
|
+
return diagnostic.recorded
|
|
26
|
+
? new Error(`${error instanceof Error ? error.message : String(error)} [diagnostic ${diagnostic.correlationId}]`, { cause: error })
|
|
27
|
+
: error;
|
|
28
|
+
}
|
|
16
29
|
export function createServer() {
|
|
17
30
|
const server = new Server({ name: "knodin", version: KNODIN_VERSION }, {
|
|
18
31
|
capabilities: { tools: {} },
|
|
19
|
-
instructions: "Use knodin first for cold or unfamiliar codebase work: context to orient, explain for source-evidenced symbol context, query impact before meaningful edits, and review before handoff. Prefer direct reads for exact literals, non-code files, or files just edited this turn. If status reports repair-needed, repair before relying on graph evidence.",
|
|
32
|
+
instructions: "Use knodin first for cold or unfamiliar codebase work: context to orient, explain for source-evidenced symbol context, query impact before meaningful edits, and review before handoff. Prefer direct reads for exact literals, non-code files, or files just edited this turn. If status reports repair-needed, follow its typed repair steps: repair graph damage or init displaced lifecycle routing before relying on graph evidence.",
|
|
20
33
|
});
|
|
21
34
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
22
35
|
tools: getKnodinTools(),
|
|
23
36
|
}));
|
|
24
37
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
25
38
|
const { name, arguments: args } = request.params;
|
|
26
|
-
if (name !== "knodin"
|
|
39
|
+
if (name !== "knodin") {
|
|
27
40
|
throw new Error(`unknown tool: ${name}`);
|
|
28
41
|
}
|
|
29
|
-
|
|
42
|
+
let result;
|
|
43
|
+
try {
|
|
44
|
+
result = await handleKnodinTool(args);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
const values = args;
|
|
48
|
+
throw recordMcpDiagnosticFailure(values, error);
|
|
49
|
+
}
|
|
30
50
|
return {
|
|
31
51
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
32
52
|
};
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { contentFingerprint, readStructuralSnapshot, } from "./structural-snapshot.js";
|
|
4
|
+
function exactFile(repo, file) {
|
|
5
|
+
try {
|
|
6
|
+
return (contentFingerprint(fs.readFileSync(path.resolve(repo, file.path))) === file.contentFingerprint);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const DIRECT_FILE_EXTENSIONS = new Set([
|
|
13
|
+
".cjs",
|
|
14
|
+
".cs",
|
|
15
|
+
".cts",
|
|
16
|
+
".go",
|
|
17
|
+
".java",
|
|
18
|
+
".js",
|
|
19
|
+
".jsx",
|
|
20
|
+
".mjs",
|
|
21
|
+
".mts",
|
|
22
|
+
".py",
|
|
23
|
+
".rs",
|
|
24
|
+
".ts",
|
|
25
|
+
".tsx",
|
|
26
|
+
]);
|
|
27
|
+
function lexicalEndLine(lines, startIndex, python) {
|
|
28
|
+
if (python) {
|
|
29
|
+
const indent = /^\s*/.exec(lines[startIndex])?.[0].length ?? 0;
|
|
30
|
+
let end = startIndex + 1;
|
|
31
|
+
for (let index = startIndex + 1; index < lines.length; index++) {
|
|
32
|
+
const line = lines[index];
|
|
33
|
+
if (line.trim() && (/^\s*/.exec(line)?.[0].length ?? 0) <= indent)
|
|
34
|
+
break;
|
|
35
|
+
end = index + 1;
|
|
36
|
+
}
|
|
37
|
+
return end;
|
|
38
|
+
}
|
|
39
|
+
let depth = 0;
|
|
40
|
+
let opened = false;
|
|
41
|
+
for (let index = startIndex; index < lines.length; index++) {
|
|
42
|
+
for (const character of lines[index]) {
|
|
43
|
+
if (character === "{") {
|
|
44
|
+
opened = true;
|
|
45
|
+
depth++;
|
|
46
|
+
}
|
|
47
|
+
else if (character === "}")
|
|
48
|
+
depth--;
|
|
49
|
+
}
|
|
50
|
+
if (opened && depth <= 0)
|
|
51
|
+
return index + 1;
|
|
52
|
+
if (!opened && /[;}]\s*$/.test(lines[index]))
|
|
53
|
+
return index + 1;
|
|
54
|
+
}
|
|
55
|
+
return startIndex + 1;
|
|
56
|
+
}
|
|
57
|
+
function directSymbols(filePath, content) {
|
|
58
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
59
|
+
const python = extension === ".py";
|
|
60
|
+
const lines = content.split(/\r?\n/);
|
|
61
|
+
const symbols = [];
|
|
62
|
+
const patterns = python
|
|
63
|
+
? [
|
|
64
|
+
{ kind: "class", expression: /^\s*class\s+([A-Za-z_$][\w$]*)\b/ },
|
|
65
|
+
{ kind: "function", expression: /^\s*(?:async\s+)?def\s+([A-Za-z_$][\w$]*)\s*\(/ },
|
|
66
|
+
]
|
|
67
|
+
: [
|
|
68
|
+
{
|
|
69
|
+
kind: "class",
|
|
70
|
+
expression: /^\s*(?:(?:export|public|private|protected|internal|abstract|final|sealed|static)\s+)*(?:class|interface|enum|struct|trait|record|type)\s+([A-Za-z_$][\w$]*)\b/,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
kind: "function",
|
|
74
|
+
expression: /^\s*(?:(?:export|public|private|protected|internal|async|static|final|pub(?:\([^)]*\))?)\s+)*(?:func|fn|function)\s+([A-Za-z_$][\w$]*)\s*\(/,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
kind: "function",
|
|
78
|
+
expression: /^\s*(?:(?:export|public|private|protected|internal|async|static|final)\s+)*(?:[A-Za-z_$][\w$<>,.?[\]\s:*&]+\s+)+([A-Za-z_$][\w$]*)\s*\([^;]*\)\s*(?:\{|=>)/,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
kind: "function",
|
|
82
|
+
expression: /^\s*(?:(?:export|const|let|var|public|private|protected|static|readonly)\s+)*([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
for (const [index, line] of lines.entries()) {
|
|
86
|
+
for (const { kind, expression } of patterns) {
|
|
87
|
+
const match = expression.exec(line);
|
|
88
|
+
if (!match)
|
|
89
|
+
continue;
|
|
90
|
+
const symbol = match[1];
|
|
91
|
+
const visibility = line.match(/\b(public|private|protected|internal)\b/)?.[1] ??
|
|
92
|
+
(python && symbol.startsWith("_") ? "private" : "default");
|
|
93
|
+
symbols.push({
|
|
94
|
+
identity: null,
|
|
95
|
+
symbol,
|
|
96
|
+
kind,
|
|
97
|
+
line: index + 1,
|
|
98
|
+
endLine: lexicalEndLine(lines, index, python),
|
|
99
|
+
signature: line
|
|
100
|
+
.trim()
|
|
101
|
+
.split(/\s*(?:\{|=>)\s*/, 1)[0]
|
|
102
|
+
.slice(0, 500),
|
|
103
|
+
visibility,
|
|
104
|
+
exported: /\bexport\b/.test(line) || /\bpub\b/.test(line),
|
|
105
|
+
evidenceQuality: "lexical-structural-fallback",
|
|
106
|
+
});
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return symbols;
|
|
111
|
+
}
|
|
112
|
+
function directFile(repo, filePath) {
|
|
113
|
+
if (!DIRECT_FILE_EXTENSIONS.has(path.extname(filePath).toLowerCase()))
|
|
114
|
+
return null;
|
|
115
|
+
const root = fs.realpathSync(repo);
|
|
116
|
+
const absolute = path.resolve(root, filePath);
|
|
117
|
+
let real;
|
|
118
|
+
try {
|
|
119
|
+
real = fs.realpathSync(absolute);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const relative = path.relative(root, real);
|
|
125
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
126
|
+
return null;
|
|
127
|
+
const content = fs.readFileSync(real, "utf8");
|
|
128
|
+
return {
|
|
129
|
+
path: relative.split(path.sep).join("/"),
|
|
130
|
+
sizeBytes: Buffer.byteLength(content),
|
|
131
|
+
mtimeMs: fs.statSync(real).mtimeMs,
|
|
132
|
+
contentFingerprint: contentFingerprint(content),
|
|
133
|
+
symbols: directSymbols(filePath, content),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export function runStructuralFastPath(repo, pattern, target, limit = 100) {
|
|
137
|
+
const snapshot = readStructuralSnapshot(repo);
|
|
138
|
+
if (!snapshot && pattern !== "file_summary")
|
|
139
|
+
return null;
|
|
140
|
+
let matching = (snapshot?.files ?? []).filter((file) => !target || file.path.includes(target));
|
|
141
|
+
if (pattern === "file_summary") {
|
|
142
|
+
const indexed = matching.find((file) => file.path === target);
|
|
143
|
+
if (!indexed || !exactFile(repo, indexed)) {
|
|
144
|
+
const direct = directFile(repo, target);
|
|
145
|
+
if (!direct)
|
|
146
|
+
return null;
|
|
147
|
+
matching = [direct];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (pattern === "batch_outline") {
|
|
151
|
+
matching = matching.flatMap((file) => {
|
|
152
|
+
if (exactFile(repo, file))
|
|
153
|
+
return [file];
|
|
154
|
+
const direct = directFile(repo, file.path);
|
|
155
|
+
return direct ? [direct] : [];
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
let rows = [];
|
|
159
|
+
let fingerprint;
|
|
160
|
+
let fresh = false;
|
|
161
|
+
if (pattern === "project_overview") {
|
|
162
|
+
fresh = matching.every((file) => {
|
|
163
|
+
try {
|
|
164
|
+
const stat = fs.statSync(path.resolve(repo, file.path));
|
|
165
|
+
return stat.size === file.sizeBytes && stat.mtimeMs === file.mtimeMs;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
const directories = new Map();
|
|
172
|
+
for (const file of matching) {
|
|
173
|
+
const directory = file.path.includes("/") ? file.path.split("/", 1)[0] : ".";
|
|
174
|
+
const value = directories.get(directory) ?? { fileCount: 0, sizeBytes: 0 };
|
|
175
|
+
value.fileCount += 1;
|
|
176
|
+
value.sizeBytes += file.sizeBytes;
|
|
177
|
+
directories.set(directory, value);
|
|
178
|
+
}
|
|
179
|
+
rows = [...directories]
|
|
180
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
181
|
+
.map(([symbol, value]) => ({ symbol, kind: "directory", ...value }));
|
|
182
|
+
fingerprint = contentFingerprint(JSON.stringify(matching.map(({ path: file, contentFingerprint: hash }) => [file, hash])));
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const files = pattern === "file_summary" ? matching.filter((file) => file.path === target) : matching;
|
|
186
|
+
fresh = files.length > 0 && files.every((file) => exactFile(repo, file));
|
|
187
|
+
fingerprint =
|
|
188
|
+
files.length === 1
|
|
189
|
+
? files[0].contentFingerprint
|
|
190
|
+
: contentFingerprint(JSON.stringify(files.map((file) => [file.path, file.contentFingerprint])));
|
|
191
|
+
rows = files.flatMap((file) => file.symbols.map((symbol) => ({ ...symbol, file: file.path })));
|
|
192
|
+
}
|
|
193
|
+
const selected = rows.slice(0, limit);
|
|
194
|
+
return {
|
|
195
|
+
pattern,
|
|
196
|
+
target,
|
|
197
|
+
count: selected.length,
|
|
198
|
+
results: selected,
|
|
199
|
+
...(rows.length > selected.length ? { hasMore: true } : {}),
|
|
200
|
+
staleness: fresh ? "fresh" : "unknown",
|
|
201
|
+
graphEnriched: false,
|
|
202
|
+
evidence: {
|
|
203
|
+
...(pattern === "file_summary" ? { path: target } : {}),
|
|
204
|
+
contentFingerprint: fingerprint,
|
|
205
|
+
evidenceQuality: matching.some((file) => file.symbols.some((symbol) => symbol.evidenceQuality === "lexical-structural-fallback"))
|
|
206
|
+
? "direct-lexical-parser"
|
|
207
|
+
: "indexed-structural-snapshot",
|
|
208
|
+
uniqueness: pattern === "project_overview" ? "not-applicable" : "file-scoped-only",
|
|
209
|
+
evidenceGeneratedAt: snapshot?.generatedAt ?? new Date().toISOString(),
|
|
210
|
+
},
|
|
211
|
+
upgrade: pattern === "file_summary"
|
|
212
|
+
? { operation: "explain", target }
|
|
213
|
+
: pattern === "project_overview"
|
|
214
|
+
? { operation: "query", pattern: "architecture_overview", target: "" }
|
|
215
|
+
: { operation: "query", pattern: "impact", target },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
export function runExactSymbolFastPath(repo, symbol, filePath) {
|
|
219
|
+
const snapshot = readStructuralSnapshot(repo);
|
|
220
|
+
const indexed = snapshot?.files.find((candidate) => candidate.path === filePath);
|
|
221
|
+
const file = indexed && exactFile(repo, indexed) ? indexed : directFile(repo, filePath);
|
|
222
|
+
if (!file)
|
|
223
|
+
return null;
|
|
224
|
+
const matches = file.symbols.filter((candidate) => candidate.symbol === symbol);
|
|
225
|
+
if (matches.length === 0)
|
|
226
|
+
return null;
|
|
227
|
+
const fresh = exactFile(repo, file);
|
|
228
|
+
const evidence = {
|
|
229
|
+
path: filePath,
|
|
230
|
+
contentFingerprint: file.contentFingerprint,
|
|
231
|
+
evidenceQuality: file.symbols.some((candidate) => candidate.evidenceQuality === "lexical-structural-fallback")
|
|
232
|
+
? "direct-lexical-parser"
|
|
233
|
+
: "indexed-structural-snapshot",
|
|
234
|
+
uniqueness: "file-scoped-only",
|
|
235
|
+
evidenceGeneratedAt: snapshot?.generatedAt ?? new Date().toISOString(),
|
|
236
|
+
};
|
|
237
|
+
if (matches.length > 1) {
|
|
238
|
+
return {
|
|
239
|
+
symbol,
|
|
240
|
+
file: filePath,
|
|
241
|
+
ambiguity: {
|
|
242
|
+
candidates: matches.map((match) => ({
|
|
243
|
+
identity: match.identity ?? "",
|
|
244
|
+
file: filePath,
|
|
245
|
+
kind: match.kind,
|
|
246
|
+
line: match.line,
|
|
247
|
+
})),
|
|
248
|
+
},
|
|
249
|
+
staleness: fresh ? "fresh" : "unknown",
|
|
250
|
+
graphEnriched: false,
|
|
251
|
+
evidence,
|
|
252
|
+
upgrade: { operation: "explain", target: symbol },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const match = matches[0];
|
|
256
|
+
const lines = fs.readFileSync(path.resolve(repo, filePath), "utf8").split(/\r?\n/);
|
|
257
|
+
const source = fresh
|
|
258
|
+
? lines
|
|
259
|
+
.slice(match.line - 1, match.endLine)
|
|
260
|
+
.map((line, offset) => `${match.line + offset}: ${line}`)
|
|
261
|
+
.join("\n")
|
|
262
|
+
: undefined;
|
|
263
|
+
return {
|
|
264
|
+
symbol,
|
|
265
|
+
...(match.identity ? { identity: match.identity } : {}),
|
|
266
|
+
file: filePath,
|
|
267
|
+
...(source ? { source } : {}),
|
|
268
|
+
staleness: fresh ? "fresh" : "unknown",
|
|
269
|
+
graphEnriched: false,
|
|
270
|
+
evidence,
|
|
271
|
+
upgrade: { operation: "explain", target: symbol },
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
export function tryStructuralFastPath(argv, cwd = process.cwd()) {
|
|
275
|
+
const valueAfter = (flag) => {
|
|
276
|
+
const index = argv.indexOf(flag);
|
|
277
|
+
return index >= 0
|
|
278
|
+
? argv[index + 1]
|
|
279
|
+
: argv.find((value) => value.startsWith(`${flag}=`))?.slice(flag.length + 1);
|
|
280
|
+
};
|
|
281
|
+
if (argv[0] === "explain" && argv[1] && valueAfter("--file")) {
|
|
282
|
+
const repo = path.resolve(valueAfter("--repo") ?? cwd);
|
|
283
|
+
const result = runExactSymbolFastPath(repo, argv[1], valueAfter("--file"));
|
|
284
|
+
if (!result)
|
|
285
|
+
return false;
|
|
286
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
if (argv[0] !== "query" ||
|
|
290
|
+
!["file_summary", "batch_outline", "project_overview"].includes(argv[1] ?? ""))
|
|
291
|
+
return false;
|
|
292
|
+
const pattern = argv[1];
|
|
293
|
+
const repo = path.resolve(valueAfter("--repo") ?? cwd);
|
|
294
|
+
const target = pattern === "project_overview" ? "" : (argv[2] ?? "");
|
|
295
|
+
const limit = Number(valueAfter("--limit") ?? 100);
|
|
296
|
+
if (!target && pattern !== "project_overview")
|
|
297
|
+
return false;
|
|
298
|
+
const result = runStructuralFastPath(repo, pattern, target, Number.isInteger(limit) && limit > 0 ? limit : 100);
|
|
299
|
+
if (!result)
|
|
300
|
+
return false;
|
|
301
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const STRUCTURAL_SNAPSHOT_VERSION = 1;
|
|
5
|
+
export function contentFingerprint(content) {
|
|
6
|
+
return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`;
|
|
7
|
+
}
|
|
8
|
+
export function structuralSnapshotPath(repo) {
|
|
9
|
+
return path.join(path.resolve(repo), ".knodin", "structural-v1.json");
|
|
10
|
+
}
|
|
11
|
+
export function writeStructuralSnapshot(repo, files) {
|
|
12
|
+
const destination = structuralSnapshotPath(repo);
|
|
13
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
14
|
+
const snapshot = {
|
|
15
|
+
schemaVersion: STRUCTURAL_SNAPSHOT_VERSION,
|
|
16
|
+
generatedAt: new Date().toISOString(),
|
|
17
|
+
files: [...files].sort((left, right) => left.path.localeCompare(right.path)),
|
|
18
|
+
};
|
|
19
|
+
const temporary = `${destination}.${process.pid}.tmp`;
|
|
20
|
+
fs.writeFileSync(temporary, `${JSON.stringify(snapshot)}\n`, { mode: 0o600 });
|
|
21
|
+
fs.renameSync(temporary, destination);
|
|
22
|
+
}
|
|
23
|
+
export function readStructuralSnapshot(repo) {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(fs.readFileSync(structuralSnapshotPath(repo), "utf8"));
|
|
26
|
+
return parsed?.schemaVersion === STRUCTURAL_SNAPSHOT_VERSION && Array.isArray(parsed.files)
|
|
27
|
+
? parsed
|
|
28
|
+
: null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -78,7 +78,7 @@ function configuredRepository(value, index) {
|
|
|
78
78
|
id: stringValue(record.id, `repositories[${index}].id`),
|
|
79
79
|
role,
|
|
80
80
|
indexMode: explicitIndex ?? (derived ? "provenance-only" : "full"),
|
|
81
|
-
policySource: explicitIndex ? "
|
|
81
|
+
policySource: explicitIndex ? "knodin.yaml" : "role-default",
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
function configuredSystem(value, systemIndex) {
|
|
@@ -97,7 +97,7 @@ function configuredSystem(value, systemIndex) {
|
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
99
|
function applyLegacyFederation(repo, repositories, relationships) {
|
|
100
|
-
const legacyPath = path.join(repo, ".
|
|
100
|
+
const legacyPath = path.join(repo, ".knodin", "federation.json");
|
|
101
101
|
let legacy;
|
|
102
102
|
try {
|
|
103
103
|
legacy = asRecord(JSON.parse(fs.readFileSync(legacyPath, "utf-8")));
|
|
@@ -146,7 +146,7 @@ function applyLegacyFederation(repo, repositories, relationships) {
|
|
|
146
146
|
to: id,
|
|
147
147
|
targetIdentity: id,
|
|
148
148
|
evidence: {
|
|
149
|
-
location: ".
|
|
149
|
+
location: ".knodin/federation.json",
|
|
150
150
|
adapter: "legacy-federation",
|
|
151
151
|
kind: "compatibility",
|
|
152
152
|
confidence: 1,
|
|
@@ -165,7 +165,7 @@ function applyPersonalMappings(repositories, xdgConfigHome) {
|
|
|
165
165
|
if (typeof checkout !== "string")
|
|
166
166
|
continue;
|
|
167
167
|
if (!path.isAbsolute(checkout)) {
|
|
168
|
-
throw new Error(`personal repository path for ${repository.id} must be absolute; team config keeps paths out of
|
|
168
|
+
throw new Error(`personal repository path for ${repository.id} must be absolute; team config keeps paths out of knodin.yaml`);
|
|
169
169
|
}
|
|
170
170
|
repository.path = path.normalize(checkout);
|
|
171
171
|
repository.pathSource = "personal-config";
|
|
@@ -303,7 +303,7 @@ function declaredRelationship(value, index) {
|
|
|
303
303
|
to,
|
|
304
304
|
targetIdentity: to,
|
|
305
305
|
evidence: {
|
|
306
|
-
location: typeof source.location === "string" && source.location ? source.location : "
|
|
306
|
+
location: typeof source.location === "string" && source.location ? source.location : "knodin.yaml",
|
|
307
307
|
adapter: "knodin-yaml",
|
|
308
308
|
kind: "declared",
|
|
309
309
|
confidence,
|
|
@@ -314,9 +314,9 @@ function declaredRelationship(value, index) {
|
|
|
314
314
|
}
|
|
315
315
|
export function loadSystemConfiguration(repoPath, options = {}) {
|
|
316
316
|
const repo = path.resolve(repoPath);
|
|
317
|
-
const team = asRecord(optionalYaml(path.join(repo, "
|
|
317
|
+
const team = asRecord(optionalYaml(path.join(repo, "knodin.yaml")));
|
|
318
318
|
if (team.schemaVersion !== undefined && team.schemaVersion !== 1) {
|
|
319
|
-
throw new Error("
|
|
319
|
+
throw new Error("knodin.yaml: schemaVersion must be 1");
|
|
320
320
|
}
|
|
321
321
|
const repositories = (Array.isArray(team.repositories) ? team.repositories : []).map((value, index) => configuredRepository(value, index));
|
|
322
322
|
const systems = (Array.isArray(team.systems) ? team.systems : []).map((value, index) => configuredSystem(value, index));
|