@devflow-tools/cli 0.15.0 → 0.16.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/dist/commands/benchmark-cmd.d.ts +17 -4
- package/dist/commands/benchmark-cmd.d.ts.map +1 -1
- package/dist/commands/benchmark-cmd.js +71 -14
- package/dist/commands/benchmark-cmd.js.map +1 -1
- package/dist/commands/connect.d.ts +0 -1
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +4 -10
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/context.d.ts +2 -2
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +5 -3
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/db.d.ts +25 -0
- package/dist/commands/db.d.ts.map +1 -0
- package/dist/commands/db.js +258 -0
- package/dist/commands/db.js.map +1 -0
- package/dist/commands/docs.d.ts +20 -0
- package/dist/commands/docs.d.ts.map +1 -0
- package/dist/commands/docs.js +37 -0
- package/dist/commands/docs.js.map +1 -0
- package/dist/commands/doctor.d.ts +59 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +3912 -50
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/index-cmd.d.ts +1 -0
- package/dist/commands/index-cmd.d.ts.map +1 -1
- package/dist/commands/index-cmd.js +7 -0
- package/dist/commands/index-cmd.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +3 -6
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/knowledge.d.ts +2 -1
- package/dist/commands/knowledge.d.ts.map +1 -1
- package/dist/commands/knowledge.js +11 -8
- package/dist/commands/knowledge.js.map +1 -1
- package/dist/commands/memory.d.ts +17 -1
- package/dist/commands/memory.d.ts.map +1 -1
- package/dist/commands/memory.js +22 -3
- package/dist/commands/memory.js.map +1 -1
- package/dist/commands/plugin-loader.d.ts +1 -1
- package/dist/commands/plugin-loader.d.ts.map +1 -1
- package/dist/commands/plugin-loader.js +1 -1
- package/dist/commands/plugin-loader.js.map +1 -1
- package/dist/commands/plugin-memory-hints.d.ts +23 -0
- package/dist/commands/plugin-memory-hints.d.ts.map +1 -0
- package/dist/commands/plugin-memory-hints.js +58 -0
- package/dist/commands/plugin-memory-hints.js.map +1 -0
- package/dist/commands/reset.d.ts +32 -0
- package/dist/commands/reset.d.ts.map +1 -0
- package/dist/commands/reset.js +316 -0
- package/dist/commands/reset.js.map +1 -0
- package/dist/commands/session.d.ts.map +1 -1
- package/dist/commands/session.js +2 -1
- package/dist/commands/session.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +33 -6
- package/dist/commands/status.js.map +1 -1
- package/dist/index.js +225 -96
- package/dist/index.js.map +1 -1
- package/dist/lib/plugin-setup.d.ts.map +1 -1
- package/dist/lib/plugin-setup.js +37 -2
- package/dist/lib/plugin-setup.js.map +1 -1
- package/dist/plugin-files/.claude-plugin/plugin.json +1 -1
- package/dist/plugin-files/dist/command-registry.json +34 -36
- package/dist/plugin-files/dist/hooks/enforcer-summary.js +6 -0
- package/dist/plugin-files/dist/hooks/hook-client.js +6 -0
- package/dist/plugin-files/dist/hooks/hook-daemon.js +14 -0
- package/dist/plugin-files/dist/hooks/post-tool-use-failure.js +2 -0
- package/dist/plugin-files/dist/hooks/post-tool-use.js +3 -0
- package/dist/plugin-files/dist/hooks/pre-compact.js +3 -0
- package/dist/plugin-files/dist/hooks/pre-tool-use.js +2 -0
- package/dist/plugin-files/dist/hooks/server-bootstrap.js +109 -0
- package/dist/plugin-files/dist/hooks/session-end.js +2 -0
- package/dist/plugin-files/dist/hooks/stop.js +2 -0
- package/dist/plugin-files/dist/hooks/user-prompt-submit.js +2 -0
- package/dist/plugin-files/dist/skills/devflow:graphql/SKILL.md +2 -4
- package/dist/plugin-files/dist/skills/devflow:nest/SKILL.md +4 -4
- package/dist/plugin-files/dist/skills/devflow:nextjs/SKILL.md +4 -4
- package/dist/plugin-files/dist/skills/devflow:react/SKILL.md +6 -8
- package/dist/plugin-files/dist/skills/devflow:tailwind/SKILL.md +4 -2
- package/dist/plugin-files/dist/skills/devflow:vue/SKILL.md +2 -4
- package/dist/plugin-files/hooks/post-tool-use +2 -2
- package/dist/plugin-files/hooks/post-tool-use-failure +2 -2
- package/dist/plugin-files/hooks/pre-compact +2 -2
- package/dist/plugin-files/hooks/pre-tool-use +4 -4
- package/dist/plugin-files/hooks/session-end +2 -2
- package/dist/plugin-files/hooks/session-start +160 -77
- package/dist/plugin-files/hooks/stop +2 -8
- package/dist/plugin-files/hooks/user-prompt-submit +2 -2
- package/dist/plugin-files/package.json +13 -2
- package/package.json +27 -27
- package/dist/commands/dashboard.d.ts +0 -3
- package/dist/commands/dashboard.d.ts.map +0 -1
- package/dist/commands/dashboard.js +0 -10
- package/dist/commands/dashboard.js.map +0 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -1,45 +1,3920 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { createRequire, isBuiltin } from "node:module";
|
|
5
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import chalk from "chalk";
|
|
4
9
|
import { serverStatus } from "./server.js";
|
|
5
|
-
|
|
6
|
-
|
|
10
|
+
import { getGlobalDevFlowDbPath } from "@devflow-tools/database";
|
|
11
|
+
function getProjectHash(rootPath) {
|
|
12
|
+
return createHash("sha256").update(rootPath).digest("hex").slice(0, 16);
|
|
13
|
+
}
|
|
14
|
+
function getDaemonSocketPath(rootPath) {
|
|
15
|
+
return join(homedir(), ".devflow", "daemons", `${getProjectHash(rootPath)}.sock`);
|
|
16
|
+
}
|
|
17
|
+
function getDaemonRegistryPath() {
|
|
18
|
+
return join(homedir(), ".devflow", "daemons.json");
|
|
19
|
+
}
|
|
20
|
+
let databaseSyncConstructor;
|
|
21
|
+
function loadDatabaseSync() {
|
|
22
|
+
if (databaseSyncConstructor)
|
|
23
|
+
return databaseSyncConstructor;
|
|
24
|
+
try {
|
|
25
|
+
const sqlite = createRequire(import.meta.url)("node:sqlite");
|
|
26
|
+
if (typeof sqlite.DatabaseSync !== "function")
|
|
27
|
+
throw new Error("DatabaseSync is unavailable");
|
|
28
|
+
databaseSyncConstructor = sqlite.DatabaseSync;
|
|
29
|
+
return databaseSyncConstructor;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new Error("DevFlow doctor requires Node.js >=22.13.0 to inspect SQLite memory data (node:sqlite is unavailable).");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function sectionStatus(metrics) {
|
|
36
|
+
if (metrics.some((metric) => !metric.ok && metric.severity === "error")) {
|
|
37
|
+
return "red";
|
|
38
|
+
}
|
|
39
|
+
if (metrics.some((metric) => !metric.ok)) {
|
|
40
|
+
return "yellow";
|
|
41
|
+
}
|
|
42
|
+
return "green";
|
|
43
|
+
}
|
|
44
|
+
function canonicalizeProject(project) {
|
|
45
|
+
const absolute = resolve(project);
|
|
46
|
+
try {
|
|
47
|
+
return { path: realpathSync.native(absolute) };
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return { path: absolute, error: error.message };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function daemonRegistryEntryIssues(value) {
|
|
54
|
+
if (typeof value !== "object" || value === null)
|
|
55
|
+
return ["entry must be an object"];
|
|
56
|
+
const entry = value;
|
|
57
|
+
const issues = [];
|
|
58
|
+
if (typeof entry.rootPath !== "string" || entry.rootPath.length === 0) {
|
|
59
|
+
issues.push("rootPath must be a nonempty string");
|
|
60
|
+
}
|
|
61
|
+
if (typeof entry.projectHash !== "string" || entry.projectHash.length === 0) {
|
|
62
|
+
issues.push("projectHash must be a nonempty string");
|
|
63
|
+
}
|
|
64
|
+
if (typeof entry.pid !== "number" || !Number.isInteger(entry.pid) || entry.pid <= 0) {
|
|
65
|
+
issues.push("pid must be a positive integer");
|
|
66
|
+
}
|
|
67
|
+
if (typeof entry.startedAt !== "number"
|
|
68
|
+
|| !Number.isFinite(entry.startedAt)
|
|
69
|
+
|| entry.startedAt < 0) {
|
|
70
|
+
issues.push("startedAt must be a finite nonnegative number");
|
|
71
|
+
}
|
|
72
|
+
if (typeof entry.rootPath === "string"
|
|
73
|
+
&& entry.rootPath.length > 0
|
|
74
|
+
&& typeof entry.projectHash === "string"
|
|
75
|
+
&& entry.projectHash.length > 0
|
|
76
|
+
&& entry.projectHash !== getProjectHash(entry.rootPath)) {
|
|
77
|
+
issues.push("projectHash does not match rootPath");
|
|
78
|
+
}
|
|
79
|
+
return issues;
|
|
80
|
+
}
|
|
81
|
+
function checkIsolation(project, dependencies) {
|
|
82
|
+
const canonical = canonicalizeProject(project);
|
|
83
|
+
const hash = getProjectHash(canonical.path);
|
|
84
|
+
const socketPath = getDaemonSocketPath(canonical.path);
|
|
85
|
+
const metrics = [
|
|
86
|
+
{
|
|
87
|
+
key: "canonical_project",
|
|
88
|
+
value: canonical.path,
|
|
89
|
+
target: "existing canonical path",
|
|
90
|
+
ok: canonical.error === undefined,
|
|
91
|
+
severity: canonical.error ? "error" : "info",
|
|
92
|
+
},
|
|
93
|
+
{ key: "project_hash", value: hash, ok: true, severity: "info" },
|
|
94
|
+
{
|
|
95
|
+
key: "daemon_socket_path",
|
|
96
|
+
value: socketPath,
|
|
97
|
+
target: `path containing ${hash}`,
|
|
98
|
+
ok: socketPath.includes(hash),
|
|
99
|
+
severity: "error",
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
const registryPath = dependencies.registryPath ?? getDaemonRegistryPath();
|
|
103
|
+
if (!existsSync(registryPath)) {
|
|
104
|
+
metrics.push({
|
|
105
|
+
key: "daemon_registry",
|
|
106
|
+
value: "not found",
|
|
107
|
+
target: "valid JSON when present",
|
|
108
|
+
ok: true,
|
|
109
|
+
severity: "info",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
115
|
+
if (!Array.isArray(parsed)) {
|
|
116
|
+
throw new Error("registry must be an array");
|
|
117
|
+
}
|
|
118
|
+
const invalidEntries = parsed.filter((entry) => daemonRegistryEntryIssues(entry).length > 0);
|
|
119
|
+
metrics.push({
|
|
120
|
+
key: "daemon_registry",
|
|
121
|
+
value: invalidEntries.length === 0 ? "valid" : "malformed",
|
|
122
|
+
target: "valid daemon entry array",
|
|
123
|
+
ok: invalidEntries.length === 0,
|
|
124
|
+
severity: invalidEntries.length === 0 ? "info" : "warning",
|
|
125
|
+
});
|
|
126
|
+
metrics.push({
|
|
127
|
+
key: "invalid_daemon_registry_entries",
|
|
128
|
+
value: invalidEntries.length,
|
|
129
|
+
target: 0,
|
|
130
|
+
ok: invalidEntries.length === 0,
|
|
131
|
+
severity: "warning",
|
|
132
|
+
});
|
|
133
|
+
const projectEntries = parsed.filter((value) => {
|
|
134
|
+
if (typeof value !== "object" || value === null)
|
|
135
|
+
return false;
|
|
136
|
+
const entry = value;
|
|
137
|
+
if (typeof entry.rootPath !== "string" || entry.rootPath.length === 0)
|
|
138
|
+
return false;
|
|
139
|
+
return canonicalizeProject(entry.rootPath).path === canonical.path;
|
|
140
|
+
});
|
|
141
|
+
metrics.push({
|
|
142
|
+
key: "project_daemon_registrations",
|
|
143
|
+
value: projectEntries.length,
|
|
144
|
+
ok: true,
|
|
145
|
+
severity: "info",
|
|
146
|
+
});
|
|
147
|
+
const registeredSocketPaths = projectEntries.map((entry) => getDaemonSocketPath(entry.rootPath));
|
|
148
|
+
metrics.push({
|
|
149
|
+
key: "registered_daemon_socket_paths",
|
|
150
|
+
value: registeredSocketPaths,
|
|
151
|
+
target: [socketPath],
|
|
152
|
+
ok: true,
|
|
153
|
+
severity: "info",
|
|
154
|
+
});
|
|
155
|
+
const identityMismatches = projectEntries.filter((entry, index) => (entry.projectHash !== hash || registeredSocketPaths[index] !== socketPath)).length;
|
|
156
|
+
metrics.push({
|
|
157
|
+
key: "daemon_identity_mismatches",
|
|
158
|
+
value: identityMismatches,
|
|
159
|
+
target: 0,
|
|
160
|
+
ok: identityMismatches === 0,
|
|
161
|
+
severity: "warning",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
metrics.push({
|
|
166
|
+
key: "daemon_registry",
|
|
167
|
+
value: "malformed",
|
|
168
|
+
target: "valid daemon entry array",
|
|
169
|
+
ok: false,
|
|
170
|
+
severity: "warning",
|
|
171
|
+
});
|
|
172
|
+
metrics.push({
|
|
173
|
+
key: "invalid_daemon_registry_entries",
|
|
174
|
+
value: 1,
|
|
175
|
+
target: 0,
|
|
176
|
+
ok: false,
|
|
177
|
+
severity: "warning",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { name: "Isolation", status: sectionStatus(metrics), metrics };
|
|
182
|
+
}
|
|
183
|
+
const REQUIRED_SCHEMA = {
|
|
184
|
+
distill_leases: ["event_ids", "expires_at", "status"],
|
|
185
|
+
observations: ["title"],
|
|
186
|
+
session_events: ["created_at", "id", "processed"],
|
|
187
|
+
};
|
|
188
|
+
function checkRequiredSchema(db) {
|
|
189
|
+
const tableRows = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all();
|
|
190
|
+
const tables = new Set(tableRows.map((row) => row.name));
|
|
191
|
+
const missing = [];
|
|
192
|
+
for (const [table, requiredColumns] of Object.entries(REQUIRED_SCHEMA)) {
|
|
193
|
+
if (!tables.has(table)) {
|
|
194
|
+
missing.push(table);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const columns = new Set(db.prepare(`PRAGMA table_info(${table})`).all()
|
|
198
|
+
.map((row) => row.name));
|
|
199
|
+
for (const column of requiredColumns) {
|
|
200
|
+
if (!columns.has(column))
|
|
201
|
+
missing.push(`${table}.${column}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return missing.sort();
|
|
205
|
+
}
|
|
206
|
+
function parseLeaseStringArray(value) {
|
|
207
|
+
if (typeof value !== "string")
|
|
208
|
+
return null;
|
|
209
|
+
try {
|
|
210
|
+
const parsed = JSON.parse(value);
|
|
211
|
+
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string" && entry.trim().length > 0) || new Set(parsed).size !== parsed.length) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
return parsed;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function parseLeaseEventIds(value) {
|
|
221
|
+
const eventIds = parseLeaseStringArray(value);
|
|
222
|
+
return eventIds && eventIds.length > 0 ? eventIds : null;
|
|
223
|
+
}
|
|
224
|
+
function parseLeaseInteger(value) {
|
|
225
|
+
if (typeof value === "bigint") {
|
|
226
|
+
return value >= 0n && value <= BigInt(Number.MAX_SAFE_INTEGER) ? value : null;
|
|
227
|
+
}
|
|
228
|
+
if (typeof value !== "number")
|
|
229
|
+
return null;
|
|
230
|
+
return Number.isSafeInteger(value) && value >= 0
|
|
231
|
+
? value
|
|
232
|
+
: null;
|
|
233
|
+
}
|
|
234
|
+
function leaseIntegerEquals(value, expected) {
|
|
235
|
+
return typeof value === "bigint" ? value === BigInt(expected) : value === expected;
|
|
236
|
+
}
|
|
237
|
+
function timestampIsAfter(value, timestamp) {
|
|
238
|
+
return typeof value === "bigint"
|
|
239
|
+
? value > BigInt(Math.trunc(timestamp))
|
|
240
|
+
: value > timestamp;
|
|
241
|
+
}
|
|
242
|
+
const LEASE_STATUSES = new Set(["pending", "committed", "expired"]);
|
|
243
|
+
function checkDistill(project, dependencies) {
|
|
244
|
+
const metrics = [];
|
|
245
|
+
const dbPath = join(project, ".devflow", "memory", "memories.db");
|
|
246
|
+
if (!existsSync(dbPath)) {
|
|
247
|
+
metrics.push({
|
|
248
|
+
key: "memories_db",
|
|
249
|
+
value: "missing",
|
|
250
|
+
target: `initialize ${dbPath} with devflow init`,
|
|
251
|
+
ok: false,
|
|
252
|
+
severity: "error",
|
|
253
|
+
});
|
|
254
|
+
return { name: "Distill", status: sectionStatus(metrics), metrics };
|
|
255
|
+
}
|
|
256
|
+
metrics.push({
|
|
257
|
+
key: "memories_db",
|
|
258
|
+
value: dbPath,
|
|
259
|
+
target: "readable memory database",
|
|
260
|
+
ok: true,
|
|
261
|
+
severity: "info",
|
|
262
|
+
});
|
|
263
|
+
const openDatabase = dependencies.openDatabase
|
|
264
|
+
?? ((path) => new (loadDatabaseSync())(path, { readOnly: true }));
|
|
265
|
+
let db;
|
|
266
|
+
try {
|
|
267
|
+
const openedDb = openDatabase(dbPath);
|
|
268
|
+
db = openedDb;
|
|
269
|
+
const missingSchema = checkRequiredSchema(openedDb);
|
|
270
|
+
if (missingSchema.length > 0) {
|
|
271
|
+
metrics.push({
|
|
272
|
+
key: "required_schema",
|
|
273
|
+
value: missingSchema,
|
|
274
|
+
target: [],
|
|
275
|
+
ok: false,
|
|
276
|
+
severity: "error",
|
|
277
|
+
});
|
|
278
|
+
return { name: "Distill", status: sectionStatus(metrics), metrics };
|
|
279
|
+
}
|
|
280
|
+
const now = (dependencies.now ?? Date.now)();
|
|
281
|
+
const leaseStatement = openedDb.prepare(`
|
|
282
|
+
SELECT batch_id, event_ids, session_ids, event_count, created_at, expires_at, status
|
|
283
|
+
FROM distill_leases
|
|
284
|
+
`);
|
|
285
|
+
leaseStatement.setReadBigInts?.(true);
|
|
286
|
+
const leaseRows = leaseStatement.all();
|
|
287
|
+
const leasedEventIds = new Set();
|
|
288
|
+
let malformedLeases = 0;
|
|
289
|
+
let malformedActiveLeases = 0;
|
|
290
|
+
for (const lease of leaseRows) {
|
|
291
|
+
const eventIds = parseLeaseEventIds(lease.event_ids);
|
|
292
|
+
const sessionIds = parseLeaseStringArray(lease.session_ids);
|
|
293
|
+
const eventCount = parseLeaseInteger(lease.event_count);
|
|
294
|
+
const createdAt = parseLeaseInteger(lease.created_at);
|
|
295
|
+
const expiresAt = parseLeaseInteger(lease.expires_at);
|
|
296
|
+
const status = typeof lease.status === "string" && LEASE_STATUSES.has(lease.status)
|
|
297
|
+
? lease.status
|
|
298
|
+
: null;
|
|
299
|
+
const batchIdIsValid = typeof lease.batch_id === "string"
|
|
300
|
+
&& lease.batch_id.trim().length > 0;
|
|
301
|
+
const eventCountMatches = eventIds !== null
|
|
302
|
+
&& eventCount !== null
|
|
303
|
+
&& leaseIntegerEquals(eventCount, eventIds.length);
|
|
304
|
+
if (!batchIdIsValid
|
|
305
|
+
|| !eventIds
|
|
306
|
+
|| !sessionIds
|
|
307
|
+
|| !eventCountMatches
|
|
308
|
+
|| createdAt === null
|
|
309
|
+
|| expiresAt === null
|
|
310
|
+
|| status === null) {
|
|
311
|
+
malformedLeases += 1;
|
|
312
|
+
if (lease.status === "pending"
|
|
313
|
+
&& (expiresAt === null || timestampIsAfter(expiresAt, now))) {
|
|
314
|
+
malformedActiveLeases += 1;
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (status !== "pending" || !timestampIsAfter(expiresAt, now))
|
|
319
|
+
continue;
|
|
320
|
+
for (const eventId of eventIds)
|
|
321
|
+
leasedEventIds.add(eventId);
|
|
322
|
+
}
|
|
323
|
+
metrics.push({
|
|
324
|
+
key: "malformed_distill_leases",
|
|
325
|
+
value: malformedLeases,
|
|
326
|
+
target: 0,
|
|
327
|
+
ok: malformedLeases === 0,
|
|
328
|
+
severity: malformedActiveLeases > 0 ? "error" : "warning",
|
|
329
|
+
});
|
|
330
|
+
const staleRows = openedDb.prepare(`
|
|
331
|
+
SELECT id
|
|
332
|
+
FROM session_events
|
|
333
|
+
WHERE processed = 0 AND created_at < ?
|
|
334
|
+
`).all(now - 5 * 60 * 1000);
|
|
335
|
+
const staleUnleased = staleRows.filter((row) => !leasedEventIds.has(row.id)).length;
|
|
336
|
+
metrics.push({
|
|
337
|
+
key: "unprocessed_stale_events",
|
|
338
|
+
value: staleUnleased,
|
|
339
|
+
target: 0,
|
|
340
|
+
ok: staleUnleased === 0,
|
|
341
|
+
severity: "error",
|
|
342
|
+
});
|
|
343
|
+
const duplicates = openedDb.prepare(`
|
|
344
|
+
SELECT COUNT(*) AS group_count, COALESCE(SUM(title_count), 0) AS row_count
|
|
345
|
+
FROM (
|
|
346
|
+
SELECT project, session_id, title, COUNT(*) AS title_count
|
|
347
|
+
FROM observations
|
|
348
|
+
GROUP BY project, session_id, title
|
|
349
|
+
HAVING COUNT(*) > 1
|
|
350
|
+
)
|
|
351
|
+
`).get();
|
|
352
|
+
metrics.push({
|
|
353
|
+
key: "duplicate_observation_title_groups",
|
|
354
|
+
value: Number(duplicates.group_count),
|
|
355
|
+
target: 0,
|
|
356
|
+
ok: Number(duplicates.group_count) === 0,
|
|
357
|
+
severity: "error",
|
|
358
|
+
});
|
|
359
|
+
metrics.push({
|
|
360
|
+
key: "duplicate_observation_title_rows",
|
|
361
|
+
value: Number(duplicates.row_count),
|
|
362
|
+
target: 0,
|
|
363
|
+
ok: Number(duplicates.row_count) === 0,
|
|
364
|
+
severity: "error",
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
metrics.push({
|
|
369
|
+
key: "database_read",
|
|
370
|
+
value: error.message,
|
|
371
|
+
target: "readable Task 2 memory schema",
|
|
372
|
+
ok: false,
|
|
373
|
+
severity: "error",
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
db?.close();
|
|
378
|
+
}
|
|
379
|
+
return { name: "Distill", status: sectionStatus(metrics), metrics };
|
|
380
|
+
}
|
|
381
|
+
function doctorOpenDatabase(path, dependencies) {
|
|
382
|
+
const openDatabase = dependencies.openDatabase
|
|
383
|
+
?? ((databasePath) => new (loadDatabaseSync())(databasePath, { readOnly: true }));
|
|
384
|
+
return openDatabase(path);
|
|
385
|
+
}
|
|
386
|
+
function doctorTableExists(db, table) {
|
|
387
|
+
return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(table));
|
|
388
|
+
}
|
|
389
|
+
function doctorCount(db, sql, ...params) {
|
|
390
|
+
const row = db.prepare(sql).get(...params);
|
|
391
|
+
return Number(row?.count ?? 0);
|
|
392
|
+
}
|
|
393
|
+
function coverage(total, vectors) {
|
|
394
|
+
return total === 0 ? 1 : vectors / total;
|
|
395
|
+
}
|
|
396
|
+
function checkVectors(project, dependencies) {
|
|
397
|
+
const metrics = [];
|
|
398
|
+
let codegraphNodes = null;
|
|
399
|
+
let symbolVectors = null;
|
|
400
|
+
const codegraphPath = join(project, ".codegraph", "codegraph.db");
|
|
401
|
+
if (!existsSync(codegraphPath)) {
|
|
402
|
+
metrics.push({ key: "codegraph_db", value: "missing", target: codegraphPath, ok: false, severity: "warning" });
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
let db;
|
|
406
|
+
try {
|
|
407
|
+
db = doctorOpenDatabase(codegraphPath, dependencies);
|
|
408
|
+
if (!doctorTableExists(db, "nodes")) {
|
|
409
|
+
metrics.push({ key: "codegraph_nodes", value: "nodes table missing", target: ">= 0", ok: false, severity: "warning" });
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
codegraphNodes = doctorCount(db, "SELECT COUNT(*) count FROM nodes");
|
|
413
|
+
metrics.push({ key: "codegraph_nodes", value: codegraphNodes, ok: true, severity: "info" });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
metrics.push({ key: "codegraph_db_read", value: error.message, target: "readable SQLite database", ok: false, severity: "error" });
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
db?.close();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const symbolsPath = join(project, ".devflow", "symbols-vec.db");
|
|
424
|
+
if (!existsSync(symbolsPath)) {
|
|
425
|
+
metrics.push({ key: "symbols_vectors_db", value: "missing", target: symbolsPath, ok: false, severity: "warning" });
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
let db;
|
|
429
|
+
try {
|
|
430
|
+
db = doctorOpenDatabase(symbolsPath, dependencies);
|
|
431
|
+
const vectorTable = doctorTableExists(db, "symbols_vec_rowids")
|
|
432
|
+
? "symbols_vec_rowids"
|
|
433
|
+
: doctorTableExists(db, "symbol_sources") ? "symbol_sources" : null;
|
|
434
|
+
if (!vectorTable) {
|
|
435
|
+
metrics.push({ key: "symbols_vectors", value: "vector metadata missing", target: ">= 0", ok: false, severity: "warning" });
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
symbolVectors = doctorCount(db, `SELECT COUNT(*) count FROM ${vectorTable}`);
|
|
439
|
+
metrics.push({ key: "symbols_vectors", value: symbolVectors, ok: true, severity: "info" });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
metrics.push({ key: "symbols_vectors_read", value: error.message, target: "readable SQLite database", ok: false, severity: "error" });
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
db?.close();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (codegraphNodes !== null && symbolVectors !== null) {
|
|
450
|
+
const value = coverage(codegraphNodes, symbolVectors);
|
|
451
|
+
metrics.push({ key: "symbols_vector_coverage", value, target: ">= 0.90", ok: value >= 0.9, severity: "warning" });
|
|
452
|
+
}
|
|
453
|
+
const knowledgePath = join(project, ".devflow", "knowledge", "knowledge.db");
|
|
454
|
+
if (!existsSync(knowledgePath)) {
|
|
455
|
+
metrics.push({ key: "knowledge_vectors_db", value: "missing", target: knowledgePath, ok: false, severity: "warning" });
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
let db;
|
|
459
|
+
try {
|
|
460
|
+
db = doctorOpenDatabase(knowledgePath, dependencies);
|
|
461
|
+
const required = ["knowledge_docs", "knowledge_chunks", "knowledge_vec_rowids"];
|
|
462
|
+
const missing = required.filter(table => !doctorTableExists(db, table));
|
|
463
|
+
if (missing.length > 0) {
|
|
464
|
+
metrics.push({ key: "knowledge_vector_schema", value: missing, target: [], ok: false, severity: "warning" });
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
const chunks = doctorCount(db, "SELECT COUNT(*) count FROM knowledge_chunks");
|
|
468
|
+
const vectors = doctorCount(db, "SELECT COUNT(*) count FROM knowledge_vec_rowids");
|
|
469
|
+
const value = coverage(chunks, vectors);
|
|
470
|
+
const rows = db.prepare(`
|
|
471
|
+
SELECT d.source source, COUNT(c.id) chunks,
|
|
472
|
+
SUM(CASE WHEN v.id IS NOT NULL THEN 1 ELSE 0 END) vectors
|
|
473
|
+
FROM knowledge_docs d
|
|
474
|
+
LEFT JOIN knowledge_chunks c ON c.doc_id = d.id
|
|
475
|
+
LEFT JOIN knowledge_vec_rowids v ON v.id = c.id
|
|
476
|
+
GROUP BY d.source ORDER BY d.source
|
|
477
|
+
`).all();
|
|
478
|
+
const bySource = rows.map(row => {
|
|
479
|
+
const sourceChunks = Number(row.chunks);
|
|
480
|
+
const sourceVectors = Number(row.vectors);
|
|
481
|
+
return { source: row.source, chunks: sourceChunks, vectors: sourceVectors, coverage: coverage(sourceChunks, sourceVectors) };
|
|
482
|
+
});
|
|
483
|
+
metrics.push({ key: "knowledge_chunks", value: chunks, ok: true, severity: "info" });
|
|
484
|
+
metrics.push({ key: "knowledge_vectors", value: vectors, ok: true, severity: "info" });
|
|
485
|
+
metrics.push({ key: "knowledge_vector_coverage", value, target: ">= 0.95", ok: value >= 0.95, severity: "warning" });
|
|
486
|
+
metrics.push({
|
|
487
|
+
key: "knowledge_vector_coverage_by_source",
|
|
488
|
+
value: bySource,
|
|
489
|
+
target: "every source >= 0.95",
|
|
490
|
+
ok: bySource.every(source => source.coverage >= 0.95),
|
|
491
|
+
severity: "warning",
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
metrics.push({ key: "knowledge_vectors_read", value: error.message, target: "readable SQLite database", ok: false, severity: "error" });
|
|
497
|
+
}
|
|
498
|
+
finally {
|
|
499
|
+
db?.close();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const memoriesPath = join(project, ".devflow", "memory", "memories.db");
|
|
503
|
+
if (!existsSync(memoriesPath)) {
|
|
504
|
+
metrics.push({ key: "observation_vectors_db", value: "missing", target: memoriesPath, ok: false, severity: "warning" });
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
let db;
|
|
508
|
+
try {
|
|
509
|
+
db = doctorOpenDatabase(memoriesPath, dependencies);
|
|
510
|
+
if (!doctorTableExists(db, "observations") || !doctorTableExists(db, "embeddings")) {
|
|
511
|
+
metrics.push({ key: "observation_vector_schema", value: "observations/embeddings missing", target: "Batch 2 vector schema", ok: false, severity: "warning" });
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
const observations = doctorCount(db, "SELECT COUNT(*) count FROM observations");
|
|
515
|
+
const vectors = doctorCount(db, `
|
|
516
|
+
SELECT COUNT(DISTINCT e.source_id) count
|
|
517
|
+
FROM embeddings e JOIN observations o ON o.id = e.source_id
|
|
518
|
+
WHERE e.source_type = 'observation'
|
|
519
|
+
`);
|
|
520
|
+
const missing = Math.max(0, observations - vectors);
|
|
521
|
+
metrics.push({ key: "observations", value: observations, ok: true, severity: "info" });
|
|
522
|
+
metrics.push({ key: "observation_vectors", value: vectors, ok: true, severity: "info" });
|
|
523
|
+
metrics.push({ key: "observation_vectors_missing", value: missing, target: 0, ok: missing === 0, severity: "warning" });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
metrics.push({ key: "observation_vectors_read", value: error.message, target: "readable SQLite database", ok: false, severity: "error" });
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
db?.close();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const now = (dependencies.now ?? Date.now)();
|
|
534
|
+
const errorLogPath = join(dependencies.homePath ?? homedir(), ".devflow", "errors", "embedder.log");
|
|
535
|
+
let recentErrors = 0;
|
|
536
|
+
if (existsSync(errorLogPath)) {
|
|
537
|
+
try {
|
|
538
|
+
const cutoff = now - 24 * 60 * 60 * 1000;
|
|
539
|
+
recentErrors = readFileSync(errorLogPath, "utf8").split("\n").filter(line => {
|
|
540
|
+
const match = /^\[([^\]]+)\]/.exec(line);
|
|
541
|
+
return match ? Date.parse(match[1]) >= cutoff : false;
|
|
542
|
+
}).length;
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
metrics.push({ key: "embedder_error_log_read", value: error.message, target: "readable error log", ok: false, severity: "error" });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
metrics.push({ key: "embedder_errors_24h", value: recentErrors, target: 0, ok: recentErrors === 0, severity: "warning" });
|
|
549
|
+
return { name: "Vectors", status: sectionStatus(metrics), metrics };
|
|
550
|
+
}
|
|
551
|
+
function checkTelemetry(project, strict, dependencies) {
|
|
552
|
+
const metrics = [];
|
|
553
|
+
const home = dependencies.homePath ?? homedir();
|
|
554
|
+
const globalDbPath = dependencies.homePath
|
|
555
|
+
? join(home, ".devflow", "global", "devflow.db")
|
|
556
|
+
: getGlobalDevFlowDbPath(home);
|
|
557
|
+
const cutoff = (dependencies.now ?? Date.now)() - 24 * 60 * 60 * 1000;
|
|
558
|
+
const strictSeverity = strict ? "error" : "warning";
|
|
559
|
+
if (!existsSync(globalDbPath)) {
|
|
560
|
+
metrics.push({ key: "global_telemetry_db", value: "missing", target: globalDbPath, ok: false, severity: "warning" });
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
let db;
|
|
564
|
+
try {
|
|
565
|
+
db = doctorOpenDatabase(globalDbPath, dependencies);
|
|
566
|
+
metrics.push({ key: "global_telemetry_db", value: globalDbPath, ok: true, severity: "info" });
|
|
567
|
+
for (const [key, table] of [["tool_metrics_24h", "tool_metrics"], ["feedback_24h", "feedback"], ["runs_24h", "runs"]]) {
|
|
568
|
+
const value = doctorTableExists(db, table)
|
|
569
|
+
? doctorCount(db, `SELECT COUNT(*) count FROM ${table} WHERE created_at >= ?`, cutoff)
|
|
570
|
+
: 0;
|
|
571
|
+
metrics.push({ key, value, ok: true, severity: "info" });
|
|
572
|
+
}
|
|
573
|
+
const failures = doctorTableExists(db, "telemetry_failures")
|
|
574
|
+
? doctorCount(db, "SELECT COUNT(*) count FROM telemetry_failures WHERE created_at >= ?", cutoff)
|
|
575
|
+
: 0;
|
|
576
|
+
metrics.push({ key: "telemetry_failures_24h", value: failures, target: 0, ok: failures === 0, severity: strictSeverity });
|
|
577
|
+
if (doctorTableExists(db, "tool_call_events")) {
|
|
578
|
+
const eventColumns = db.prepare("PRAGMA table_info(tool_call_events)").all()
|
|
579
|
+
.map(column => column.name);
|
|
580
|
+
if (eventColumns.includes("failure_category")) {
|
|
581
|
+
const rows = db.prepare(`
|
|
582
|
+
SELECT failure_category category, COUNT(*) count
|
|
583
|
+
FROM tool_call_events
|
|
584
|
+
WHERE timestamp >= ? AND failure_category IS NOT NULL
|
|
585
|
+
GROUP BY failure_category
|
|
586
|
+
ORDER BY failure_category
|
|
587
|
+
`).all(cutoff);
|
|
588
|
+
const distribution = Object.fromEntries(rows.map(row => [row.category, Number(row.count)]));
|
|
589
|
+
const failedEvents = doctorCount(db, "SELECT COUNT(*) count FROM tool_call_events WHERE timestamp >= ? AND error IS NOT NULL", cutoff);
|
|
590
|
+
const classifiedEvents = rows.reduce((sum, row) => sum + Number(row.count), 0);
|
|
591
|
+
const classificationCoverage = failedEvents === 0 ? 1 : classifiedEvents / failedEvents;
|
|
592
|
+
metrics.push({
|
|
593
|
+
key: "failure_categories_24h",
|
|
594
|
+
value: distribution,
|
|
595
|
+
ok: true,
|
|
596
|
+
severity: "info",
|
|
597
|
+
});
|
|
598
|
+
metrics.push({
|
|
599
|
+
key: "failure_category_coverage_24h",
|
|
600
|
+
value: classificationCoverage,
|
|
601
|
+
target: ">= 0.80",
|
|
602
|
+
ok: classificationCoverage >= 0.8,
|
|
603
|
+
severity: strictSeverity,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
metrics.push({
|
|
608
|
+
key: "failure_category_schema",
|
|
609
|
+
value: "failure_category column missing",
|
|
610
|
+
target: "Batch 4G telemetry schema",
|
|
611
|
+
ok: false,
|
|
612
|
+
severity: strictSeverity,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
metrics.push({ key: "global_telemetry_db_read", value: error.message, target: "readable SQLite database", ok: false, severity: "error" });
|
|
619
|
+
}
|
|
620
|
+
finally {
|
|
621
|
+
db?.close();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const cacheDirs = [...new Set([
|
|
625
|
+
join(dirname(globalDbPath), "telemetry-cache"),
|
|
626
|
+
join(home, ".devflow", "telemetry-cache"),
|
|
627
|
+
])];
|
|
628
|
+
let cacheFiles = 0;
|
|
629
|
+
try {
|
|
630
|
+
cacheFiles = cacheDirs.reduce((count, cacheDir) => count + (existsSync(cacheDir)
|
|
631
|
+
? readdirSync(cacheDir).filter(file => file.endsWith(".json")).length
|
|
632
|
+
: 0), 0);
|
|
633
|
+
metrics.push({ key: "telemetry_cache_files", value: cacheFiles, target: 0, ok: cacheFiles === 0, severity: strictSeverity });
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
metrics.push({ key: "telemetry_cache_read", value: error.message, target: "readable cache directory", ok: false, severity: "error" });
|
|
637
|
+
}
|
|
638
|
+
const projectDbPath = join(project, ".devflow", "devflow.db");
|
|
639
|
+
const projectDbPresent = existsSync(projectDbPath);
|
|
640
|
+
metrics.push({ key: "project_local_telemetry_db", value: projectDbPresent ? projectDbPath : "absent", target: "absent", ok: !projectDbPresent, severity: strictSeverity });
|
|
641
|
+
const codegraphPath = join(project, ".codegraph", "codegraph.db");
|
|
642
|
+
let codegraphDevflowTables = [];
|
|
643
|
+
if (existsSync(codegraphPath)) {
|
|
644
|
+
let db;
|
|
645
|
+
try {
|
|
646
|
+
db = doctorOpenDatabase(codegraphPath, dependencies);
|
|
647
|
+
codegraphDevflowTables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'devflow_%' ORDER BY name").all().map(row => row.name);
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
metrics.push({ key: "codegraph_telemetry_read", value: error.message, target: "readable SQLite database", ok: false, severity: "error" });
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
db?.close();
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
metrics.push({ key: "codegraph_devflow_tables", value: codegraphDevflowTables, target: [], ok: codegraphDevflowTables.length === 0, severity: strictSeverity });
|
|
657
|
+
return { name: "Telemetry", status: sectionStatus(metrics), metrics };
|
|
658
|
+
}
|
|
659
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
660
|
+
const BENCHMARK_MAX_AGE_MS = 30 * DAY_MS;
|
|
661
|
+
const HOOK_DEBUG_MAX_TOTAL_BYTES = 60 * 1024 * 1024;
|
|
662
|
+
function fileMode(path) {
|
|
663
|
+
return (statSync(path).mode & 0o777).toString(8).padStart(3, "0");
|
|
664
|
+
}
|
|
665
|
+
function diagnosticTimestamp(value) {
|
|
666
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
667
|
+
return value;
|
|
668
|
+
if (typeof value !== "string")
|
|
669
|
+
return null;
|
|
670
|
+
const parsed = Date.parse(value);
|
|
671
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
672
|
+
}
|
|
673
|
+
function checkRuntimeSecurity(dependencies) {
|
|
674
|
+
const metrics = [];
|
|
675
|
+
const home = dependencies.homePath ?? homedir();
|
|
676
|
+
const env = dependencies.env ?? process.env;
|
|
677
|
+
const keyDir = join(home, ".devflow", "keys");
|
|
678
|
+
const secretSpecs = [
|
|
679
|
+
["local_api_key", "DEVFLOW_API_KEY", "local.key"],
|
|
680
|
+
["local_hmac_secret", "DEVFLOW_HMAC_SECRET", "hmac.key"],
|
|
681
|
+
];
|
|
682
|
+
const needsKeyDirectory = secretSpecs.some(([, variable]) => !env[variable]?.trim());
|
|
683
|
+
if (!needsKeyDirectory && !existsSync(keyDir)) {
|
|
684
|
+
metrics.push({
|
|
685
|
+
key: "key_directory",
|
|
686
|
+
value: "not required (environment overrides active)",
|
|
687
|
+
ok: true,
|
|
688
|
+
severity: "info",
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
else if (!existsSync(keyDir)) {
|
|
692
|
+
metrics.push({
|
|
693
|
+
key: "key_directory",
|
|
694
|
+
value: "missing",
|
|
695
|
+
target: keyDir,
|
|
696
|
+
ok: false,
|
|
697
|
+
severity: "warning",
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
try {
|
|
702
|
+
const directory = lstatSync(keyDir);
|
|
703
|
+
const mode = fileMode(keyDir);
|
|
704
|
+
const validType = directory.isDirectory() && !directory.isSymbolicLink();
|
|
705
|
+
const validMode = process.platform === "win32" || mode === "700";
|
|
706
|
+
metrics.push({
|
|
707
|
+
key: "key_directory",
|
|
708
|
+
value: { path: keyDir, mode },
|
|
709
|
+
target: { type: "directory", mode: "700" },
|
|
710
|
+
ok: validType && validMode,
|
|
711
|
+
severity: validType ? "warning" : "error",
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
metrics.push({
|
|
716
|
+
key: "key_directory",
|
|
717
|
+
value: error.message,
|
|
718
|
+
target: "readable directory with mode 700",
|
|
719
|
+
ok: false,
|
|
720
|
+
severity: "error",
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
for (const [metricKey, variable, filename] of secretSpecs) {
|
|
725
|
+
if (env[variable]?.trim()) {
|
|
726
|
+
metrics.push({
|
|
727
|
+
key: `${metricKey}_source`,
|
|
728
|
+
value: variable,
|
|
729
|
+
target: "environment override or generated key file",
|
|
730
|
+
ok: true,
|
|
731
|
+
severity: "info",
|
|
732
|
+
});
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
const path = join(keyDir, filename);
|
|
736
|
+
if (!existsSync(path)) {
|
|
737
|
+
metrics.push({
|
|
738
|
+
key: `${metricKey}_source`,
|
|
739
|
+
value: "missing",
|
|
740
|
+
target: path,
|
|
741
|
+
ok: false,
|
|
742
|
+
severity: "warning",
|
|
743
|
+
});
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
const entry = lstatSync(path);
|
|
748
|
+
const mode = fileMode(path);
|
|
749
|
+
const value = readFileSync(path, "utf8").trim();
|
|
750
|
+
const validType = entry.isFile() && !entry.isSymbolicLink();
|
|
751
|
+
metrics.push({
|
|
752
|
+
key: `${metricKey}_source`,
|
|
753
|
+
value: { path, mode, bytes: Buffer.byteLength(value) },
|
|
754
|
+
target: { type: "regular file", mode: "400", nonempty: true },
|
|
755
|
+
ok: validType && value.length > 0 && (process.platform === "win32" || mode === "400"),
|
|
756
|
+
severity: !validType || value.length === 0 ? "error" : "warning",
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
metrics.push({
|
|
761
|
+
key: `${metricKey}_source`,
|
|
762
|
+
value: error.message,
|
|
763
|
+
target: "readable nonempty key file with mode 400",
|
|
764
|
+
ok: false,
|
|
765
|
+
severity: "error",
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const compressorLog = join(home, ".devflow", "errors", "compressor.log");
|
|
770
|
+
let recentErrors = 0;
|
|
771
|
+
let malformedRecords = 0;
|
|
772
|
+
if (existsSync(compressorLog)) {
|
|
773
|
+
try {
|
|
774
|
+
const cutoff = (dependencies.now ?? Date.now)() - DAY_MS;
|
|
775
|
+
for (const line of readFileSync(compressorLog, "utf8").split("\n")) {
|
|
776
|
+
if (!line.trim())
|
|
777
|
+
continue;
|
|
778
|
+
try {
|
|
779
|
+
const record = JSON.parse(line);
|
|
780
|
+
const timestamp = diagnosticTimestamp(record.ts);
|
|
781
|
+
if (timestamp === null)
|
|
782
|
+
malformedRecords += 1;
|
|
783
|
+
else if (timestamp >= cutoff)
|
|
784
|
+
recentErrors += 1;
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
malformedRecords += 1;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
metrics.push({
|
|
793
|
+
key: "compressor_error_log_read",
|
|
794
|
+
value: error.message,
|
|
795
|
+
target: "readable JSONL diagnostics",
|
|
796
|
+
ok: false,
|
|
797
|
+
severity: "error",
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
metrics.push({
|
|
802
|
+
key: "compressor_errors_24h",
|
|
803
|
+
value: recentErrors,
|
|
804
|
+
target: 0,
|
|
805
|
+
ok: recentErrors === 0,
|
|
806
|
+
severity: "warning",
|
|
807
|
+
});
|
|
808
|
+
metrics.push({
|
|
809
|
+
key: "malformed_compressor_records",
|
|
810
|
+
value: malformedRecords,
|
|
811
|
+
target: 0,
|
|
812
|
+
ok: malformedRecords === 0,
|
|
813
|
+
severity: "error",
|
|
814
|
+
});
|
|
815
|
+
return { name: "Runtime security", status: sectionStatus(metrics), metrics };
|
|
816
|
+
}
|
|
817
|
+
function checkBenchmark(dependencies) {
|
|
818
|
+
const metrics = [];
|
|
819
|
+
const home = dependencies.homePath ?? homedir();
|
|
820
|
+
const path = getGlobalDevFlowDbPath(home);
|
|
821
|
+
if (!existsSync(path)) {
|
|
822
|
+
metrics.push({
|
|
823
|
+
key: "latest_benchmark",
|
|
824
|
+
value: "missing global database",
|
|
825
|
+
target: "completed baseline within 30 days",
|
|
826
|
+
ok: false,
|
|
827
|
+
severity: "warning",
|
|
828
|
+
});
|
|
829
|
+
return { name: "Benchmark", status: sectionStatus(metrics), metrics };
|
|
830
|
+
}
|
|
831
|
+
let db;
|
|
832
|
+
try {
|
|
833
|
+
db = doctorOpenDatabase(path, dependencies);
|
|
834
|
+
if (!doctorTableExists(db, "benchmark_reports")) {
|
|
835
|
+
metrics.push({
|
|
836
|
+
key: "latest_benchmark",
|
|
837
|
+
value: "benchmark_reports table missing",
|
|
838
|
+
target: "completed baseline within 30 days",
|
|
839
|
+
ok: false,
|
|
840
|
+
severity: "warning",
|
|
841
|
+
});
|
|
842
|
+
return { name: "Benchmark", status: sectionStatus(metrics), metrics };
|
|
843
|
+
}
|
|
844
|
+
const row = db.prepare(`
|
|
845
|
+
SELECT run_id, suite_id, suite_version, task_count, status, report_json, created_at
|
|
846
|
+
FROM benchmark_reports
|
|
847
|
+
ORDER BY created_at DESC
|
|
848
|
+
LIMIT 1
|
|
849
|
+
`).get();
|
|
850
|
+
if (!row) {
|
|
851
|
+
metrics.push({
|
|
852
|
+
key: "latest_benchmark",
|
|
853
|
+
value: "no reports",
|
|
854
|
+
target: "completed baseline within 30 days",
|
|
855
|
+
ok: false,
|
|
856
|
+
severity: "warning",
|
|
857
|
+
});
|
|
858
|
+
return { name: "Benchmark", status: sectionStatus(metrics), metrics };
|
|
859
|
+
}
|
|
860
|
+
let reportTaskCount = null;
|
|
861
|
+
try {
|
|
862
|
+
if (typeof row.report_json !== "string")
|
|
863
|
+
throw new Error("report_json is not text");
|
|
864
|
+
const report = JSON.parse(row.report_json);
|
|
865
|
+
if (!Array.isArray(report.tasks))
|
|
866
|
+
throw new Error("report tasks are missing");
|
|
867
|
+
reportTaskCount = report.tasks.length;
|
|
868
|
+
}
|
|
869
|
+
catch (error) {
|
|
870
|
+
metrics.push({
|
|
871
|
+
key: "benchmark_report_json",
|
|
872
|
+
value: error.message,
|
|
873
|
+
target: "valid report with tasks array",
|
|
874
|
+
ok: false,
|
|
875
|
+
severity: "error",
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
const taskCount = Number(row.task_count);
|
|
879
|
+
const createdAt = Number(row.created_at);
|
|
880
|
+
const ageMs = (dependencies.now ?? Date.now)() - createdAt;
|
|
881
|
+
metrics.push({
|
|
882
|
+
key: "latest_benchmark",
|
|
883
|
+
value: {
|
|
884
|
+
runId: row.run_id,
|
|
885
|
+
suiteId: row.suite_id,
|
|
886
|
+
suiteVersion: row.suite_version,
|
|
887
|
+
status: row.status,
|
|
888
|
+
createdAt,
|
|
889
|
+
},
|
|
890
|
+
ok: row.status === "completed" && Number.isFinite(createdAt),
|
|
891
|
+
severity: "error",
|
|
892
|
+
});
|
|
893
|
+
metrics.push({
|
|
894
|
+
key: "benchmark_task_count",
|
|
895
|
+
value: taskCount,
|
|
896
|
+
target: ">= 5",
|
|
897
|
+
ok: Number.isInteger(taskCount) && taskCount >= 5 && reportTaskCount === taskCount,
|
|
898
|
+
severity: reportTaskCount === null ? "error" : "warning",
|
|
899
|
+
});
|
|
900
|
+
metrics.push({
|
|
901
|
+
key: "benchmark_age_days",
|
|
902
|
+
value: Number.isFinite(ageMs) ? Number((ageMs / DAY_MS).toFixed(2)) : null,
|
|
903
|
+
target: "0-30",
|
|
904
|
+
ok: Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= BENCHMARK_MAX_AGE_MS,
|
|
905
|
+
severity: ageMs < 0 || !Number.isFinite(ageMs) ? "error" : "warning",
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
catch (error) {
|
|
909
|
+
metrics.push({
|
|
910
|
+
key: "benchmark_database_read",
|
|
911
|
+
value: error.message,
|
|
912
|
+
target: "readable canonical benchmark database",
|
|
913
|
+
ok: false,
|
|
914
|
+
severity: "error",
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
finally {
|
|
918
|
+
db?.close();
|
|
919
|
+
}
|
|
920
|
+
return { name: "Benchmark", status: sectionStatus(metrics), metrics };
|
|
921
|
+
}
|
|
922
|
+
function hookDebugFootprint(root) {
|
|
923
|
+
if (!existsSync(root))
|
|
924
|
+
return { bytes: 0, files: 0 };
|
|
925
|
+
const pending = [root];
|
|
926
|
+
let bytes = 0;
|
|
927
|
+
let files = 0;
|
|
928
|
+
while (pending.length > 0) {
|
|
929
|
+
const path = pending.pop();
|
|
930
|
+
const entry = lstatSync(path);
|
|
931
|
+
if (entry.isSymbolicLink())
|
|
932
|
+
continue;
|
|
933
|
+
if (entry.isDirectory()) {
|
|
934
|
+
for (const name of readdirSync(path))
|
|
935
|
+
pending.push(join(path, name));
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
if (entry.isFile()) {
|
|
939
|
+
bytes += entry.size;
|
|
940
|
+
files += 1;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return { bytes, files };
|
|
944
|
+
}
|
|
945
|
+
function checkHookPathHealth(dependencies) {
|
|
946
|
+
const metrics = [];
|
|
947
|
+
const home = dependencies.homePath ?? homedir();
|
|
948
|
+
const fallbackPaths = [join(home, ".devflow", "errors", "hook-fallback.log")];
|
|
949
|
+
const datedFallbackDir = join(home, ".devflow", "logs", "hook-fallback");
|
|
950
|
+
if (existsSync(datedFallbackDir)) {
|
|
951
|
+
try {
|
|
952
|
+
for (const name of readdirSync(datedFallbackDir)) {
|
|
953
|
+
if (name.endsWith(".log"))
|
|
954
|
+
fallbackPaths.push(join(datedFallbackDir, name));
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
catch (error) {
|
|
958
|
+
metrics.push({
|
|
959
|
+
key: "hook_fallback_directory_read",
|
|
960
|
+
value: error.message,
|
|
961
|
+
target: "readable fallback directory",
|
|
962
|
+
ok: false,
|
|
963
|
+
severity: "error",
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const cutoff = (dependencies.now ?? Date.now)() - DAY_MS;
|
|
968
|
+
let recentFallbacks = 0;
|
|
969
|
+
let malformedFallbacks = 0;
|
|
970
|
+
for (const path of [...new Set(fallbackPaths)]) {
|
|
971
|
+
if (!existsSync(path))
|
|
972
|
+
continue;
|
|
973
|
+
try {
|
|
974
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
975
|
+
if (!line.trim())
|
|
976
|
+
continue;
|
|
977
|
+
try {
|
|
978
|
+
const record = JSON.parse(line);
|
|
979
|
+
const timestamp = diagnosticTimestamp(record.ts);
|
|
980
|
+
if (timestamp === null || typeof record.reason !== "string") {
|
|
981
|
+
malformedFallbacks += 1;
|
|
982
|
+
}
|
|
983
|
+
else if (timestamp >= cutoff) {
|
|
984
|
+
recentFallbacks += 1;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
catch {
|
|
988
|
+
malformedFallbacks += 1;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
metrics.push({
|
|
994
|
+
key: "hook_fallback_log_read",
|
|
995
|
+
value: { path, error: error.message },
|
|
996
|
+
target: "readable JSONL diagnostics",
|
|
997
|
+
ok: false,
|
|
998
|
+
severity: "error",
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
metrics.push({
|
|
1003
|
+
key: "hook_fallback_events_24h",
|
|
1004
|
+
value: recentFallbacks,
|
|
1005
|
+
target: 0,
|
|
1006
|
+
ok: recentFallbacks === 0,
|
|
1007
|
+
severity: "warning",
|
|
1008
|
+
});
|
|
1009
|
+
metrics.push({
|
|
1010
|
+
key: "malformed_hook_fallback_records",
|
|
1011
|
+
value: malformedFallbacks,
|
|
1012
|
+
target: 0,
|
|
1013
|
+
ok: malformedFallbacks === 0,
|
|
1014
|
+
severity: "error",
|
|
1015
|
+
});
|
|
1016
|
+
const globalDbPath = dependencies.homePath
|
|
1017
|
+
? join(home, ".devflow", "global", "devflow.db")
|
|
1018
|
+
: getGlobalDevFlowDbPath(home);
|
|
1019
|
+
let daemonRequests = 0;
|
|
1020
|
+
let failedDaemonRequests = 0;
|
|
1021
|
+
let averageDaemonResponseMs = null;
|
|
1022
|
+
if (existsSync(globalDbPath)) {
|
|
1023
|
+
let db;
|
|
1024
|
+
try {
|
|
1025
|
+
db = doctorOpenDatabase(globalDbPath, dependencies);
|
|
1026
|
+
if (doctorTableExists(db, "telemetry_events")) {
|
|
1027
|
+
const row = db.prepare(`
|
|
1028
|
+
SELECT COUNT(*) total,
|
|
1029
|
+
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) failures,
|
|
1030
|
+
AVG(CASE WHEN success = 1 THEN duration END) average_duration
|
|
1031
|
+
FROM telemetry_events
|
|
1032
|
+
WHERE kind = 'hook_daemon_request' AND timestamp >= ?
|
|
1033
|
+
`).get(cutoff);
|
|
1034
|
+
daemonRequests = Number(row?.total ?? 0);
|
|
1035
|
+
failedDaemonRequests = Number(row?.failures ?? 0);
|
|
1036
|
+
averageDaemonResponseMs = row?.average_duration == null ? null : Math.round(Number(row.average_duration) * 100) / 100;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
catch (error) {
|
|
1040
|
+
metrics.push({
|
|
1041
|
+
key: "hook_daemon_metrics_read",
|
|
1042
|
+
value: error.message,
|
|
1043
|
+
target: "readable canonical telemetry",
|
|
1044
|
+
ok: false,
|
|
1045
|
+
severity: "error",
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
finally {
|
|
1049
|
+
db?.close();
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
const observedFailures = Math.max(recentFallbacks, failedDaemonRequests);
|
|
1053
|
+
const observedRequests = Math.max(daemonRequests, observedFailures);
|
|
1054
|
+
const fallbackRate = observedRequests === 0 ? 0 : observedFailures / observedRequests;
|
|
1055
|
+
metrics.push({ key: "hook_daemon_requests_24h", value: observedRequests, ok: true, severity: "info" });
|
|
1056
|
+
metrics.push({
|
|
1057
|
+
key: "hook_fallback_rate_24h",
|
|
1058
|
+
value: Math.round(fallbackRate * 10_000) / 10_000,
|
|
1059
|
+
target: 0,
|
|
1060
|
+
ok: observedFailures === 0,
|
|
1061
|
+
severity: "warning",
|
|
1062
|
+
});
|
|
1063
|
+
metrics.push({
|
|
1064
|
+
key: "hook_daemon_response_ms_avg",
|
|
1065
|
+
value: averageDaemonResponseMs ?? "no successful requests",
|
|
1066
|
+
target: "<= 1000",
|
|
1067
|
+
ok: averageDaemonResponseMs === null || averageDaemonResponseMs <= 1000,
|
|
1068
|
+
severity: "warning",
|
|
1069
|
+
});
|
|
1070
|
+
const degradedFeatures = observedFailures > 0
|
|
1071
|
+
? ["4-Gate enforcement", "memory prefetch cache", "daemon-shared hook state"]
|
|
1072
|
+
: [];
|
|
1073
|
+
metrics.push({
|
|
1074
|
+
key: "degraded_hook_features",
|
|
1075
|
+
value: degradedFeatures,
|
|
1076
|
+
target: [],
|
|
1077
|
+
ok: degradedFeatures.length === 0,
|
|
1078
|
+
severity: "warning",
|
|
1079
|
+
});
|
|
1080
|
+
const debugRoot = join(home, ".devflow", "logs", "hook-debug");
|
|
1081
|
+
try {
|
|
1082
|
+
const footprint = hookDebugFootprint(debugRoot);
|
|
1083
|
+
metrics.push({
|
|
1084
|
+
key: "hook_debug_log_bytes",
|
|
1085
|
+
value: footprint.bytes,
|
|
1086
|
+
target: `<= ${HOOK_DEBUG_MAX_TOTAL_BYTES}`,
|
|
1087
|
+
ok: footprint.bytes <= HOOK_DEBUG_MAX_TOTAL_BYTES,
|
|
1088
|
+
severity: "warning",
|
|
1089
|
+
});
|
|
1090
|
+
metrics.push({
|
|
1091
|
+
key: "hook_debug_log_files",
|
|
1092
|
+
value: footprint.files,
|
|
1093
|
+
target: "rotated per project",
|
|
1094
|
+
ok: true,
|
|
1095
|
+
severity: "info",
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
catch (error) {
|
|
1099
|
+
metrics.push({
|
|
1100
|
+
key: "hook_debug_log_read",
|
|
1101
|
+
value: error.message,
|
|
1102
|
+
target: "readable regular files without symlink traversal",
|
|
1103
|
+
ok: false,
|
|
1104
|
+
severity: "error",
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
return { name: "Hook path health", status: sectionStatus(metrics), metrics };
|
|
1108
|
+
}
|
|
1109
|
+
const RETIRED_PRODUCER_PATH = /\/api\/memory\/(?:process|events|pending-count|extract(?:-rules)?|ollama\/status)(?![\w/-])/g;
|
|
1110
|
+
const PLUGIN_PATH_REFERENCE = /\$(?:\{(?:CLAUDE_)?PLUGIN_ROOT\}|(?:CLAUDE_)?PLUGIN_ROOT)\/([A-Za-z0-9._/-]+)/g;
|
|
1111
|
+
const MAX_LEGACY_ARTIFACTS_PER_ROOT = 64;
|
|
1112
|
+
const MAX_LEGACY_ARTIFACT_BYTES = 1024 * 1024;
|
|
1113
|
+
const MAX_LANGUAGE_DEPENDENCY_DEPTH = 8;
|
|
1114
|
+
const MAX_SHELL_COMMAND_DEPTH = 8;
|
|
1115
|
+
const MAX_SHELL_COMMAND_BYTES = 64 * 1024;
|
|
1116
|
+
function canonicalDirectory(candidate) {
|
|
1117
|
+
try {
|
|
1118
|
+
return statSync(candidate).isDirectory() ? realpathSync.native(candidate) : null;
|
|
1119
|
+
}
|
|
1120
|
+
catch {
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
function parseJsonFile(path) {
|
|
1125
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1126
|
+
}
|
|
1127
|
+
function asRecord(value) {
|
|
1128
|
+
return typeof value === "object" && value !== null
|
|
1129
|
+
? value
|
|
1130
|
+
: null;
|
|
1131
|
+
}
|
|
1132
|
+
const UNSUPPORTED_SHELL_WORDS = new Set([
|
|
1133
|
+
".",
|
|
1134
|
+
"!",
|
|
1135
|
+
"[[",
|
|
1136
|
+
"]]",
|
|
1137
|
+
"case",
|
|
1138
|
+
"coproc",
|
|
1139
|
+
"do",
|
|
1140
|
+
"done",
|
|
1141
|
+
"elif",
|
|
1142
|
+
"else",
|
|
1143
|
+
"esac",
|
|
1144
|
+
"eval",
|
|
1145
|
+
"fi",
|
|
1146
|
+
"for",
|
|
1147
|
+
"function",
|
|
1148
|
+
"if",
|
|
1149
|
+
"in",
|
|
1150
|
+
"select",
|
|
1151
|
+
"source",
|
|
1152
|
+
"then",
|
|
1153
|
+
"time",
|
|
1154
|
+
"trap",
|
|
1155
|
+
"until",
|
|
1156
|
+
"while",
|
|
1157
|
+
]);
|
|
1158
|
+
const PLUGIN_CONTROL_SHELL_WORDS = new Set([
|
|
1159
|
+
"!",
|
|
1160
|
+
"[[",
|
|
1161
|
+
"]]",
|
|
1162
|
+
"case",
|
|
1163
|
+
"do",
|
|
1164
|
+
"done",
|
|
1165
|
+
"elif",
|
|
1166
|
+
"else",
|
|
1167
|
+
"esac",
|
|
1168
|
+
"fi",
|
|
1169
|
+
"for",
|
|
1170
|
+
"function",
|
|
1171
|
+
"if",
|
|
1172
|
+
"in",
|
|
1173
|
+
"select",
|
|
1174
|
+
"then",
|
|
1175
|
+
"time",
|
|
1176
|
+
"until",
|
|
1177
|
+
"while",
|
|
1178
|
+
]);
|
|
1179
|
+
const SUPPORTED_BRACED_SHELL_VARIABLE = /^\$\{(?:CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR|HOME|PLUGIN_ROOT)\}$/;
|
|
1180
|
+
function unsupportedShellWord(word, options) {
|
|
1181
|
+
return UNSUPPORTED_SHELL_WORDS.has(word)
|
|
1182
|
+
&& !(options.tolerateControlSyntax && PLUGIN_CONTROL_SHELL_WORDS.has(word));
|
|
1183
|
+
}
|
|
1184
|
+
// Supported subset: simple commands separated by ;, newline, &, &&, ||, or |,
|
|
1185
|
+
// plus the explicit wrappers and interpreter options below. Other execution
|
|
1186
|
+
// syntax is reported as malformed instead of being approximated.
|
|
1187
|
+
function parseShellCommand(command, options = {}) {
|
|
1188
|
+
if (Buffer.byteLength(command) > MAX_SHELL_COMMAND_BYTES) {
|
|
1189
|
+
return { segments: [], malformed: true };
|
|
1190
|
+
}
|
|
1191
|
+
const segments = [];
|
|
1192
|
+
let segment = [];
|
|
1193
|
+
let token = "";
|
|
1194
|
+
let quote = null;
|
|
1195
|
+
let escaped = false;
|
|
1196
|
+
let malformed = false;
|
|
1197
|
+
const flushToken = () => {
|
|
1198
|
+
if (token.length > 0)
|
|
1199
|
+
segment.push(token);
|
|
1200
|
+
token = "";
|
|
1201
|
+
};
|
|
1202
|
+
const flushSegment = () => {
|
|
1203
|
+
flushToken();
|
|
1204
|
+
if (segment.length > 0)
|
|
1205
|
+
segments.push(segment);
|
|
1206
|
+
segment = [];
|
|
1207
|
+
};
|
|
1208
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
1209
|
+
const character = command[index];
|
|
1210
|
+
if (escaped) {
|
|
1211
|
+
token += character;
|
|
1212
|
+
escaped = false;
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
if (quote === "'") {
|
|
1216
|
+
if (character === "'")
|
|
1217
|
+
quote = null;
|
|
1218
|
+
else
|
|
1219
|
+
token += character;
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
if (character === "\\") {
|
|
1223
|
+
if (command[index + 1] === "\n") {
|
|
1224
|
+
index += 1;
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
if (command[index + 1] === "\r" && command[index + 2] === "\n") {
|
|
1228
|
+
index += 2;
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
escaped = true;
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
if (quote === '"') {
|
|
1235
|
+
if (character === '"')
|
|
1236
|
+
quote = null;
|
|
1237
|
+
else {
|
|
1238
|
+
if ((character === "`" || (character === "$" && command[index + 1] === "("))
|
|
1239
|
+
&& !options.tolerateControlSyntax) {
|
|
1240
|
+
malformed = true;
|
|
1241
|
+
}
|
|
1242
|
+
token += character;
|
|
1243
|
+
}
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (character === "'" || character === '"') {
|
|
1247
|
+
quote = character;
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
if ((character === "`" || (character === "$" && command[index + 1] === "("))
|
|
1251
|
+
&& !options.tolerateControlSyntax) {
|
|
1252
|
+
malformed = true;
|
|
1253
|
+
token += character;
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
const pair = command.slice(index, index + 2);
|
|
1257
|
+
if (character === "#" && token.length === 0) {
|
|
1258
|
+
while (index < command.length && command[index] !== "\n")
|
|
1259
|
+
index += 1;
|
|
1260
|
+
flushSegment();
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
if (pair === "<<" && !options.tolerateControlSyntax)
|
|
1264
|
+
malformed = true;
|
|
1265
|
+
const supportedVariableBrace = (character === "{"
|
|
1266
|
+
&& token === "$"
|
|
1267
|
+
&& /^\{(?:CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR|HOME|PLUGIN_ROOT)\}/
|
|
1268
|
+
.test(command.slice(index))) || (character === "}"
|
|
1269
|
+
&& SUPPORTED_BRACED_SHELL_VARIABLE.test(`${token}}`));
|
|
1270
|
+
if (["(", ")", "{", "}"].includes(character)
|
|
1271
|
+
&& !supportedVariableBrace
|
|
1272
|
+
&& !options.tolerateControlSyntax)
|
|
1273
|
+
malformed = true;
|
|
1274
|
+
if (pair === "&&" || pair === "||") {
|
|
1275
|
+
flushSegment();
|
|
1276
|
+
index += 1;
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
if ([";", "|", "&", "\n"].includes(character)) {
|
|
1280
|
+
flushSegment();
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
if (/\s/.test(character)) {
|
|
1284
|
+
flushToken();
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
token += character;
|
|
1288
|
+
}
|
|
1289
|
+
if (quote !== null || escaped)
|
|
1290
|
+
malformed = true;
|
|
1291
|
+
flushSegment();
|
|
1292
|
+
if (segments.some((tokens) => {
|
|
1293
|
+
const commandWord = tokens.find((item) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(item));
|
|
1294
|
+
return commandWord !== undefined && unsupportedShellWord(commandWord, options);
|
|
1295
|
+
})) {
|
|
1296
|
+
malformed = true;
|
|
1297
|
+
}
|
|
1298
|
+
return { segments, malformed };
|
|
1299
|
+
}
|
|
1300
|
+
function activeShellText(command) {
|
|
1301
|
+
return parseShellCommand(command).segments.flat().join(" ");
|
|
1302
|
+
}
|
|
1303
|
+
function isShellArtifact(path, content) {
|
|
1304
|
+
const extension = extname(path);
|
|
1305
|
+
if ([".bash", ".sh", ".zsh"].includes(extension))
|
|
1306
|
+
return true;
|
|
1307
|
+
if (/^#![^\n]*\b(?:ba|z)?sh\b/.test(content))
|
|
1308
|
+
return true;
|
|
1309
|
+
return extension === "" && !/^#!/.test(content);
|
|
1310
|
+
}
|
|
1311
|
+
function inferArtifactParserKind(path, content) {
|
|
1312
|
+
if (isShellArtifact(path, content))
|
|
1313
|
+
return "shell";
|
|
1314
|
+
if (/^#![^\n]*\b(?:node|tsx)\b/.test(content))
|
|
1315
|
+
return "node";
|
|
1316
|
+
if (/^#![^\n]*\bpython(?:3)?\b/.test(content))
|
|
1317
|
+
return "python";
|
|
1318
|
+
if ([".cjs", ".js", ".mjs", ".ts"].includes(extname(path)))
|
|
1319
|
+
return "node";
|
|
1320
|
+
if (extname(path) === ".py")
|
|
1321
|
+
return "python";
|
|
1322
|
+
return "infer";
|
|
1323
|
+
}
|
|
1324
|
+
function stripJavaScriptComments(content) {
|
|
1325
|
+
const stripped = content.split("");
|
|
1326
|
+
let quote = null;
|
|
1327
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
1328
|
+
const character = content[index];
|
|
1329
|
+
if (quote !== null) {
|
|
1330
|
+
if (character === "\\") {
|
|
1331
|
+
index += 1;
|
|
1332
|
+
}
|
|
1333
|
+
else if (character === quote) {
|
|
1334
|
+
quote = null;
|
|
1335
|
+
}
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
if (character === "'" || character === '"' || character === "`") {
|
|
1339
|
+
quote = character;
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
if (character !== "/" || !["/", "*"].includes(content[index + 1] ?? ""))
|
|
1343
|
+
continue;
|
|
1344
|
+
const block = content[index + 1] === "*";
|
|
1345
|
+
stripped[index] = " ";
|
|
1346
|
+
stripped[index + 1] = " ";
|
|
1347
|
+
index += 2;
|
|
1348
|
+
while (index < content.length) {
|
|
1349
|
+
if (block && content[index] === "*" && content[index + 1] === "/") {
|
|
1350
|
+
stripped[index] = " ";
|
|
1351
|
+
stripped[index + 1] = " ";
|
|
1352
|
+
index += 1;
|
|
1353
|
+
break;
|
|
1354
|
+
}
|
|
1355
|
+
if (!block && content[index] === "\n") {
|
|
1356
|
+
index -= 1;
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
if (content[index] !== "\n" && content[index] !== "\r")
|
|
1360
|
+
stripped[index] = " ";
|
|
1361
|
+
index += 1;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return stripped.join("");
|
|
1365
|
+
}
|
|
1366
|
+
function stripPythonComments(content) {
|
|
1367
|
+
const stripped = content.split("");
|
|
1368
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
1369
|
+
const character = content[index];
|
|
1370
|
+
if (character === "#") {
|
|
1371
|
+
while (index < content.length && content[index] !== "\n") {
|
|
1372
|
+
stripped[index] = " ";
|
|
1373
|
+
index += 1;
|
|
1374
|
+
}
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
if (character !== "'" && character !== '"')
|
|
1378
|
+
continue;
|
|
1379
|
+
const quote = character;
|
|
1380
|
+
const delimiterLength = content.slice(index, index + 3) === quote.repeat(3) ? 3 : 1;
|
|
1381
|
+
index += delimiterLength;
|
|
1382
|
+
while (index < content.length) {
|
|
1383
|
+
if (content[index] === "\\") {
|
|
1384
|
+
index += 2;
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
if (content.slice(index, index + delimiterLength) === quote.repeat(delimiterLength)) {
|
|
1388
|
+
index += delimiterLength - 1;
|
|
1389
|
+
break;
|
|
1390
|
+
}
|
|
1391
|
+
if (delimiterLength === 1 && (content[index] === "\n" || content[index] === "\r"))
|
|
1392
|
+
break;
|
|
1393
|
+
index += 1;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
return stripped.join("");
|
|
1397
|
+
}
|
|
1398
|
+
function retiredProducerPaths(content, shellSyntax, parserKind = "infer") {
|
|
1399
|
+
const inspectedContent = shellSyntax
|
|
1400
|
+
? activeShellText(content)
|
|
1401
|
+
: parserKind === "node"
|
|
1402
|
+
? stripJavaScriptComments(content)
|
|
1403
|
+
: parserKind === "python"
|
|
1404
|
+
? stripPythonComments(content)
|
|
1405
|
+
: content;
|
|
1406
|
+
return [...new Set(inspectedContent.match(RETIRED_PRODUCER_PATH) ?? [])];
|
|
1407
|
+
}
|
|
1408
|
+
function commandTokens(command) {
|
|
1409
|
+
return parseShellCommand(command).segments.flat();
|
|
1410
|
+
}
|
|
1411
|
+
function settingsArtifactReference(token, context, contextualPath = false, parserKind = "infer") {
|
|
1412
|
+
const variableForms = [
|
|
1413
|
+
[/^\$\{CLAUDE_PROJECT_DIR\}(?:\/(.*))?$/, context.project],
|
|
1414
|
+
[/^\$CLAUDE_PROJECT_DIR(?:\/(.*))?$/, context.project],
|
|
1415
|
+
[/^\$\{HOME\}(?:\/(.*))?$/, context.homePath],
|
|
1416
|
+
[/^\$HOME(?:\/(.*))?$/, context.homePath],
|
|
1417
|
+
[/^~(?:\/(.*))?$/, context.homePath],
|
|
1418
|
+
];
|
|
1419
|
+
for (const [pattern, base] of variableForms) {
|
|
1420
|
+
const match = token.match(pattern);
|
|
1421
|
+
if (match) {
|
|
1422
|
+
return {
|
|
1423
|
+
path: resolve(base, match[1] ?? ""),
|
|
1424
|
+
containmentRoot: base,
|
|
1425
|
+
cwd: context.cwd,
|
|
1426
|
+
parserKind,
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(token)
|
|
1431
|
+
|| token.startsWith("node:")) {
|
|
1432
|
+
return null;
|
|
1433
|
+
}
|
|
1434
|
+
if (isAbsolute(token)) {
|
|
1435
|
+
const absolute = resolve(token);
|
|
1436
|
+
const projectRoot = resolve(context.project);
|
|
1437
|
+
const homeRoot = resolve(context.homePath);
|
|
1438
|
+
const containmentRoot = pathIsContained(projectRoot, absolute)
|
|
1439
|
+
? projectRoot
|
|
1440
|
+
: pathIsContained(homeRoot, absolute)
|
|
1441
|
+
? homeRoot
|
|
1442
|
+
: dirname(absolute);
|
|
1443
|
+
return { path: absolute, containmentRoot, cwd: context.cwd, parserKind };
|
|
1444
|
+
}
|
|
1445
|
+
if (contextualPath) {
|
|
1446
|
+
return {
|
|
1447
|
+
path: resolve(context.cwd, token),
|
|
1448
|
+
containmentRoot: context.project,
|
|
1449
|
+
cwd: context.cwd,
|
|
1450
|
+
parserKind,
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
if (token.startsWith("-") || token.startsWith("#"))
|
|
1454
|
+
return null;
|
|
1455
|
+
const isPlainPath = /^[A-Za-z0-9._/-]+$/.test(token);
|
|
1456
|
+
if (isPlainPath && (token.startsWith("./")
|
|
1457
|
+
|| token.startsWith("../")
|
|
1458
|
+
|| token.includes("/")
|
|
1459
|
+
|| /\.(?:bash|cjs|js|mjs|py|sh|ts|zsh)$/.test(token))) {
|
|
1460
|
+
return {
|
|
1461
|
+
path: resolve(context.cwd, token),
|
|
1462
|
+
containmentRoot: context.project,
|
|
1463
|
+
cwd: context.cwd,
|
|
1464
|
+
parserKind,
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
return null;
|
|
1468
|
+
}
|
|
1469
|
+
function decodeDataSpecifier(specifier) {
|
|
1470
|
+
const comma = specifier.indexOf(",");
|
|
1471
|
+
if (comma < 0)
|
|
1472
|
+
return null;
|
|
1473
|
+
try {
|
|
1474
|
+
const metadata = specifier.slice(5, comma);
|
|
1475
|
+
const payload = specifier.slice(comma + 1);
|
|
1476
|
+
const bytes = metadata.split(";").includes("base64")
|
|
1477
|
+
? Buffer.from(payload, "base64")
|
|
1478
|
+
: Buffer.from(decodeURIComponent(payload), "utf8");
|
|
1479
|
+
if (bytes.length > MAX_SHELL_COMMAND_BYTES || bytes.includes(0))
|
|
1480
|
+
return null;
|
|
1481
|
+
const content = bytes.toString("utf8");
|
|
1482
|
+
return content.includes("\uFFFD") ? null : content;
|
|
1483
|
+
}
|
|
1484
|
+
catch {
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
function resolveNodeSpecifierCandidate(candidate, resolveBarePackages = true) {
|
|
1489
|
+
const specifier = candidate.value;
|
|
1490
|
+
if (specifier.startsWith("node:") || isBuiltin(specifier))
|
|
1491
|
+
return { malformed: false };
|
|
1492
|
+
if (specifier.startsWith("data:")) {
|
|
1493
|
+
const content = decodeDataSpecifier(specifier);
|
|
1494
|
+
return content === null
|
|
1495
|
+
? { malformed: true }
|
|
1496
|
+
: {
|
|
1497
|
+
inlineContent: { content, source: specifier },
|
|
1498
|
+
malformed: inlineNodeCodeMayLoadExternal(content),
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
if (specifier.startsWith("file:")) {
|
|
1502
|
+
try {
|
|
1503
|
+
const path = fileURLToPath(specifier);
|
|
1504
|
+
const context = {
|
|
1505
|
+
project: candidate.project,
|
|
1506
|
+
homePath: candidate.homePath,
|
|
1507
|
+
cwd: candidate.cwd,
|
|
1508
|
+
};
|
|
1509
|
+
return {
|
|
1510
|
+
reference: settingsArtifactReference(path, context, true, candidate.parserKind) ?? undefined,
|
|
1511
|
+
malformed: false,
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
catch {
|
|
1515
|
+
return { malformed: true };
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(specifier) || artifactTokenIsAmbiguous(specifier)) {
|
|
1519
|
+
return { malformed: true };
|
|
1520
|
+
}
|
|
1521
|
+
const localSpecifier = isAbsolute(specifier)
|
|
1522
|
+
|| specifier.startsWith("./")
|
|
1523
|
+
|| specifier.startsWith("../")
|
|
1524
|
+
|| specifier.startsWith("~")
|
|
1525
|
+
|| specifier.startsWith("$");
|
|
1526
|
+
if (!resolveBarePackages && !localSpecifier)
|
|
1527
|
+
return { malformed: false };
|
|
1528
|
+
const context = {
|
|
1529
|
+
project: candidate.project,
|
|
1530
|
+
homePath: candidate.homePath,
|
|
1531
|
+
cwd: candidate.cwd,
|
|
1532
|
+
};
|
|
1533
|
+
if (localSpecifier) {
|
|
1534
|
+
if (candidate.issuerPath
|
|
1535
|
+
&& !specifier.startsWith("~")
|
|
1536
|
+
&& !specifier.startsWith("$")) {
|
|
1537
|
+
const resolvedLocalPath = resolveNodeLocalSpecifier(specifier, candidate.issuerPath, candidate.nodeResolutionKind ?? "esm");
|
|
1538
|
+
if (resolvedLocalPath) {
|
|
1539
|
+
return {
|
|
1540
|
+
reference: settingsArtifactReference(resolvedLocalPath, context, true, candidate.parserKind) ?? undefined,
|
|
1541
|
+
malformed: false,
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
return {
|
|
1546
|
+
reference: settingsArtifactReference(specifier, context, true, candidate.parserKind) ?? undefined,
|
|
1547
|
+
malformed: false,
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
if (candidate.nodeResolutionKind === "esm")
|
|
1551
|
+
return { malformed: true };
|
|
1552
|
+
try {
|
|
1553
|
+
const issuerPath = candidate.issuerPath ?? join(candidate.cwd, "package.json");
|
|
1554
|
+
const resolvedPath = createRequire(issuerPath).resolve(specifier);
|
|
1555
|
+
if (isBuiltin(resolvedPath))
|
|
1556
|
+
return { malformed: false };
|
|
1557
|
+
return {
|
|
1558
|
+
reference: settingsArtifactReference(resolvedPath, context, true, candidate.parserKind) ?? undefined,
|
|
1559
|
+
malformed: false,
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
catch {
|
|
1563
|
+
return { malformed: true };
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
const NODE_LOCAL_EXTENSIONS = [".cjs", ".js", ".mjs", ".ts", ".tsx"];
|
|
1567
|
+
function existingNodeTextPath(path) {
|
|
1568
|
+
const candidates = [path];
|
|
1569
|
+
if (extname(path) === "") {
|
|
1570
|
+
candidates.push(...NODE_LOCAL_EXTENSIONS.map((extension) => `${path}${extension}`), ...NODE_LOCAL_EXTENSIONS.map((extension) => join(path, `index${extension}`)));
|
|
1571
|
+
}
|
|
1572
|
+
for (const candidate of candidates) {
|
|
1573
|
+
try {
|
|
1574
|
+
if (statSync(candidate).isFile())
|
|
1575
|
+
return candidate;
|
|
1576
|
+
}
|
|
1577
|
+
catch {
|
|
1578
|
+
// Try the next bounded Node text candidate.
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
return null;
|
|
1582
|
+
}
|
|
1583
|
+
function resolveNodeLocalSpecifier(specifier, issuerPath, resolutionKind) {
|
|
1584
|
+
if (resolutionKind === "cjs") {
|
|
1585
|
+
try {
|
|
1586
|
+
const resolvedPath = createRequire(issuerPath).resolve(specifier);
|
|
1587
|
+
return isBuiltin(resolvedPath) ? null : resolvedPath;
|
|
1588
|
+
}
|
|
1589
|
+
catch {
|
|
1590
|
+
// Preserve a missing local path so the queue reports it visibly.
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
const unresolvedPath = isAbsolute(specifier)
|
|
1594
|
+
? resolve(specifier)
|
|
1595
|
+
: resolve(dirname(issuerPath), specifier);
|
|
1596
|
+
return existingNodeTextPath(unresolvedPath) ?? unresolvedPath;
|
|
1597
|
+
}
|
|
1598
|
+
function tokenizeJavaScriptDependencies(content) {
|
|
1599
|
+
const tokens = [];
|
|
1600
|
+
let malformed = false;
|
|
1601
|
+
for (let index = 0; index < content.length;) {
|
|
1602
|
+
const character = content[index];
|
|
1603
|
+
if (/\s/.test(character)) {
|
|
1604
|
+
index += 1;
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
if (character === "/" && content[index + 1] === "/") {
|
|
1608
|
+
index += 2;
|
|
1609
|
+
while (index < content.length && content[index] !== "\n")
|
|
1610
|
+
index += 1;
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
if (character === "/" && content[index + 1] === "*") {
|
|
1614
|
+
const end = content.indexOf("*/", index + 2);
|
|
1615
|
+
if (end < 0) {
|
|
1616
|
+
malformed = true;
|
|
1617
|
+
break;
|
|
1618
|
+
}
|
|
1619
|
+
index = end + 2;
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
if (character === "'" || character === '"') {
|
|
1623
|
+
const quote = character;
|
|
1624
|
+
let value = "";
|
|
1625
|
+
let valid = true;
|
|
1626
|
+
index += 1;
|
|
1627
|
+
let closed = false;
|
|
1628
|
+
while (index < content.length) {
|
|
1629
|
+
const current = content[index];
|
|
1630
|
+
if (current === quote) {
|
|
1631
|
+
closed = true;
|
|
1632
|
+
index += 1;
|
|
1633
|
+
break;
|
|
1634
|
+
}
|
|
1635
|
+
if (current === "\\") {
|
|
1636
|
+
valid = false;
|
|
1637
|
+
if (index + 1 >= content.length)
|
|
1638
|
+
break;
|
|
1639
|
+
value += content[index + 1];
|
|
1640
|
+
index += 2;
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
if (current === "\n" || current === "\r") {
|
|
1644
|
+
valid = false;
|
|
1645
|
+
break;
|
|
1646
|
+
}
|
|
1647
|
+
value += current;
|
|
1648
|
+
index += 1;
|
|
1649
|
+
}
|
|
1650
|
+
malformed ||= !closed;
|
|
1651
|
+
tokens.push({ kind: "string", value, valid: valid && closed });
|
|
1652
|
+
continue;
|
|
1653
|
+
}
|
|
1654
|
+
if (character === "`") {
|
|
1655
|
+
let escaped = false;
|
|
1656
|
+
let closed = false;
|
|
1657
|
+
index += 1;
|
|
1658
|
+
while (index < content.length) {
|
|
1659
|
+
const current = content[index];
|
|
1660
|
+
if (!escaped && current === "`") {
|
|
1661
|
+
closed = true;
|
|
1662
|
+
index += 1;
|
|
1663
|
+
break;
|
|
1664
|
+
}
|
|
1665
|
+
escaped = !escaped && current === "\\";
|
|
1666
|
+
if (current !== "\\")
|
|
1667
|
+
escaped = false;
|
|
1668
|
+
index += 1;
|
|
1669
|
+
}
|
|
1670
|
+
malformed ||= !closed;
|
|
1671
|
+
tokens.push({ kind: "template", value: "", valid: closed });
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
if (/[A-Za-z_$]/.test(character)) {
|
|
1675
|
+
let end = index + 1;
|
|
1676
|
+
while (end < content.length && /[A-Za-z0-9_$]/.test(content[end]))
|
|
1677
|
+
end += 1;
|
|
1678
|
+
tokens.push({
|
|
1679
|
+
kind: "identifier",
|
|
1680
|
+
value: content.slice(index, end),
|
|
1681
|
+
valid: true,
|
|
1682
|
+
});
|
|
1683
|
+
index = end;
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
tokens.push({ kind: "punctuation", value: character, valid: true });
|
|
1687
|
+
index += 1;
|
|
1688
|
+
}
|
|
1689
|
+
return { tokens, malformed };
|
|
1690
|
+
}
|
|
1691
|
+
function scanNodeArtifactDependencies(artifact, content, context, resolveBarePackages = true) {
|
|
1692
|
+
const tokenized = tokenizeJavaScriptDependencies(content);
|
|
1693
|
+
const candidates = [];
|
|
1694
|
+
let malformed = tokenized.malformed;
|
|
1695
|
+
const addCandidate = (token, resolutionKind) => {
|
|
1696
|
+
if (!token || token.kind !== "string" || !token.valid || token.value.length === 0) {
|
|
1697
|
+
malformed = true;
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
candidates.push({
|
|
1701
|
+
value: token.value,
|
|
1702
|
+
cwd: dirname(artifact),
|
|
1703
|
+
project: context.project,
|
|
1704
|
+
homePath: context.homePath,
|
|
1705
|
+
parserKind: "node",
|
|
1706
|
+
nodeResolutionKind: resolutionKind,
|
|
1707
|
+
issuerPath: artifact,
|
|
1708
|
+
});
|
|
1709
|
+
};
|
|
1710
|
+
const { tokens } = tokenized;
|
|
1711
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
1712
|
+
const token = tokens[index];
|
|
1713
|
+
const previous = tokens[index - 1];
|
|
1714
|
+
if (token.kind !== "identifier")
|
|
1715
|
+
continue;
|
|
1716
|
+
if (token.value === "eval" && previous?.value !== "." && tokens[index + 1]?.value === "(") {
|
|
1717
|
+
malformed = true;
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
if (token.value === "require") {
|
|
1721
|
+
if (previous?.value === ".") {
|
|
1722
|
+
if (tokens[index - 2]?.value === "module" && tokens[index + 1]?.value === "(") {
|
|
1723
|
+
malformed = true;
|
|
1724
|
+
}
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
if (tokens[index + 1]?.value === "(") {
|
|
1728
|
+
const literal = tokens[index + 2];
|
|
1729
|
+
const closed = tokens[index + 3]?.value === ")";
|
|
1730
|
+
if (!closed)
|
|
1731
|
+
malformed = true;
|
|
1732
|
+
addCandidate(literal, "cjs");
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
malformed = true;
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
if (previous?.value === "." || !["export", "import"].includes(token.value))
|
|
1739
|
+
continue;
|
|
1740
|
+
if (token.value === "import" && tokens[index + 1]?.value === "(") {
|
|
1741
|
+
const literal = tokens[index + 2];
|
|
1742
|
+
const closed = tokens[index + 3]?.value === ")";
|
|
1743
|
+
if (!closed)
|
|
1744
|
+
malformed = true;
|
|
1745
|
+
addCandidate(literal, "esm");
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
if (token.value === "import" && tokens[index + 1]?.kind === "string") {
|
|
1749
|
+
addCandidate(tokens[index + 1], "esm");
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1752
|
+
if (token.value === "import" && tokens[index + 1]?.value === ".")
|
|
1753
|
+
continue;
|
|
1754
|
+
let fromIndex = -1;
|
|
1755
|
+
for (let cursor = index + 1; cursor < Math.min(tokens.length, index + 32); cursor += 1) {
|
|
1756
|
+
if (tokens[cursor].value === ";")
|
|
1757
|
+
break;
|
|
1758
|
+
if (tokens[cursor].value === "from") {
|
|
1759
|
+
fromIndex = cursor;
|
|
1760
|
+
break;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
if (fromIndex >= 0) {
|
|
1764
|
+
addCandidate(tokens[fromIndex + 1], "esm");
|
|
1765
|
+
}
|
|
1766
|
+
else if (token.value === "import") {
|
|
1767
|
+
malformed = true;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
const references = [];
|
|
1771
|
+
const inlineContents = [];
|
|
1772
|
+
for (const candidate of candidates) {
|
|
1773
|
+
const resolved = resolveNodeSpecifierCandidate(candidate, resolveBarePackages);
|
|
1774
|
+
if (resolved.reference)
|
|
1775
|
+
references.push(resolved.reference);
|
|
1776
|
+
if (resolved.inlineContent)
|
|
1777
|
+
inlineContents.push(resolved.inlineContent);
|
|
1778
|
+
malformed ||= resolved.malformed;
|
|
1779
|
+
}
|
|
1780
|
+
return { references, inlineContents, malformed };
|
|
1781
|
+
}
|
|
1782
|
+
function maskPythonStringsAndComments(content) {
|
|
1783
|
+
const masked = content.split("");
|
|
1784
|
+
let malformed = false;
|
|
1785
|
+
for (let index = 0; index < content.length;) {
|
|
1786
|
+
const character = content[index];
|
|
1787
|
+
if (character === "#") {
|
|
1788
|
+
while (index < content.length && content[index] !== "\n") {
|
|
1789
|
+
masked[index] = " ";
|
|
1790
|
+
index += 1;
|
|
1791
|
+
}
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1794
|
+
if (character !== "'" && character !== '"') {
|
|
1795
|
+
index += 1;
|
|
1796
|
+
continue;
|
|
1797
|
+
}
|
|
1798
|
+
let prefixStart = index;
|
|
1799
|
+
while (prefixStart > 0 && /[A-Za-z]/.test(content[prefixStart - 1])) {
|
|
1800
|
+
prefixStart -= 1;
|
|
1801
|
+
}
|
|
1802
|
+
const prefix = content.slice(prefixStart, index);
|
|
1803
|
+
const fString = /^(?:f|fr|rf)$/i.test(prefix);
|
|
1804
|
+
const quote = character;
|
|
1805
|
+
const triple = content.slice(index, index + 3) === quote.repeat(3);
|
|
1806
|
+
const delimiterLength = triple ? 3 : 1;
|
|
1807
|
+
let cursor = index;
|
|
1808
|
+
for (let count = 0; count < delimiterLength; count += 1)
|
|
1809
|
+
masked[cursor + count] = " ";
|
|
1810
|
+
cursor += delimiterLength;
|
|
1811
|
+
let closed = false;
|
|
1812
|
+
while (cursor < content.length) {
|
|
1813
|
+
if (content[cursor] === "\\") {
|
|
1814
|
+
masked[cursor] = " ";
|
|
1815
|
+
if (cursor + 1 < content.length)
|
|
1816
|
+
masked[cursor + 1] = " ";
|
|
1817
|
+
cursor += 2;
|
|
1818
|
+
continue;
|
|
1819
|
+
}
|
|
1820
|
+
if (content.slice(cursor, cursor + delimiterLength) === quote.repeat(delimiterLength)) {
|
|
1821
|
+
for (let count = 0; count < delimiterLength; count += 1)
|
|
1822
|
+
masked[cursor + count] = " ";
|
|
1823
|
+
cursor += delimiterLength;
|
|
1824
|
+
closed = true;
|
|
1825
|
+
break;
|
|
1826
|
+
}
|
|
1827
|
+
if (!triple && (content[cursor] === "\n" || content[cursor] === "\r"))
|
|
1828
|
+
break;
|
|
1829
|
+
if (content[cursor] !== "\n" && content[cursor] !== "\r")
|
|
1830
|
+
masked[cursor] = " ";
|
|
1831
|
+
cursor += 1;
|
|
1832
|
+
}
|
|
1833
|
+
if (fString) {
|
|
1834
|
+
const stringBody = content.slice(index + delimiterLength, cursor - delimiterLength);
|
|
1835
|
+
for (let bodyIndex = 0; bodyIndex < stringBody.length; bodyIndex += 1) {
|
|
1836
|
+
if (stringBody[bodyIndex] !== "{")
|
|
1837
|
+
continue;
|
|
1838
|
+
if (stringBody[bodyIndex + 1] === "{") {
|
|
1839
|
+
bodyIndex += 1;
|
|
1840
|
+
continue;
|
|
1841
|
+
}
|
|
1842
|
+
malformed = true;
|
|
1843
|
+
break;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
malformed ||= !closed;
|
|
1847
|
+
index = cursor;
|
|
1848
|
+
}
|
|
1849
|
+
return { content: masked.join(""), malformed };
|
|
1850
|
+
}
|
|
1851
|
+
function resolvePythonModulePaths(baseDirectory, moduleName) {
|
|
1852
|
+
const components = moduleName.split(".");
|
|
1853
|
+
if (components.length > MAX_LANGUAGE_DEPENDENCY_DEPTH) {
|
|
1854
|
+
return { paths: [], resolved: false };
|
|
1855
|
+
}
|
|
1856
|
+
const paths = [];
|
|
1857
|
+
let currentPath = baseDirectory;
|
|
1858
|
+
for (let index = 0; index < components.length; index += 1) {
|
|
1859
|
+
currentPath = join(currentPath, components[index]);
|
|
1860
|
+
const packageInitializer = join(currentPath, "__init__.py");
|
|
1861
|
+
try {
|
|
1862
|
+
if (statSync(packageInitializer).isFile()) {
|
|
1863
|
+
paths.push(packageInitializer);
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
catch {
|
|
1868
|
+
// A namespace package may legitimately omit an initializer.
|
|
1869
|
+
}
|
|
1870
|
+
const terminal = index === components.length - 1;
|
|
1871
|
+
if (terminal) {
|
|
1872
|
+
const modulePath = `${currentPath}.py`;
|
|
1873
|
+
try {
|
|
1874
|
+
if (statSync(modulePath).isFile()) {
|
|
1875
|
+
paths.push(modulePath);
|
|
1876
|
+
return { paths, resolved: true };
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
catch {
|
|
1880
|
+
return { paths, resolved: false };
|
|
1881
|
+
}
|
|
1882
|
+
return { paths, resolved: false };
|
|
1883
|
+
}
|
|
1884
|
+
try {
|
|
1885
|
+
if (!statSync(currentPath).isDirectory())
|
|
1886
|
+
return { paths, resolved: false };
|
|
1887
|
+
}
|
|
1888
|
+
catch {
|
|
1889
|
+
return { paths, resolved: false };
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
return { paths, resolved: paths.length > 0 };
|
|
1893
|
+
}
|
|
1894
|
+
function scanPythonArtifactDependencies(artifact, content, context) {
|
|
1895
|
+
const masked = maskPythonStringsAndComments(content);
|
|
1896
|
+
const references = [];
|
|
1897
|
+
let malformed = masked.malformed
|
|
1898
|
+
|| /\b(?:exec|eval|__import__)\s*\(|\bimportlib\s*\./.test(masked.content);
|
|
1899
|
+
const baseDirectoryForDepth = (relativeDepth) => {
|
|
1900
|
+
let baseDirectory = dirname(artifact);
|
|
1901
|
+
for (let depth = 1; depth < relativeDepth; depth += 1) {
|
|
1902
|
+
baseDirectory = dirname(baseDirectory);
|
|
1903
|
+
}
|
|
1904
|
+
return baseDirectory;
|
|
1905
|
+
};
|
|
1906
|
+
const addReference = (modulePath) => {
|
|
1907
|
+
const reference = settingsArtifactReference(modulePath, { ...context, cwd: dirname(artifact) }, true, "python");
|
|
1908
|
+
if (reference)
|
|
1909
|
+
references.push(reference);
|
|
1910
|
+
else
|
|
1911
|
+
malformed = true;
|
|
1912
|
+
};
|
|
1913
|
+
const addModule = (moduleName, relativeDepth = 0) => {
|
|
1914
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(moduleName)) {
|
|
1915
|
+
malformed = true;
|
|
1916
|
+
return null;
|
|
1917
|
+
}
|
|
1918
|
+
const baseDirectory = baseDirectoryForDepth(relativeDepth);
|
|
1919
|
+
const resolvedModule = resolvePythonModulePaths(baseDirectory, moduleName);
|
|
1920
|
+
malformed ||= !resolvedModule.resolved;
|
|
1921
|
+
for (const modulePath of resolvedModule.paths)
|
|
1922
|
+
addReference(modulePath);
|
|
1923
|
+
const terminalPath = resolvedModule.paths.at(-1);
|
|
1924
|
+
return terminalPath && basename(terminalPath) === "__init__.py"
|
|
1925
|
+
? dirname(terminalPath)
|
|
1926
|
+
: null;
|
|
1927
|
+
};
|
|
1928
|
+
const fromListNames = (value) => {
|
|
1929
|
+
const names = [];
|
|
1930
|
+
for (const imported of value.split(",")) {
|
|
1931
|
+
const importedName = imported.trim().split(/\s+as\s+/)[0];
|
|
1932
|
+
if (importedName === "*" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(importedName)) {
|
|
1933
|
+
malformed = true;
|
|
1934
|
+
continue;
|
|
1935
|
+
}
|
|
1936
|
+
names.push(importedName);
|
|
1937
|
+
}
|
|
1938
|
+
return names;
|
|
1939
|
+
};
|
|
1940
|
+
const inspectPackageFromList = (packageDirectory, value) => {
|
|
1941
|
+
for (const importedName of fromListNames(value)) {
|
|
1942
|
+
if (packageDirectory
|
|
1943
|
+
&& resolvePythonModulePaths(packageDirectory, importedName).resolved) {
|
|
1944
|
+
malformed = true;
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
};
|
|
1948
|
+
for (const line of masked.content.split(/\r?\n/)) {
|
|
1949
|
+
for (const rawSegment of line.split(/[;:]/)) {
|
|
1950
|
+
const segment = rawSegment.trim();
|
|
1951
|
+
if (segment.startsWith("import ")) {
|
|
1952
|
+
const imports = segment.slice("import ".length).split(",");
|
|
1953
|
+
for (const imported of imports) {
|
|
1954
|
+
addModule(imported.trim().split(/\s+as\s+/)[0]);
|
|
1955
|
+
}
|
|
1956
|
+
continue;
|
|
1957
|
+
}
|
|
1958
|
+
const fromImport = segment.match(/^from\s+(\.*)([A-Za-z_][A-Za-z0-9_.]*)?\s+import\s+(.+)$/);
|
|
1959
|
+
if (!fromImport)
|
|
1960
|
+
continue;
|
|
1961
|
+
const relativeDepth = fromImport[1].length;
|
|
1962
|
+
const sourceModule = fromImport[2];
|
|
1963
|
+
if (sourceModule) {
|
|
1964
|
+
const packageDirectory = addModule(sourceModule, relativeDepth);
|
|
1965
|
+
inspectPackageFromList(packageDirectory, fromImport[3]);
|
|
1966
|
+
continue;
|
|
1967
|
+
}
|
|
1968
|
+
if (relativeDepth === 0) {
|
|
1969
|
+
malformed = true;
|
|
1970
|
+
continue;
|
|
1971
|
+
}
|
|
1972
|
+
const packageDirectory = baseDirectoryForDepth(relativeDepth);
|
|
1973
|
+
const packageInitializer = join(packageDirectory, "__init__.py");
|
|
1974
|
+
let localPackage = false;
|
|
1975
|
+
try {
|
|
1976
|
+
localPackage = statSync(packageInitializer).isFile();
|
|
1977
|
+
}
|
|
1978
|
+
catch {
|
|
1979
|
+
// Without an initializer, preserve namespace-package traversal.
|
|
1980
|
+
}
|
|
1981
|
+
if (localPackage) {
|
|
1982
|
+
addReference(packageInitializer);
|
|
1983
|
+
inspectPackageFromList(packageDirectory, fromImport[3]);
|
|
1984
|
+
continue;
|
|
1985
|
+
}
|
|
1986
|
+
for (const importedModule of fromListNames(fromImport[3])) {
|
|
1987
|
+
addModule(importedModule, relativeDepth);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
return {
|
|
1992
|
+
references: [...new Map(references.map((reference) => [reference.path, reference])).values()],
|
|
1993
|
+
inlineContents: [],
|
|
1994
|
+
malformed,
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
function scanLanguageArtifactDependencies(artifact, content, parserKind, context, resolveBareNodePackages = true) {
|
|
1998
|
+
if (parserKind === "node") {
|
|
1999
|
+
return scanNodeArtifactDependencies(artifact, content, context, resolveBareNodePackages);
|
|
2000
|
+
}
|
|
2001
|
+
if (parserKind === "python") {
|
|
2002
|
+
return scanPythonArtifactDependencies(artifact, content, context);
|
|
2003
|
+
}
|
|
2004
|
+
return { references: [], inlineContents: [], malformed: false };
|
|
2005
|
+
}
|
|
2006
|
+
function settingsArtifactReferences(command, project, homePath, cwd = project) {
|
|
2007
|
+
const context = { project, homePath, cwd };
|
|
2008
|
+
const scan = settingsArtifactTokens(command, 0, context);
|
|
2009
|
+
const candidateValues = new Set((scan.artifacts ?? []).map((candidate) => candidate.value));
|
|
2010
|
+
const inferredReferences = scan.tokens
|
|
2011
|
+
.filter((token) => !candidateValues.has(token))
|
|
2012
|
+
.map((token) => settingsArtifactReference(token, context))
|
|
2013
|
+
.filter((reference) => reference !== null);
|
|
2014
|
+
const resolvedReferences = [];
|
|
2015
|
+
const inlineContents = [...(scan.inlineContents ?? [])];
|
|
2016
|
+
let malformed = scan.malformed;
|
|
2017
|
+
for (const candidate of scan.artifacts ?? []) {
|
|
2018
|
+
if (candidate.nodeResolutionKind) {
|
|
2019
|
+
const resolved = resolveNodeSpecifierCandidate(candidate);
|
|
2020
|
+
if (resolved.reference)
|
|
2021
|
+
resolvedReferences.push(resolved.reference);
|
|
2022
|
+
if (resolved.inlineContent)
|
|
2023
|
+
inlineContents.push(resolved.inlineContent);
|
|
2024
|
+
malformed ||= resolved.malformed;
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
const reference = settingsArtifactReference(candidate.value, { ...context, cwd: candidate.cwd }, true, candidate.parserKind);
|
|
2028
|
+
if (reference)
|
|
2029
|
+
resolvedReferences.push(reference);
|
|
2030
|
+
else
|
|
2031
|
+
malformed = true;
|
|
2032
|
+
}
|
|
2033
|
+
const references = [...inferredReferences, ...resolvedReferences];
|
|
2034
|
+
return {
|
|
2035
|
+
references: [...new Map(references.map((reference) => [
|
|
2036
|
+
`${reference.path}:${reference.parserKind}`,
|
|
2037
|
+
reference,
|
|
2038
|
+
])).values()],
|
|
2039
|
+
inlineContents,
|
|
2040
|
+
malformed,
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
const SETTINGS_INTERPRETERS = new Set([
|
|
2044
|
+
"bash",
|
|
2045
|
+
"node",
|
|
2046
|
+
"python",
|
|
2047
|
+
"python3",
|
|
2048
|
+
"sh",
|
|
2049
|
+
"tsx",
|
|
2050
|
+
"zsh",
|
|
2051
|
+
]);
|
|
2052
|
+
function isEnvironmentAssignment(token) {
|
|
2053
|
+
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
|
|
2054
|
+
}
|
|
2055
|
+
function artifactTokenIsAmbiguous(token) {
|
|
2056
|
+
if (/[*?\[]/.test(token))
|
|
2057
|
+
return true;
|
|
2058
|
+
if (!token.includes("$"))
|
|
2059
|
+
return false;
|
|
2060
|
+
return !/^(?:\$(?:CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR|HOME|PLUGIN_ROOT)|\$\{(?:CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR|HOME|PLUGIN_ROOT)\})(?:\/[A-Za-z0-9._/-]*)?$/.test(token);
|
|
2061
|
+
}
|
|
2062
|
+
const NODE_CJS_EXECUTABLE_PATH_OPTIONS = new Set([
|
|
2063
|
+
"-r",
|
|
2064
|
+
"--require",
|
|
2065
|
+
]);
|
|
2066
|
+
const NODE_ESM_EXECUTABLE_PATH_OPTIONS = new Set([
|
|
2067
|
+
"--experimental-loader",
|
|
2068
|
+
"--import",
|
|
2069
|
+
"--loader",
|
|
2070
|
+
]);
|
|
2071
|
+
const NODE_INLINE_CODE_OPTIONS = new Set(["-e", "--eval", "-p", "--print"]);
|
|
2072
|
+
const NODE_NON_PATH_VALUE_OPTIONS = new Set([
|
|
2073
|
+
"-C",
|
|
2074
|
+
"--conditions",
|
|
2075
|
+
"--input-type",
|
|
2076
|
+
"--inspect-port",
|
|
2077
|
+
"--title",
|
|
2078
|
+
"--unhandled-rejections",
|
|
2079
|
+
]);
|
|
2080
|
+
const NODE_NO_VALUE_OPTIONS = new Set([
|
|
2081
|
+
"--enable-source-maps",
|
|
2082
|
+
"--experimental-strip-types",
|
|
2083
|
+
"--experimental-transform-types",
|
|
2084
|
+
"--no-deprecation",
|
|
2085
|
+
"--no-warnings",
|
|
2086
|
+
"--test",
|
|
2087
|
+
"--trace-deprecation",
|
|
2088
|
+
"--trace-warnings",
|
|
2089
|
+
"--watch",
|
|
2090
|
+
]);
|
|
2091
|
+
const NODE_CHECK_ONLY_OPTIONS = new Set(["--check", "-c"]);
|
|
2092
|
+
const NODE_TERMINAL_OPTIONS = new Set(["--help", "--version", "-h", "-v"]);
|
|
2093
|
+
function inlineNodeCodeMayLoadExternal(code) {
|
|
2094
|
+
return /\b(?:import|require)\s*\(/.test(code)
|
|
2095
|
+
|| /\bimport\s+(?:[^;\n]*\s+from\s+)?["']/.test(code)
|
|
2096
|
+
|| /\b(?:eval|Function)\s*\(/.test(code);
|
|
2097
|
+
}
|
|
2098
|
+
function matchingOptionValue(token, options) {
|
|
2099
|
+
for (const option of options) {
|
|
2100
|
+
if (token.startsWith(`${option}=`))
|
|
2101
|
+
return token.slice(option.length + 1);
|
|
2102
|
+
}
|
|
2103
|
+
return null;
|
|
2104
|
+
}
|
|
2105
|
+
function scanNodeInterpreterTokens(tokens, startIndex, context) {
|
|
2106
|
+
const references = [];
|
|
2107
|
+
const artifacts = [];
|
|
2108
|
+
let malformed = false;
|
|
2109
|
+
const addReference = (reference, nodeResolutionKind) => {
|
|
2110
|
+
references.push(reference);
|
|
2111
|
+
artifacts.push({
|
|
2112
|
+
value: reference,
|
|
2113
|
+
cwd: context.cwd,
|
|
2114
|
+
project: context.project,
|
|
2115
|
+
homePath: context.homePath,
|
|
2116
|
+
parserKind: "node",
|
|
2117
|
+
nodeResolutionKind,
|
|
2118
|
+
});
|
|
2119
|
+
malformed ||= artifactTokenIsAmbiguous(reference);
|
|
2120
|
+
};
|
|
2121
|
+
const result = (isMalformed = malformed) => ({
|
|
2122
|
+
tokens: references,
|
|
2123
|
+
artifacts,
|
|
2124
|
+
malformed: isMalformed,
|
|
2125
|
+
});
|
|
2126
|
+
let index = startIndex;
|
|
2127
|
+
let checkOnly = false;
|
|
2128
|
+
while (index < tokens.length) {
|
|
2129
|
+
const token = tokens[index];
|
|
2130
|
+
if (token === "--") {
|
|
2131
|
+
const script = tokens[index + 1];
|
|
2132
|
+
if (script && !checkOnly)
|
|
2133
|
+
addReference(script);
|
|
2134
|
+
return script ? result() : result(true);
|
|
2135
|
+
}
|
|
2136
|
+
if (!token.startsWith("-")) {
|
|
2137
|
+
if (!checkOnly)
|
|
2138
|
+
addReference(token);
|
|
2139
|
+
return result();
|
|
2140
|
+
}
|
|
2141
|
+
if (NODE_TERMINAL_OPTIONS.has(token))
|
|
2142
|
+
return result();
|
|
2143
|
+
if (NODE_CHECK_ONLY_OPTIONS.has(token)) {
|
|
2144
|
+
checkOnly = true;
|
|
2145
|
+
index += 1;
|
|
2146
|
+
continue;
|
|
2147
|
+
}
|
|
2148
|
+
const resolutionKind = NODE_CJS_EXECUTABLE_PATH_OPTIONS.has(token)
|
|
2149
|
+
? "cjs"
|
|
2150
|
+
: NODE_ESM_EXECUTABLE_PATH_OPTIONS.has(token)
|
|
2151
|
+
? "esm"
|
|
2152
|
+
: null;
|
|
2153
|
+
if (resolutionKind) {
|
|
2154
|
+
const pathOperand = tokens[index + 1];
|
|
2155
|
+
if (!pathOperand)
|
|
2156
|
+
return result(true);
|
|
2157
|
+
addReference(pathOperand, resolutionKind);
|
|
2158
|
+
index += 2;
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
2161
|
+
const cjsPathValue = matchingOptionValue(token, NODE_CJS_EXECUTABLE_PATH_OPTIONS);
|
|
2162
|
+
const esmPathValue = matchingOptionValue(token, NODE_ESM_EXECUTABLE_PATH_OPTIONS);
|
|
2163
|
+
const pathValue = cjsPathValue ?? esmPathValue;
|
|
2164
|
+
if (pathValue !== null) {
|
|
2165
|
+
if (!pathValue)
|
|
2166
|
+
return result(true);
|
|
2167
|
+
addReference(pathValue, cjsPathValue !== null ? "cjs" : "esm");
|
|
2168
|
+
index += 1;
|
|
2169
|
+
continue;
|
|
2170
|
+
}
|
|
2171
|
+
if (token.startsWith("-r") && token.length > 2) {
|
|
2172
|
+
addReference(token.slice(2), "cjs");
|
|
2173
|
+
index += 1;
|
|
2174
|
+
continue;
|
|
2175
|
+
}
|
|
2176
|
+
if (NODE_INLINE_CODE_OPTIONS.has(token)) {
|
|
2177
|
+
const code = tokens[index + 1];
|
|
2178
|
+
if (code === undefined)
|
|
2179
|
+
return result(true);
|
|
2180
|
+
return result(malformed || inlineNodeCodeMayLoadExternal(code));
|
|
2181
|
+
}
|
|
2182
|
+
const inlineCode = matchingOptionValue(token, NODE_INLINE_CODE_OPTIONS);
|
|
2183
|
+
if (inlineCode !== null) {
|
|
2184
|
+
return result(malformed || inlineNodeCodeMayLoadExternal(inlineCode));
|
|
2185
|
+
}
|
|
2186
|
+
if ((token.startsWith("-e") || token.startsWith("-p")) && token.length > 2) {
|
|
2187
|
+
return result(malformed || inlineNodeCodeMayLoadExternal(token.slice(2)));
|
|
2188
|
+
}
|
|
2189
|
+
if (NODE_NON_PATH_VALUE_OPTIONS.has(token)) {
|
|
2190
|
+
if (tokens[index + 1] === undefined)
|
|
2191
|
+
return result(true);
|
|
2192
|
+
index += 2;
|
|
2193
|
+
continue;
|
|
2194
|
+
}
|
|
2195
|
+
if (matchingOptionValue(token, NODE_NON_PATH_VALUE_OPTIONS) !== null) {
|
|
2196
|
+
index += 1;
|
|
2197
|
+
continue;
|
|
2198
|
+
}
|
|
2199
|
+
if (NODE_NO_VALUE_OPTIONS.has(token)) {
|
|
2200
|
+
index += 1;
|
|
2201
|
+
continue;
|
|
2202
|
+
}
|
|
2203
|
+
return result(true);
|
|
2204
|
+
}
|
|
2205
|
+
return checkOnly ? result() : result(true);
|
|
2206
|
+
}
|
|
2207
|
+
const SHELL_SCRIPT_FLAGS = new Set(["-e", "-eu", "-o", "-u", "-ue"]);
|
|
2208
|
+
const PYTHON_NO_VALUE_OPTIONS = new Set([
|
|
2209
|
+
"-B",
|
|
2210
|
+
"-E",
|
|
2211
|
+
"-I",
|
|
2212
|
+
"-O",
|
|
2213
|
+
"-OO",
|
|
2214
|
+
"-P",
|
|
2215
|
+
"-q",
|
|
2216
|
+
"-s",
|
|
2217
|
+
"-S",
|
|
2218
|
+
"-u",
|
|
2219
|
+
"-v",
|
|
2220
|
+
"-x",
|
|
2221
|
+
]);
|
|
2222
|
+
const PYTHON_VALUE_OPTIONS = new Set(["-W", "-X", "--check-hash-based-pycs"]);
|
|
2223
|
+
const PYTHON_TERMINAL_OPTIONS = new Set(["--help", "--version", "-h", "-V"]);
|
|
2224
|
+
function scanShellInterpreterTokens(tokens, startIndex, depth, context, options) {
|
|
2225
|
+
let index = startIndex;
|
|
2226
|
+
while (index < tokens.length) {
|
|
2227
|
+
const token = tokens[index];
|
|
2228
|
+
if (token === "--") {
|
|
2229
|
+
const script = tokens[index + 1];
|
|
2230
|
+
return script
|
|
2231
|
+
? {
|
|
2232
|
+
tokens: [script],
|
|
2233
|
+
artifacts: [{
|
|
2234
|
+
value: script,
|
|
2235
|
+
cwd: context.cwd,
|
|
2236
|
+
project: context.project,
|
|
2237
|
+
homePath: context.homePath,
|
|
2238
|
+
parserKind: "shell",
|
|
2239
|
+
}],
|
|
2240
|
+
malformed: artifactTokenIsAmbiguous(script),
|
|
2241
|
+
}
|
|
2242
|
+
: { tokens: [], malformed: true };
|
|
2243
|
+
}
|
|
2244
|
+
if (token === "-c" || token === "-lc") {
|
|
2245
|
+
const nestedCommand = tokens[index + 1];
|
|
2246
|
+
if (!nestedCommand)
|
|
2247
|
+
return { tokens: [], malformed: true };
|
|
2248
|
+
return settingsArtifactTokens(nestedCommand, depth + 1, context, options);
|
|
2249
|
+
}
|
|
2250
|
+
if (token === "-o") {
|
|
2251
|
+
if (!tokens[index + 1])
|
|
2252
|
+
return { tokens: [], malformed: true };
|
|
2253
|
+
index += 2;
|
|
2254
|
+
continue;
|
|
2255
|
+
}
|
|
2256
|
+
if (SHELL_SCRIPT_FLAGS.has(token)) {
|
|
2257
|
+
index += 1;
|
|
2258
|
+
continue;
|
|
2259
|
+
}
|
|
2260
|
+
if (token.startsWith("-") || token.startsWith("+")) {
|
|
2261
|
+
return { tokens: [], malformed: true };
|
|
2262
|
+
}
|
|
2263
|
+
return {
|
|
2264
|
+
tokens: [token],
|
|
2265
|
+
artifacts: [{
|
|
2266
|
+
value: token,
|
|
2267
|
+
cwd: context.cwd,
|
|
2268
|
+
project: context.project,
|
|
2269
|
+
homePath: context.homePath,
|
|
2270
|
+
parserKind: "shell",
|
|
2271
|
+
}],
|
|
2272
|
+
malformed: artifactTokenIsAmbiguous(token),
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
return { tokens: [], malformed: true };
|
|
2276
|
+
}
|
|
2277
|
+
function scanPythonInterpreterTokens(tokens, startIndex, context) {
|
|
2278
|
+
let index = startIndex;
|
|
2279
|
+
while (index < tokens.length) {
|
|
2280
|
+
const token = tokens[index];
|
|
2281
|
+
if (token === "--") {
|
|
2282
|
+
const script = tokens[index + 1];
|
|
2283
|
+
return script
|
|
2284
|
+
? {
|
|
2285
|
+
tokens: [script],
|
|
2286
|
+
artifacts: [{
|
|
2287
|
+
value: script,
|
|
2288
|
+
cwd: context.cwd,
|
|
2289
|
+
project: context.project,
|
|
2290
|
+
homePath: context.homePath,
|
|
2291
|
+
parserKind: "python",
|
|
2292
|
+
}],
|
|
2293
|
+
malformed: artifactTokenIsAmbiguous(script),
|
|
2294
|
+
}
|
|
2295
|
+
: { tokens: [], malformed: true };
|
|
2296
|
+
}
|
|
2297
|
+
if (!token.startsWith("-")) {
|
|
2298
|
+
return {
|
|
2299
|
+
tokens: [token],
|
|
2300
|
+
artifacts: [{
|
|
2301
|
+
value: token,
|
|
2302
|
+
cwd: context.cwd,
|
|
2303
|
+
project: context.project,
|
|
2304
|
+
homePath: context.homePath,
|
|
2305
|
+
parserKind: "python",
|
|
2306
|
+
}],
|
|
2307
|
+
malformed: artifactTokenIsAmbiguous(token),
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
if (PYTHON_TERMINAL_OPTIONS.has(token))
|
|
2311
|
+
return { tokens: [], malformed: false };
|
|
2312
|
+
if (token === "-c" || token === "-m" || token.startsWith("-c")) {
|
|
2313
|
+
return { tokens: [], malformed: true };
|
|
2314
|
+
}
|
|
2315
|
+
if (PYTHON_VALUE_OPTIONS.has(token)) {
|
|
2316
|
+
if (tokens[index + 1] === undefined)
|
|
2317
|
+
return { tokens: [], malformed: true };
|
|
2318
|
+
index += 2;
|
|
2319
|
+
continue;
|
|
2320
|
+
}
|
|
2321
|
+
if (matchingOptionValue(token, PYTHON_VALUE_OPTIONS) !== null) {
|
|
2322
|
+
index += 1;
|
|
2323
|
+
continue;
|
|
2324
|
+
}
|
|
2325
|
+
if (PYTHON_NO_VALUE_OPTIONS.has(token)) {
|
|
2326
|
+
index += 1;
|
|
2327
|
+
continue;
|
|
2328
|
+
}
|
|
2329
|
+
return { tokens: [], malformed: true };
|
|
2330
|
+
}
|
|
2331
|
+
return { tokens: [], malformed: true };
|
|
2332
|
+
}
|
|
2333
|
+
const COMMAND_WRAPPERS = new Set(["builtin", "command", "env", "exec", "nohup", "timeout"]);
|
|
2334
|
+
const SAFE_WRAPPED_BUILTINS = new Set([":", "[", "echo", "false", "printf", "pwd", "test", "true"]);
|
|
2335
|
+
function resolveCommandDirectory(token, context) {
|
|
2336
|
+
const reference = settingsArtifactReference(token, context, true);
|
|
2337
|
+
if (!reference)
|
|
2338
|
+
return null;
|
|
2339
|
+
const directory = canonicalDirectory(reference.path);
|
|
2340
|
+
const projectRoot = canonicalDirectory(context.project);
|
|
2341
|
+
if (!directory || !projectRoot || !pathIsContained(projectRoot, directory))
|
|
2342
|
+
return null;
|
|
2343
|
+
return directory;
|
|
2344
|
+
}
|
|
2345
|
+
function unwrapCommandWrapper(wrapper, tokens, startIndex, context, options) {
|
|
2346
|
+
let index = startIndex;
|
|
2347
|
+
if (wrapper === "builtin") {
|
|
2348
|
+
while (tokens[index]?.startsWith("-"))
|
|
2349
|
+
index += 1;
|
|
2350
|
+
return { index, malformed: index >= tokens.length };
|
|
2351
|
+
}
|
|
2352
|
+
if (wrapper === "command") {
|
|
2353
|
+
while (tokens[index]?.startsWith("-")) {
|
|
2354
|
+
const option = tokens[index];
|
|
2355
|
+
if (/^-[pvV]+$/.test(option) && /[vV]/.test(option)) {
|
|
2356
|
+
return { index: tokens.length, malformed: false };
|
|
2357
|
+
}
|
|
2358
|
+
index += 1;
|
|
2359
|
+
}
|
|
2360
|
+
return { index, malformed: index >= tokens.length };
|
|
2361
|
+
}
|
|
2362
|
+
if (wrapper === "exec") {
|
|
2363
|
+
while (tokens[index]?.startsWith("-")) {
|
|
2364
|
+
if (tokens[index] === "-a" || tokens[index] === "--argv0")
|
|
2365
|
+
index += 2;
|
|
2366
|
+
else
|
|
2367
|
+
index += 1;
|
|
2368
|
+
}
|
|
2369
|
+
return { index, malformed: index >= tokens.length };
|
|
2370
|
+
}
|
|
2371
|
+
if (wrapper === "nohup") {
|
|
2372
|
+
while (tokens[index]?.startsWith("-"))
|
|
2373
|
+
index += 1;
|
|
2374
|
+
return { index, malformed: index >= tokens.length };
|
|
2375
|
+
}
|
|
2376
|
+
if (wrapper === "env") {
|
|
2377
|
+
let nextContext = context;
|
|
2378
|
+
while (index < tokens.length) {
|
|
2379
|
+
const token = tokens[index];
|
|
2380
|
+
if (isEnvironmentAssignment(token)) {
|
|
2381
|
+
index += 1;
|
|
2382
|
+
}
|
|
2383
|
+
else if (token === "-S" || token === "--split-string") {
|
|
2384
|
+
const splitValue = tokens[index + 1];
|
|
2385
|
+
if (!splitValue)
|
|
2386
|
+
return { index, malformed: true };
|
|
2387
|
+
const split = parseShellCommand(splitValue, options);
|
|
2388
|
+
if (split.malformed || split.segments.length !== 1)
|
|
2389
|
+
return { index, malformed: true };
|
|
2390
|
+
return {
|
|
2391
|
+
index,
|
|
2392
|
+
malformed: false,
|
|
2393
|
+
replacement: [...split.segments[0], ...tokens.slice(index + 2)],
|
|
2394
|
+
context: nextContext,
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
else if (["-C", "--chdir"].includes(token)) {
|
|
2398
|
+
const directory = tokens[index + 1];
|
|
2399
|
+
const resolvedDirectory = directory
|
|
2400
|
+
? resolveCommandDirectory(directory, nextContext)
|
|
2401
|
+
: null;
|
|
2402
|
+
if (!resolvedDirectory)
|
|
2403
|
+
return { index, malformed: true };
|
|
2404
|
+
nextContext = { ...nextContext, cwd: resolvedDirectory };
|
|
2405
|
+
index += 2;
|
|
2406
|
+
}
|
|
2407
|
+
else if (token.startsWith("--chdir=")) {
|
|
2408
|
+
const resolvedDirectory = resolveCommandDirectory(token.slice("--chdir=".length), nextContext);
|
|
2409
|
+
if (!resolvedDirectory)
|
|
2410
|
+
return { index, malformed: true };
|
|
2411
|
+
nextContext = { ...nextContext, cwd: resolvedDirectory };
|
|
2412
|
+
index += 1;
|
|
2413
|
+
}
|
|
2414
|
+
else if (token.startsWith("-C") && token.length > 2) {
|
|
2415
|
+
const resolvedDirectory = resolveCommandDirectory(token.slice(2), nextContext);
|
|
2416
|
+
if (!resolvedDirectory)
|
|
2417
|
+
return { index, malformed: true };
|
|
2418
|
+
nextContext = { ...nextContext, cwd: resolvedDirectory };
|
|
2419
|
+
index += 1;
|
|
2420
|
+
}
|
|
2421
|
+
else if (["-u", "--unset"].includes(token)) {
|
|
2422
|
+
if (!tokens[index + 1])
|
|
2423
|
+
return { index, malformed: true };
|
|
2424
|
+
index += 2;
|
|
2425
|
+
}
|
|
2426
|
+
else if (token.startsWith("--unset=") || (token.startsWith("-u") && token.length > 2)) {
|
|
2427
|
+
index += 1;
|
|
2428
|
+
}
|
|
2429
|
+
else if (["--", "-i", "--ignore-environment", "--null"].includes(token)) {
|
|
2430
|
+
index += 1;
|
|
2431
|
+
}
|
|
2432
|
+
else if (token.startsWith("-")) {
|
|
2433
|
+
return { index, malformed: true };
|
|
2434
|
+
}
|
|
2435
|
+
else {
|
|
2436
|
+
break;
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
return { index, malformed: index >= tokens.length, context: nextContext };
|
|
2440
|
+
}
|
|
2441
|
+
while (tokens[index]?.startsWith("-")) {
|
|
2442
|
+
const token = tokens[index];
|
|
2443
|
+
if (["-k", "--kill-after", "-s", "--signal"].includes(token))
|
|
2444
|
+
index += 2;
|
|
2445
|
+
else
|
|
2446
|
+
index += 1;
|
|
2447
|
+
}
|
|
2448
|
+
if (index >= tokens.length)
|
|
2449
|
+
return { index, malformed: true };
|
|
2450
|
+
index += 1;
|
|
2451
|
+
return { index, malformed: index >= tokens.length };
|
|
2452
|
+
}
|
|
2453
|
+
function settingsArtifactTokens(command, depth = 0, context = {
|
|
2454
|
+
project: process.cwd(),
|
|
2455
|
+
homePath: homedir(),
|
|
2456
|
+
cwd: process.cwd(),
|
|
2457
|
+
}, options = {}) {
|
|
2458
|
+
if (depth > MAX_SHELL_COMMAND_DEPTH)
|
|
2459
|
+
return { tokens: [], malformed: true };
|
|
2460
|
+
const parsed = parseShellCommand(command, options);
|
|
2461
|
+
const references = [];
|
|
2462
|
+
const artifacts = [];
|
|
2463
|
+
const inlineContents = [];
|
|
2464
|
+
let malformed = parsed.malformed;
|
|
2465
|
+
for (const tokens of parsed.segments) {
|
|
2466
|
+
const segment = settingsArtifactTokensForSegment(tokens, depth, context, options);
|
|
2467
|
+
references.push(...segment.tokens);
|
|
2468
|
+
artifacts.push(...(segment.artifacts ?? []));
|
|
2469
|
+
inlineContents.push(...(segment.inlineContents ?? []));
|
|
2470
|
+
malformed ||= segment.malformed;
|
|
2471
|
+
}
|
|
2472
|
+
return { tokens: references, artifacts, inlineContents, malformed };
|
|
2473
|
+
}
|
|
2474
|
+
function settingsArtifactTokensForSegment(tokens, depth, context, options) {
|
|
2475
|
+
let index = 0;
|
|
2476
|
+
while (isEnvironmentAssignment(tokens[index] ?? ""))
|
|
2477
|
+
index += 1;
|
|
2478
|
+
let executable = tokens[index];
|
|
2479
|
+
if (!executable)
|
|
2480
|
+
return { tokens: [], malformed: false };
|
|
2481
|
+
const references = [];
|
|
2482
|
+
let wrapperDepth = depth;
|
|
2483
|
+
let builtinWrapper = false;
|
|
2484
|
+
while (COMMAND_WRAPPERS.has(basename(executable))) {
|
|
2485
|
+
builtinWrapper ||= basename(executable) === "builtin";
|
|
2486
|
+
references.push(executable);
|
|
2487
|
+
wrapperDepth += 1;
|
|
2488
|
+
if (wrapperDepth > MAX_SHELL_COMMAND_DEPTH)
|
|
2489
|
+
return { tokens: references, malformed: true };
|
|
2490
|
+
const unwrapped = unwrapCommandWrapper(basename(executable), tokens, index + 1, context, options);
|
|
2491
|
+
if (unwrapped.malformed)
|
|
2492
|
+
return { tokens: references, malformed: true };
|
|
2493
|
+
context = unwrapped.context ?? context;
|
|
2494
|
+
if (unwrapped.replacement) {
|
|
2495
|
+
const nested = settingsArtifactTokensForSegment(unwrapped.replacement, wrapperDepth, unwrapped.context ?? context, options);
|
|
2496
|
+
references.push(...nested.tokens);
|
|
2497
|
+
return {
|
|
2498
|
+
tokens: references,
|
|
2499
|
+
artifacts: nested.artifacts,
|
|
2500
|
+
inlineContents: nested.inlineContents,
|
|
2501
|
+
malformed: nested.malformed,
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
index = unwrapped.index;
|
|
2505
|
+
executable = tokens[index];
|
|
2506
|
+
if (!executable)
|
|
2507
|
+
return { tokens: references, malformed: false };
|
|
2508
|
+
}
|
|
2509
|
+
const executableName = basename(executable);
|
|
2510
|
+
const unsupportedWrappedCommand = !executable.includes(sep)
|
|
2511
|
+
&& unsupportedShellWord(executableName, options);
|
|
2512
|
+
const unsupportedBuiltin = builtinWrapper && !SAFE_WRAPPED_BUILTINS.has(executableName);
|
|
2513
|
+
if (unsupportedWrappedCommand || unsupportedBuiltin) {
|
|
2514
|
+
return { tokens: references, malformed: true };
|
|
2515
|
+
}
|
|
2516
|
+
const executableMalformed = artifactTokenIsAmbiguous(executable);
|
|
2517
|
+
references.push(executable);
|
|
2518
|
+
const interpreter = executableName;
|
|
2519
|
+
if (!SETTINGS_INTERPRETERS.has(interpreter)) {
|
|
2520
|
+
return { tokens: references, malformed: executableMalformed };
|
|
2521
|
+
}
|
|
2522
|
+
const interpreterScan = ["node", "tsx"].includes(interpreter)
|
|
2523
|
+
? scanNodeInterpreterTokens(tokens, index + 1, context)
|
|
2524
|
+
: ["bash", "sh", "zsh"].includes(interpreter)
|
|
2525
|
+
? scanShellInterpreterTokens(tokens, index + 1, wrapperDepth, context, options)
|
|
2526
|
+
: scanPythonInterpreterTokens(tokens, index + 1, context);
|
|
2527
|
+
references.push(...interpreterScan.tokens);
|
|
2528
|
+
return {
|
|
2529
|
+
tokens: references,
|
|
2530
|
+
artifacts: interpreterScan.artifacts,
|
|
2531
|
+
inlineContents: interpreterScan.inlineContents,
|
|
2532
|
+
malformed: executableMalformed || interpreterScan.malformed,
|
|
2533
|
+
};
|
|
2534
|
+
}
|
|
2535
|
+
function pluginRootFromHookPath(path) {
|
|
2536
|
+
if (!isAbsolute(path))
|
|
2537
|
+
return null;
|
|
2538
|
+
const absolute = resolve(path);
|
|
2539
|
+
const marker = `${sep}hooks${sep}`;
|
|
2540
|
+
const markerIndex = absolute.lastIndexOf(marker);
|
|
2541
|
+
if (markerIndex < 0)
|
|
2542
|
+
return null;
|
|
2543
|
+
return canonicalDirectory(absolute.slice(0, markerIndex));
|
|
2544
|
+
}
|
|
2545
|
+
function explicitSettingsTargets(settingsPath, commands) {
|
|
2546
|
+
const grouped = new Map();
|
|
2547
|
+
for (const command of commands) {
|
|
2548
|
+
for (const token of commandTokens(command)) {
|
|
2549
|
+
const root = pluginRootFromHookPath(token);
|
|
2550
|
+
if (!root)
|
|
2551
|
+
continue;
|
|
2552
|
+
const existing = grouped.get(root) ?? [];
|
|
2553
|
+
existing.push(command);
|
|
2554
|
+
grouped.set(root, existing);
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
return [...grouped].map(([root, targetCommands]) => ({
|
|
2558
|
+
root,
|
|
2559
|
+
configPath: settingsPath,
|
|
2560
|
+
commands: [...new Set(targetCommands)],
|
|
2561
|
+
}));
|
|
2562
|
+
}
|
|
2563
|
+
function isDevflowPluginKey(key) {
|
|
2564
|
+
return key === "devflow@devflow"
|
|
2565
|
+
|| key === "devflow@devflow-local"
|
|
2566
|
+
|| key.startsWith("@devflow-tools/claude-code-plugin@");
|
|
2567
|
+
}
|
|
2568
|
+
function marketplaceName(pluginKey) {
|
|
2569
|
+
return pluginKey.slice(pluginKey.lastIndexOf("@") + 1);
|
|
2570
|
+
}
|
|
2571
|
+
function marketplaceSourceRoot(sourcePath) {
|
|
2572
|
+
for (const candidate of [
|
|
2573
|
+
sourcePath,
|
|
2574
|
+
join(sourcePath, "claude-code"),
|
|
2575
|
+
join(sourcePath, "plugins", "claude-code"),
|
|
2576
|
+
]) {
|
|
2577
|
+
const root = canonicalDirectory(candidate);
|
|
2578
|
+
if (root && existsSync(join(root, "hooks.json")))
|
|
2579
|
+
return root;
|
|
2580
|
+
}
|
|
2581
|
+
return null;
|
|
2582
|
+
}
|
|
2583
|
+
function localMarketplaceRoot(pluginKey, settings, knownMarketplacesPath) {
|
|
2584
|
+
const marketplace = marketplaceName(pluginKey);
|
|
2585
|
+
const extra = asRecord(settings.extraKnownMarketplaces);
|
|
2586
|
+
const settingsEntry = asRecord(extra?.[marketplace]);
|
|
2587
|
+
if (settingsEntry) {
|
|
2588
|
+
const settingsSource = asRecord(settingsEntry.source);
|
|
2589
|
+
const root = settingsSource?.source === "directory"
|
|
2590
|
+
&& typeof settingsSource.path === "string"
|
|
2591
|
+
? marketplaceSourceRoot(settingsSource.path)
|
|
2592
|
+
: null;
|
|
2593
|
+
return { configured: true, root };
|
|
2594
|
+
}
|
|
2595
|
+
try {
|
|
2596
|
+
const known = asRecord(parseJsonFile(knownMarketplacesPath));
|
|
2597
|
+
const knownEntry = asRecord(known?.[marketplace]);
|
|
2598
|
+
if (knownEntry) {
|
|
2599
|
+
const source = asRecord(knownEntry.source);
|
|
2600
|
+
const root = source?.source === "directory" && typeof source.path === "string"
|
|
2601
|
+
? marketplaceSourceRoot(source.path)
|
|
2602
|
+
: null;
|
|
2603
|
+
return { configured: true, root };
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
catch {
|
|
2607
|
+
// The installed plugin manifest remains the next deterministic source.
|
|
2608
|
+
}
|
|
2609
|
+
return { configured: false, root: null };
|
|
2610
|
+
}
|
|
2611
|
+
function installedEntryTimestamp(entry) {
|
|
2612
|
+
const timestamps = [entry?.lastUpdated, entry?.installedAt]
|
|
2613
|
+
.map((value) => {
|
|
2614
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0)
|
|
2615
|
+
return value;
|
|
2616
|
+
if (typeof value !== "string")
|
|
2617
|
+
return null;
|
|
2618
|
+
const parsed = Date.parse(value);
|
|
2619
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
2620
|
+
})
|
|
2621
|
+
.filter((value) => value !== null);
|
|
2622
|
+
return timestamps.length > 0 ? Math.max(...timestamps) : null;
|
|
2623
|
+
}
|
|
2624
|
+
function compareInstalledEntries(left, right) {
|
|
2625
|
+
const leftRecord = asRecord(left.entry);
|
|
2626
|
+
const rightRecord = asRecord(right.entry);
|
|
2627
|
+
const leftTimestamp = installedEntryTimestamp(leftRecord);
|
|
2628
|
+
const rightTimestamp = installedEntryTimestamp(rightRecord);
|
|
2629
|
+
if (leftTimestamp !== rightTimestamp) {
|
|
2630
|
+
if (leftTimestamp === null)
|
|
2631
|
+
return 1;
|
|
2632
|
+
if (rightTimestamp === null)
|
|
2633
|
+
return -1;
|
|
2634
|
+
return rightTimestamp - leftTimestamp;
|
|
2635
|
+
}
|
|
2636
|
+
const leftVersion = typeof leftRecord?.version === "string"
|
|
2637
|
+
? parseSemanticVersion(leftRecord.version)
|
|
2638
|
+
: null;
|
|
2639
|
+
const rightVersion = typeof rightRecord?.version === "string"
|
|
2640
|
+
? parseSemanticVersion(rightRecord.version)
|
|
2641
|
+
: null;
|
|
2642
|
+
if (leftVersion && rightVersion) {
|
|
2643
|
+
const versionOrder = compareSemanticVersions(rightVersion, leftVersion);
|
|
2644
|
+
if (versionOrder !== 0)
|
|
2645
|
+
return versionOrder;
|
|
2646
|
+
}
|
|
2647
|
+
else if (leftVersion || rightVersion) {
|
|
2648
|
+
return leftVersion ? -1 : 1;
|
|
2649
|
+
}
|
|
2650
|
+
return right.index - left.index;
|
|
2651
|
+
}
|
|
2652
|
+
function installedPluginRoot(pluginKey, installedPluginsPath) {
|
|
2653
|
+
try {
|
|
2654
|
+
const manifest = asRecord(parseJsonFile(installedPluginsPath));
|
|
2655
|
+
if (!manifest)
|
|
2656
|
+
throw new Error("installed plugin manifest must be an object");
|
|
2657
|
+
const plugins = asRecord(manifest.plugins);
|
|
2658
|
+
if (!plugins) {
|
|
2659
|
+
throw new Error("installed plugin manifest plugins must be an object");
|
|
2660
|
+
}
|
|
2661
|
+
const entries = plugins?.[pluginKey];
|
|
2662
|
+
if (entries === undefined) {
|
|
2663
|
+
return { configured: false, root: null };
|
|
2664
|
+
}
|
|
2665
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
2666
|
+
return { configured: true, malformed: true, root: null };
|
|
2667
|
+
}
|
|
2668
|
+
const latest = entries
|
|
2669
|
+
.map((entry, index) => ({ entry, index }))
|
|
2670
|
+
.sort(compareInstalledEntries)[0];
|
|
2671
|
+
const latestRecord = asRecord(latest?.entry);
|
|
2672
|
+
if (!latestRecord
|
|
2673
|
+
|| typeof latestRecord.installPath !== "string"
|
|
2674
|
+
|| latestRecord.installPath.length === 0) {
|
|
2675
|
+
return { configured: true, malformed: true, root: null };
|
|
2676
|
+
}
|
|
2677
|
+
return {
|
|
2678
|
+
configured: true,
|
|
2679
|
+
root: canonicalDirectory(latestRecord.installPath),
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
catch {
|
|
2683
|
+
if (existsSync(installedPluginsPath)) {
|
|
2684
|
+
return { configured: true, malformed: true, root: null };
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
return { configured: false, root: null };
|
|
2688
|
+
}
|
|
2689
|
+
function parseSemanticVersion(value) {
|
|
2690
|
+
const match = value.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
|
|
2691
|
+
if (!match)
|
|
2692
|
+
return null;
|
|
2693
|
+
return {
|
|
2694
|
+
major: Number(match[1]),
|
|
2695
|
+
minor: Number(match[2]),
|
|
2696
|
+
patch: Number(match[3]),
|
|
2697
|
+
prerelease: match[4]?.split(".") ?? [],
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
function compareSemanticVersions(left, right) {
|
|
2701
|
+
for (const key of ["major", "minor", "patch"]) {
|
|
2702
|
+
if (left[key] !== right[key])
|
|
2703
|
+
return left[key] - right[key];
|
|
2704
|
+
}
|
|
2705
|
+
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
|
|
2706
|
+
return right.prerelease.length - left.prerelease.length;
|
|
2707
|
+
}
|
|
2708
|
+
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
|
2709
|
+
for (let index = 0; index < length; index += 1) {
|
|
2710
|
+
const leftPart = left.prerelease[index];
|
|
2711
|
+
const rightPart = right.prerelease[index];
|
|
2712
|
+
if (leftPart === undefined)
|
|
2713
|
+
return -1;
|
|
2714
|
+
if (rightPart === undefined)
|
|
2715
|
+
return 1;
|
|
2716
|
+
if (leftPart === rightPart)
|
|
2717
|
+
continue;
|
|
2718
|
+
const leftNumber = /^\d+$/.test(leftPart) ? Number(leftPart) : null;
|
|
2719
|
+
const rightNumber = /^\d+$/.test(rightPart) ? Number(rightPart) : null;
|
|
2720
|
+
if (leftNumber !== null && rightNumber !== null)
|
|
2721
|
+
return leftNumber - rightNumber;
|
|
2722
|
+
if (leftNumber !== null)
|
|
2723
|
+
return -1;
|
|
2724
|
+
if (rightNumber !== null)
|
|
2725
|
+
return 1;
|
|
2726
|
+
return leftPart.localeCompare(rightPart);
|
|
2727
|
+
}
|
|
2728
|
+
return 0;
|
|
2729
|
+
}
|
|
2730
|
+
function cachedPluginRoot(pluginKey, pluginCachePath) {
|
|
2731
|
+
const separatorIndex = pluginKey.lastIndexOf("@");
|
|
2732
|
+
if (separatorIndex <= 0 || separatorIndex === pluginKey.length - 1)
|
|
2733
|
+
return null;
|
|
2734
|
+
const pluginName = pluginKey.slice(0, separatorIndex);
|
|
2735
|
+
const marketplace = pluginKey.slice(separatorIndex + 1);
|
|
2736
|
+
const cacheBase = join(pluginCachePath, marketplace, pluginName);
|
|
2737
|
+
let entries;
|
|
2738
|
+
try {
|
|
2739
|
+
entries = readdirSync(cacheBase, { withFileTypes: true })
|
|
2740
|
+
.filter((entry) => entry.isDirectory())
|
|
2741
|
+
.map((entry) => {
|
|
2742
|
+
const path = join(cacheBase, entry.name);
|
|
2743
|
+
let version = entry.name;
|
|
2744
|
+
try {
|
|
2745
|
+
const packageJson = asRecord(parseJsonFile(join(path, "package.json")));
|
|
2746
|
+
if (typeof packageJson?.version === "string")
|
|
2747
|
+
version = packageJson.version;
|
|
2748
|
+
}
|
|
2749
|
+
catch {
|
|
2750
|
+
// Directory name, mtime, and name remain deterministic fallbacks.
|
|
2751
|
+
}
|
|
2752
|
+
return { name: entry.name, path, version, mtimeMs: statSync(path).mtimeMs };
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
catch {
|
|
2756
|
+
return null;
|
|
2757
|
+
}
|
|
2758
|
+
entries.sort((left, right) => {
|
|
2759
|
+
const leftSemantic = parseSemanticVersion(left.version);
|
|
2760
|
+
const rightSemantic = parseSemanticVersion(right.version);
|
|
2761
|
+
if (leftSemantic && rightSemantic) {
|
|
2762
|
+
const versionOrder = compareSemanticVersions(rightSemantic, leftSemantic);
|
|
2763
|
+
if (versionOrder !== 0)
|
|
2764
|
+
return versionOrder;
|
|
2765
|
+
}
|
|
2766
|
+
else if (leftSemantic || rightSemantic) {
|
|
2767
|
+
return leftSemantic ? -1 : 1;
|
|
2768
|
+
}
|
|
2769
|
+
return right.mtimeMs - left.mtimeMs || right.name.localeCompare(left.name);
|
|
2770
|
+
});
|
|
2771
|
+
return entries[0] ? canonicalDirectory(entries[0].path) : null;
|
|
2772
|
+
}
|
|
2773
|
+
function selectActiveLegacyTargets(project, dependencies) {
|
|
2774
|
+
if (dependencies.legacyArtifactRoots !== undefined) {
|
|
2775
|
+
const targets = dependencies.legacyArtifactRoots
|
|
2776
|
+
.map((root) => canonicalDirectory(root))
|
|
2777
|
+
.filter((root) => root !== null)
|
|
2778
|
+
.map((root) => ({ root, inspectPluginConfig: true }));
|
|
2779
|
+
return {
|
|
2780
|
+
targets,
|
|
2781
|
+
configCommands: [],
|
|
2782
|
+
hooksDisabled: false,
|
|
2783
|
+
malformedSettings: [],
|
|
2784
|
+
malformedPluginMetadata: [],
|
|
2785
|
+
method: "injected",
|
|
2786
|
+
ok: targets.length > 0,
|
|
2787
|
+
};
|
|
2788
|
+
}
|
|
2789
|
+
const settingsPath = dependencies.claudeSettingsPath
|
|
2790
|
+
?? join(dependencies.homePath ?? homedir(), ".claude", "settings.json");
|
|
2791
|
+
const installedPluginsPath = dependencies.installedPluginsPath
|
|
2792
|
+
?? join(dependencies.homePath ?? homedir(), ".claude", "plugins", "installed_plugins.json");
|
|
2793
|
+
const knownMarketplacesPath = dependencies.knownMarketplacesPath
|
|
2794
|
+
?? join(dependencies.homePath ?? homedir(), ".claude", "plugins", "known_marketplaces.json");
|
|
2795
|
+
const pluginCachePath = dependencies.pluginCachePath
|
|
2796
|
+
?? join(dependencies.homePath ?? homedir(), ".claude", "plugins", "cache");
|
|
2797
|
+
const settingsSources = [];
|
|
2798
|
+
const malformedSettings = [];
|
|
2799
|
+
const malformedPluginMetadata = [];
|
|
2800
|
+
const settingsPaths = [...new Set([
|
|
2801
|
+
settingsPath,
|
|
2802
|
+
join(project, ".claude", "settings.json"),
|
|
2803
|
+
join(project, ".claude", "settings.local.json"),
|
|
2804
|
+
])];
|
|
2805
|
+
for (const activeSettingsPath of settingsPaths) {
|
|
2806
|
+
if (!existsSync(activeSettingsPath))
|
|
2807
|
+
continue;
|
|
2808
|
+
try {
|
|
2809
|
+
const parsed = asRecord(parseJsonFile(activeSettingsPath));
|
|
2810
|
+
if (!parsed)
|
|
2811
|
+
throw new Error("settings must be an object");
|
|
2812
|
+
settingsSources.push({ path: activeSettingsPath, settings: parsed });
|
|
2813
|
+
}
|
|
2814
|
+
catch {
|
|
2815
|
+
malformedSettings.push(activeSettingsPath);
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
const hooksDisabled = settingsSources.reduce((disabled, source) => (typeof source.settings.disableAllHooks === "boolean"
|
|
2819
|
+
? source.settings.disableAllHooks
|
|
2820
|
+
: disabled), false);
|
|
2821
|
+
if (hooksDisabled) {
|
|
2822
|
+
return {
|
|
2823
|
+
targets: [],
|
|
2824
|
+
configCommands: [],
|
|
2825
|
+
hooksDisabled: true,
|
|
2826
|
+
malformedSettings,
|
|
2827
|
+
malformedPluginMetadata: [],
|
|
2828
|
+
method: "hooks-disabled",
|
|
2829
|
+
ok: malformedSettings.length === 0,
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
const settingsCommandsByPath = settingsSources.map((source) => {
|
|
2833
|
+
const commands = [];
|
|
2834
|
+
collectCommandStrings(source.settings.hooks, commands);
|
|
2835
|
+
return { path: source.path, commands };
|
|
2836
|
+
});
|
|
2837
|
+
const settingsCommands = settingsCommandsByPath.flatMap((source) => source.commands);
|
|
2838
|
+
const explicitTargets = settingsCommandsByPath.flatMap((source) => (explicitSettingsTargets(source.path, source.commands)));
|
|
2839
|
+
const enabledPlugins = settingsSources.reduce((merged, source) => {
|
|
2840
|
+
const enabled = source.settings.enabledPlugins;
|
|
2841
|
+
if (typeof enabled !== "object" || enabled === null)
|
|
2842
|
+
return merged;
|
|
2843
|
+
for (const [key, value] of Object.entries(enabled)) {
|
|
2844
|
+
if (isDevflowPluginKey(key) && typeof value === "boolean")
|
|
2845
|
+
merged.set(key, value);
|
|
2846
|
+
}
|
|
2847
|
+
return merged;
|
|
2848
|
+
}, new Map());
|
|
2849
|
+
const enabledKeys = [...enabledPlugins]
|
|
2850
|
+
.filter(([, enabled]) => enabled)
|
|
2851
|
+
.map(([key]) => key)
|
|
2852
|
+
.sort();
|
|
2853
|
+
const targets = [...explicitTargets];
|
|
2854
|
+
let unresolved = false;
|
|
2855
|
+
for (const pluginKey of enabledKeys) {
|
|
2856
|
+
const installed = installedPluginRoot(pluginKey, installedPluginsPath);
|
|
2857
|
+
if (installed.malformed)
|
|
2858
|
+
malformedPluginMetadata.push(installedPluginsPath);
|
|
2859
|
+
let root = installed.root;
|
|
2860
|
+
if (!installed.configured) {
|
|
2861
|
+
const configuredMarketplaces = settingsSources
|
|
2862
|
+
.map((source) => localMarketplaceRoot(pluginKey, source.settings, knownMarketplacesPath))
|
|
2863
|
+
.filter((selection) => selection.configured);
|
|
2864
|
+
const marketplace = configuredMarketplaces[configuredMarketplaces.length - 1];
|
|
2865
|
+
if (marketplace) {
|
|
2866
|
+
root = marketplace.root;
|
|
2867
|
+
}
|
|
2868
|
+
else {
|
|
2869
|
+
root = cachedPluginRoot(pluginKey, pluginCachePath);
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
if (root)
|
|
2873
|
+
targets.push({ root, inspectPluginConfig: true });
|
|
2874
|
+
else
|
|
2875
|
+
unresolved = true;
|
|
2876
|
+
}
|
|
2877
|
+
const uniqueTargets = [...targets.reduce((byRoot, target) => {
|
|
2878
|
+
const existing = byRoot.get(target.root);
|
|
2879
|
+
if (!existing) {
|
|
2880
|
+
byRoot.set(target.root, target);
|
|
2881
|
+
return byRoot;
|
|
2882
|
+
}
|
|
2883
|
+
byRoot.set(target.root, {
|
|
2884
|
+
root: target.root,
|
|
2885
|
+
configPath: existing.configPath ?? target.configPath,
|
|
2886
|
+
commands: [...new Set([...(existing.commands ?? []), ...(target.commands ?? [])])],
|
|
2887
|
+
inspectPluginConfig: Boolean(existing.inspectPluginConfig || target.inspectPluginConfig),
|
|
2888
|
+
});
|
|
2889
|
+
return byRoot;
|
|
2890
|
+
}, new Map()).values()];
|
|
2891
|
+
const methods = [
|
|
2892
|
+
settingsCommands.length > 0 ? "settings-hooks" : null,
|
|
2893
|
+
enabledKeys.length > 0 ? "enabled-plugin" : null,
|
|
2894
|
+
].filter((method) => method !== null);
|
|
2895
|
+
return {
|
|
2896
|
+
targets: uniqueTargets,
|
|
2897
|
+
configCommands: settingsCommandsByPath.filter((source) => source.commands.length > 0),
|
|
2898
|
+
hooksDisabled: false,
|
|
2899
|
+
method: methods.length > 0
|
|
2900
|
+
? `${methods.join("+")}${unresolved ? "-unresolved" : ""}`
|
|
2901
|
+
: malformedSettings.length > 0 ? "settings-unresolved" : "enabled-plugin-unresolved",
|
|
2902
|
+
malformedSettings,
|
|
2903
|
+
malformedPluginMetadata: [...new Set(malformedPluginMetadata)],
|
|
2904
|
+
ok: (uniqueTargets.length > 0 || settingsCommands.length > 0)
|
|
2905
|
+
&& !unresolved
|
|
2906
|
+
&& malformedSettings.length === 0
|
|
2907
|
+
&& malformedPluginMetadata.length === 0,
|
|
2908
|
+
};
|
|
2909
|
+
}
|
|
2910
|
+
function findWorkspaceRoot(start) {
|
|
2911
|
+
let cursor = start;
|
|
2912
|
+
while (true) {
|
|
2913
|
+
const packagePath = join(cursor, "package.json");
|
|
2914
|
+
if (existsSync(packagePath)) {
|
|
2915
|
+
try {
|
|
2916
|
+
const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
2917
|
+
if (packageJson.workspaces)
|
|
2918
|
+
return cursor;
|
|
2919
|
+
}
|
|
2920
|
+
catch {
|
|
2921
|
+
// Keep walking; a plugin package.json is not necessarily the workspace root.
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
const parent = dirname(cursor);
|
|
2925
|
+
if (parent === cursor)
|
|
2926
|
+
return undefined;
|
|
2927
|
+
cursor = parent;
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
function collectCommandStrings(value, commands) {
|
|
2931
|
+
if (Array.isArray(value)) {
|
|
2932
|
+
for (const item of value)
|
|
2933
|
+
collectCommandStrings(item, commands);
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
2936
|
+
if (typeof value !== "object" || value === null)
|
|
2937
|
+
return;
|
|
2938
|
+
for (const [key, item] of Object.entries(value)) {
|
|
2939
|
+
if (key === "command" && typeof item === "string")
|
|
2940
|
+
commands.push(item);
|
|
2941
|
+
else
|
|
2942
|
+
collectCommandStrings(item, commands);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
function pluginReferences(content) {
|
|
2946
|
+
return [...content.matchAll(PLUGIN_PATH_REFERENCE)].map((match) => match[1]);
|
|
2947
|
+
}
|
|
2948
|
+
function expandStaticPluginPathAliases(content) {
|
|
2949
|
+
const aliases = new Map();
|
|
2950
|
+
const parsed = parseShellCommand(content, { tolerateControlSyntax: true });
|
|
2951
|
+
for (const token of parsed.segments.flat()) {
|
|
2952
|
+
const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(\$(?:\{(?:CLAUDE_)?PLUGIN_ROOT\}|(?:CLAUDE_)?PLUGIN_ROOT)\/[A-Za-z0-9._/-]+)$/);
|
|
2953
|
+
if (assignment)
|
|
2954
|
+
aliases.set(assignment[1], assignment[2]);
|
|
2955
|
+
}
|
|
2956
|
+
if (aliases.size === 0)
|
|
2957
|
+
return content;
|
|
2958
|
+
return content.replace(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (reference, bracedName, bareName) => (aliases.get(bracedName ?? bareName ?? "") ?? reference));
|
|
2959
|
+
}
|
|
2960
|
+
function pluginShellSyntaxMalformed(content) {
|
|
2961
|
+
const parsed = spawnSync("bash", ["--noprofile", "--norc", "-n"], {
|
|
2962
|
+
encoding: "utf8",
|
|
2963
|
+
env: {
|
|
2964
|
+
PATH: process.env.PATH ?? "",
|
|
2965
|
+
HOME: process.env.HOME ?? "",
|
|
2966
|
+
BASH_ENV: "",
|
|
2967
|
+
ENV: "",
|
|
2968
|
+
},
|
|
2969
|
+
input: content,
|
|
2970
|
+
timeout: 2_000,
|
|
2971
|
+
});
|
|
2972
|
+
return parsed.error !== undefined || parsed.status !== 0;
|
|
2973
|
+
}
|
|
2974
|
+
function pluginNodeSyntaxMalformed(artifact) {
|
|
2975
|
+
if (![".cjs", ".js", ".mjs"].includes(extname(artifact)))
|
|
2976
|
+
return false;
|
|
2977
|
+
const parsed = spawnSync(process.execPath, ["--check", artifact], {
|
|
2978
|
+
encoding: "utf8",
|
|
2979
|
+
env: {
|
|
2980
|
+
PATH: process.env.PATH ?? "",
|
|
2981
|
+
HOME: process.env.HOME ?? "",
|
|
2982
|
+
},
|
|
2983
|
+
timeout: 2_000,
|
|
2984
|
+
});
|
|
2985
|
+
return parsed.error !== undefined || parsed.status !== 0;
|
|
2986
|
+
}
|
|
2987
|
+
function resolvePluginReference(root, relativePath) {
|
|
2988
|
+
const absolute = resolve(root, relativePath);
|
|
2989
|
+
return absolute.startsWith(`${resolve(root)}${sep}`) ? absolute : null;
|
|
2990
|
+
}
|
|
2991
|
+
function pathIsContained(root, candidate) {
|
|
2992
|
+
return candidate === root || candidate.startsWith(`${root}${sep}`);
|
|
2993
|
+
}
|
|
2994
|
+
function readLegacyTextArtifact(path) {
|
|
2995
|
+
let pathStats;
|
|
2996
|
+
try {
|
|
2997
|
+
pathStats = lstatSync(path);
|
|
2998
|
+
}
|
|
2999
|
+
catch {
|
|
3000
|
+
return { kind: "missing" };
|
|
3001
|
+
}
|
|
3002
|
+
if (!pathStats.isFile())
|
|
3003
|
+
return { kind: "unsafe", reason: "nonregular" };
|
|
3004
|
+
if (pathStats.size > MAX_LEGACY_ARTIFACT_BYTES) {
|
|
3005
|
+
return { kind: "unsafe", reason: "oversized" };
|
|
3006
|
+
}
|
|
3007
|
+
let descriptor;
|
|
3008
|
+
try {
|
|
3009
|
+
descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW);
|
|
3010
|
+
}
|
|
3011
|
+
catch {
|
|
3012
|
+
return { kind: "missing" };
|
|
3013
|
+
}
|
|
3014
|
+
try {
|
|
3015
|
+
const stats = fstatSync(descriptor);
|
|
3016
|
+
if (!stats.isFile())
|
|
3017
|
+
return { kind: "unsafe", reason: "nonregular" };
|
|
3018
|
+
if (stats.size > MAX_LEGACY_ARTIFACT_BYTES) {
|
|
3019
|
+
return { kind: "unsafe", reason: "oversized" };
|
|
3020
|
+
}
|
|
3021
|
+
const bytes = Buffer.alloc(Math.min(stats.size + 1, MAX_LEGACY_ARTIFACT_BYTES + 1));
|
|
3022
|
+
let offset = 0;
|
|
3023
|
+
while (offset < bytes.length) {
|
|
3024
|
+
const count = readSync(descriptor, bytes, offset, bytes.length - offset, null);
|
|
3025
|
+
if (count === 0)
|
|
3026
|
+
break;
|
|
3027
|
+
offset += count;
|
|
3028
|
+
}
|
|
3029
|
+
if (offset > MAX_LEGACY_ARTIFACT_BYTES) {
|
|
3030
|
+
return { kind: "unsafe", reason: "oversized" };
|
|
3031
|
+
}
|
|
3032
|
+
const contentBytes = bytes.subarray(0, offset);
|
|
3033
|
+
if (contentBytes.includes(0))
|
|
3034
|
+
return { kind: "unsafe", reason: "binary" };
|
|
3035
|
+
const content = contentBytes.toString("utf8");
|
|
3036
|
+
if (content.includes("\uFFFD"))
|
|
3037
|
+
return { kind: "unsafe", reason: "binary" };
|
|
3038
|
+
return { kind: "text", content };
|
|
3039
|
+
}
|
|
3040
|
+
catch {
|
|
3041
|
+
return { kind: "missing" };
|
|
3042
|
+
}
|
|
3043
|
+
finally {
|
|
3044
|
+
closeSync(descriptor);
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
function unsafeArtifactLabel(path, reason) {
|
|
3048
|
+
return `${path} (${reason})`;
|
|
3049
|
+
}
|
|
3050
|
+
function inspectSettingsArtifacts(project, homePath, commands, inspection) {
|
|
3051
|
+
const queue = [];
|
|
3052
|
+
const queued = new Set();
|
|
3053
|
+
let traversedArtifacts = 0;
|
|
3054
|
+
const enqueue = (reference, depth = 0) => {
|
|
3055
|
+
if (depth > MAX_LANGUAGE_DEPENDENCY_DEPTH) {
|
|
3056
|
+
inspection.traversalLimitRoots.push(project);
|
|
3057
|
+
return;
|
|
3058
|
+
}
|
|
3059
|
+
const lexicalPath = resolve(reference.path);
|
|
3060
|
+
const lexicalRoot = resolve(reference.containmentRoot);
|
|
3061
|
+
let canonicalRoot;
|
|
3062
|
+
try {
|
|
3063
|
+
canonicalRoot = realpathSync.native(lexicalRoot);
|
|
3064
|
+
}
|
|
3065
|
+
catch {
|
|
3066
|
+
canonicalRoot = lexicalRoot;
|
|
3067
|
+
}
|
|
3068
|
+
if (!existsSync(lexicalPath)) {
|
|
3069
|
+
if (pathIsContained(lexicalRoot, lexicalPath))
|
|
3070
|
+
inspection.missing.push(lexicalPath);
|
|
3071
|
+
else
|
|
3072
|
+
inspection.escapedArtifacts.push(lexicalPath);
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
let canonicalPath;
|
|
3076
|
+
try {
|
|
3077
|
+
canonicalPath = realpathSync.native(lexicalPath);
|
|
3078
|
+
}
|
|
3079
|
+
catch {
|
|
3080
|
+
inspection.missing.push(lexicalPath);
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
if (!pathIsContained(canonicalRoot, canonicalPath)) {
|
|
3084
|
+
inspection.escapedArtifacts.push(lexicalPath);
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
const queueKey = `${canonicalPath}:${reference.parserKind}:${reference.cwd}`;
|
|
3088
|
+
if (queued.has(queueKey))
|
|
3089
|
+
return;
|
|
3090
|
+
queued.add(queueKey);
|
|
3091
|
+
queue.push({ ...reference, path: canonicalPath, depth });
|
|
3092
|
+
};
|
|
3093
|
+
for (const command of commands) {
|
|
3094
|
+
const scan = settingsArtifactReferences(command, project, homePath);
|
|
3095
|
+
if (scan.malformed)
|
|
3096
|
+
inspection.malformedCommands.push(command);
|
|
3097
|
+
for (const inline of scan.inlineContents) {
|
|
3098
|
+
for (const endpoint of retiredProducerPaths(inline.content, false, "node")) {
|
|
3099
|
+
inspection.findings.push({ artifact: inline.source, endpoint });
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
for (const reference of scan.references) {
|
|
3103
|
+
enqueue(reference);
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
while (queue.length > 0) {
|
|
3107
|
+
if (traversedArtifacts >= MAX_LEGACY_ARTIFACTS_PER_ROOT) {
|
|
3108
|
+
inspection.traversalLimitRoots.push(project);
|
|
3109
|
+
break;
|
|
3110
|
+
}
|
|
3111
|
+
const queuedArtifact = queue.shift();
|
|
3112
|
+
const artifact = queuedArtifact.path;
|
|
3113
|
+
traversedArtifacts += 1;
|
|
3114
|
+
const artifactRead = readLegacyTextArtifact(artifact);
|
|
3115
|
+
if (artifactRead.kind === "missing") {
|
|
3116
|
+
inspection.missing.push(artifact);
|
|
3117
|
+
continue;
|
|
3118
|
+
}
|
|
3119
|
+
if (artifactRead.kind === "unsafe") {
|
|
3120
|
+
if (artifactRead.reason !== "binary") {
|
|
3121
|
+
inspection.unsafeArtifacts.push(unsafeArtifactLabel(artifact, artifactRead.reason));
|
|
3122
|
+
}
|
|
3123
|
+
continue;
|
|
3124
|
+
}
|
|
3125
|
+
const { content } = artifactRead;
|
|
3126
|
+
inspection.inspected.push(artifact);
|
|
3127
|
+
const parserKind = queuedArtifact.parserKind === "infer"
|
|
3128
|
+
? inferArtifactParserKind(artifact, content)
|
|
3129
|
+
: queuedArtifact.parserKind;
|
|
3130
|
+
const shellArtifact = parserKind === "shell";
|
|
3131
|
+
for (const endpoint of retiredProducerPaths(content, shellArtifact, parserKind)) {
|
|
3132
|
+
inspection.findings.push({ artifact, endpoint });
|
|
3133
|
+
}
|
|
3134
|
+
if (shellArtifact) {
|
|
3135
|
+
const nestedScan = settingsArtifactReferences(content, project, homePath, queuedArtifact.cwd);
|
|
3136
|
+
if (nestedScan.malformed)
|
|
3137
|
+
inspection.malformedCommands.push(artifact);
|
|
3138
|
+
for (const inline of nestedScan.inlineContents) {
|
|
3139
|
+
for (const endpoint of retiredProducerPaths(inline.content, false, "node")) {
|
|
3140
|
+
inspection.findings.push({ artifact: inline.source, endpoint });
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
for (const reference of nestedScan.references)
|
|
3144
|
+
enqueue(reference, queuedArtifact.depth + 1);
|
|
3145
|
+
}
|
|
3146
|
+
else if (parserKind === "node" || parserKind === "python") {
|
|
3147
|
+
const dependencyScan = scanLanguageArtifactDependencies(artifact, content, parserKind, { project, homePath, cwd: dirname(artifact) });
|
|
3148
|
+
if (dependencyScan.malformed)
|
|
3149
|
+
inspection.malformedCommands.push(artifact);
|
|
3150
|
+
for (const inline of dependencyScan.inlineContents) {
|
|
3151
|
+
for (const endpoint of retiredProducerPaths(inline.content, false, "node")) {
|
|
3152
|
+
inspection.findings.push({ artifact: inline.source, endpoint });
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
for (const reference of dependencyScan.references) {
|
|
3156
|
+
enqueue(reference, queuedArtifact.depth + 1);
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
function inspectLegacyRoot(project, homePath, target, inspection) {
|
|
3162
|
+
const root = realpathSync.native(target.root);
|
|
3163
|
+
const queue = [];
|
|
3164
|
+
const queued = new Set();
|
|
3165
|
+
const workspaceRoot = findWorkspaceRoot(root);
|
|
3166
|
+
let traversedArtifacts = 0;
|
|
3167
|
+
const enqueue = (path, required = false, containmentRoot = root, parserKind = "infer", cwd = project, depth = 0) => {
|
|
3168
|
+
if (depth > MAX_LANGUAGE_DEPENDENCY_DEPTH) {
|
|
3169
|
+
inspection.traversalLimitRoots.push(root);
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
const canonicalContainmentRoot = realpathSync.native(containmentRoot);
|
|
3173
|
+
const lexicalPath = resolve(path);
|
|
3174
|
+
if (!existsSync(lexicalPath)) {
|
|
3175
|
+
if (!pathIsContained(canonicalContainmentRoot, lexicalPath)) {
|
|
3176
|
+
inspection.escapedArtifacts.push(lexicalPath);
|
|
3177
|
+
return;
|
|
3178
|
+
}
|
|
3179
|
+
if (required)
|
|
3180
|
+
inspection.missing.push(lexicalPath);
|
|
3181
|
+
return;
|
|
3182
|
+
}
|
|
3183
|
+
let canonicalPath;
|
|
3184
|
+
try {
|
|
3185
|
+
canonicalPath = realpathSync.native(lexicalPath);
|
|
3186
|
+
}
|
|
3187
|
+
catch {
|
|
3188
|
+
if (required)
|
|
3189
|
+
inspection.missing.push(lexicalPath);
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
if (!pathIsContained(canonicalContainmentRoot, canonicalPath)) {
|
|
3193
|
+
inspection.escapedArtifacts.push(lexicalPath);
|
|
3194
|
+
return;
|
|
3195
|
+
}
|
|
3196
|
+
const queueKey = `${canonicalPath}:${parserKind}:${cwd}`;
|
|
3197
|
+
if (queued.has(queueKey))
|
|
3198
|
+
return;
|
|
3199
|
+
queued.add(queueKey);
|
|
3200
|
+
queue.push({ path: canonicalPath, parserKind, cwd, depth });
|
|
3201
|
+
};
|
|
3202
|
+
const enqueuePluginReferences = (content, scan, cwd, depth = 0) => {
|
|
3203
|
+
for (const relativePath of pluginReferences(content)) {
|
|
3204
|
+
const referencedPath = resolvePluginReference(root, relativePath);
|
|
3205
|
+
if (!referencedPath)
|
|
3206
|
+
continue;
|
|
3207
|
+
const candidate = (scan.artifacts ?? []).find((artifact) => (artifact.value.includes("PLUGIN_ROOT") && artifact.value.endsWith(relativePath)));
|
|
3208
|
+
enqueue(referencedPath, true, root, candidate?.parserKind ?? "infer", candidate?.cwd ?? cwd, depth);
|
|
3209
|
+
}
|
|
3210
|
+
};
|
|
3211
|
+
const enqueueSupportedReferences = (scan, fallbackCwd, depth = 0, containedInPluginRoot = false) => {
|
|
3212
|
+
for (const candidate of scan.artifacts ?? []) {
|
|
3213
|
+
if (candidate.value.includes("PLUGIN_ROOT"))
|
|
3214
|
+
continue;
|
|
3215
|
+
const resolved = candidate.nodeResolutionKind
|
|
3216
|
+
? resolveNodeSpecifierCandidate(candidate, !containedInPluginRoot)
|
|
3217
|
+
: {
|
|
3218
|
+
reference: settingsArtifactReference(candidate.value, {
|
|
3219
|
+
project: candidate.project,
|
|
3220
|
+
homePath: candidate.homePath,
|
|
3221
|
+
cwd: candidate.cwd,
|
|
3222
|
+
}, true, candidate.parserKind) ?? undefined,
|
|
3223
|
+
malformed: false,
|
|
3224
|
+
};
|
|
3225
|
+
if (resolved.malformed) {
|
|
3226
|
+
inspection.malformedCommands.push(candidate.value);
|
|
3227
|
+
continue;
|
|
3228
|
+
}
|
|
3229
|
+
if (resolved.inlineContent) {
|
|
3230
|
+
for (const endpoint of retiredProducerPaths(resolved.inlineContent.content, false, "node")) {
|
|
3231
|
+
inspection.findings.push({ artifact: resolved.inlineContent.source, endpoint });
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
if (resolved.reference) {
|
|
3235
|
+
enqueue(resolved.reference.path, true, containedInPluginRoot ? root : resolved.reference.containmentRoot, resolved.reference.parserKind, resolved.reference.cwd ?? fallbackCwd, depth);
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
};
|
|
3239
|
+
const enqueueCommand = (command) => {
|
|
3240
|
+
for (const endpoint of retiredProducerPaths(command, true)) {
|
|
3241
|
+
inspection.findings.push({ artifact: target.configPath ?? join(root, "hooks.json"), endpoint });
|
|
3242
|
+
}
|
|
3243
|
+
const commandScan = settingsArtifactTokens(command, 0, {
|
|
3244
|
+
project,
|
|
3245
|
+
homePath,
|
|
3246
|
+
cwd: project,
|
|
3247
|
+
});
|
|
3248
|
+
if (commandScan.malformed)
|
|
3249
|
+
inspection.malformedCommands.push(command);
|
|
3250
|
+
enqueuePluginReferences(command, commandScan, project);
|
|
3251
|
+
enqueueSupportedReferences(commandScan, project);
|
|
3252
|
+
for (const token of commandScan.tokens) {
|
|
3253
|
+
if (!isAbsolute(token))
|
|
3254
|
+
continue;
|
|
3255
|
+
const tokenRoot = pluginRootFromHookPath(token);
|
|
3256
|
+
const candidate = (commandScan.artifacts ?? []).find((artifact) => artifact.value === token);
|
|
3257
|
+
if (tokenRoot === root) {
|
|
3258
|
+
enqueue(token, true, root, candidate?.parserKind ?? "infer", candidate?.cwd ?? project);
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
};
|
|
3262
|
+
if (target.commands) {
|
|
3263
|
+
if (target.configPath)
|
|
3264
|
+
inspection.configsInspected.push(target.configPath);
|
|
3265
|
+
for (const command of target.commands)
|
|
3266
|
+
enqueueCommand(command);
|
|
3267
|
+
}
|
|
3268
|
+
if (target.inspectPluginConfig || !target.commands) {
|
|
3269
|
+
const configPath = join(root, "hooks.json");
|
|
3270
|
+
if (!existsSync(configPath)) {
|
|
3271
|
+
inspection.missing.push(configPath);
|
|
3272
|
+
enqueue(join(root, "hooks", "session-start"));
|
|
3273
|
+
}
|
|
3274
|
+
else {
|
|
3275
|
+
inspection.configsInspected.push(configPath);
|
|
3276
|
+
let canonicalConfigPath;
|
|
3277
|
+
try {
|
|
3278
|
+
canonicalConfigPath = realpathSync.native(configPath);
|
|
3279
|
+
}
|
|
3280
|
+
catch {
|
|
3281
|
+
inspection.missing.push(configPath);
|
|
3282
|
+
enqueue(join(root, "hooks", "session-start"));
|
|
3283
|
+
canonicalConfigPath = "";
|
|
3284
|
+
}
|
|
3285
|
+
if (canonicalConfigPath && !pathIsContained(root, canonicalConfigPath)) {
|
|
3286
|
+
inspection.escapedArtifacts.push(configPath);
|
|
3287
|
+
enqueue(join(root, "hooks", "session-start"));
|
|
3288
|
+
canonicalConfigPath = "";
|
|
3289
|
+
}
|
|
3290
|
+
if (canonicalConfigPath) {
|
|
3291
|
+
const configRead = readLegacyTextArtifact(canonicalConfigPath);
|
|
3292
|
+
if (configRead.kind === "unsafe") {
|
|
3293
|
+
inspection.unsafeArtifacts.push(unsafeArtifactLabel(configPath, configRead.reason));
|
|
3294
|
+
enqueue(join(root, "hooks", "session-start"));
|
|
3295
|
+
}
|
|
3296
|
+
else if (configRead.kind === "missing") {
|
|
3297
|
+
inspection.missing.push(configPath);
|
|
3298
|
+
enqueue(join(root, "hooks", "session-start"));
|
|
3299
|
+
}
|
|
3300
|
+
else
|
|
3301
|
+
try {
|
|
3302
|
+
const config = JSON.parse(configRead.content);
|
|
3303
|
+
const commands = [];
|
|
3304
|
+
collectCommandStrings(config, commands);
|
|
3305
|
+
for (const command of commands)
|
|
3306
|
+
enqueueCommand(command);
|
|
3307
|
+
}
|
|
3308
|
+
catch {
|
|
3309
|
+
inspection.malformedConfigs.push(configPath);
|
|
3310
|
+
enqueue(join(root, "hooks", "session-start"));
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
while (queue.length > 0) {
|
|
3316
|
+
if (traversedArtifacts >= MAX_LEGACY_ARTIFACTS_PER_ROOT) {
|
|
3317
|
+
inspection.traversalLimitRoots.push(root);
|
|
3318
|
+
break;
|
|
3319
|
+
}
|
|
3320
|
+
const queuedArtifact = queue.shift();
|
|
3321
|
+
const artifact = queuedArtifact.path;
|
|
3322
|
+
traversedArtifacts += 1;
|
|
3323
|
+
const artifactRead = readLegacyTextArtifact(artifact);
|
|
3324
|
+
if (artifactRead.kind === "missing") {
|
|
3325
|
+
inspection.missing.push(artifact);
|
|
3326
|
+
continue;
|
|
3327
|
+
}
|
|
3328
|
+
if (artifactRead.kind === "unsafe") {
|
|
3329
|
+
inspection.unsafeArtifacts.push(unsafeArtifactLabel(artifact, artifactRead.reason));
|
|
3330
|
+
continue;
|
|
3331
|
+
}
|
|
3332
|
+
const { content } = artifactRead;
|
|
3333
|
+
inspection.inspected.push(artifact);
|
|
3334
|
+
const parserKind = queuedArtifact.parserKind === "infer"
|
|
3335
|
+
? inferArtifactParserKind(artifact, content)
|
|
3336
|
+
: queuedArtifact.parserKind;
|
|
3337
|
+
const shellArtifact = parserKind === "shell";
|
|
3338
|
+
for (const endpoint of retiredProducerPaths(content, shellArtifact, parserKind)) {
|
|
3339
|
+
inspection.findings.push({ artifact, endpoint });
|
|
3340
|
+
}
|
|
3341
|
+
if (shellArtifact) {
|
|
3342
|
+
const expandedContent = expandStaticPluginPathAliases(content);
|
|
3343
|
+
const artifactScan = settingsArtifactTokens(expandedContent, 0, {
|
|
3344
|
+
project,
|
|
3345
|
+
homePath,
|
|
3346
|
+
cwd: dirname(artifact),
|
|
3347
|
+
}, { tolerateControlSyntax: true });
|
|
3348
|
+
if (pluginShellSyntaxMalformed(content))
|
|
3349
|
+
inspection.malformedCommands.push(artifact);
|
|
3350
|
+
enqueuePluginReferences(expandedContent, artifactScan, dirname(artifact), queuedArtifact.depth + 1);
|
|
3351
|
+
enqueueSupportedReferences(artifactScan, dirname(artifact), queuedArtifact.depth + 1, true);
|
|
3352
|
+
}
|
|
3353
|
+
else if (parserKind === "node" || parserKind === "python") {
|
|
3354
|
+
const dependencyScan = scanLanguageArtifactDependencies(artifact, content, parserKind, { project, homePath, cwd: dirname(artifact) }, false);
|
|
3355
|
+
if (parserKind === "node"
|
|
3356
|
+
? pluginNodeSyntaxMalformed(artifact)
|
|
3357
|
+
: dependencyScan.malformed)
|
|
3358
|
+
inspection.malformedCommands.push(artifact);
|
|
3359
|
+
for (const inline of dependencyScan.inlineContents) {
|
|
3360
|
+
for (const endpoint of retiredProducerPaths(inline.content, false, "node")) {
|
|
3361
|
+
inspection.findings.push({ artifact: inline.source, endpoint });
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
for (const reference of dependencyScan.references) {
|
|
3365
|
+
const relativeReference = relative(root, resolve(reference.path));
|
|
3366
|
+
if (!relativeReference.startsWith(`..${sep}`)
|
|
3367
|
+
&& relativeReference.split(sep).includes("node_modules"))
|
|
3368
|
+
continue;
|
|
3369
|
+
enqueue(reference.path, true, root, reference.parserKind, reference.cwd, queuedArtifact.depth + 1);
|
|
3370
|
+
}
|
|
3371
|
+
for (const relativePath of pluginReferences(content)) {
|
|
3372
|
+
const referencedPath = resolvePluginReference(root, relativePath);
|
|
3373
|
+
if (referencedPath) {
|
|
3374
|
+
enqueue(referencedPath, true, root, parserKind, queuedArtifact.cwd, queuedArtifact.depth + 1);
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
else {
|
|
3379
|
+
for (const relativePath of pluginReferences(content)) {
|
|
3380
|
+
const referencedPath = resolvePluginReference(root, relativePath);
|
|
3381
|
+
if (referencedPath) {
|
|
3382
|
+
enqueue(referencedPath, true, root, parserKind, queuedArtifact.cwd, queuedArtifact.depth + 1);
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
const relativeToRoot = artifact.startsWith(`${root}/`)
|
|
3387
|
+
? artifact.slice(root.length + 1)
|
|
3388
|
+
: "";
|
|
3389
|
+
const hookName = basename(relativeToRoot).replace(/\.js$/, "");
|
|
3390
|
+
if (workspaceRoot && (relativeToRoot.startsWith("hooks/") || relativeToRoot.startsWith("dist/hooks/"))) {
|
|
3391
|
+
enqueue(join(workspaceRoot, "packages", "devflow", "src", "hooks", `${hookName}.ts`), false, workspaceRoot);
|
|
3392
|
+
}
|
|
3393
|
+
if (relativeToRoot.startsWith("hooks/")) {
|
|
3394
|
+
enqueue(join(root, "dist", "hooks", `${hookName}.js`));
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
function checkLegacyPaths(project, dependencies) {
|
|
3399
|
+
const selection = selectActiveLegacyTargets(project, dependencies);
|
|
3400
|
+
const inspection = {
|
|
3401
|
+
configsInspected: [],
|
|
3402
|
+
commandsInspected: [],
|
|
3403
|
+
inspected: [],
|
|
3404
|
+
missing: [],
|
|
3405
|
+
malformedConfigs: [],
|
|
3406
|
+
malformedCommands: [],
|
|
3407
|
+
unsafeArtifacts: [],
|
|
3408
|
+
traversalLimitRoots: [],
|
|
3409
|
+
escapedArtifacts: [],
|
|
3410
|
+
findings: [],
|
|
3411
|
+
};
|
|
3412
|
+
for (const configCommands of selection.configCommands) {
|
|
3413
|
+
inspection.configsInspected.push(configCommands.path);
|
|
3414
|
+
for (const command of configCommands.commands) {
|
|
3415
|
+
inspection.commandsInspected.push({ configPath: configCommands.path, command });
|
|
3416
|
+
for (const endpoint of retiredProducerPaths(command, true)) {
|
|
3417
|
+
inspection.findings.push({ artifact: configCommands.path, endpoint });
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
inspectSettingsArtifacts(project, dependencies.homePath ?? homedir(), selection.configCommands.flatMap((source) => source.commands).filter((command) => (!selection.targets.some((target) => target.commands?.includes(command)))), inspection);
|
|
3422
|
+
for (const target of selection.targets) {
|
|
3423
|
+
inspectLegacyRoot(project, dependencies.homePath ?? homedir(), target, inspection);
|
|
3424
|
+
}
|
|
3425
|
+
const configsInspected = [...new Set(inspection.configsInspected)];
|
|
3426
|
+
const inspected = [...new Set(inspection.inspected)];
|
|
3427
|
+
const commandsInspected = inspection.commandsInspected.filter((entry, index, all) => (all.findIndex((candidate) => (candidate.configPath === entry.configPath && candidate.command === entry.command)) === index));
|
|
3428
|
+
const missing = [...new Set(inspection.missing)];
|
|
3429
|
+
const malformedConfigs = [...new Set(inspection.malformedConfigs)];
|
|
3430
|
+
const malformedCommands = [...new Set(inspection.malformedCommands)];
|
|
3431
|
+
const unsafeArtifacts = [...new Set(inspection.unsafeArtifacts)];
|
|
3432
|
+
const traversalLimitRoots = [...new Set(inspection.traversalLimitRoots)];
|
|
3433
|
+
const escapedArtifacts = [...new Set(inspection.escapedArtifacts)];
|
|
3434
|
+
const findings = inspection.findings.filter((finding, index, all) => (all.findIndex((item) => (item.artifact === finding.artifact && item.endpoint === finding.endpoint)) === index));
|
|
3435
|
+
const artifactResolutionFailed = missing.length > 0
|
|
3436
|
+
|| escapedArtifacts.length > 0
|
|
3437
|
+
|| unsafeArtifacts.length > 0;
|
|
3438
|
+
const resolutionMethod = artifactResolutionFailed && !selection.method.endsWith("-unresolved")
|
|
3439
|
+
? `${selection.method}-unresolved`
|
|
3440
|
+
: selection.method;
|
|
3441
|
+
const metrics = [
|
|
3442
|
+
{
|
|
3443
|
+
key: "active_plugin_resolution",
|
|
3444
|
+
value: resolutionMethod,
|
|
3445
|
+
target: "resolved active DevFlow hook configuration",
|
|
3446
|
+
ok: selection.ok && !artifactResolutionFailed,
|
|
3447
|
+
severity: "warning",
|
|
3448
|
+
},
|
|
3449
|
+
{
|
|
3450
|
+
key: "active_plugin_roots",
|
|
3451
|
+
value: selection.targets.map((target) => target.root),
|
|
3452
|
+
ok: true,
|
|
3453
|
+
severity: "info",
|
|
3454
|
+
},
|
|
3455
|
+
{
|
|
3456
|
+
key: "hooks_disabled",
|
|
3457
|
+
value: selection.hooksDisabled,
|
|
3458
|
+
ok: true,
|
|
3459
|
+
severity: "info",
|
|
3460
|
+
},
|
|
3461
|
+
{
|
|
3462
|
+
key: "malformed_active_settings",
|
|
3463
|
+
value: selection.malformedSettings,
|
|
3464
|
+
target: [],
|
|
3465
|
+
ok: selection.malformedSettings.length === 0,
|
|
3466
|
+
severity: "warning",
|
|
3467
|
+
},
|
|
3468
|
+
{
|
|
3469
|
+
key: "malformed_plugin_metadata",
|
|
3470
|
+
value: selection.malformedPluginMetadata,
|
|
3471
|
+
target: [],
|
|
3472
|
+
ok: selection.malformedPluginMetadata.length === 0,
|
|
3473
|
+
severity: "warning",
|
|
3474
|
+
},
|
|
3475
|
+
{
|
|
3476
|
+
key: "hook_configs_inspected",
|
|
3477
|
+
value: configsInspected.length,
|
|
3478
|
+
target: selection.hooksDisabled ? "hooks disabled" : ">= 1 hook config file",
|
|
3479
|
+
ok: selection.hooksDisabled || configsInspected.length > 0,
|
|
3480
|
+
severity: "warning",
|
|
3481
|
+
},
|
|
3482
|
+
{
|
|
3483
|
+
key: "legacy_artifacts_inspected",
|
|
3484
|
+
value: inspected.length + commandsInspected.length,
|
|
3485
|
+
target: selection.hooksDisabled ? "hooks disabled" : ">= 1 active hook artifact",
|
|
3486
|
+
ok: selection.hooksDisabled || inspected.length + commandsInspected.length > 0,
|
|
3487
|
+
severity: "warning",
|
|
3488
|
+
},
|
|
3489
|
+
{
|
|
3490
|
+
key: "missing_legacy_artifacts",
|
|
3491
|
+
value: missing,
|
|
3492
|
+
target: [],
|
|
3493
|
+
ok: missing.length === 0,
|
|
3494
|
+
severity: "warning",
|
|
3495
|
+
},
|
|
3496
|
+
{
|
|
3497
|
+
key: "malformed_hook_configs",
|
|
3498
|
+
value: malformedConfigs,
|
|
3499
|
+
target: [],
|
|
3500
|
+
ok: malformedConfigs.length === 0,
|
|
3501
|
+
severity: "warning",
|
|
3502
|
+
},
|
|
3503
|
+
{
|
|
3504
|
+
key: "malformed_hook_commands",
|
|
3505
|
+
value: malformedCommands,
|
|
3506
|
+
target: [],
|
|
3507
|
+
ok: malformedCommands.length === 0,
|
|
3508
|
+
severity: "warning",
|
|
3509
|
+
},
|
|
3510
|
+
{
|
|
3511
|
+
key: "unsafe_legacy_artifacts",
|
|
3512
|
+
value: unsafeArtifacts,
|
|
3513
|
+
target: [],
|
|
3514
|
+
ok: unsafeArtifacts.length === 0,
|
|
3515
|
+
severity: "warning",
|
|
3516
|
+
},
|
|
3517
|
+
{
|
|
3518
|
+
key: "legacy_artifact_traversal_limit",
|
|
3519
|
+
value: traversalLimitRoots,
|
|
3520
|
+
target: [],
|
|
3521
|
+
ok: traversalLimitRoots.length === 0,
|
|
3522
|
+
severity: "warning",
|
|
3523
|
+
},
|
|
3524
|
+
{
|
|
3525
|
+
key: "escaped_legacy_artifacts",
|
|
3526
|
+
value: escapedArtifacts.length,
|
|
3527
|
+
target: 0,
|
|
3528
|
+
ok: escapedArtifacts.length === 0,
|
|
3529
|
+
severity: "warning",
|
|
3530
|
+
},
|
|
3531
|
+
{
|
|
3532
|
+
key: "retired_producer_paths",
|
|
3533
|
+
value: findings.length,
|
|
3534
|
+
target: 0,
|
|
3535
|
+
ok: findings.length === 0,
|
|
3536
|
+
severity: "error",
|
|
3537
|
+
},
|
|
3538
|
+
{
|
|
3539
|
+
key: "retired_producer_path_findings",
|
|
3540
|
+
value: findings,
|
|
3541
|
+
ok: true,
|
|
3542
|
+
severity: "info",
|
|
3543
|
+
},
|
|
3544
|
+
];
|
|
3545
|
+
return { name: "Legacy paths", status: sectionStatus(metrics), metrics };
|
|
3546
|
+
}
|
|
3547
|
+
function loadAcceptanceManifest(dependencies) {
|
|
3548
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
3549
|
+
const candidates = [...new Set([
|
|
3550
|
+
dependencies.acceptanceManifestPath,
|
|
3551
|
+
dependencies.env?.DEVFLOW_ACCEPTANCE_MANIFEST,
|
|
3552
|
+
process.env.DEVFLOW_ACCEPTANCE_MANIFEST,
|
|
3553
|
+
join(moduleDir, "..", "acceptance-manifest.json"),
|
|
3554
|
+
join(moduleDir, "..", "..", "..", "..", "packages", "devflow", "acceptance-manifest.json"),
|
|
3555
|
+
join(process.cwd(), "packages", "devflow", "acceptance-manifest.json"),
|
|
3556
|
+
].filter((path) => Boolean(path)))];
|
|
3557
|
+
for (const path of candidates) {
|
|
3558
|
+
if (!existsSync(path))
|
|
3559
|
+
continue;
|
|
3560
|
+
try {
|
|
3561
|
+
return {
|
|
3562
|
+
path,
|
|
3563
|
+
manifest: JSON.parse(readFileSync(path, "utf8")),
|
|
3564
|
+
};
|
|
3565
|
+
}
|
|
3566
|
+
catch (error) {
|
|
3567
|
+
return { path, error: error.message };
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
return { error: `not found (${candidates.join(", ")})` };
|
|
3571
|
+
}
|
|
3572
|
+
function acceptanceManifestMetrics(dependencies) {
|
|
3573
|
+
const loaded = loadAcceptanceManifest(dependencies);
|
|
3574
|
+
if (!loaded.manifest) {
|
|
3575
|
+
return {
|
|
3576
|
+
metrics: [{
|
|
3577
|
+
key: "batch4_acceptance_manifest",
|
|
3578
|
+
value: loaded.error ?? "missing",
|
|
3579
|
+
target: "readable acceptance-manifest.json",
|
|
3580
|
+
ok: false,
|
|
3581
|
+
severity: "warning",
|
|
3582
|
+
}],
|
|
3583
|
+
};
|
|
3584
|
+
}
|
|
3585
|
+
const verifiedAt = Date.parse(loaded.manifest.verifiedAt);
|
|
3586
|
+
const ageDays = Number.isFinite(verifiedAt)
|
|
3587
|
+
? ((dependencies.now ?? Date.now)() - verifiedAt) / DAY_MS
|
|
3588
|
+
: Number.POSITIVE_INFINITY;
|
|
3589
|
+
return {
|
|
3590
|
+
manifest: loaded.manifest,
|
|
3591
|
+
metrics: [
|
|
3592
|
+
{
|
|
3593
|
+
key: "batch4_acceptance_manifest",
|
|
3594
|
+
value: loaded.path,
|
|
3595
|
+
target: "schemaVersion 1",
|
|
3596
|
+
ok: loaded.manifest.schemaVersion === 1,
|
|
3597
|
+
severity: "error",
|
|
3598
|
+
},
|
|
3599
|
+
{
|
|
3600
|
+
key: "acceptance_evidence_age_days",
|
|
3601
|
+
value: Number.isFinite(ageDays) ? Number(ageDays.toFixed(2)) : "invalid timestamp",
|
|
3602
|
+
target: "0-30",
|
|
3603
|
+
ok: ageDays >= 0 && ageDays <= 30,
|
|
3604
|
+
severity: "warning",
|
|
3605
|
+
},
|
|
3606
|
+
],
|
|
3607
|
+
};
|
|
3608
|
+
}
|
|
3609
|
+
function checkInjection(project, dependencies) {
|
|
3610
|
+
const metrics = [];
|
|
3611
|
+
const configPath = join(project, ".devflow", "config.json");
|
|
3612
|
+
let injectionSetting = "default-enabled";
|
|
3613
|
+
if (existsSync(configPath)) {
|
|
3614
|
+
try {
|
|
3615
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
3616
|
+
injectionSetting = config.sessionStart?.injectMemories ?? "default-enabled";
|
|
3617
|
+
}
|
|
3618
|
+
catch (error) {
|
|
3619
|
+
metrics.push({
|
|
3620
|
+
key: "session_start_config",
|
|
3621
|
+
value: error.message,
|
|
3622
|
+
target: "valid .devflow/config.json",
|
|
3623
|
+
ok: false,
|
|
3624
|
+
severity: "error",
|
|
3625
|
+
});
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
const injectionEnabled = injectionSetting !== false;
|
|
3629
|
+
metrics.push({
|
|
3630
|
+
key: "session_start_memory_injection",
|
|
3631
|
+
value: injectionSetting,
|
|
3632
|
+
target: "enabled or configured budget",
|
|
3633
|
+
ok: injectionEnabled,
|
|
3634
|
+
severity: "warning",
|
|
3635
|
+
});
|
|
3636
|
+
const memoryPath = join(project, ".devflow", "memory", "memories.db");
|
|
3637
|
+
if (!existsSync(memoryPath)) {
|
|
3638
|
+
metrics.push({
|
|
3639
|
+
key: "explicit_memory_channel",
|
|
3640
|
+
value: "memory database missing",
|
|
3641
|
+
target: "observations.source supports explicit_intent",
|
|
3642
|
+
ok: false,
|
|
3643
|
+
severity: "warning",
|
|
3644
|
+
});
|
|
3645
|
+
}
|
|
3646
|
+
else {
|
|
3647
|
+
let db;
|
|
3648
|
+
try {
|
|
3649
|
+
db = doctorOpenDatabase(memoryPath, dependencies);
|
|
3650
|
+
const columns = doctorTableExists(db, "observations")
|
|
3651
|
+
? db.prepare("PRAGMA table_info(observations)").all()
|
|
3652
|
+
.map(column => column.name)
|
|
3653
|
+
: [];
|
|
3654
|
+
const supportsExplicitIntent = columns.includes("source");
|
|
3655
|
+
metrics.push({
|
|
3656
|
+
key: "explicit_memory_channel",
|
|
3657
|
+
value: supportsExplicitIntent ? "explicit_intent source available" : "source column missing",
|
|
3658
|
+
target: "explicit_intent source available",
|
|
3659
|
+
ok: supportsExplicitIntent,
|
|
3660
|
+
severity: "warning",
|
|
3661
|
+
});
|
|
3662
|
+
if (supportsExplicitIntent) {
|
|
3663
|
+
metrics.push({
|
|
3664
|
+
key: "explicit_memory_observations",
|
|
3665
|
+
value: doctorCount(db, "SELECT COUNT(*) count FROM observations WHERE source = 'explicit_intent'"),
|
|
3666
|
+
ok: true,
|
|
3667
|
+
severity: "info",
|
|
3668
|
+
});
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
catch (error) {
|
|
3672
|
+
metrics.push({
|
|
3673
|
+
key: "explicit_memory_channel_read",
|
|
3674
|
+
value: error.message,
|
|
3675
|
+
target: "readable memory database",
|
|
3676
|
+
ok: false,
|
|
3677
|
+
severity: "error",
|
|
3678
|
+
});
|
|
3679
|
+
}
|
|
3680
|
+
finally {
|
|
3681
|
+
db?.close();
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
return { name: "Injection", status: sectionStatus(metrics), metrics };
|
|
3685
|
+
}
|
|
3686
|
+
function checkMemoryQuality(project, dependencies) {
|
|
3687
|
+
const metrics = [];
|
|
3688
|
+
const memoryPath = join(project, ".devflow", "memory", "memories.db");
|
|
3689
|
+
if (!existsSync(memoryPath)) {
|
|
3690
|
+
metrics.push({ key: "memories_db", value: "missing", target: memoryPath, ok: false, severity: "warning" });
|
|
3691
|
+
return { name: "Memory quality", status: sectionStatus(metrics), metrics };
|
|
3692
|
+
}
|
|
3693
|
+
let db;
|
|
3694
|
+
try {
|
|
3695
|
+
db = doctorOpenDatabase(memoryPath, dependencies);
|
|
3696
|
+
if (!doctorTableExists(db, "memories")) {
|
|
3697
|
+
metrics.push({ key: "memories_schema", value: "memories table missing", target: "Batch 3 memory schema", ok: false, severity: "warning" });
|
|
3698
|
+
}
|
|
3699
|
+
else {
|
|
3700
|
+
const cutoff = (dependencies.now ?? Date.now)() - 7 * DAY_MS;
|
|
3701
|
+
const sources = db.prepare(`
|
|
3702
|
+
SELECT source, COUNT(*) count FROM memories
|
|
3703
|
+
WHERE created_at >= ? GROUP BY source ORDER BY source
|
|
3704
|
+
`).all(cutoff);
|
|
3705
|
+
const access = db.prepare(`
|
|
3706
|
+
SELECT
|
|
3707
|
+
SUM(CASE WHEN access_count = 0 THEN 1 ELSE 0 END) never_accessed,
|
|
3708
|
+
SUM(CASE WHEN access_count > 0 THEN 1 ELSE 0 END) accessed
|
|
3709
|
+
FROM memories WHERE status = 'active'
|
|
3710
|
+
`).get();
|
|
3711
|
+
metrics.push({
|
|
3712
|
+
key: "memory_sources_7d",
|
|
3713
|
+
value: Object.fromEntries(sources.map(row => [row.source, Number(row.count)])),
|
|
3714
|
+
ok: true,
|
|
3715
|
+
severity: "info",
|
|
3716
|
+
});
|
|
3717
|
+
metrics.push({
|
|
3718
|
+
key: "memory_access_distribution",
|
|
3719
|
+
value: {
|
|
3720
|
+
neverAccessed: Number(access?.never_accessed ?? 0),
|
|
3721
|
+
accessed: Number(access?.accessed ?? 0),
|
|
3722
|
+
},
|
|
3723
|
+
ok: true,
|
|
3724
|
+
severity: "info",
|
|
3725
|
+
});
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
catch (error) {
|
|
3729
|
+
metrics.push({ key: "memory_quality_read", value: error.message, target: "readable memory database", ok: false, severity: "error" });
|
|
3730
|
+
}
|
|
3731
|
+
finally {
|
|
3732
|
+
db?.close();
|
|
3733
|
+
}
|
|
3734
|
+
return { name: "Memory quality", status: sectionStatus(metrics), metrics };
|
|
3735
|
+
}
|
|
3736
|
+
function checkKnowledgeQuality(project, dependencies) {
|
|
3737
|
+
const metrics = [];
|
|
3738
|
+
const knowledgePath = join(project, ".devflow", "knowledge", "knowledge.db");
|
|
3739
|
+
if (!existsSync(knowledgePath)) {
|
|
3740
|
+
metrics.push({ key: "knowledge_db", value: "missing", target: knowledgePath, ok: false, severity: "warning" });
|
|
3741
|
+
return { name: "Knowledge quality", status: sectionStatus(metrics), metrics };
|
|
3742
|
+
}
|
|
3743
|
+
let db;
|
|
3744
|
+
try {
|
|
3745
|
+
db = doctorOpenDatabase(knowledgePath, dependencies);
|
|
3746
|
+
if (!doctorTableExists(db, "knowledge_chunks")) {
|
|
3747
|
+
metrics.push({ key: "knowledge_schema", value: "knowledge_chunks missing", target: "Batch 3 knowledge schema", ok: false, severity: "warning" });
|
|
3748
|
+
}
|
|
3749
|
+
else {
|
|
3750
|
+
const lengths = db.prepare("SELECT length(content) length FROM knowledge_chunks ORDER BY length(content)").all().map(row => Number(row.length));
|
|
3751
|
+
const p95Index = Math.max(0, Math.ceil(lengths.length * 0.95) - 1);
|
|
3752
|
+
const p95 = lengths.length === 0 ? 0 : lengths[p95Index];
|
|
3753
|
+
metrics.push({ key: "knowledge_chunk_length_p95", value: p95, target: "<= 3000", ok: p95 <= 3000, severity: "warning" });
|
|
3754
|
+
const fts = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'knowledge_fts'").get();
|
|
3755
|
+
const trigram = /tokenize\s*=\s*['\"]?trigram/i.test(fts?.sql ?? "");
|
|
3756
|
+
metrics.push({ key: "knowledge_fts_tokenizer", value: trigram ? "trigram" : "missing/non-trigram", target: "trigram", ok: trigram, severity: "warning" });
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
catch (error) {
|
|
3760
|
+
metrics.push({ key: "knowledge_quality_read", value: error.message, target: "readable knowledge database", ok: false, severity: "error" });
|
|
3761
|
+
}
|
|
3762
|
+
finally {
|
|
3763
|
+
db?.close();
|
|
3764
|
+
}
|
|
3765
|
+
return { name: "Knowledge quality", status: sectionStatus(metrics), metrics };
|
|
3766
|
+
}
|
|
3767
|
+
function checkContract(dependencies) {
|
|
3768
|
+
const { manifest, metrics } = acceptanceManifestMetrics(dependencies);
|
|
3769
|
+
const surfaces = manifest?.contract?.surfaces ?? [];
|
|
3770
|
+
metrics.push({ key: "contract_surfaces", value: surfaces, target: ["cli", "http", "mcp", "hook"], ok: manifest?.contract?.status === "green" && surfaces.length === 4, severity: "error" });
|
|
3771
|
+
metrics.push({ key: "contract_test_files", value: manifest?.contract?.tests?.length ?? 0, target: 4, ok: manifest?.contract?.tests?.length === 4, severity: "warning" });
|
|
3772
|
+
return { name: "Contract", status: sectionStatus(metrics), metrics };
|
|
3773
|
+
}
|
|
3774
|
+
function checkDeadcode(dependencies) {
|
|
3775
|
+
const { manifest, metrics } = acceptanceManifestMetrics(dependencies);
|
|
3776
|
+
const actionable = manifest?.deadcode?.actionableExports;
|
|
3777
|
+
metrics.push({ key: "ts_prune_actionable_exports", value: actionable ?? "missing", target: 0, ok: manifest?.deadcode?.status === "green" && actionable === 0, severity: "error" });
|
|
3778
|
+
metrics.push({ key: "ts_prune_allowlisted_exports", value: manifest?.deadcode?.allowlistedExports ?? "missing", ok: true, severity: "info" });
|
|
3779
|
+
return { name: "Deadcode", status: sectionStatus(metrics), metrics };
|
|
3780
|
+
}
|
|
3781
|
+
function checkDataFace(dependencies) {
|
|
3782
|
+
const { manifest, metrics } = acceptanceManifestMetrics(dependencies);
|
|
3783
|
+
const adapters = manifest?.dataFace?.sharedAdapters ?? [];
|
|
3784
|
+
metrics.push({
|
|
3785
|
+
key: "shared_retrieval_adapters",
|
|
3786
|
+
value: adapters,
|
|
3787
|
+
target: ["getProjectContextResult", "getMemoryResult", "getKnowledgeResult"],
|
|
3788
|
+
ok: manifest?.dataFace?.status === "green" && adapters.length === 3,
|
|
3789
|
+
severity: "error",
|
|
3790
|
+
});
|
|
3791
|
+
return { name: "Data face", status: sectionStatus(metrics), metrics };
|
|
3792
|
+
}
|
|
3793
|
+
async function defaultHttpGet(url) {
|
|
3794
|
+
const controller = new AbortController();
|
|
3795
|
+
const timeout = setTimeout(() => controller.abort(), 500);
|
|
3796
|
+
try {
|
|
3797
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
3798
|
+
return { status: response.status };
|
|
3799
|
+
}
|
|
3800
|
+
finally {
|
|
3801
|
+
clearTimeout(timeout);
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
async function checkUiReachability(dependencies) {
|
|
3805
|
+
const { manifest, metrics } = acceptanceManifestMetrics(dependencies);
|
|
3806
|
+
const routes = manifest?.ui?.routes ?? [];
|
|
3807
|
+
metrics.push({ key: "dashboard_routes", value: routes.length, target: ">= 13", ok: manifest?.ui?.status === "green" && routes.length >= 13, severity: "warning" });
|
|
3808
|
+
const port = dependencies.env?.PORT ?? process.env.PORT ?? "13337";
|
|
3809
|
+
const healthUrl = `http://127.0.0.1:${port}/api/health`;
|
|
3810
|
+
try {
|
|
3811
|
+
const response = await (dependencies.httpGet ?? defaultHttpGet)(healthUrl);
|
|
3812
|
+
metrics.push({ key: "dashboard_server_health", value: { url: healthUrl, status: response.status }, target: "HTTP 200", ok: response.status === 200, severity: "warning" });
|
|
3813
|
+
}
|
|
3814
|
+
catch (error) {
|
|
3815
|
+
metrics.push({ key: "dashboard_server_health", value: error.message, target: "HTTP 200", ok: false, severity: "warning" });
|
|
3816
|
+
}
|
|
3817
|
+
return { name: "UI reachability", status: sectionStatus(metrics), metrics };
|
|
3818
|
+
}
|
|
3819
|
+
export async function runStructuredDoctor(options = {}, dependencies = {}) {
|
|
3820
|
+
const requestedProject = options.project ?? process.cwd();
|
|
3821
|
+
const canonical = canonicalizeProject(requestedProject);
|
|
3822
|
+
const sections = [
|
|
3823
|
+
checkIsolation(requestedProject, dependencies),
|
|
3824
|
+
checkDistill(canonical.path, dependencies),
|
|
3825
|
+
checkLegacyPaths(canonical.path, dependencies),
|
|
3826
|
+
checkVectors(canonical.path, dependencies),
|
|
3827
|
+
checkTelemetry(canonical.path, Boolean(options.strict), dependencies),
|
|
3828
|
+
checkInjection(canonical.path, dependencies),
|
|
3829
|
+
checkMemoryQuality(canonical.path, dependencies),
|
|
3830
|
+
checkKnowledgeQuality(canonical.path, dependencies),
|
|
3831
|
+
checkContract(dependencies),
|
|
3832
|
+
checkDeadcode(dependencies),
|
|
3833
|
+
checkBenchmark(dependencies),
|
|
3834
|
+
checkDataFace(dependencies),
|
|
3835
|
+
checkRuntimeSecurity(dependencies),
|
|
3836
|
+
await checkUiReachability(dependencies),
|
|
3837
|
+
checkHookPathHealth(dependencies),
|
|
3838
|
+
];
|
|
3839
|
+
const exitCode = options.strict && sections.some((section) => section.status !== "green")
|
|
3840
|
+
? 1
|
|
3841
|
+
: 0;
|
|
3842
|
+
return { sections, exitCode };
|
|
3843
|
+
}
|
|
3844
|
+
export function runDoctor(options, dependencies = {}) {
|
|
3845
|
+
return options === undefined
|
|
3846
|
+
? runLegacyDoctorReport()
|
|
3847
|
+
: runStructuredDoctor(options, dependencies);
|
|
3848
|
+
}
|
|
3849
|
+
function displayValue(value) {
|
|
3850
|
+
const json = JSON.stringify(value);
|
|
3851
|
+
return json === undefined ? String(value) : json;
|
|
3852
|
+
}
|
|
3853
|
+
export function renderDoctorText(result) {
|
|
3854
|
+
const lines = [];
|
|
3855
|
+
for (const section of result.sections) {
|
|
3856
|
+
const label = `[${section.status.toUpperCase()}]`;
|
|
3857
|
+
const color = section.status === "green"
|
|
3858
|
+
? chalk.green
|
|
3859
|
+
: section.status === "yellow"
|
|
3860
|
+
? chalk.yellow
|
|
3861
|
+
: chalk.red;
|
|
3862
|
+
lines.push(`${color(label)} ${chalk.bold(section.name)}`);
|
|
3863
|
+
for (const metric of section.metrics) {
|
|
3864
|
+
const target = metric.target === undefined
|
|
3865
|
+
? ""
|
|
3866
|
+
: ` (target: ${displayValue(metric.target)})`;
|
|
3867
|
+
lines.push(` ${metric.ok ? "OK" : "FAIL"} ${metric.key}: ${displayValue(metric.value)}${target}`);
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
return lines.join("\n");
|
|
3871
|
+
}
|
|
3872
|
+
export async function runDoctorCommand(options = {}, io = {
|
|
3873
|
+
stdout: (output) => console.log(output),
|
|
3874
|
+
stderr: (output) => console.error(output),
|
|
3875
|
+
}, dependencies = {}) {
|
|
3876
|
+
const result = await runStructuredDoctor(options, dependencies);
|
|
3877
|
+
io.stdout(options.json ? JSON.stringify(result) : renderDoctorText(result));
|
|
3878
|
+
return result.exitCode;
|
|
3879
|
+
}
|
|
3880
|
+
async function createLegacyEngines(project) {
|
|
3881
|
+
const { createEngines } = await import("@devflow-tools/context-engine");
|
|
3882
|
+
return createEngines(project);
|
|
3883
|
+
}
|
|
3884
|
+
export async function runLegacyDoctorReport(project = process.cwd()) {
|
|
3885
|
+
const { codegraph, graph, memory, knowledge, workflow, data } = await createLegacyEngines(project);
|
|
7
3886
|
const stats = codegraph.getStats();
|
|
8
3887
|
const graphOutput = graph.export();
|
|
9
3888
|
let workflowCount = 0;
|
|
10
3889
|
try {
|
|
11
|
-
|
|
12
|
-
workflowCount = wfList.length;
|
|
3890
|
+
workflowCount = (await workflow.list()).length;
|
|
13
3891
|
}
|
|
14
3892
|
catch {
|
|
15
|
-
// workflow engine
|
|
3893
|
+
// Legacy report treats an unavailable workflow engine as empty.
|
|
16
3894
|
}
|
|
17
3895
|
let memoryReady = false;
|
|
18
3896
|
let userPrefs = 0;
|
|
19
3897
|
try {
|
|
20
3898
|
const mem = await memory.getAll();
|
|
21
|
-
memoryReady =
|
|
22
|
-
|
|
23
|
-
userPrefs = Object.keys(prefs).length;
|
|
3899
|
+
memoryReady = Boolean(mem?.project);
|
|
3900
|
+
userPrefs = Object.keys(mem?.user ?? {}).length;
|
|
24
3901
|
}
|
|
25
3902
|
catch {
|
|
26
|
-
// memory engine
|
|
3903
|
+
// Legacy report treats an unavailable memory engine as uninitialized.
|
|
27
3904
|
}
|
|
28
3905
|
let knowledgeSources = 0;
|
|
29
3906
|
try {
|
|
30
|
-
|
|
31
|
-
knowledgeSources = sources.length;
|
|
3907
|
+
knowledgeSources = (await knowledge.getSources()).length;
|
|
32
3908
|
}
|
|
33
3909
|
catch {
|
|
34
|
-
// knowledge engine
|
|
3910
|
+
// Legacy report treats an unavailable knowledge engine as empty.
|
|
35
3911
|
}
|
|
36
|
-
// Layer 4: Fetch local tool call telemetry
|
|
37
3912
|
let toolCallStats = { total: 0, byTool: {} };
|
|
38
3913
|
try {
|
|
39
3914
|
toolCallStats = await data.getToolCallStats();
|
|
40
3915
|
}
|
|
41
3916
|
catch {
|
|
42
|
-
//
|
|
3917
|
+
// Legacy report tolerates missing telemetry tables.
|
|
43
3918
|
}
|
|
44
3919
|
const server = await serverStatus();
|
|
45
3920
|
let readyScore = 0;
|
|
@@ -65,33 +3940,18 @@ export async function runDoctor() {
|
|
|
65
3940
|
totalSymbols: stats.nodeCount,
|
|
66
3941
|
indexing: codegraph.isIndexing(),
|
|
67
3942
|
},
|
|
68
|
-
graph: {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
},
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
userPrefs,
|
|
75
|
-
},
|
|
76
|
-
knowledge: {
|
|
77
|
-
sources: knowledgeSources,
|
|
78
|
-
},
|
|
79
|
-
plugins: {
|
|
80
|
-
installed: 0,
|
|
81
|
-
names: [],
|
|
82
|
-
},
|
|
83
|
-
workflows: {
|
|
84
|
-
registered: workflowCount,
|
|
85
|
-
},
|
|
86
|
-
server: {
|
|
87
|
-
running: server.running,
|
|
88
|
-
},
|
|
3943
|
+
graph: { nodes: graphOutput.nodes.length, edges: graphOutput.edges.length },
|
|
3944
|
+
memory: { projectReady: memoryReady, userPrefs },
|
|
3945
|
+
knowledge: { sources: knowledgeSources },
|
|
3946
|
+
plugins: { installed: 0, names: [] },
|
|
3947
|
+
workflows: { registered: workflowCount },
|
|
3948
|
+
server: { running: server.running },
|
|
89
3949
|
toolCalls: toolCallStats,
|
|
90
3950
|
readyScore,
|
|
91
3951
|
};
|
|
92
3952
|
}
|
|
93
3953
|
export async function runDoctorAudit() {
|
|
94
|
-
const { data } = await
|
|
3954
|
+
const { data } = await createLegacyEngines(process.cwd());
|
|
95
3955
|
const secret = await data.auditGetSecret();
|
|
96
3956
|
const entries = await data.auditGetAll();
|
|
97
3957
|
let prevHash = createHash("sha256").update("genesis").digest("hex");
|
|
@@ -109,7 +3969,7 @@ export async function runDoctorAudit() {
|
|
|
109
3969
|
return { valid: true, brokenAt: null, entryCount: entries.length };
|
|
110
3970
|
}
|
|
111
3971
|
export async function runDoctorAuditFix() {
|
|
112
|
-
const { data } = await
|
|
3972
|
+
const { data } = await createLegacyEngines(process.cwd());
|
|
113
3973
|
const secret = await data.auditGetSecret();
|
|
114
3974
|
const entries = await data.auditGetAll();
|
|
115
3975
|
let prevHash = createHash("sha256").update("genesis").digest("hex");
|
|
@@ -122,22 +3982,25 @@ export async function runDoctorAuditFix() {
|
|
|
122
3982
|
return { rebuilt: true };
|
|
123
3983
|
}
|
|
124
3984
|
export async function runDoctorViolations(last) {
|
|
125
|
-
const { data } = await
|
|
3985
|
+
const { data } = await createLegacyEngines(process.cwd());
|
|
126
3986
|
const entries = await data.auditGetAll();
|
|
127
|
-
let violations = entries.filter((
|
|
3987
|
+
let violations = entries.filter((entry) => entry.decision !== "allow");
|
|
128
3988
|
if (last) {
|
|
129
3989
|
const match = last.match(/^(\d+)([dh])$/);
|
|
130
3990
|
if (match) {
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const cutoffMs = unit === "d" ? num * 86400000 : num * 3600000;
|
|
3991
|
+
const amount = Number.parseInt(match[1], 10);
|
|
3992
|
+
const cutoffMs = match[2] === "d" ? amount * 86_400_000 : amount * 3_600_000;
|
|
134
3993
|
const cutoff = Date.now() - cutoffMs;
|
|
135
|
-
violations = violations.filter((
|
|
3994
|
+
violations = violations.filter((entry) => entry.ts > cutoff);
|
|
136
3995
|
}
|
|
137
3996
|
}
|
|
138
3997
|
return {
|
|
139
|
-
violations: violations.map((
|
|
140
|
-
tool:
|
|
3998
|
+
violations: violations.map((entry) => ({
|
|
3999
|
+
tool: entry.tool,
|
|
4000
|
+
decision: entry.decision,
|
|
4001
|
+
ruleId: entry.ruleId,
|
|
4002
|
+
ts: entry.ts,
|
|
4003
|
+
sessionId: entry.sessionId,
|
|
141
4004
|
})),
|
|
142
4005
|
};
|
|
143
4006
|
}
|
|
@@ -146,8 +4009,7 @@ export function runDoctorReceipts() {
|
|
|
146
4009
|
const sessionId = createHash("sha256").update(process.cwd()).digest("hex").slice(0, 16);
|
|
147
4010
|
const receiptFile = `${receiptDir}/${sessionId}.json`;
|
|
148
4011
|
try {
|
|
149
|
-
|
|
150
|
-
return { sessionId, data: JSON.parse(raw) };
|
|
4012
|
+
return { sessionId, data: JSON.parse(readFileSync(receiptFile, "utf8")) };
|
|
151
4013
|
}
|
|
152
4014
|
catch {
|
|
153
4015
|
return { sessionId, data: null };
|