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,250 @@
|
|
|
1
|
+
import child_process from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export function parseInitScope(value) {
|
|
5
|
+
if (value === "personal" || value === "team" || value === "cli-only")
|
|
6
|
+
return value;
|
|
7
|
+
throw new Error("knodin: --scope must be personal, team, or cli-only");
|
|
8
|
+
}
|
|
9
|
+
const MCP_NAME = "knodin";
|
|
10
|
+
const LEGACY_MCP_NAME = "reckon-graph";
|
|
11
|
+
const TOML_START = "# knodin:start";
|
|
12
|
+
const TOML_END = "# knodin:end";
|
|
13
|
+
const LEGACY_TOML_START = "# reckon-graph:start";
|
|
14
|
+
const LEGACY_TOML_END = "# reckon-graph:end";
|
|
15
|
+
const TOML_BLOCK = `${TOML_START}
|
|
16
|
+
[mcp_servers."${MCP_NAME}"]
|
|
17
|
+
command = "knodin"
|
|
18
|
+
args = ["serve"]
|
|
19
|
+
${TOML_END}`;
|
|
20
|
+
function errorMessage(error) {
|
|
21
|
+
return error instanceof Error ? error.message : String(error);
|
|
22
|
+
}
|
|
23
|
+
function commandExists(command) {
|
|
24
|
+
try {
|
|
25
|
+
child_process.execFileSync("which", [command], { stdio: "ignore" });
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Detect primary MCP-capable coding agents without launching them. */
|
|
33
|
+
export function detectSupportedAgents() {
|
|
34
|
+
const detected = [];
|
|
35
|
+
if (commandExists("claude"))
|
|
36
|
+
detected.push("claude");
|
|
37
|
+
if (commandExists("codex"))
|
|
38
|
+
detected.push("codex");
|
|
39
|
+
if (commandExists("gemini"))
|
|
40
|
+
detected.push("gemini");
|
|
41
|
+
if (commandExists("antigravity") || commandExists("agy"))
|
|
42
|
+
detected.push("antigravity");
|
|
43
|
+
return detected;
|
|
44
|
+
}
|
|
45
|
+
function defaultRunner(command) {
|
|
46
|
+
try {
|
|
47
|
+
child_process.execFileSync(command.executable, command.args, {
|
|
48
|
+
cwd: command.cwd,
|
|
49
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (command.executable !== "claude" || !command.args.includes("add"))
|
|
54
|
+
throw error;
|
|
55
|
+
child_process.execFileSync("claude", ["mcp", "get", MCP_NAME], {
|
|
56
|
+
cwd: command.cwd,
|
|
57
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Configure command-backed personal integrations. Claude stores local scope
|
|
63
|
+
* outside the checkout; Codex/Gemini/Antigravity use excluded project files.
|
|
64
|
+
*/
|
|
65
|
+
export function configurePersonalAgents(options) {
|
|
66
|
+
const run = options.run ?? defaultRunner;
|
|
67
|
+
const configured = [];
|
|
68
|
+
const removed = [];
|
|
69
|
+
const failed = [];
|
|
70
|
+
for (const agent of options.detected) {
|
|
71
|
+
if (agent !== "claude")
|
|
72
|
+
continue;
|
|
73
|
+
const removeLegacy = {
|
|
74
|
+
executable: "claude",
|
|
75
|
+
args: ["mcp", "remove", LEGACY_MCP_NAME, "--scope", "local"],
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
run(removeLegacy);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// A missing legacy registration is already the desired state.
|
|
82
|
+
}
|
|
83
|
+
const command = options.remove
|
|
84
|
+
? {
|
|
85
|
+
executable: "claude",
|
|
86
|
+
args: ["mcp", "remove", MCP_NAME, "--scope", "local"],
|
|
87
|
+
}
|
|
88
|
+
: {
|
|
89
|
+
executable: "claude",
|
|
90
|
+
args: ["mcp", "add", MCP_NAME, "--scope", "local", "--", "knodin", "serve"],
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
run(command);
|
|
94
|
+
(options.remove ? removed : configured).push(agent);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
// Removing a missing registration is already the desired state.
|
|
98
|
+
if (options.remove)
|
|
99
|
+
removed.push(agent);
|
|
100
|
+
else
|
|
101
|
+
failed.push({ agent, message: errorMessage(error) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { configured, removed, failed, projectFiles: [] };
|
|
105
|
+
}
|
|
106
|
+
function readJsonObject(filePath) {
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
109
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
110
|
+
throw new Error("expected a JSON object");
|
|
111
|
+
return parsed;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error.code === "ENOENT")
|
|
115
|
+
return {};
|
|
116
|
+
throw new Error(`${filePath}: ${errorMessage(error)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function assertSafeProjectPath(repo, filePath) {
|
|
120
|
+
const relative = path.relative(repo, filePath);
|
|
121
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
122
|
+
throw new Error(`${filePath}: agent configuration must stay inside the repository`);
|
|
123
|
+
let current = repo;
|
|
124
|
+
for (const part of relative.split(path.sep)) {
|
|
125
|
+
current = path.join(current, part);
|
|
126
|
+
try {
|
|
127
|
+
if (fs.lstatSync(current).isSymbolicLink())
|
|
128
|
+
throw new Error(`${filePath}: refusing to follow a symbolic link`);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (error.code === "ENOENT")
|
|
132
|
+
break;
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function formatProjectJson(document) {
|
|
138
|
+
return `${JSON.stringify(document, null, "\t").replace(/"args": \[\n(\t+)"serve"\n\t+\]/g, '"args": ["serve"]')}\n`;
|
|
139
|
+
}
|
|
140
|
+
function updateJsonMcp(filePath, remove) {
|
|
141
|
+
const document = readJsonObject(filePath);
|
|
142
|
+
const existing = document.mcpServers;
|
|
143
|
+
const servers = typeof existing === "object" && existing !== null && !Array.isArray(existing)
|
|
144
|
+
? { ...existing }
|
|
145
|
+
: {};
|
|
146
|
+
delete servers[LEGACY_MCP_NAME];
|
|
147
|
+
if (remove)
|
|
148
|
+
delete servers[MCP_NAME];
|
|
149
|
+
else
|
|
150
|
+
servers[MCP_NAME] = { command: "knodin", args: ["serve"] };
|
|
151
|
+
if (Object.keys(servers).length > 0)
|
|
152
|
+
document.mcpServers = servers;
|
|
153
|
+
else
|
|
154
|
+
delete document.mcpServers;
|
|
155
|
+
if (remove && Object.keys(document).length === 0) {
|
|
156
|
+
fs.rmSync(filePath, { force: true });
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
160
|
+
fs.writeFileSync(filePath, formatProjectJson(document), "utf-8");
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
function updateTomlMcp(filePath, remove) {
|
|
164
|
+
let existing = "";
|
|
165
|
+
try {
|
|
166
|
+
existing = fs.readFileSync(filePath, "utf-8");
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error.code !== "ENOENT")
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
function removeManagedBlock(content, startMarker, endMarker) {
|
|
173
|
+
const start = content.indexOf(startMarker);
|
|
174
|
+
const end = content.indexOf(endMarker);
|
|
175
|
+
return start >= 0 && end >= start
|
|
176
|
+
? `${content.slice(0, start)}${content.slice(end + endMarker.length)}`.trim()
|
|
177
|
+
: content.trim();
|
|
178
|
+
}
|
|
179
|
+
const without = removeManagedBlock(removeManagedBlock(existing, LEGACY_TOML_START, LEGACY_TOML_END), TOML_START, TOML_END);
|
|
180
|
+
const next = remove ? without : [without, TOML_BLOCK].filter(Boolean).join("\n\n");
|
|
181
|
+
if (!next) {
|
|
182
|
+
fs.rmSync(filePath, { force: true });
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
186
|
+
fs.writeFileSync(filePath, `${next}\n`, "utf-8");
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
/** Merge or remove knodin's project-local agent adapters without replacing user configuration. */
|
|
190
|
+
export function configureProjectAgents(options) {
|
|
191
|
+
const repo = path.resolve(options.repo);
|
|
192
|
+
const remove = options.scope === "cli-only";
|
|
193
|
+
const agents = options.scope === "team"
|
|
194
|
+
? ["claude", "codex", "gemini", "antigravity"]
|
|
195
|
+
: options.agents;
|
|
196
|
+
const commandResult = options.scope === "team"
|
|
197
|
+
? { configured: [], removed: [], failed: [], projectFiles: [] }
|
|
198
|
+
: configurePersonalAgents({
|
|
199
|
+
detected: agents,
|
|
200
|
+
remove,
|
|
201
|
+
run: options.run ?? ((command) => defaultRunner({ ...command, cwd: repo })),
|
|
202
|
+
});
|
|
203
|
+
const configured = [...commandResult.configured];
|
|
204
|
+
const removed = [...commandResult.removed];
|
|
205
|
+
const failed = [...commandResult.failed];
|
|
206
|
+
const projectFiles = [];
|
|
207
|
+
for (const agent of agents) {
|
|
208
|
+
try {
|
|
209
|
+
let relative = null;
|
|
210
|
+
let remains = false;
|
|
211
|
+
if (agent === "claude" && options.scope === "team") {
|
|
212
|
+
relative = ".mcp.json";
|
|
213
|
+
assertSafeProjectPath(repo, path.join(repo, relative));
|
|
214
|
+
remains = updateJsonMcp(path.join(repo, relative), false);
|
|
215
|
+
}
|
|
216
|
+
else if (agent === "claude") {
|
|
217
|
+
relative = ".mcp.json";
|
|
218
|
+
assertSafeProjectPath(repo, path.join(repo, relative));
|
|
219
|
+
remains = updateJsonMcp(path.join(repo, relative), true);
|
|
220
|
+
}
|
|
221
|
+
else if (agent === "codex") {
|
|
222
|
+
relative = ".codex/config.toml";
|
|
223
|
+
assertSafeProjectPath(repo, path.join(repo, relative));
|
|
224
|
+
remains = updateTomlMcp(path.join(repo, relative), remove);
|
|
225
|
+
}
|
|
226
|
+
else if (agent === "gemini") {
|
|
227
|
+
relative = ".gemini/settings.json";
|
|
228
|
+
assertSafeProjectPath(repo, path.join(repo, relative));
|
|
229
|
+
remains = updateJsonMcp(path.join(repo, relative), remove);
|
|
230
|
+
}
|
|
231
|
+
else if (agent === "antigravity") {
|
|
232
|
+
relative = ".agents/mcp_config.json";
|
|
233
|
+
assertSafeProjectPath(repo, path.join(repo, relative));
|
|
234
|
+
remains = updateJsonMcp(path.join(repo, relative), remove);
|
|
235
|
+
}
|
|
236
|
+
if (relative && remains)
|
|
237
|
+
projectFiles.push(relative);
|
|
238
|
+
if (agent === "claude" && options.scope === "team" && !configured.includes(agent))
|
|
239
|
+
configured.push(agent);
|
|
240
|
+
if (agent !== "claude" && !remove && !configured.includes(agent))
|
|
241
|
+
configured.push(agent);
|
|
242
|
+
if (remove && !removed.includes(agent))
|
|
243
|
+
removed.push(agent);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
failed.push({ agent, message: errorMessage(error) });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { configured, removed, failed, projectFiles };
|
|
250
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const REFRESH_TIMEOUT_MS = 30_000;
|
|
5
|
+
function defaultRuntime() {
|
|
6
|
+
return {
|
|
7
|
+
commandExists: (command) => {
|
|
8
|
+
const probe = spawnSync(command, ["--help"], { stdio: "ignore", timeout: 5_000 });
|
|
9
|
+
return (probe.status !== null ||
|
|
10
|
+
probe.error?.code !== "ENOENT");
|
|
11
|
+
},
|
|
12
|
+
run: (command, cwd) => {
|
|
13
|
+
const startedAt = performance.now();
|
|
14
|
+
const result = spawnSync(command[0], command.slice(1), {
|
|
15
|
+
stdio: "ignore",
|
|
16
|
+
timeout: REFRESH_TIMEOUT_MS,
|
|
17
|
+
cwd,
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
exitCode: result.status,
|
|
21
|
+
elapsedMs: Math.round(performance.now() - startedAt),
|
|
22
|
+
timedOut: result.signal === "SIGTERM" || result.signal === "SIGKILL",
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Explicitly refresh supported local, external graph artifacts. This is never a
|
|
29
|
+
* commit hook: callers opt in, each rebuild is time-bounded, and unavailable
|
|
30
|
+
* tools are recorded as skipped rather than being represented as fresh.
|
|
31
|
+
*/
|
|
32
|
+
export function refreshExternalGraphArtifacts(repoPath, event, runtime = defaultRuntime()) {
|
|
33
|
+
const repo = path.resolve(repoPath);
|
|
34
|
+
const gitNexusRunner = path.join(repo, ".gitnexus", "run.cjs");
|
|
35
|
+
const commands = [
|
|
36
|
+
fs.existsSync(gitNexusRunner)
|
|
37
|
+
? {
|
|
38
|
+
artifact: "gitnexus",
|
|
39
|
+
command: ["node", gitNexusRunner, "analyze"],
|
|
40
|
+
requiredCommand: "node",
|
|
41
|
+
}
|
|
42
|
+
: {
|
|
43
|
+
artifact: "gitnexus",
|
|
44
|
+
command: ["gitnexus", "analyze", repo],
|
|
45
|
+
requiredCommand: "gitnexus",
|
|
46
|
+
},
|
|
47
|
+
{ artifact: "graphify", command: ["graphify", "update", repo], requiredCommand: "graphify" },
|
|
48
|
+
];
|
|
49
|
+
const artifacts = commands.map(({ artifact, command, requiredCommand }) => {
|
|
50
|
+
if (!runtime.commandExists(requiredCommand)) {
|
|
51
|
+
return { artifact, state: "skipped", reason: `${artifact} is not installed` };
|
|
52
|
+
}
|
|
53
|
+
const result = runtime.run(command, repo);
|
|
54
|
+
if (result.exitCode === 0) {
|
|
55
|
+
return { artifact, state: "success", command, elapsedMs: result.elapsedMs };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
artifact,
|
|
59
|
+
state: "failed",
|
|
60
|
+
command,
|
|
61
|
+
elapsedMs: result.elapsedMs,
|
|
62
|
+
reason: result.timedOut
|
|
63
|
+
? `timed out after ${REFRESH_TIMEOUT_MS}ms`
|
|
64
|
+
: `exited ${result.exitCode ?? "unknown"}`,
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
const successes = artifacts.filter((artifact) => artifact.state === "success").length;
|
|
68
|
+
const failures = artifacts.filter((artifact) => artifact.state === "failed").length;
|
|
69
|
+
const status = failures > 0 && successes === 0
|
|
70
|
+
? "failed"
|
|
71
|
+
: failures > 0 || successes > 0
|
|
72
|
+
? "partial"
|
|
73
|
+
: "skipped";
|
|
74
|
+
return { event, status, artifacts };
|
|
75
|
+
}
|
|
76
|
+
/** Persist an audit record locally; no source content or tool output is retained. */
|
|
77
|
+
export function writeArtifactRefreshRecord(repoPath, result) {
|
|
78
|
+
const knodinDir = path.join(path.resolve(repoPath), ".reckon");
|
|
79
|
+
fs.mkdirSync(knodinDir, { recursive: true });
|
|
80
|
+
fs.appendFileSync(path.join(knodinDir, "artifact-refresh.jsonl"), `${JSON.stringify({ at: new Date().toISOString(), ...result })}\n`, "utf8");
|
|
81
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI argument & repo-target resolution for the `knodin` CLI (R19).
|
|
3
|
+
*
|
|
4
|
+
* The MCP gateway takes an explicit `repoPath`; the CLI historically fell back
|
|
5
|
+
* to `process.cwd()` with no equivalent flag, so `knodin index /other/repo` from
|
|
6
|
+
* the wrong cwd silently indexed nothing and exited 0. These pure helpers give
|
|
7
|
+
* the CLI one unambiguous way to target a repo (`--repo`) and make every no-op
|
|
8
|
+
* fail loudly. Kept side-effect-free (only stat the filesystem) so bin/cli.ts is
|
|
9
|
+
* a thin caller and the logic is unit-testable without an engine or database.
|
|
10
|
+
*/
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
const GLOBAL_VALUE_FLAGS = new Set([
|
|
15
|
+
"--identity",
|
|
16
|
+
"--file",
|
|
17
|
+
"--kind",
|
|
18
|
+
"--to-identity",
|
|
19
|
+
"--to-file",
|
|
20
|
+
"--to-kind",
|
|
21
|
+
"--bytes",
|
|
22
|
+
"--tokens",
|
|
23
|
+
"--items",
|
|
24
|
+
"--impact-mode",
|
|
25
|
+
"--direction",
|
|
26
|
+
"--depth",
|
|
27
|
+
"--relations",
|
|
28
|
+
"--min-confidence",
|
|
29
|
+
"--limit",
|
|
30
|
+
]);
|
|
31
|
+
const GLOBAL_BOOLEAN_FLAGS = new Set(["--exclude-tests", "--data-flow", "--json"]);
|
|
32
|
+
const isDir = (p) => {
|
|
33
|
+
try {
|
|
34
|
+
return fs.statSync(p).isDirectory();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const RUNTIME_MODULE_FLAGS = new Set([
|
|
41
|
+
"--import",
|
|
42
|
+
"--loader",
|
|
43
|
+
"--experimental-loader",
|
|
44
|
+
"--require",
|
|
45
|
+
"-r",
|
|
46
|
+
]);
|
|
47
|
+
function resolveRuntimeModuleReference(value, cwd, resolveModule) {
|
|
48
|
+
if (path.isAbsolute(value) || /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value))
|
|
49
|
+
return value;
|
|
50
|
+
if (value.startsWith("."))
|
|
51
|
+
return path.resolve(cwd, value);
|
|
52
|
+
return resolveModule(value, cwd);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Reconstruct the executable command that launched this CLI. Source-checkout
|
|
56
|
+
* invocations need loader arguments such as `node --import tsx`; dropping
|
|
57
|
+
* `execArgv` creates a background hook that exists but cannot start. Bare
|
|
58
|
+
* loader names are resolved now because the hook later runs from another repo.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveCliRuntimeCommand(runtime, resolveModule = (specifier, cwd) => createRequire(path.join(cwd, "package.json")).resolve(specifier)) {
|
|
61
|
+
const entry = runtime.argv[1];
|
|
62
|
+
if (!entry)
|
|
63
|
+
throw new Error("knodin: CLI entry path is unavailable");
|
|
64
|
+
const cwd = runtime.cwd();
|
|
65
|
+
const runtimeArguments = [];
|
|
66
|
+
for (let index = 0; index < runtime.execArgv.length; index++) {
|
|
67
|
+
const argument = runtime.execArgv[index];
|
|
68
|
+
if (RUNTIME_MODULE_FLAGS.has(argument)) {
|
|
69
|
+
const value = runtime.execArgv[index + 1];
|
|
70
|
+
if (!value)
|
|
71
|
+
throw new Error(`knodin: ${argument} requires a runtime module`);
|
|
72
|
+
runtimeArguments.push(argument, resolveRuntimeModuleReference(value, cwd, resolveModule));
|
|
73
|
+
index++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const equalsFlag = [...RUNTIME_MODULE_FLAGS].find((flag) => argument.startsWith(`${flag}=`));
|
|
77
|
+
if (equalsFlag) {
|
|
78
|
+
const value = argument.slice(equalsFlag.length + 1);
|
|
79
|
+
if (!value)
|
|
80
|
+
throw new Error(`knodin: ${equalsFlag} requires a runtime module`);
|
|
81
|
+
runtimeArguments.push(`${equalsFlag}=${resolveRuntimeModuleReference(value, cwd, resolveModule)}`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
runtimeArguments.push(argument);
|
|
85
|
+
}
|
|
86
|
+
return [path.resolve(cwd, runtime.execPath), ...runtimeArguments, path.resolve(cwd, entry)];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Pull a global `--repo <path>` / `--repo=<path>` out of an argv slice and
|
|
90
|
+
* move any other documented global flags before the subcommand to immediately
|
|
91
|
+
* after it. This preserves the existing command parsers while accepting both
|
|
92
|
+
* conventional `knodin --file x explain y` and `knodin explain y --file x`.
|
|
93
|
+
*/
|
|
94
|
+
export function extractRepoFlag(argv) {
|
|
95
|
+
const rest = [];
|
|
96
|
+
const leadingGlobalOptions = [];
|
|
97
|
+
let repoFlag;
|
|
98
|
+
let commandSeen = false;
|
|
99
|
+
for (let i = 0; i < argv.length; i++) {
|
|
100
|
+
const a = argv[i];
|
|
101
|
+
if (a === "--repo") {
|
|
102
|
+
repoFlag = argv[i + 1] ?? "";
|
|
103
|
+
i++; // consume the value
|
|
104
|
+
}
|
|
105
|
+
else if (a.startsWith("--repo=")) {
|
|
106
|
+
repoFlag = a.slice("--repo=".length);
|
|
107
|
+
}
|
|
108
|
+
else if (!commandSeen && GLOBAL_VALUE_FLAGS.has(a)) {
|
|
109
|
+
leadingGlobalOptions.push(a);
|
|
110
|
+
if (argv[i + 1] !== undefined)
|
|
111
|
+
leadingGlobalOptions.push(argv[++i]);
|
|
112
|
+
}
|
|
113
|
+
else if (!commandSeen && GLOBAL_BOOLEAN_FLAGS.has(a)) {
|
|
114
|
+
leadingGlobalOptions.push(a);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
rest.push(a);
|
|
118
|
+
commandSeen = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
rest.push(...leadingGlobalOptions);
|
|
122
|
+
return { repoFlag, rest };
|
|
123
|
+
}
|
|
124
|
+
/** Return positional arguments without mistaking recognized option values for paths. */
|
|
125
|
+
export function extractPositionals(args) {
|
|
126
|
+
const positionals = [];
|
|
127
|
+
for (let index = 0; index < args.length; index++) {
|
|
128
|
+
const argument = args[index];
|
|
129
|
+
if (argument.startsWith("--")) {
|
|
130
|
+
if (!argument.includes("=") && GLOBAL_VALUE_FLAGS.has(argument))
|
|
131
|
+
index++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
positionals.push(argument);
|
|
135
|
+
}
|
|
136
|
+
return positionals;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the target repo: an explicit `--repo` (validated as an existing
|
|
140
|
+
* directory) wins over `cwd`. An absent flag falls back to `cwd`.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveRepo(repoFlag, cwd) {
|
|
143
|
+
if (repoFlag === undefined)
|
|
144
|
+
return { ok: true, repo: cwd };
|
|
145
|
+
if (repoFlag === "")
|
|
146
|
+
return { ok: false, error: "knodin: --repo requires a <path>" };
|
|
147
|
+
const repo = path.resolve(cwd, repoFlag);
|
|
148
|
+
if (!fs.existsSync(repo)) {
|
|
149
|
+
return { ok: false, error: `knodin: --repo path does not exist: ${repo}` };
|
|
150
|
+
}
|
|
151
|
+
if (!isDir(repo)) {
|
|
152
|
+
return { ok: false, error: `knodin: --repo path is not a directory: ${repo}` };
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, repo };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Decide what `knodin index [positionals...]` actually targets, closing the
|
|
158
|
+
* silent-no-op hole:
|
|
159
|
+
* - a lone directory positional with no `--repo` IS the repo (full reindex) —
|
|
160
|
+
* this is the observed-bug fix: `knodin index /other/repo` now indexes it;
|
|
161
|
+
* - any other directory positional is rejected, pointing at `--repo`;
|
|
162
|
+
* - file positionals resolving outside the target repo are rejected loudly;
|
|
163
|
+
* - otherwise the positionals are files to index within the resolved repo.
|
|
164
|
+
*/
|
|
165
|
+
export function planIndex(repoFlag, positionals, cwd) {
|
|
166
|
+
// Case A: no --repo and a single directory positional => that directory is
|
|
167
|
+
// the repo to index, not a file. Absorb it as the target.
|
|
168
|
+
if (repoFlag === undefined && positionals.length === 1) {
|
|
169
|
+
const abs = path.resolve(cwd, positionals[0]);
|
|
170
|
+
if (isDir(abs)) {
|
|
171
|
+
return { ok: true, repo: abs, files: undefined };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const resolved = resolveRepo(repoFlag, cwd);
|
|
175
|
+
if (!resolved.ok)
|
|
176
|
+
return resolved;
|
|
177
|
+
const repo = resolved.repo;
|
|
178
|
+
if (positionals.length === 0) {
|
|
179
|
+
return { ok: true, repo, files: undefined }; // full reindex
|
|
180
|
+
}
|
|
181
|
+
// Case B: positionals are files. Validate each is a within-repo non-directory.
|
|
182
|
+
const rejectedDirs = [];
|
|
183
|
+
const rejectedOutside = [];
|
|
184
|
+
const files = [];
|
|
185
|
+
for (const p of positionals) {
|
|
186
|
+
const abs = path.resolve(repo, p);
|
|
187
|
+
const rel = path.relative(repo, abs);
|
|
188
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
189
|
+
rejectedOutside.push(p);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (isDir(abs)) {
|
|
193
|
+
rejectedDirs.push(p);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
files.push(p);
|
|
197
|
+
}
|
|
198
|
+
if (rejectedOutside.length > 0 || rejectedDirs.length > 0) {
|
|
199
|
+
const parts = [];
|
|
200
|
+
if (rejectedOutside.length > 0) {
|
|
201
|
+
parts.push(`outside repo (${rejectedOutside.join(", ")})`);
|
|
202
|
+
}
|
|
203
|
+
if (rejectedDirs.length > 0) {
|
|
204
|
+
const hint = repoFlag === undefined
|
|
205
|
+
? "use --repo to index a whole repo"
|
|
206
|
+
: "pass files, not directories, alongside --repo";
|
|
207
|
+
parts.push(`directories — ${hint} (${rejectedDirs.join(", ")})`);
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
error: `knodin index: refusing to index against ${repo}; rejected ${parts.join("; ")}`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return { ok: true, repo, files };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Guard against reporting success for a no-op: an index run that touched zero
|
|
218
|
+
* files must exit non-zero with a diagnostic naming the repo it actually used.
|
|
219
|
+
*/
|
|
220
|
+
export function checkIndexed(indexed, repo) {
|
|
221
|
+
if (!indexed || indexed.length === 0) {
|
|
222
|
+
return { ok: false, error: `knodin index: indexed 0 files in ${repo} (nothing matched)` };
|
|
223
|
+
}
|
|
224
|
+
return { ok: true };
|
|
225
|
+
}
|
|
226
|
+
/** Pure parser for `knodin review`, shared by the executable and CLI contract tests. */
|
|
227
|
+
export function parseReviewArgs(args) {
|
|
228
|
+
const value = (flag) => {
|
|
229
|
+
const index = args.indexOf(flag);
|
|
230
|
+
if (index < 0)
|
|
231
|
+
return undefined;
|
|
232
|
+
const candidate = args[index + 1];
|
|
233
|
+
if (!candidate || candidate.startsWith("--")) {
|
|
234
|
+
throw new Error(`knodin review: ${flag} requires a value`);
|
|
235
|
+
}
|
|
236
|
+
return candidate;
|
|
237
|
+
};
|
|
238
|
+
const rawScope = value("--scope");
|
|
239
|
+
const scopes = ["unstaged", "staged", "all", "compare"];
|
|
240
|
+
if (rawScope && !scopes.includes(rawScope)) {
|
|
241
|
+
throw new Error(`invalid review scope: ${rawScope}`);
|
|
242
|
+
}
|
|
243
|
+
const optionValues = new Set([...GLOBAL_VALUE_FLAGS, "--scope", "--from", "--to", "--files"]);
|
|
244
|
+
const positionals = args.filter((arg, index) => {
|
|
245
|
+
if (arg.startsWith("--"))
|
|
246
|
+
return false;
|
|
247
|
+
return index === 0 || !optionValues.has(args[index - 1]);
|
|
248
|
+
});
|
|
249
|
+
const filesValue = value("--files");
|
|
250
|
+
const files = filesValue
|
|
251
|
+
?.split(",")
|
|
252
|
+
.map((file) => file.trim())
|
|
253
|
+
.filter(Boolean);
|
|
254
|
+
if (filesValue !== undefined && files?.length === 0) {
|
|
255
|
+
throw new Error("knodin review: --files requires at least one repo-relative path");
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
base: positionals.find((item) => item !== "minimal") ?? "HEAD~1",
|
|
259
|
+
detailLevel: positionals.includes("minimal") ? "minimal" : "standard",
|
|
260
|
+
options: {
|
|
261
|
+
scope: rawScope,
|
|
262
|
+
from: value("--from"),
|
|
263
|
+
to: value("--to"),
|
|
264
|
+
files,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|