@vantageos/mcp-doctor 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +4 -0
- package/dist/doctor.d.ts +195 -3
- package/dist/doctor.js +3730 -101
- package/package.json +10 -2
package/dist/doctor.js
CHANGED
|
@@ -1,70 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/scan.ts
|
|
4
|
-
import { readFileSync, readdirSync, statSync, existsSync } from "fs";
|
|
5
|
-
import { join, dirname, relative } from "path";
|
|
6
|
-
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "tests", "__tests__", "coverage"]);
|
|
7
|
-
function isCommentLine(raw) {
|
|
8
|
-
const t = raw.trim();
|
|
9
|
-
if (t === "") return false;
|
|
10
|
-
return t.startsWith("*") || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*/");
|
|
11
|
-
}
|
|
12
|
-
function walk(dir, out) {
|
|
13
|
-
let entries;
|
|
14
|
-
try {
|
|
15
|
-
entries = readdirSync(dir);
|
|
16
|
-
} catch {
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
for (const name of entries) {
|
|
20
|
-
const full = join(dir, name);
|
|
21
|
-
let st;
|
|
22
|
-
try {
|
|
23
|
-
st = statSync(full);
|
|
24
|
-
} catch {
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
if (st.isDirectory()) {
|
|
28
|
-
if (SKIP_DIRS.has(name)) continue;
|
|
29
|
-
walk(full, out);
|
|
30
|
-
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
|
|
31
|
-
if (name.endsWith(".d.ts") || name.endsWith(".test.ts")) continue;
|
|
32
|
-
out.push(full);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
function loadFile(path, root) {
|
|
37
|
-
const raw = readFileSync(path, "utf8");
|
|
38
|
-
const lines = raw.split("\n").map((r, i) => ({ n: i + 1, raw: r, comment: isCommentLine(r) }));
|
|
39
|
-
return { path, rel: relative(root, path) || path, lines };
|
|
40
|
-
}
|
|
41
|
-
function findPackageJson(startDir) {
|
|
42
|
-
let dir = startDir;
|
|
43
|
-
for (let depth = 0; depth < 8; depth++) {
|
|
44
|
-
const candidate = join(dir, "package.json");
|
|
45
|
-
if (existsSync(candidate)) {
|
|
46
|
-
try {
|
|
47
|
-
return { path: candidate, json: JSON.parse(readFileSync(candidate, "utf8")) };
|
|
48
|
-
} catch {
|
|
49
|
-
return { path: candidate, json: {} };
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
const parent = dirname(dir);
|
|
53
|
-
if (parent === dir) break;
|
|
54
|
-
dir = parent;
|
|
55
|
-
}
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
function loadTree(root) {
|
|
59
|
-
const st = statSync(root);
|
|
60
|
-
const baseDir = st.isDirectory() ? root : dirname(root);
|
|
61
|
-
const filePaths = [];
|
|
62
|
-
if (st.isDirectory()) walk(root, filePaths);
|
|
63
|
-
else if (root.endsWith(".ts") || root.endsWith(".tsx")) filePaths.push(root);
|
|
64
|
-
const files = filePaths.map((p) => loadFile(p, baseDir));
|
|
65
|
-
return { root, files, packageJson: findPackageJson(baseDir) };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
3
|
// src/detectors/d2.ts
|
|
69
4
|
var SESSION_RE = /mcp-session-id/i;
|
|
70
5
|
var d2 = {
|
|
@@ -96,12 +31,148 @@ var d2 = {
|
|
|
96
31
|
};
|
|
97
32
|
|
|
98
33
|
// src/detectors/toolContext.ts
|
|
99
|
-
var LIST_READ_RE = /^(list|read|get|search)
|
|
100
|
-
var
|
|
34
|
+
var LIST_READ_RE = /^(list|read|get|search)/i;
|
|
35
|
+
var NAME_LITERAL = "[^\"'`]+";
|
|
36
|
+
var IDENT = "[A-Za-z_][A-Za-z0-9_]*";
|
|
37
|
+
var TOOL_INLINE_LITERAL_RE = new RegExp(
|
|
38
|
+
`\\b(?:s\\.tool|\\.tool|registerTool)\\s*\\(\\s*["'\`](${NAME_LITERAL})["'\`]`
|
|
39
|
+
);
|
|
40
|
+
var TOOL_INLINE_IDENT_RE = new RegExp(
|
|
41
|
+
`\\b(?:s\\.tool|\\.tool|registerTool)\\s*\\(\\s*(${IDENT})(\\.name)?\\s*[,)]`
|
|
42
|
+
);
|
|
101
43
|
var OPENER_RE = /\b(?:s\.tool|\.tool|registerTool)\s*\(\s*$/;
|
|
102
|
-
var BARE_NAME_RE =
|
|
103
|
-
var
|
|
44
|
+
var BARE_NAME_RE = new RegExp(`^\\s*["'\`](${NAME_LITERAL})["'\`]\\s*,?`);
|
|
45
|
+
var BARE_IDENT_RE = new RegExp(`^\\s*(${IDENT})(\\.name)?\\s*,?\\s*$`);
|
|
46
|
+
var DESCRIPTOR_NAME_RE = new RegExp(`\\bname:\\s*["'\`](${NAME_LITERAL})["'\`]`);
|
|
104
47
|
var CONTENT_RE = /content:\s*\[/;
|
|
48
|
+
var CONST_STRING_RE = new RegExp(
|
|
49
|
+
`\\bconst\\s+(${IDENT})\\s*(?::[^=]+)?=\\s*["'\`](${NAME_LITERAL})["'\`]\\s*[;,]?\\s*$`
|
|
50
|
+
);
|
|
51
|
+
var CONST_OBJECT_OPEN_RE = new RegExp(`\\bconst\\s+(${IDENT})\\s*(?::[^=]+)?=\\s*\\{`);
|
|
52
|
+
function resolveNameBindings(tree) {
|
|
53
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
54
|
+
for (const file of tree.files) {
|
|
55
|
+
let trackingObject = null;
|
|
56
|
+
let braceDepth = 0;
|
|
57
|
+
for (const line of file.lines) {
|
|
58
|
+
if (line.comment) continue;
|
|
59
|
+
const raw = line.raw;
|
|
60
|
+
if (trackingObject) {
|
|
61
|
+
const nameField = raw.match(DESCRIPTOR_NAME_RE);
|
|
62
|
+
if (nameField && !bindings.has(trackingObject)) {
|
|
63
|
+
bindings.set(trackingObject, nameField[1]);
|
|
64
|
+
}
|
|
65
|
+
braceDepth += (raw.match(/\{/g) ?? []).length;
|
|
66
|
+
braceDepth -= (raw.match(/\}/g) ?? []).length;
|
|
67
|
+
if (braceDepth <= 0) trackingObject = null;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const constString = raw.match(CONST_STRING_RE);
|
|
71
|
+
if (constString) {
|
|
72
|
+
bindings.set(constString[1], constString[2]);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const objOpen = raw.match(CONST_OBJECT_OPEN_RE);
|
|
76
|
+
if (objOpen) {
|
|
77
|
+
braceDepth = (raw.match(/\{/g) ?? []).length - (raw.match(/\}/g) ?? []).length;
|
|
78
|
+
trackingObject = braceDepth > 0 ? objOpen[1] : null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return bindings;
|
|
83
|
+
}
|
|
84
|
+
function findRegisteredTools(tree) {
|
|
85
|
+
const bindings = resolveNameBindings(tree);
|
|
86
|
+
const out = [];
|
|
87
|
+
const seen = /* @__PURE__ */ new Set();
|
|
88
|
+
const push = (name, anchor) => {
|
|
89
|
+
if (!seen.has(name)) {
|
|
90
|
+
seen.add(name);
|
|
91
|
+
out.push({ name, anchor });
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
for (const file of tree.files) {
|
|
95
|
+
let awaitingName = false;
|
|
96
|
+
for (const line of file.lines) {
|
|
97
|
+
if (line.comment) continue;
|
|
98
|
+
const raw = line.raw;
|
|
99
|
+
const inlineLit = raw.match(TOOL_INLINE_LITERAL_RE);
|
|
100
|
+
if (inlineLit) {
|
|
101
|
+
push(inlineLit[1], `${file.rel}:${line.n}`);
|
|
102
|
+
awaitingName = false;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const inlineIdent = raw.match(TOOL_INLINE_IDENT_RE);
|
|
106
|
+
if (inlineIdent) {
|
|
107
|
+
const resolved = bindings.get(inlineIdent[1]);
|
|
108
|
+
if (resolved) push(resolved, `${file.rel}:${line.n}`);
|
|
109
|
+
awaitingName = false;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (OPENER_RE.test(raw)) {
|
|
113
|
+
awaitingName = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (awaitingName) {
|
|
117
|
+
const bare = raw.match(BARE_NAME_RE);
|
|
118
|
+
if (bare) {
|
|
119
|
+
push(bare[1], `${file.rel}:${line.n}`);
|
|
120
|
+
} else {
|
|
121
|
+
const bareIdent = raw.match(BARE_IDENT_RE);
|
|
122
|
+
if (bareIdent) {
|
|
123
|
+
const resolved = bindings.get(bareIdent[1]);
|
|
124
|
+
if (resolved) push(resolved, `${file.rel}:${line.n}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
awaitingName = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
var REGISTRATION_CALL_RE = /\b(?:s\.tool|\.tool|registerTool)\s*\(/;
|
|
134
|
+
function findUnresolvedToolSites(tree) {
|
|
135
|
+
const bindings = resolveNameBindings(tree);
|
|
136
|
+
const out = [];
|
|
137
|
+
for (const file of tree.files) {
|
|
138
|
+
let awaitingName = false;
|
|
139
|
+
let openerAnchor = "";
|
|
140
|
+
for (const line of file.lines) {
|
|
141
|
+
if (line.comment) continue;
|
|
142
|
+
const raw = line.raw;
|
|
143
|
+
if (awaitingName) {
|
|
144
|
+
const bare = raw.match(BARE_NAME_RE);
|
|
145
|
+
if (bare) {
|
|
146
|
+
awaitingName = false;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const bareIdent = raw.match(BARE_IDENT_RE);
|
|
150
|
+
if (bareIdent && bindings.get(bareIdent[1])) {
|
|
151
|
+
awaitingName = false;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
out.push(openerAnchor);
|
|
155
|
+
awaitingName = false;
|
|
156
|
+
}
|
|
157
|
+
if (TOOL_INLINE_LITERAL_RE.test(raw)) continue;
|
|
158
|
+
const inlineIdent = raw.match(TOOL_INLINE_IDENT_RE);
|
|
159
|
+
if (inlineIdent) {
|
|
160
|
+
if (!bindings.get(inlineIdent[1])) out.push(`${file.rel}:${line.n}`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (OPENER_RE.test(raw)) {
|
|
164
|
+
awaitingName = true;
|
|
165
|
+
openerAnchor = `${file.rel}:${line.n}`;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (REGISTRATION_CALL_RE.test(raw)) {
|
|
169
|
+
out.push(`${file.rel}:${line.n}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (awaitingName) out.push(openerAnchor);
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
105
176
|
function findListReadBuilders(tree) {
|
|
106
177
|
const out = [];
|
|
107
178
|
for (const file of tree.files) {
|
|
@@ -111,7 +182,7 @@ function findListReadBuilders(tree) {
|
|
|
111
182
|
for (const line of file.lines) {
|
|
112
183
|
if (line.comment) continue;
|
|
113
184
|
const raw = line.raw;
|
|
114
|
-
const inline = raw.match(
|
|
185
|
+
const inline = raw.match(TOOL_INLINE_LITERAL_RE);
|
|
115
186
|
if (inline) {
|
|
116
187
|
currentTool = inline[1];
|
|
117
188
|
currentToolLine = line.n;
|
|
@@ -143,6 +214,16 @@ function findListReadBuilders(tree) {
|
|
|
143
214
|
}
|
|
144
215
|
return out;
|
|
145
216
|
}
|
|
217
|
+
var SDK_FIRST_RE = /\bregisterTool\s*\(|\.tool\s*\(|new\s+McpServer\s*\(/;
|
|
218
|
+
function sdkFirstAnchor(tree) {
|
|
219
|
+
for (const file of tree.files) {
|
|
220
|
+
for (const line of file.lines) {
|
|
221
|
+
if (line.comment) continue;
|
|
222
|
+
if (SDK_FIRST_RE.test(line.raw)) return `${file.rel}:${line.n}`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
146
227
|
function countKeywordInCode(tree, re) {
|
|
147
228
|
let count = 0;
|
|
148
229
|
let firstFile;
|
|
@@ -181,14 +262,30 @@ var d3 = {
|
|
|
181
262
|
};
|
|
182
263
|
}
|
|
183
264
|
if (builders.length === 0) {
|
|
184
|
-
|
|
265
|
+
const sdk = sdkFirstAnchor(tree);
|
|
266
|
+
if (sdk) {
|
|
267
|
+
const reason = "no hand-rolled list/read builder found and the server is SDK-first (server.registerTool/server.tool/new McpServer) \u2014 result shaping and resultType are delegated to the MCP SDK and not statically readable in this tree";
|
|
268
|
+
return {
|
|
269
|
+
id: this.id,
|
|
270
|
+
anchor: sdk,
|
|
271
|
+
state: "could-not-judge",
|
|
272
|
+
reason,
|
|
273
|
+
verdict: `could not judge: ${reason}`
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
id: this.id,
|
|
278
|
+
anchor: "-",
|
|
279
|
+
state: "absent",
|
|
280
|
+
verdict: "no list/read builders found; resultType absent"
|
|
281
|
+
};
|
|
185
282
|
}
|
|
186
283
|
const b = builders[0];
|
|
187
284
|
return {
|
|
188
285
|
id: this.id,
|
|
189
286
|
anchor: `${b.file.rel}:${b.contentLine}`,
|
|
190
287
|
state: "absent",
|
|
191
|
-
verdict:
|
|
288
|
+
verdict: "list/read builder returns {content} without resultType (grep resultType=0) -> add via protocol-kit"
|
|
192
289
|
};
|
|
193
290
|
}
|
|
194
291
|
};
|
|
@@ -209,7 +306,23 @@ var d4 = {
|
|
|
209
306
|
};
|
|
210
307
|
}
|
|
211
308
|
if (builders.length === 0) {
|
|
212
|
-
|
|
309
|
+
const sdk = sdkFirstAnchor(tree);
|
|
310
|
+
if (sdk) {
|
|
311
|
+
const reason = "no hand-rolled list/read builder found and the server is SDK-first (server.registerTool/server.tool/new McpServer) \u2014 result shaping and any ttlMs/cacheScope are delegated to the MCP SDK and not statically readable in this tree";
|
|
312
|
+
return {
|
|
313
|
+
id: this.id,
|
|
314
|
+
anchor: sdk,
|
|
315
|
+
state: "could-not-judge",
|
|
316
|
+
reason,
|
|
317
|
+
verdict: `could not judge: ${reason}`
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
id: this.id,
|
|
322
|
+
anchor: "-",
|
|
323
|
+
state: "absent",
|
|
324
|
+
verdict: "no list/read builders found; CacheableResult absent"
|
|
325
|
+
};
|
|
213
326
|
}
|
|
214
327
|
const anchorLines = builders.slice(0, 2).map((b) => b.toolNameLine);
|
|
215
328
|
return {
|
|
@@ -225,7 +338,11 @@ var d4 = {
|
|
|
225
338
|
var DISCOVER_RE = /server\/discover/;
|
|
226
339
|
var POSITIVE_CONTROL = 'method: zv4.literal("server/discover"),';
|
|
227
340
|
function d5PositiveControl() {
|
|
228
|
-
return {
|
|
341
|
+
return {
|
|
342
|
+
pattern: DISCOVER_RE.source,
|
|
343
|
+
sample: POSITIVE_CONTROL,
|
|
344
|
+
matched: DISCOVER_RE.test(POSITIVE_CONTROL)
|
|
345
|
+
};
|
|
229
346
|
}
|
|
230
347
|
var d5 = {
|
|
231
348
|
id: "D5 server/discover",
|
|
@@ -302,7 +419,12 @@ var d6 = {
|
|
|
302
419
|
verdict: "GA nested _meta.ui.resourceUri"
|
|
303
420
|
};
|
|
304
421
|
}
|
|
305
|
-
return {
|
|
422
|
+
return {
|
|
423
|
+
id: this.id,
|
|
424
|
+
anchor: "-",
|
|
425
|
+
state: "absent",
|
|
426
|
+
verdict: "no MCP Apps UI key (no _meta ui)"
|
|
427
|
+
};
|
|
306
428
|
}
|
|
307
429
|
};
|
|
308
430
|
|
|
@@ -312,9 +434,15 @@ var KIT_CALL_RE = /\b(buildToolsListResult|buildListResult|buildToolCallResult|h
|
|
|
312
434
|
var KIT_IMPORT_RE = /from\s+["'`]@vantageos\/mcp-protocol-kit["'`]/;
|
|
313
435
|
function depListed(pkgJson) {
|
|
314
436
|
if (!pkgJson) return false;
|
|
315
|
-
for (const key of [
|
|
437
|
+
for (const key of [
|
|
438
|
+
"dependencies",
|
|
439
|
+
"devDependencies",
|
|
440
|
+
"peerDependencies",
|
|
441
|
+
"optionalDependencies"
|
|
442
|
+
]) {
|
|
316
443
|
const deps = pkgJson[key];
|
|
317
|
-
if (deps && typeof deps === "object" && KIT_PKG in deps)
|
|
444
|
+
if (deps && typeof deps === "object" && KIT_PKG in deps)
|
|
445
|
+
return true;
|
|
318
446
|
}
|
|
319
447
|
return false;
|
|
320
448
|
}
|
|
@@ -327,12 +455,13 @@ var d7 = {
|
|
|
327
455
|
for (const file of tree.files) {
|
|
328
456
|
for (const line of file.lines) {
|
|
329
457
|
if (line.comment) continue;
|
|
330
|
-
if (!importLine && KIT_IMPORT_RE.test(line.raw))
|
|
458
|
+
if (!importLine && KIT_IMPORT_RE.test(line.raw))
|
|
459
|
+
importLine = { file: file.rel, line: line.n };
|
|
331
460
|
if (!callLine && KIT_CALL_RE.test(line.raw)) callLine = { file: file.rel, line: line.n };
|
|
332
461
|
}
|
|
333
462
|
}
|
|
334
463
|
const called = importLine !== null && callLine !== null;
|
|
335
|
-
if (listed && called) {
|
|
464
|
+
if (listed && called && callLine !== null) {
|
|
336
465
|
return {
|
|
337
466
|
id: this.id,
|
|
338
467
|
anchor: `${callLine.file}:${callLine.line}`,
|
|
@@ -357,37 +486,3537 @@ var d7 = {
|
|
|
357
486
|
}
|
|
358
487
|
};
|
|
359
488
|
|
|
360
|
-
// src/detectors/
|
|
361
|
-
|
|
489
|
+
// src/detectors/d8.ts
|
|
490
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
491
|
+
import { dirname, join, relative } from "path";
|
|
492
|
+
var POSITIVE_CONTROL2 = 'server.registerTool("list_items", { ... }, handler);';
|
|
493
|
+
function d8PositiveControl() {
|
|
494
|
+
return {
|
|
495
|
+
pattern: TOOL_INLINE_LITERAL_RE.source,
|
|
496
|
+
sample: POSITIVE_CONTROL2,
|
|
497
|
+
matched: TOOL_INLINE_LITERAL_RE.test(POSITIVE_CONTROL2)
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
function resolveBaseDir(tree) {
|
|
501
|
+
if (tree.packageJson) return dirname(tree.packageJson.path);
|
|
502
|
+
try {
|
|
503
|
+
return statSync(tree.root).isDirectory() ? tree.root : dirname(tree.root);
|
|
504
|
+
} catch {
|
|
505
|
+
return tree.root;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function findEvalsFile(tree) {
|
|
509
|
+
let dir = resolveBaseDir(tree);
|
|
510
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
511
|
+
const candidate = join(dir, "evals", "evals.json");
|
|
512
|
+
if (existsSync(candidate)) return candidate;
|
|
513
|
+
const parent = dirname(dir);
|
|
514
|
+
if (parent === dir) break;
|
|
515
|
+
dir = parent;
|
|
516
|
+
}
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
var d8 = {
|
|
520
|
+
id: "D8 eval-suite-coverage",
|
|
521
|
+
run(tree) {
|
|
522
|
+
const control = d8PositiveControl();
|
|
523
|
+
if (!control.matched) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
`D8 positive control FAILED: pattern /${control.pattern}/ did not match sample \`${control.sample}\` \u2014 matcher broken, coverage verdict untrustworthy`
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
const tools = findRegisteredTools(tree);
|
|
529
|
+
if (tools.length === 0) {
|
|
530
|
+
const sdk = sdkFirstAnchor(tree);
|
|
531
|
+
if (sdk) {
|
|
532
|
+
const reason = "no statically-enumerable tool found, but the server is SDK-first (server.registerTool/server.tool/new McpServer) \u2014 tool registration is dynamic/computed and delegated to the MCP SDK, so tool names and their eval coverage are not statically readable in this tree";
|
|
533
|
+
return {
|
|
534
|
+
id: this.id,
|
|
535
|
+
anchor: sdk,
|
|
536
|
+
state: "could-not-judge",
|
|
537
|
+
reason,
|
|
538
|
+
verdict: `could not judge: ${reason}`
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
id: this.id,
|
|
543
|
+
anchor: "-",
|
|
544
|
+
state: "absent",
|
|
545
|
+
verdict: "no registered tools found in tree -> n/a for eval coverage"
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const evalsPath = findEvalsFile(tree);
|
|
549
|
+
if (!evalsPath) {
|
|
550
|
+
return {
|
|
551
|
+
id: this.id,
|
|
552
|
+
anchor: "-",
|
|
553
|
+
state: "absent",
|
|
554
|
+
verdict: `no evals/evals.json found for ${tools.length} registered tool(s) (${tools.map((t) => t.name).join(", ")}) -> add eval suite with >=3 cases/tool`
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
const baseDir = resolveBaseDir(tree);
|
|
558
|
+
const displayPath = relative(baseDir, evalsPath) || evalsPath;
|
|
559
|
+
let cases;
|
|
560
|
+
try {
|
|
561
|
+
const raw = readFileSync(evalsPath, "utf8");
|
|
562
|
+
const parsed = JSON.parse(raw);
|
|
563
|
+
if (!Array.isArray(parsed.cases)) {
|
|
564
|
+
throw new Error("`cases` is not an array");
|
|
565
|
+
}
|
|
566
|
+
cases = parsed.cases;
|
|
567
|
+
} catch (err) {
|
|
568
|
+
throw new Error(
|
|
569
|
+
`D8 could not parse ${displayPath}: ${err instanceof Error ? err.message : String(err)} \u2014 coverage verdict untrustworthy, fix the eval file`
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const counts = /* @__PURE__ */ new Map();
|
|
573
|
+
for (const t of tools) counts.set(t.name, 0);
|
|
574
|
+
for (const c of cases) {
|
|
575
|
+
if (c.tool && counts.has(c.tool)) {
|
|
576
|
+
counts.set(c.tool, (counts.get(c.tool) ?? 0) + 1);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const under = tools.filter((t) => (counts.get(t.name) ?? 0) < 3);
|
|
580
|
+
const summary = tools.map((t) => `${t.name}=${counts.get(t.name) ?? 0}`).join(" ");
|
|
581
|
+
if (under.length > 0) {
|
|
582
|
+
return {
|
|
583
|
+
id: this.id,
|
|
584
|
+
anchor: `${displayPath} (${under[0].anchor})`,
|
|
585
|
+
state: "non-conforming",
|
|
586
|
+
verdict: `${summary} (need >=3 each); under-covered: ${under.map((t) => t.name).join(", ")} -> add eval cases in ${displayPath}`
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
id: this.id,
|
|
591
|
+
anchor: displayPath,
|
|
592
|
+
state: "present",
|
|
593
|
+
verdict: `${summary} \u2014 every registered tool has >=3 eval cases`
|
|
594
|
+
};
|
|
595
|
+
},
|
|
596
|
+
/**
|
|
597
|
+
* DYNAMIC — eval coverage against the REAL tool set the running server
|
|
598
|
+
* advertises. The static path is `could-not-judge` when tool names are
|
|
599
|
+
* computed/SDK-first (it cannot enumerate the tools, so it cannot check their
|
|
600
|
+
* coverage); the runtime tool list closes that gap. Per-tool eval counts are
|
|
601
|
+
* resolved by the runtime builder from the target's evals/evals.json and
|
|
602
|
+
* carried on each `RuntimeTool.evalCaseCount`. A server that could not be
|
|
603
|
+
* started is `could-not-judge` naming the launch failure — never absent,
|
|
604
|
+
* never a pass.
|
|
605
|
+
*/
|
|
606
|
+
runDynamic(runtime) {
|
|
607
|
+
if (runtime.launchError) {
|
|
608
|
+
const reason = `server could not be started/introspected \u2014 runtime eval coverage unverifiable: ${runtime.launchError} (Server Rule 5)`;
|
|
609
|
+
return {
|
|
610
|
+
id: this.id,
|
|
611
|
+
anchor: `${runtime.root} (runtime)`,
|
|
612
|
+
state: "could-not-judge",
|
|
613
|
+
reason,
|
|
614
|
+
verdict: `could not judge: ${reason}`
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
if (runtime.tools.length === 0) {
|
|
618
|
+
return {
|
|
619
|
+
id: this.id,
|
|
620
|
+
anchor: `${runtime.root} (runtime tools/list)`,
|
|
621
|
+
state: "absent",
|
|
622
|
+
verdict: "server advertises no tools at runtime -> n/a for eval coverage"
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
const summary = runtime.tools.map((t) => `${t.name}=${t.evalCaseCount ?? 0}`).join(" ");
|
|
626
|
+
const under = runtime.tools.filter((t) => (t.evalCaseCount ?? 0) < 3);
|
|
627
|
+
if (under.length > 0) {
|
|
628
|
+
return {
|
|
629
|
+
id: this.id,
|
|
630
|
+
anchor: `${runtime.root} (runtime tools/list)`,
|
|
631
|
+
state: "non-conforming",
|
|
632
|
+
verdict: `${summary} (need >=3 each); under-covered: ${under.map((t) => t.name).join(", ")} -> add eval cases in evals/evals.json`
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
id: this.id,
|
|
637
|
+
anchor: `${runtime.root} (runtime tools/list)`,
|
|
638
|
+
state: "present",
|
|
639
|
+
verdict: `${summary} \u2014 every runtime tool has >=3 eval cases`
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
};
|
|
362
643
|
|
|
363
|
-
// src/
|
|
364
|
-
|
|
365
|
-
|
|
644
|
+
// src/detectors/d9.ts
|
|
645
|
+
import { existsSync as existsSync2 } from "fs";
|
|
646
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
647
|
+
var SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
648
|
+
var POSITIVE_CONTROL3 = "1.2.3-beta.1+build.7";
|
|
649
|
+
function d9PositiveControl() {
|
|
650
|
+
return {
|
|
651
|
+
pattern: SEMVER_RE.source,
|
|
652
|
+
sample: POSITIVE_CONTROL3,
|
|
653
|
+
matched: SEMVER_RE.test(POSITIVE_CONTROL3)
|
|
654
|
+
};
|
|
366
655
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
656
|
+
var d9 = {
|
|
657
|
+
id: "D9 semver+changelog",
|
|
658
|
+
run(tree) {
|
|
659
|
+
const control = d9PositiveControl();
|
|
660
|
+
if (!control.matched) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
`D9 positive control FAILED: pattern /${control.pattern}/ did not match sample \`${control.sample}\` \u2014 matcher broken, verdict untrustworthy`
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
if (!tree.packageJson) {
|
|
666
|
+
throw new Error(
|
|
667
|
+
"D9 could not locate package.json anywhere above the scanned root \u2014 cannot verify SemVer/CHANGELOG"
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
const { path: pkgPath, json: pkgJson } = tree.packageJson;
|
|
671
|
+
if (!pkgJson || Object.keys(pkgJson).length === 0) {
|
|
672
|
+
throw new Error(`D9 could not parse ${pkgPath} as JSON \u2014 cannot verify SemVer/CHANGELOG`);
|
|
673
|
+
}
|
|
674
|
+
const version = pkgJson.version;
|
|
675
|
+
if (typeof version !== "string") {
|
|
676
|
+
return {
|
|
677
|
+
id: this.id,
|
|
678
|
+
anchor: pkgPath.split("/").slice(-2).join("/"),
|
|
679
|
+
state: "non-conforming",
|
|
680
|
+
verdict: `no "version" field in ${pkgPath.split("/").pop()} -> set a valid SemVer version`
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
const projectDir = dirname2(pkgPath);
|
|
684
|
+
const changelogPath = join2(projectDir, "CHANGELOG.md");
|
|
685
|
+
const hasChangelog = existsSync2(changelogPath);
|
|
686
|
+
const validSemver = SEMVER_RE.test(version);
|
|
687
|
+
if (validSemver && hasChangelog) {
|
|
688
|
+
return {
|
|
689
|
+
id: this.id,
|
|
690
|
+
anchor: `${changelogPath.split("/").slice(-2).join("/")} + version=${version}`,
|
|
691
|
+
state: "present",
|
|
692
|
+
verdict: `SemVer valid (${version}) and CHANGELOG.md present (positive-control OK)`
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
if (!validSemver) {
|
|
696
|
+
return {
|
|
697
|
+
id: this.id,
|
|
698
|
+
anchor: pkgPath.split("/").slice(-2).join("/"),
|
|
699
|
+
state: "non-conforming",
|
|
700
|
+
verdict: `"version": "${version}" is not valid SemVer (positive-control OK) -> use MAJOR.MINOR.PATCH`
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
id: this.id,
|
|
705
|
+
anchor: projectDir.split("/").pop() ?? projectDir,
|
|
706
|
+
state: "absent",
|
|
707
|
+
verdict: "no CHANGELOG.md next to package.json (grep=0, positive-control OK) -> add CHANGELOG.md, update it each release"
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// src/detectors/d10.ts
|
|
713
|
+
import { existsSync as existsSync3 } from "fs";
|
|
714
|
+
import { dirname as dirname3, join as join3 } from "path";
|
|
715
|
+
var DESCRIPTION_FR_RE = /"description_fr"\s*:\s*"[^"]+"/;
|
|
716
|
+
var POSITIVE_CONTROL4 = '"description_fr": "Un serveur MCP conforme.",';
|
|
717
|
+
function d10PositiveControl() {
|
|
718
|
+
return {
|
|
719
|
+
pattern: DESCRIPTION_FR_RE.source,
|
|
720
|
+
sample: POSITIVE_CONTROL4,
|
|
721
|
+
matched: DESCRIPTION_FR_RE.test(POSITIVE_CONTROL4)
|
|
722
|
+
};
|
|
370
723
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
724
|
+
var SOURCE_DESCRIPTION_FR_RE = /description_fr\s*:\s*["'`]/;
|
|
725
|
+
var MENTION_RE = /description_fr/;
|
|
726
|
+
var SOURCE_POSITIVE_CONTROL = 'description_fr: "Un serveur MCP fixture bilingue.",';
|
|
727
|
+
var SOURCE_NEGATIVE_CONTROL = 'const key = "description_fr"; obj[key] = frText;';
|
|
728
|
+
function d10SourceFormControl() {
|
|
729
|
+
return {
|
|
730
|
+
pattern: SOURCE_DESCRIPTION_FR_RE.source,
|
|
731
|
+
positiveSample: SOURCE_POSITIVE_CONTROL,
|
|
732
|
+
negativeSample: SOURCE_NEGATIVE_CONTROL,
|
|
733
|
+
matchedPositive: SOURCE_DESCRIPTION_FR_RE.test(SOURCE_POSITIVE_CONTROL),
|
|
734
|
+
matchedNegative: SOURCE_DESCRIPTION_FR_RE.test(SOURCE_NEGATIVE_CONTROL)
|
|
735
|
+
};
|
|
379
736
|
}
|
|
380
|
-
function
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
737
|
+
function firstSourceAnchor(tree, re) {
|
|
738
|
+
for (const file of tree.files) {
|
|
739
|
+
for (const line of file.lines) {
|
|
740
|
+
if (line.comment) continue;
|
|
741
|
+
if (re.test(line.raw)) return { anchor: `${file.rel}:${line.n}`, raw: line.raw.trim() };
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
function scanSourceDescriptionFr(tree) {
|
|
747
|
+
const recognized = firstSourceAnchor(tree, SOURCE_DESCRIPTION_FR_RE);
|
|
748
|
+
if (recognized) return { present: true, anchor: recognized.anchor };
|
|
749
|
+
const mentioned = firstSourceAnchor(tree, MENTION_RE);
|
|
750
|
+
if (mentioned) {
|
|
751
|
+
return {
|
|
752
|
+
present: false,
|
|
753
|
+
anchor: null,
|
|
754
|
+
undecidable: {
|
|
755
|
+
anchor: mentioned.anchor,
|
|
756
|
+
reason: `description_fr occurrence at ${mentioned.anchor} (\`${mentioned.raw}\`) does not match the recognized source form /${SOURCE_DESCRIPTION_FR_RE.source}/ \u2014 unknown bilingual-declaration shape, verdict undecidable`
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
return { present: false, anchor: null };
|
|
761
|
+
}
|
|
762
|
+
var d10 = {
|
|
763
|
+
id: "D10 bilingual",
|
|
764
|
+
run(tree) {
|
|
765
|
+
const control = d10PositiveControl();
|
|
766
|
+
if (!control.matched) {
|
|
767
|
+
throw new Error(
|
|
768
|
+
`D10 positive control FAILED: pattern /${control.pattern}/ did not match sample \`${control.sample}\` \u2014 matcher broken, absence verdict untrustworthy`
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
const sourceControl = d10SourceFormControl();
|
|
772
|
+
if (!sourceControl.matchedPositive || sourceControl.matchedNegative) {
|
|
773
|
+
throw new Error(
|
|
774
|
+
`D10 source-form control FAILED: /${sourceControl.pattern}/ accept=${sourceControl.matchedPositive} reject=${!sourceControl.matchedNegative} \u2014 matcher broken, absence verdict untrustworthy`
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
if (!tree.packageJson) {
|
|
778
|
+
return {
|
|
779
|
+
id: this.id,
|
|
780
|
+
anchor: tree.root,
|
|
781
|
+
state: "could-not-judge",
|
|
782
|
+
reason: "no package.json found in tree \u2014 cannot locate the release surface to audit bilingual conformance",
|
|
783
|
+
verdict: "could not judge: no package.json found in tree \u2014 cannot audit bilingual release (Server Rule / bilingual)"
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
const { path: pkgPath, json: pkg } = tree.packageJson;
|
|
787
|
+
const root = dirname3(pkgPath);
|
|
788
|
+
const descriptionFr = typeof pkg.description_fr === "string" ? pkg.description_fr.trim() : "";
|
|
789
|
+
const hasDescriptionFrJson = descriptionFr.length > 0;
|
|
790
|
+
const sourceScan = scanSourceDescriptionFr(tree);
|
|
791
|
+
if (sourceScan.undecidable) {
|
|
792
|
+
return {
|
|
793
|
+
id: this.id,
|
|
794
|
+
anchor: sourceScan.undecidable.anchor,
|
|
795
|
+
state: "could-not-judge",
|
|
796
|
+
reason: sourceScan.undecidable.reason,
|
|
797
|
+
verdict: `could not judge: ${sourceScan.undecidable.reason}`
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
const hasDescriptionFr = hasDescriptionFrJson || sourceScan.present;
|
|
801
|
+
const readmeFrPath = join3(root, "README.fr.md");
|
|
802
|
+
const hasReadmeFr = existsSync3(readmeFrPath);
|
|
803
|
+
const missing = [];
|
|
804
|
+
if (!hasDescriptionFr) missing.push("description_fr (package.json or source _meta)");
|
|
805
|
+
if (!hasReadmeFr) missing.push("README.fr.md");
|
|
806
|
+
if (missing.length > 0) {
|
|
807
|
+
return {
|
|
808
|
+
id: this.id,
|
|
809
|
+
anchor: `${pkgPath}`,
|
|
810
|
+
state: "absent",
|
|
811
|
+
verdict: `bilingual release missing: ${missing.join(", ")} -> no release without FR description + README.fr.md`
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
const files = Array.isArray(pkg.files) ? pkg.files : null;
|
|
815
|
+
if (files && !files.includes("README.fr.md")) {
|
|
816
|
+
return {
|
|
817
|
+
id: this.id,
|
|
818
|
+
anchor: `${pkgPath}`,
|
|
819
|
+
state: "non-conforming",
|
|
820
|
+
verdict: "README.fr.md present but not listed in files[] -> will not ship in published tarball"
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
id: this.id,
|
|
825
|
+
anchor: `${readmeFrPath}`,
|
|
826
|
+
state: "present",
|
|
827
|
+
verdict: "description_fr + README.fr.md present (and shipped via files[] when manifest present)"
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
// src/detectors/d11.ts
|
|
833
|
+
var ZOD_ANY_RE = /\bz\s*\.\s*any\s*\(\s*\)/;
|
|
834
|
+
var TOOL_REGISTRATION_RE = /\b(?:s\.tool|\.tool|registerTool)\s*\(/;
|
|
835
|
+
var OUTPUT_SCHEMA_RE = /\boutputSchema\s*[:=]/;
|
|
836
|
+
var ZOD_ANY_CONTROL = 'payload: z.any().describe("unconstrained escape hatch"),';
|
|
837
|
+
var TOOL_REGISTRATION_CONTROL = 's.tool("example_tool", "desc", {}, async () => {});';
|
|
838
|
+
var OUTPUT_SCHEMA_CONTROL = 'outputSchema: { type: "object", properties: {} },';
|
|
839
|
+
function d11PositiveControls() {
|
|
840
|
+
return {
|
|
841
|
+
zodAny: {
|
|
842
|
+
pattern: ZOD_ANY_RE.source,
|
|
843
|
+
sample: ZOD_ANY_CONTROL,
|
|
844
|
+
matched: ZOD_ANY_RE.test(ZOD_ANY_CONTROL)
|
|
845
|
+
},
|
|
846
|
+
toolRegistration: {
|
|
847
|
+
pattern: TOOL_REGISTRATION_RE.source,
|
|
848
|
+
sample: TOOL_REGISTRATION_CONTROL,
|
|
849
|
+
matched: TOOL_REGISTRATION_RE.test(TOOL_REGISTRATION_CONTROL)
|
|
850
|
+
},
|
|
851
|
+
outputSchema: {
|
|
852
|
+
pattern: OUTPUT_SCHEMA_RE.source,
|
|
853
|
+
sample: OUTPUT_SCHEMA_CONTROL,
|
|
854
|
+
matched: OUTPUT_SCHEMA_RE.test(OUTPUT_SCHEMA_CONTROL)
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
var d11 = {
|
|
859
|
+
id: "D11 zod-schemas",
|
|
860
|
+
run(tree) {
|
|
861
|
+
const controls = d11PositiveControls();
|
|
862
|
+
const failed = Object.entries(controls).find(([, c]) => !c.matched);
|
|
863
|
+
if (failed) {
|
|
864
|
+
const [name, c] = failed;
|
|
865
|
+
throw new Error(
|
|
866
|
+
`D11 positive control "${name}" FAILED: pattern /${c.pattern}/ did not match sample \`${c.sample}\` \u2014 matcher broken, verdict untrustworthy`
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
for (const file of tree.files) {
|
|
870
|
+
for (const line of file.lines) {
|
|
871
|
+
if (line.comment) continue;
|
|
872
|
+
if (ZOD_ANY_RE.test(line.raw)) {
|
|
873
|
+
return {
|
|
874
|
+
id: this.id,
|
|
875
|
+
anchor: `${file.rel}:${line.n}`,
|
|
876
|
+
state: "non-conforming",
|
|
877
|
+
verdict: "z.any() escape schema found -> replace with a constrained Zod schema (Server Rule 4)"
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
let toolCount = 0;
|
|
883
|
+
let firstToolAnchor = null;
|
|
884
|
+
let outputSchemaCount = 0;
|
|
885
|
+
let firstOutputSchemaAnchor = null;
|
|
886
|
+
for (const file of tree.files) {
|
|
887
|
+
for (const line of file.lines) {
|
|
888
|
+
if (line.comment) continue;
|
|
889
|
+
if (TOOL_REGISTRATION_RE.test(line.raw)) {
|
|
890
|
+
toolCount += 1;
|
|
891
|
+
if (!firstToolAnchor) firstToolAnchor = `${file.rel}:${line.n}`;
|
|
892
|
+
}
|
|
893
|
+
if (OUTPUT_SCHEMA_RE.test(line.raw)) {
|
|
894
|
+
outputSchemaCount += 1;
|
|
895
|
+
if (!firstOutputSchemaAnchor) firstOutputSchemaAnchor = `${file.rel}:${line.n}`;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (toolCount === 0) {
|
|
900
|
+
return {
|
|
901
|
+
id: this.id,
|
|
902
|
+
anchor: "-",
|
|
903
|
+
state: "absent",
|
|
904
|
+
verdict: "no tool registrations found; zod-schemas check n/a"
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
if (outputSchemaCount === 0) {
|
|
908
|
+
return {
|
|
909
|
+
id: this.id,
|
|
910
|
+
anchor: firstToolAnchor ?? "-",
|
|
911
|
+
state: "absent",
|
|
912
|
+
verdict: `${toolCount} tool registration(s) found, 0 outputSchema in tree (positive-control OK) -> add output Zod schemas (Server Rule 4; tree-wide proxy, not per-tool \u2014 see D11 scope note)`
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
id: this.id,
|
|
917
|
+
anchor: firstOutputSchemaAnchor ?? "-",
|
|
918
|
+
state: "present",
|
|
919
|
+
verdict: `outputSchema present (${outputSchemaCount} occurrence(s) across tree; no z.any() found) \u2014 per-tool coverage not individually verified, see D11 scope note`
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
// src/detectors/d12.ts
|
|
925
|
+
var STDIO_RE = /StdioServerTransport/;
|
|
926
|
+
var HTTP_RE = /StreamableHTTPServerTransport|SSEServerTransport/;
|
|
927
|
+
var STDIO_POSITIVE_CONTROL = "const transport = new StdioServerTransport();";
|
|
928
|
+
var HTTP_POSITIVE_CONTROL = "const transport = new StreamableHTTPServerTransport({ sessionIdGenerator });";
|
|
929
|
+
function d12StdioPositiveControl() {
|
|
930
|
+
return {
|
|
931
|
+
pattern: STDIO_RE.source,
|
|
932
|
+
sample: STDIO_POSITIVE_CONTROL,
|
|
933
|
+
matched: STDIO_RE.test(STDIO_POSITIVE_CONTROL)
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
function d12HttpPositiveControl() {
|
|
937
|
+
return {
|
|
938
|
+
pattern: HTTP_RE.source,
|
|
939
|
+
sample: HTTP_POSITIVE_CONTROL,
|
|
940
|
+
matched: HTTP_RE.test(HTTP_POSITIVE_CONTROL)
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
function findStdioForms(tree) {
|
|
944
|
+
let viaImport = false;
|
|
945
|
+
let importAnchor = null;
|
|
946
|
+
for (const file of tree.files) {
|
|
947
|
+
for (const line of file.lines) {
|
|
948
|
+
if (line.comment) continue;
|
|
949
|
+
if (STDIO_RE.test(line.raw)) {
|
|
950
|
+
viaImport = true;
|
|
951
|
+
importAnchor = `${file.rel}:${line.n}`;
|
|
952
|
+
break;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (viaImport) break;
|
|
956
|
+
}
|
|
957
|
+
const binFormReadable = tree.packageJson !== null;
|
|
958
|
+
let viaBin = false;
|
|
959
|
+
let binAnchor = null;
|
|
960
|
+
if (tree.packageJson) {
|
|
961
|
+
const bin = tree.packageJson.json.bin;
|
|
962
|
+
const hasBin = typeof bin === "string" && bin.trim().length > 0 || typeof bin === "object" && bin !== null && Object.keys(bin).length > 0;
|
|
963
|
+
if (hasBin) {
|
|
964
|
+
viaBin = true;
|
|
965
|
+
binAnchor = `${tree.packageJson.path}:bin`;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return { viaImport, importAnchor, viaBin, binAnchor, binFormReadable };
|
|
969
|
+
}
|
|
970
|
+
function findHttpSite(tree) {
|
|
971
|
+
for (const file of tree.files) {
|
|
972
|
+
for (const line of file.lines) {
|
|
973
|
+
if (line.comment) continue;
|
|
974
|
+
if (HTTP_RE.test(line.raw)) return `${file.rel}:${line.n}`;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
var d12 = {
|
|
980
|
+
id: "D12 stdio-transport",
|
|
981
|
+
run(tree) {
|
|
982
|
+
const stdioControl = d12StdioPositiveControl();
|
|
983
|
+
if (!stdioControl.matched) {
|
|
984
|
+
throw new Error(
|
|
985
|
+
`D12 positive control FAILED (stdio-import matcher): pattern /${stdioControl.pattern}/ did not match sample \`${stdioControl.sample}\` \u2014 matcher broken, absence verdict untrustworthy`
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
const httpControl = d12HttpPositiveControl();
|
|
989
|
+
if (!httpControl.matched) {
|
|
990
|
+
throw new Error(
|
|
991
|
+
`D12 positive control FAILED (http matcher): pattern /${httpControl.pattern}/ did not match sample \`${httpControl.sample}\` \u2014 matcher broken, absence verdict untrustworthy`
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
const forms = findStdioForms(tree);
|
|
995
|
+
const stdioPresent = forms.viaImport || forms.viaBin;
|
|
996
|
+
const formsChecked = [
|
|
997
|
+
`StdioServerTransport-import=${forms.viaImport ? "yes" : "no"}`,
|
|
998
|
+
forms.binFormReadable ? `package.json-bin=${forms.viaBin ? "yes" : "no"}` : "package.json-bin=unreadable(no package.json found)"
|
|
999
|
+
].join(" ");
|
|
1000
|
+
if (stdioPresent) {
|
|
1001
|
+
const anchor = forms.importAnchor ?? forms.binAnchor ?? "-";
|
|
1002
|
+
const via = [
|
|
1003
|
+
forms.viaImport && "StdioServerTransport import",
|
|
1004
|
+
forms.viaBin && "package.json bin entry"
|
|
1005
|
+
].filter(Boolean).join(" + ");
|
|
1006
|
+
return {
|
|
1007
|
+
id: this.id,
|
|
1008
|
+
anchor,
|
|
1009
|
+
state: "present",
|
|
1010
|
+
verdict: `stdio transport present via ${via} (${formsChecked})`
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
const httpAnchor = findHttpSite(tree);
|
|
1014
|
+
if (httpAnchor) {
|
|
1015
|
+
return {
|
|
1016
|
+
id: this.id,
|
|
1017
|
+
anchor: httpAnchor,
|
|
1018
|
+
state: "non-conforming",
|
|
1019
|
+
verdict: `HTTP/Streamable transport wired at ${httpAnchor} but no stdio path found (${formsChecked}) -> Rule 7 violated, add StdioServerTransport or a bin entrypoint`
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
return {
|
|
1023
|
+
id: this.id,
|
|
1024
|
+
anchor: "-",
|
|
1025
|
+
state: "absent",
|
|
1026
|
+
verdict: `no stdio and no HTTP/Streamable transport signal found (${formsChecked}) -> n/a, cannot determine transport wiring from source`
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
// src/detectors/d15.ts
|
|
1032
|
+
var HANDLE_ASSIGN_RE = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*[^;]*\.(?:registerTool|tool)\s*\(/;
|
|
1033
|
+
var HANDLE_METHOD_RE = /\.(?:enable|disable|update|remove)\s*\(/;
|
|
1034
|
+
var GLOBAL_MUTATION_RE = /\b(?:unregisterTool|removeTool|deregisterTool)\s*\(/;
|
|
1035
|
+
var EMISSION_RE = /sendToolListChanged|notifications\/tools\/list_changed|tools\/list_changed/;
|
|
1036
|
+
var LISTCHANGED_CAP_RE = /listChanged\s*:\s*true/;
|
|
1037
|
+
var COMPUTED_NOTIFY_RE = /\b(?:sendNotification|notification|notify)\s*\(\s*[A-Za-z_$][\w$.]*\s*[,)]/;
|
|
1038
|
+
var CONTROLS = [
|
|
1039
|
+
{
|
|
1040
|
+
key: "mutation-method",
|
|
1041
|
+
re: HANDLE_METHOD_RE,
|
|
1042
|
+
positive: "tool.disable()",
|
|
1043
|
+
negative: "tool.render()"
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
key: "global-mutation",
|
|
1047
|
+
re: GLOBAL_MUTATION_RE,
|
|
1048
|
+
positive: "server.unregisterTool(name)",
|
|
1049
|
+
negative: "server.registerTool(name)"
|
|
1050
|
+
},
|
|
1051
|
+
{
|
|
1052
|
+
key: "emission",
|
|
1053
|
+
re: EMISSION_RE,
|
|
1054
|
+
positive: "server.sendToolListChanged()",
|
|
1055
|
+
negative: "server.sendPromptListChanged()"
|
|
1056
|
+
},
|
|
1057
|
+
{
|
|
1058
|
+
key: "capability",
|
|
1059
|
+
re: LISTCHANGED_CAP_RE,
|
|
1060
|
+
positive: "tools: { listChanged: true }",
|
|
1061
|
+
negative: "tools: { listChanged: false }"
|
|
1062
|
+
}
|
|
1063
|
+
];
|
|
1064
|
+
function d15PositiveControls() {
|
|
1065
|
+
return CONTROLS.map((c) => ({
|
|
1066
|
+
key: c.key,
|
|
1067
|
+
pattern: c.re.source,
|
|
1068
|
+
positive: c.re.test(c.positive),
|
|
1069
|
+
negative: c.re.test(c.negative)
|
|
1070
|
+
}));
|
|
1071
|
+
}
|
|
1072
|
+
function collectHandles(tree) {
|
|
1073
|
+
const handles = /* @__PURE__ */ new Set();
|
|
1074
|
+
for (const file of tree.files) {
|
|
1075
|
+
for (const line of file.lines) {
|
|
1076
|
+
if (line.comment) continue;
|
|
1077
|
+
const m = line.raw.match(HANDLE_ASSIGN_RE);
|
|
1078
|
+
if (m) handles.add(m[1]);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
return handles;
|
|
1082
|
+
}
|
|
1083
|
+
function findMutations(tree, handles) {
|
|
1084
|
+
const handleMutatorRes = [...handles].map(
|
|
1085
|
+
(h) => new RegExp(`\\b${h}\\.(?:enable|disable|update|remove)\\s*\\(`)
|
|
1086
|
+
);
|
|
1087
|
+
const hits = [];
|
|
1088
|
+
for (const file of tree.files) {
|
|
1089
|
+
for (const line of file.lines) {
|
|
1090
|
+
if (line.comment) continue;
|
|
1091
|
+
const raw = line.raw;
|
|
1092
|
+
if (GLOBAL_MUTATION_RE.test(raw) || handleMutatorRes.some((re) => re.test(raw))) {
|
|
1093
|
+
hits.push({ file, line: line.n });
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return hits;
|
|
1098
|
+
}
|
|
1099
|
+
function firstMatch(tree, re) {
|
|
1100
|
+
for (const file of tree.files) {
|
|
1101
|
+
for (const line of file.lines) {
|
|
1102
|
+
if (line.comment) continue;
|
|
1103
|
+
if (re.test(line.raw)) return { file, line: line.n };
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
function firstMatchInFiles(tree, re, fileRels) {
|
|
1109
|
+
for (const file of tree.files) {
|
|
1110
|
+
if (!fileRels.has(file.rel)) continue;
|
|
1111
|
+
for (const line of file.lines) {
|
|
1112
|
+
if (line.comment) continue;
|
|
1113
|
+
if (re.test(line.raw)) return { file, line: line.n };
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return null;
|
|
1117
|
+
}
|
|
1118
|
+
var d15 = {
|
|
1119
|
+
id: "D15 cache-coherence",
|
|
1120
|
+
run(tree) {
|
|
1121
|
+
for (const c of d15PositiveControls()) {
|
|
1122
|
+
if (!c.positive || c.negative) {
|
|
1123
|
+
throw new Error(
|
|
1124
|
+
`D15 ${c.key} control FAILED: /${c.pattern}/ accept=${c.positive} reject=${!c.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
const handles = collectHandles(tree);
|
|
1129
|
+
const mutations = findMutations(tree, handles);
|
|
1130
|
+
if (mutations.length === 0) {
|
|
1131
|
+
return {
|
|
1132
|
+
id: this.id,
|
|
1133
|
+
anchor: tree.files.length > 0 ? `${tree.files[0].rel}:1` : tree.root,
|
|
1134
|
+
state: "absent",
|
|
1135
|
+
verdict: "no tool-set mutation detected (n/a for Server Rule 15)"
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
const mutAnchor = `${mutations[0].file.rel}:${mutations.map((m) => m.line).join(",")}`;
|
|
1139
|
+
const mutationFileRels = new Set(mutations.map((m) => m.file.rel));
|
|
1140
|
+
const localEmission = firstMatchInFiles(tree, EMISSION_RE, mutationFileRels) ?? firstMatchInFiles(tree, LISTCHANGED_CAP_RE, mutationFileRels);
|
|
1141
|
+
if (localEmission) {
|
|
1142
|
+
return {
|
|
1143
|
+
id: this.id,
|
|
1144
|
+
anchor: mutAnchor,
|
|
1145
|
+
state: "present",
|
|
1146
|
+
verdict: `tool-set mutation at ${mutAnchor} correlated with tools/list_changed emission in the same file at ${localEmission.file.rel}:${localEmission.line} (Server Rule 15)`
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
const remoteEmission = firstMatch(tree, EMISSION_RE) ?? firstMatch(tree, LISTCHANGED_CAP_RE);
|
|
1150
|
+
if (remoteEmission) {
|
|
1151
|
+
const reason = `tool set is mutated at ${mutAnchor}; a tools/list_changed emission exists at ${remoteEmission.file.rel}:${remoteEmission.line} but in a DIFFERENT file from every mutation site \u2014 whether the mutating path emits is unreadable statically (Server Rule 15)`;
|
|
1152
|
+
return {
|
|
1153
|
+
id: this.id,
|
|
1154
|
+
anchor: mutAnchor,
|
|
1155
|
+
state: "could-not-judge",
|
|
1156
|
+
reason,
|
|
1157
|
+
verdict: `could not judge: ${reason}`
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
const computed = firstMatch(tree, COMPUTED_NOTIFY_RE);
|
|
1161
|
+
if (computed) {
|
|
1162
|
+
const reason = `tool set is mutated at ${mutAnchor} and the only notification is a computed call at ${computed.file.rel}:${computed.line} \u2014 whether it emits tools/list_changed is unreadable statically (Server Rule 15)`;
|
|
1163
|
+
return {
|
|
1164
|
+
id: this.id,
|
|
1165
|
+
anchor: computed.file ? `${computed.file.rel}:${computed.line}` : mutAnchor,
|
|
1166
|
+
state: "could-not-judge",
|
|
1167
|
+
reason,
|
|
1168
|
+
verdict: `could not judge: ${reason}`
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
return {
|
|
1172
|
+
id: this.id,
|
|
1173
|
+
anchor: mutAnchor,
|
|
1174
|
+
state: "non-conforming",
|
|
1175
|
+
verdict: `tool set is mutated at ${mutAnchor} with NO tools/list_changed emission -> cached tools/list goes stale; emit notifications/tools/list_changed (Server Rule 15)`
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
// src/detectors/d16.ts
|
|
1181
|
+
var DISCOVER_RE2 = /server\/discover|DiscoverResult|ServerDiscover|handleServerDiscover/;
|
|
1182
|
+
var HEALTH_ROUTE_RE = /\b[A-Za-z_$][\w$]*\.(?:get|post|put|patch|delete|all|route|use)\s*\(\s*["'`][^"'`]*\/health/;
|
|
1183
|
+
var VERSION_FIELD_RE = /\bversion\s*:/;
|
|
1184
|
+
var PKG_SOURCE_RE = /(?:pkg|pkgJson|packageJson|pjson|packageInfo)\.version|process\.env\.npm_package_version|["'`][^"'`]*package\.json["'`]/;
|
|
1185
|
+
var PKG_IMPORT_IMPORT_RE = /import\s+(?:\*\s+as\s+)?([A-Za-z_$][\w$]*)\s+from\s+["'`][^"'`]*package\.json["'`]/;
|
|
1186
|
+
var PKG_IMPORT_BIND_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*[^;\n]*package\.json/;
|
|
1187
|
+
var VERSION_COMPUTED_RE = /\bversion\s*:\s*[A-Za-z_$][\w.]*\s*\(/;
|
|
1188
|
+
var VERSION_LITERAL_RE = /\bversion\s*:\s*["'`][^"'`]+["'`]/;
|
|
1189
|
+
var AUTH_GATE_RE = /requireAuth|verifyBearer|assertAuth|ensureAuthenticated|withAuth|requireBearer/;
|
|
1190
|
+
var WINDOW = 25;
|
|
1191
|
+
var CONTROLS2 = [
|
|
1192
|
+
{
|
|
1193
|
+
key: "surface",
|
|
1194
|
+
re: DISCOVER_RE2,
|
|
1195
|
+
positive: 'method: "server/discover"',
|
|
1196
|
+
negative: 'method: "tools/list"'
|
|
1197
|
+
},
|
|
1198
|
+
{
|
|
1199
|
+
key: "health",
|
|
1200
|
+
re: HEALTH_ROUTE_RE,
|
|
1201
|
+
positive: 'app.get("/health", handler)',
|
|
1202
|
+
negative: 'app.get("/tools", handler)'
|
|
1203
|
+
},
|
|
1204
|
+
{
|
|
1205
|
+
key: "pkg-source",
|
|
1206
|
+
re: PKG_SOURCE_RE,
|
|
1207
|
+
positive: "version: pkg.version",
|
|
1208
|
+
negative: "version: tool.version"
|
|
1209
|
+
},
|
|
1210
|
+
{
|
|
1211
|
+
key: "auth-gate",
|
|
1212
|
+
re: AUTH_GATE_RE,
|
|
1213
|
+
positive: "await requireAuth(req)",
|
|
1214
|
+
negative: "await parseBody(req)"
|
|
1215
|
+
}
|
|
1216
|
+
];
|
|
1217
|
+
function d16PositiveControls() {
|
|
1218
|
+
return CONTROLS2.map((c) => ({
|
|
1219
|
+
key: c.key,
|
|
1220
|
+
pattern: c.re.source,
|
|
1221
|
+
positive: c.re.test(c.positive),
|
|
1222
|
+
negative: c.re.test(c.negative)
|
|
1223
|
+
}));
|
|
1224
|
+
}
|
|
1225
|
+
function collectPkgIdents(file) {
|
|
1226
|
+
const idents = /* @__PURE__ */ new Set();
|
|
1227
|
+
for (const line of file.lines) {
|
|
1228
|
+
if (line.comment) continue;
|
|
1229
|
+
const mi = line.raw.match(PKG_IMPORT_IMPORT_RE);
|
|
1230
|
+
if (mi) idents.add(mi[1]);
|
|
1231
|
+
const mb = line.raw.match(PKG_IMPORT_BIND_RE);
|
|
1232
|
+
if (mb) idents.add(mb[1]);
|
|
1233
|
+
}
|
|
1234
|
+
return idents;
|
|
1235
|
+
}
|
|
1236
|
+
function extractHandlerBody(file, startIdx) {
|
|
1237
|
+
const code = file.lines.filter((l) => !l.comment);
|
|
1238
|
+
const startPos = code.findIndex((l) => l.n === file.lines[startIdx].n);
|
|
1239
|
+
if (startPos < 0) return null;
|
|
1240
|
+
const text = code.slice(startPos).map((l) => l.raw).join("\n");
|
|
1241
|
+
const openIdx = text.search(/[({]/);
|
|
1242
|
+
if (openIdx < 0) return null;
|
|
1243
|
+
const open = text[openIdx];
|
|
1244
|
+
const close = open === "(" ? ")" : "}";
|
|
1245
|
+
let depth = 0;
|
|
1246
|
+
let body = "";
|
|
1247
|
+
for (let i = openIdx; i < text.length; i++) {
|
|
1248
|
+
const ch = text[i];
|
|
1249
|
+
if (ch === open) {
|
|
1250
|
+
depth++;
|
|
1251
|
+
if (depth === 1) continue;
|
|
1252
|
+
} else if (ch === close) {
|
|
1253
|
+
depth--;
|
|
1254
|
+
if (depth === 0) return body;
|
|
1255
|
+
}
|
|
1256
|
+
if (depth >= 1) body += ch;
|
|
1257
|
+
}
|
|
1258
|
+
return null;
|
|
1259
|
+
}
|
|
1260
|
+
function classifySurface(file, startIdx, anchor, pkgIdents) {
|
|
1261
|
+
const code = file.lines.filter((l) => !l.comment);
|
|
1262
|
+
const startPos = code.findIndex((l) => l.n === file.lines[startIdx].n);
|
|
1263
|
+
const window = startPos >= 0 ? code.slice(startPos, startPos + 1 + WINDOW) : [];
|
|
1264
|
+
const blob = window.map((l) => l.raw).join("\n");
|
|
1265
|
+
const hasVersion = VERSION_FIELD_RE.test(blob);
|
|
1266
|
+
const body = extractHandlerBody(file, startIdx);
|
|
1267
|
+
if (body === null) {
|
|
1268
|
+
return hasVersion ? { kind: "undelimited-body", anchor } : { kind: "undelimited-no-version", anchor };
|
|
1269
|
+
}
|
|
1270
|
+
const hasAuth = AUTH_GATE_RE.test(body);
|
|
1271
|
+
const pkgIdentMatch = pkgIdents.size > 0 && new RegExp(`\\b(?:${[...pkgIdents].join("|")})\\.version\\b`).test(blob);
|
|
1272
|
+
const hasPkg = PKG_SOURCE_RE.test(blob) || pkgIdentMatch;
|
|
1273
|
+
const hasComputed = VERSION_COMPUTED_RE.test(blob);
|
|
1274
|
+
const hasLiteral = VERSION_LITERAL_RE.test(blob);
|
|
1275
|
+
if (hasAuth) return { kind: "behind-auth", anchor };
|
|
1276
|
+
if (hasVersion && hasPkg) return { kind: "present", anchor };
|
|
1277
|
+
if (hasVersion && hasComputed && !hasPkg) return { kind: "computed", anchor };
|
|
1278
|
+
if (hasVersion && hasLiteral && !hasPkg) return { kind: "drift-literal", anchor };
|
|
1279
|
+
if (!hasVersion) return { kind: "absent-version", anchor };
|
|
1280
|
+
return { kind: "unreadable-source", anchor };
|
|
1281
|
+
}
|
|
1282
|
+
function evalSurfaces(tree) {
|
|
1283
|
+
const out = [];
|
|
1284
|
+
for (const file of tree.files) {
|
|
1285
|
+
const pkgIdents = collectPkgIdents(file);
|
|
1286
|
+
file.lines.forEach((line, idx) => {
|
|
1287
|
+
if (line.comment) return;
|
|
1288
|
+
if (DISCOVER_RE2.test(line.raw) || HEALTH_ROUTE_RE.test(line.raw)) {
|
|
1289
|
+
out.push(classifySurface(file, idx, `${file.rel}:${line.n}`, pkgIdents));
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
return out;
|
|
1294
|
+
}
|
|
1295
|
+
var d16 = {
|
|
1296
|
+
id: "D16 version-observable",
|
|
1297
|
+
run(tree) {
|
|
1298
|
+
for (const c of d16PositiveControls()) {
|
|
1299
|
+
if (!c.positive || c.negative) {
|
|
1300
|
+
throw new Error(
|
|
1301
|
+
`D16 ${c.key} control FAILED: /${c.pattern}/ accept=${c.positive} reject=${!c.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
const surfaces = evalSurfaces(tree);
|
|
1306
|
+
if (surfaces.length === 0) {
|
|
1307
|
+
return {
|
|
1308
|
+
id: this.id,
|
|
1309
|
+
anchor: tree.files.length > 0 ? `${tree.files[0].rel}:1` : tree.root,
|
|
1310
|
+
state: "absent",
|
|
1311
|
+
verdict: "no server/discover handler and no /health route (n/a for Server Rule 16)"
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
const present = surfaces.find((s) => s.kind === "present");
|
|
1315
|
+
if (present) {
|
|
1316
|
+
return {
|
|
1317
|
+
id: this.id,
|
|
1318
|
+
anchor: present.anchor,
|
|
1319
|
+
state: "present",
|
|
1320
|
+
verdict: `version exposed pre-auth at ${present.anchor}, sourced from package.json (Server Rule 16)`
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
const decisive = surfaces.find(
|
|
1324
|
+
(s) => s.kind === "behind-auth" || s.kind === "drift-literal" || s.kind === "absent-version"
|
|
1325
|
+
);
|
|
1326
|
+
if (decisive) {
|
|
1327
|
+
const fix = {
|
|
1328
|
+
"behind-auth": "version is gated by an auth check -> expose it pre-auth on discover/health",
|
|
1329
|
+
"drift-literal": "version is a hardcoded literal -> source it from package.json (drift risk / may != package.json)",
|
|
1330
|
+
"absent-version": "no version field on the discover/health surface -> add version from package.json",
|
|
1331
|
+
present: "",
|
|
1332
|
+
computed: "",
|
|
1333
|
+
"unreadable-source": "",
|
|
1334
|
+
"undelimited-no-version": "",
|
|
1335
|
+
"undelimited-body": ""
|
|
1336
|
+
};
|
|
1337
|
+
return {
|
|
1338
|
+
id: this.id,
|
|
1339
|
+
anchor: decisive.anchor,
|
|
1340
|
+
state: "non-conforming",
|
|
1341
|
+
verdict: `${fix[decisive.kind]} at ${decisive.anchor} (Server Rule 16)`
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
const unreadable = surfaces[0];
|
|
1345
|
+
let detail;
|
|
1346
|
+
if (unreadable.kind === "undelimited-no-version") {
|
|
1347
|
+
detail = `body undelimited; version searched over a ${WINDOW}-line adjacency window rather than the handler body \u2014 scope unestablished, absence not decisive`;
|
|
1348
|
+
} else if (unreadable.kind === "undelimited-body") {
|
|
1349
|
+
detail = "could not delimit handler body \u2014 auth-vs-surface binding unreadable";
|
|
1350
|
+
} else if (unreadable.kind === "computed") {
|
|
1351
|
+
detail = "is a computed value with no package.json source in sight";
|
|
1352
|
+
} else {
|
|
1353
|
+
detail = "is present but its source is not a resolvable package.json binding in the read window";
|
|
1354
|
+
}
|
|
1355
|
+
const reason = `surface at ${unreadable.anchor} ${detail} \u2014 verdict unreadable statically (Server Rule 16)`;
|
|
1356
|
+
return {
|
|
1357
|
+
id: this.id,
|
|
1358
|
+
anchor: unreadable.anchor,
|
|
1359
|
+
state: "could-not-judge",
|
|
1360
|
+
reason,
|
|
1361
|
+
verdict: `could not judge: ${reason}`
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
// src/detectors/dAppI18n.ts
|
|
1367
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
1368
|
+
import { dirname as dirname4, join as join4 } from "path";
|
|
1369
|
+
function flattenKeys(obj, prefix = "") {
|
|
1370
|
+
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
1371
|
+
return prefix ? [prefix] : [];
|
|
1372
|
+
}
|
|
1373
|
+
const keys = [];
|
|
1374
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
1375
|
+
const path = prefix ? `${prefix}.${k}` : k;
|
|
1376
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
1377
|
+
keys.push(...flattenKeys(v, path));
|
|
1378
|
+
} else {
|
|
1379
|
+
keys.push(path);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
return keys;
|
|
1383
|
+
}
|
|
1384
|
+
function missingKeys(source, fr) {
|
|
1385
|
+
const frSet = new Set(fr);
|
|
1386
|
+
return source.filter((k) => !frSet.has(k));
|
|
1387
|
+
}
|
|
1388
|
+
function dAppI18nPositiveControls() {
|
|
1389
|
+
const src = flattenKeys({ a: 1, nested: { b: 2 } });
|
|
1390
|
+
const frGap = flattenKeys({ a: 1 });
|
|
1391
|
+
const frFull = flattenKeys({ a: 1, nested: { b: 2 } });
|
|
1392
|
+
return {
|
|
1393
|
+
keyDiff: {
|
|
1394
|
+
detectsGap: missingKeys(src, frGap).length > 0,
|
|
1395
|
+
detectsComplete: missingKeys(src, frFull).length === 0
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
function readJson(path) {
|
|
1400
|
+
try {
|
|
1401
|
+
return { ok: true, value: JSON.parse(readFileSync2(path, "utf8")) };
|
|
1402
|
+
} catch {
|
|
1403
|
+
return { ok: false };
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
var dAppI18n = {
|
|
1407
|
+
id: "DAppI18n app-i18n",
|
|
1408
|
+
run(tree) {
|
|
1409
|
+
const control = dAppI18nPositiveControls().keyDiff;
|
|
1410
|
+
if (!control.detectsGap || !control.detectsComplete) {
|
|
1411
|
+
throw new Error(
|
|
1412
|
+
`DAppI18n key-diff control FAILED: detectsGap=${control.detectsGap} detectsComplete=${control.detectsComplete} \u2014 matcher broken, verdict untrustworthy`
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
const root = tree.packageJson ? dirname4(tree.packageJson.path) : tree.root;
|
|
1416
|
+
const i18nDir = join4(root, "i18n");
|
|
1417
|
+
const enPath = join4(i18nDir, "en.json");
|
|
1418
|
+
const frPath = join4(i18nDir, "fr.json");
|
|
1419
|
+
const hasI18n = existsSync4(i18nDir);
|
|
1420
|
+
const hasFr = existsSync4(frPath);
|
|
1421
|
+
if (!hasI18n && !hasFr) {
|
|
1422
|
+
return {
|
|
1423
|
+
id: this.id,
|
|
1424
|
+
anchor: `${root}/i18n`,
|
|
1425
|
+
state: "absent",
|
|
1426
|
+
verdict: "no i18n/ layer (n/a for App Rule 2 i18n-half)"
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
if (!hasFr) {
|
|
1430
|
+
return {
|
|
1431
|
+
id: this.id,
|
|
1432
|
+
anchor: `${i18nDir}/fr.json`,
|
|
1433
|
+
state: "non-conforming",
|
|
1434
|
+
verdict: "i18n/ layer present but i18n/fr.json is missing -> ship the French locale (App Rule 2)"
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
const frRead = readJson(frPath);
|
|
1438
|
+
if (!frRead.ok) {
|
|
1439
|
+
const reason = "i18n/fr.json is not readable JSON \u2014 cannot compare its keys against the source locale (App Rule 2)";
|
|
1440
|
+
return {
|
|
1441
|
+
id: this.id,
|
|
1442
|
+
anchor: `${i18nDir}/fr.json`,
|
|
1443
|
+
state: "could-not-judge",
|
|
1444
|
+
reason,
|
|
1445
|
+
verdict: `could not judge: ${reason}`
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
if (!existsSync4(enPath)) {
|
|
1449
|
+
const reason = "i18n/fr.json exists but no source-locale i18n/en.json to compare its keys against (App Rule 2)";
|
|
1450
|
+
return {
|
|
1451
|
+
id: this.id,
|
|
1452
|
+
anchor: `${i18nDir}/en.json`,
|
|
1453
|
+
state: "could-not-judge",
|
|
1454
|
+
reason,
|
|
1455
|
+
verdict: `could not judge: ${reason}`
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
const enRead = readJson(enPath);
|
|
1459
|
+
if (!enRead.ok) {
|
|
1460
|
+
const reason = "i18n/en.json (source locale) is not readable JSON \u2014 cannot derive the reference key set (App Rule 2)";
|
|
1461
|
+
return {
|
|
1462
|
+
id: this.id,
|
|
1463
|
+
anchor: `${i18nDir}/en.json`,
|
|
1464
|
+
state: "could-not-judge",
|
|
1465
|
+
reason,
|
|
1466
|
+
verdict: `could not judge: ${reason}`
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
const missing = missingKeys(flattenKeys(enRead.value), flattenKeys(frRead.value));
|
|
1470
|
+
if (missing.length > 0) {
|
|
1471
|
+
return {
|
|
1472
|
+
id: this.id,
|
|
1473
|
+
anchor: `${i18nDir}/fr.json`,
|
|
1474
|
+
state: "non-conforming",
|
|
1475
|
+
verdict: `i18n/fr.json is missing ${missing.length} source-locale key(s): ${missing.join(", ")} -> add the French translations (App Rule 2)`
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
return {
|
|
1479
|
+
id: this.id,
|
|
1480
|
+
anchor: `${i18nDir}/fr.json`,
|
|
1481
|
+
state: "present",
|
|
1482
|
+
verdict: "i18n/fr.json covers every i18n/en.json key (App Rule 2 i18n-half)"
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
// src/detectors/dDebugOptIn.ts
|
|
1488
|
+
var DEBUG_CALL_RE = /\b(?:console|logger|log)\.debug\s*\(/;
|
|
1489
|
+
var GATE_RE = /process\.env\.[A-Z0-9_]*DEBUG\b|process\.env\.NODE_ENV|\b(?:config|options|opts|settings|env)\.debug\b|\bdebugEnabled\b|\bisDebug(?:Enabled)?\b/;
|
|
1490
|
+
var COMPUTED_LEVEL_RE = /\b(?:console|logger|log)\s*\[/;
|
|
1491
|
+
var CONTROLS3 = [
|
|
1492
|
+
{
|
|
1493
|
+
key: "debug-call",
|
|
1494
|
+
re: DEBUG_CALL_RE,
|
|
1495
|
+
positive: "console.debug(x)",
|
|
1496
|
+
negative: "console.info(x)"
|
|
1497
|
+
},
|
|
1498
|
+
{
|
|
1499
|
+
key: "gate",
|
|
1500
|
+
re: GATE_RE,
|
|
1501
|
+
positive: "if (process.env.DEBUG)",
|
|
1502
|
+
negative: "if (process.env.HOME)"
|
|
1503
|
+
}
|
|
1504
|
+
];
|
|
1505
|
+
function dDebugOptInPositiveControls() {
|
|
1506
|
+
return CONTROLS3.map((c) => ({
|
|
1507
|
+
key: c.key,
|
|
1508
|
+
pattern: c.re.source,
|
|
1509
|
+
positive: c.re.test(c.positive),
|
|
1510
|
+
negative: c.re.test(c.negative)
|
|
1511
|
+
}));
|
|
1512
|
+
}
|
|
1513
|
+
function findAll(tree, re) {
|
|
1514
|
+
const hits = [];
|
|
1515
|
+
for (const file of tree.files) {
|
|
1516
|
+
for (const line of file.lines) {
|
|
1517
|
+
if (line.comment) continue;
|
|
1518
|
+
if (re.test(line.raw)) hits.push({ file, line: line.n });
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
return hits;
|
|
1522
|
+
}
|
|
1523
|
+
function firstMatchInFiles2(tree, re, fileRels) {
|
|
1524
|
+
for (const file of tree.files) {
|
|
1525
|
+
if (!fileRels.has(file.rel)) continue;
|
|
1526
|
+
for (const line of file.lines) {
|
|
1527
|
+
if (line.comment) continue;
|
|
1528
|
+
if (re.test(line.raw)) return { file, line: line.n };
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
return null;
|
|
1532
|
+
}
|
|
1533
|
+
var dDebugOptIn = {
|
|
1534
|
+
id: "DDebugOptIn debug-opt-in",
|
|
1535
|
+
run(tree) {
|
|
1536
|
+
for (const c of dDebugOptInPositiveControls()) {
|
|
1537
|
+
if (!c.positive || c.negative) {
|
|
1538
|
+
throw new Error(
|
|
1539
|
+
`DDebugOptIn ${c.key} control FAILED: /${c.pattern}/ accept=${c.positive} reject=${!c.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
const computed = findAll(tree, COMPUTED_LEVEL_RE);
|
|
1544
|
+
if (computed.length > 0) {
|
|
1545
|
+
const sites = computed.map((h) => `${h.file.rel}:${h.line}`).join(", ");
|
|
1546
|
+
throw new Error(
|
|
1547
|
+
`DDebugOptIn cannot read the level of ${computed.length} computed-level log call(s): ${sites} \u2014 a bracket-indexed level hides whether it is debug (Server Rule 6). Fail-closed, not a silent present.`
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
const debugCalls = findAll(tree, DEBUG_CALL_RE);
|
|
1551
|
+
if (debugCalls.length === 0) {
|
|
1552
|
+
return {
|
|
1553
|
+
id: this.id,
|
|
1554
|
+
anchor: tree.files.length > 0 ? `${tree.files[0].rel}:1` : tree.root,
|
|
1555
|
+
state: "absent",
|
|
1556
|
+
verdict: "no debug output emitted (n/a for Server Rule 6 debug-half)"
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
const callAnchor = `${debugCalls[0].file.rel}:${debugCalls.map((h) => h.line).join(",")}`;
|
|
1560
|
+
const debugFileRels = new Set(debugCalls.map((h) => h.file.rel));
|
|
1561
|
+
const localGate = firstMatchInFiles2(tree, GATE_RE, debugFileRels);
|
|
1562
|
+
if (localGate) {
|
|
1563
|
+
return {
|
|
1564
|
+
id: this.id,
|
|
1565
|
+
anchor: callAnchor,
|
|
1566
|
+
state: "present",
|
|
1567
|
+
verdict: `debug output at ${callAnchor} gated by an env/config switch in the same file at ${localGate.file.rel}:${localGate.line} (Server Rule 6 debug-half)`
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
const remoteGate = firstMatchInFiles2(tree, GATE_RE, new Set(tree.files.map((f) => f.rel)));
|
|
1571
|
+
if (remoteGate) {
|
|
1572
|
+
const reason = `debug output at ${callAnchor}; an env/config gate exists at ${remoteGate.file.rel}:${remoteGate.line} but in a DIFFERENT file from every debug call \u2014 whether the debug path is opt-in is unreadable statically (Server Rule 6 debug-half)`;
|
|
1573
|
+
return {
|
|
1574
|
+
id: this.id,
|
|
1575
|
+
anchor: callAnchor,
|
|
1576
|
+
state: "could-not-judge",
|
|
1577
|
+
reason,
|
|
1578
|
+
verdict: `could not judge: ${reason}`
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
return {
|
|
1582
|
+
id: this.id,
|
|
1583
|
+
anchor: callAnchor,
|
|
1584
|
+
state: "non-conforming",
|
|
1585
|
+
verdict: `debug output at ${callAnchor} with NO env/config gate -> debug is on by default; gate it behind process.env.*DEBUG* or a config switch (Server Rule 6 debug-half)`
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
|
|
1590
|
+
// src/detectors/dExt1.ts
|
|
1591
|
+
import { readFileSync as readFileSync3, readdirSync, statSync as statSync2 } from "fs";
|
|
1592
|
+
import { join as join5 } from "path";
|
|
1593
|
+
var IDENTIFIER_RE = /^[a-z0-9]+(\.[a-z0-9-]+)+\/[a-z0-9-]+$/;
|
|
1594
|
+
var IDENTIFIER_POSITIVE = "com.elpi/middleware-redact-pii";
|
|
1595
|
+
var IDENTIFIER_NEGATIVE = "Com.Elpi/Bad_Id";
|
|
1596
|
+
function dExt1PositiveControls() {
|
|
1597
|
+
return {
|
|
1598
|
+
identifier: {
|
|
1599
|
+
pattern: IDENTIFIER_RE.source,
|
|
1600
|
+
positive: IDENTIFIER_RE.test(IDENTIFIER_POSITIVE),
|
|
1601
|
+
negative: IDENTIFIER_RE.test(IDENTIFIER_NEGATIVE)
|
|
1602
|
+
}
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
1606
|
+
function findExtensionManifests(root) {
|
|
1607
|
+
const out = [];
|
|
1608
|
+
let base;
|
|
1609
|
+
try {
|
|
1610
|
+
base = statSync2(root).isDirectory() ? root : join5(root, "..");
|
|
1611
|
+
} catch {
|
|
1612
|
+
return out;
|
|
1613
|
+
}
|
|
1614
|
+
const walk2 = (dir) => {
|
|
1615
|
+
let entries;
|
|
1616
|
+
try {
|
|
1617
|
+
entries = readdirSync(dir);
|
|
1618
|
+
} catch {
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
for (const name of entries) {
|
|
1622
|
+
const full = join5(dir, name);
|
|
1623
|
+
let st;
|
|
1624
|
+
try {
|
|
1625
|
+
st = statSync2(full);
|
|
1626
|
+
} catch {
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
if (st.isDirectory()) {
|
|
1630
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
1631
|
+
walk2(full);
|
|
1632
|
+
} else if (name === "extension.json") {
|
|
1633
|
+
out.push(full);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
};
|
|
1637
|
+
walk2(base);
|
|
1638
|
+
return out;
|
|
1639
|
+
}
|
|
1640
|
+
var TS_IDENT_LITERAL_RE = /\bidentifier:\s*["'`]([^"'`]*)["'`]/;
|
|
1641
|
+
var TS_IDENT_KEY_RE = /\bidentifier:\s*[^\s"'`]/;
|
|
1642
|
+
function findTsIdentifiers(tree) {
|
|
1643
|
+
const sites = [];
|
|
1644
|
+
const unresolved = [];
|
|
1645
|
+
for (const file of tree.files) {
|
|
1646
|
+
for (const line of file.lines) {
|
|
1647
|
+
if (line.comment) continue;
|
|
1648
|
+
const lit = line.raw.match(TS_IDENT_LITERAL_RE);
|
|
1649
|
+
if (lit) {
|
|
1650
|
+
sites.push({ value: lit[1], anchor: `${file.rel}:${line.n}` });
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
if (TS_IDENT_KEY_RE.test(line.raw)) {
|
|
1654
|
+
unresolved.push(`${file.rel}:${line.n}`);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
return { sites, unresolved };
|
|
1659
|
+
}
|
|
1660
|
+
var dExt1 = {
|
|
1661
|
+
id: "DExt1 identifier",
|
|
1662
|
+
run(tree) {
|
|
1663
|
+
const controls = dExt1PositiveControls();
|
|
1664
|
+
if (!controls.identifier.positive || controls.identifier.negative) {
|
|
1665
|
+
throw new Error(
|
|
1666
|
+
`DExt1 identifier control FAILED: /${controls.identifier.pattern}/ accept(\`${IDENTIFIER_POSITIVE}\`)=${controls.identifier.positive} reject(\`${IDENTIFIER_NEGATIVE}\`)=${!controls.identifier.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
const manifests = findExtensionManifests(tree.root);
|
|
1670
|
+
for (const manifestPath of manifests) {
|
|
1671
|
+
const shortPath = manifestPath.split("/").slice(-2).join("/");
|
|
1672
|
+
let parsed;
|
|
1673
|
+
try {
|
|
1674
|
+
parsed = JSON.parse(readFileSync3(manifestPath, "utf8"));
|
|
1675
|
+
} catch {
|
|
1676
|
+
throw new Error(
|
|
1677
|
+
`DExt1 could not parse ${shortPath} as JSON \u2014 a present manifest with an unreadable identifier cannot pass (Extension Rule 1). Fail-closed, not a silent present.`
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
const idValue = parsed.identifier;
|
|
1681
|
+
if (typeof idValue !== "string") {
|
|
1682
|
+
throw new Error(
|
|
1683
|
+
`DExt1 found a present manifest ${shortPath} with no string "identifier" field (got ${typeof idValue}) \u2014 Extension Rule 1 requires a reverse-DNS identifier. Fail-closed, not a silent present.`
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1686
|
+
if (!IDENTIFIER_RE.test(idValue)) {
|
|
1687
|
+
return {
|
|
1688
|
+
id: this.id,
|
|
1689
|
+
anchor: shortPath,
|
|
1690
|
+
state: "non-conforming",
|
|
1691
|
+
verdict: `manifest identifier "${idValue}" is not reverse-DNS (positive-control OK) -> use <reverse.dns>/<slug> e.g. com.elpi/redact-pii (Extension Rule 1)`
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
const { sites, unresolved } = findTsIdentifiers(tree);
|
|
1696
|
+
if (unresolved.length > 0) {
|
|
1697
|
+
throw new Error(
|
|
1698
|
+
`DExt1 cannot read the identifier at ${unresolved.length} TS site(s): ${unresolved.join(", ")} \u2014 value is computed/imported/template, reverse-DNS unverifiable (Extension Rule 1). Fail-closed, not a silent present.`
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
for (const site of sites) {
|
|
1702
|
+
if (!IDENTIFIER_RE.test(site.value)) {
|
|
1703
|
+
return {
|
|
1704
|
+
id: this.id,
|
|
1705
|
+
anchor: site.anchor,
|
|
1706
|
+
state: "non-conforming",
|
|
1707
|
+
verdict: `identifier "${site.value}" is not reverse-DNS (positive-control OK) -> use <reverse.dns>/<slug> e.g. com.elpi/redact-pii (Extension Rule 1)`
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
const total = manifests.length + sites.length;
|
|
1712
|
+
if (total === 0) {
|
|
1713
|
+
return {
|
|
1714
|
+
id: this.id,
|
|
1715
|
+
anchor: tree.root.split("/").pop() ?? tree.root,
|
|
1716
|
+
state: "absent",
|
|
1717
|
+
verdict: "no extension identifier found (no extension.json manifest, no TS `identifier:` literal) -> declare a reverse-DNS identifier (Extension Rule 1)"
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
return {
|
|
1721
|
+
id: this.id,
|
|
1722
|
+
anchor: sites.length > 0 ? sites[0].anchor : manifests[0].split("/").slice(-2).join("/"),
|
|
1723
|
+
state: "present",
|
|
1724
|
+
verdict: `${total} identifier(s) reverse-DNS conforming (manifest=${manifests.length}, ts=${sites.length}, positive-control OK)`
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
};
|
|
1728
|
+
|
|
1729
|
+
// src/detectors/dExt2.ts
|
|
1730
|
+
import { readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
|
|
1731
|
+
import { join as join6 } from "path";
|
|
1732
|
+
var TS_GD_TRUE_RE = /\bgracefulDegradation:\s*true\b/;
|
|
1733
|
+
var TS_GD_FALSE_RE = /\bgracefulDegradation:\s*false\b/;
|
|
1734
|
+
var TS_GD_KEY_RE = /\bgracefulDegradation:\s*([^\s,}]+)/;
|
|
1735
|
+
var DEFINE_EXTENSION_OPEN_RE = /\bdefineExtension\s*\(\s*\{/;
|
|
1736
|
+
var GD_POSITIVE = "gracefulDegradation: true";
|
|
1737
|
+
var GD_NEGATIVE = "gracefulDegradation: false";
|
|
1738
|
+
function dExt2PositiveControls() {
|
|
1739
|
+
return {
|
|
1740
|
+
gracefulDegradation: {
|
|
1741
|
+
acceptsTrue: TS_GD_TRUE_RE.test(GD_POSITIVE),
|
|
1742
|
+
rejectsFalse: !TS_GD_TRUE_RE.test(GD_NEGATIVE)
|
|
1743
|
+
}
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
var SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
1747
|
+
function findExtensionManifests2(root) {
|
|
1748
|
+
const out = [];
|
|
1749
|
+
let base;
|
|
1750
|
+
try {
|
|
1751
|
+
base = statSync3(root).isDirectory() ? root : join6(root, "..");
|
|
1752
|
+
} catch {
|
|
1753
|
+
return out;
|
|
1754
|
+
}
|
|
1755
|
+
const walk2 = (dir) => {
|
|
1756
|
+
let entries;
|
|
1757
|
+
try {
|
|
1758
|
+
entries = readdirSync2(dir);
|
|
1759
|
+
} catch {
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
for (const name of entries) {
|
|
1763
|
+
const full = join6(dir, name);
|
|
1764
|
+
let st;
|
|
1765
|
+
try {
|
|
1766
|
+
st = statSync3(full);
|
|
1767
|
+
} catch {
|
|
1768
|
+
continue;
|
|
1769
|
+
}
|
|
1770
|
+
if (st.isDirectory()) {
|
|
1771
|
+
if (SKIP_DIRS2.has(name)) continue;
|
|
1772
|
+
walk2(full);
|
|
1773
|
+
} else if (name === "extension.json") {
|
|
1774
|
+
out.push(full);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
};
|
|
1778
|
+
walk2(base);
|
|
1779
|
+
return out;
|
|
1780
|
+
}
|
|
1781
|
+
function findTsFactorySites(tree) {
|
|
1782
|
+
const sites = [];
|
|
1783
|
+
for (const file of tree.files) {
|
|
1784
|
+
const lines = file.lines;
|
|
1785
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1786
|
+
const line = lines[i];
|
|
1787
|
+
if (line.comment) continue;
|
|
1788
|
+
if (!DEFINE_EXTENSION_OPEN_RE.test(line.raw)) continue;
|
|
1789
|
+
const anchor = `${file.rel}:${line.n}`;
|
|
1790
|
+
let depth = 0;
|
|
1791
|
+
let started = false;
|
|
1792
|
+
let state = "absent";
|
|
1793
|
+
let value;
|
|
1794
|
+
for (let j = i; j < lines.length; j++) {
|
|
1795
|
+
const bl = lines[j];
|
|
1796
|
+
if (!bl.comment) {
|
|
1797
|
+
if (state === "absent") {
|
|
1798
|
+
if (TS_GD_TRUE_RE.test(bl.raw)) {
|
|
1799
|
+
state = "true";
|
|
1800
|
+
} else if (TS_GD_FALSE_RE.test(bl.raw)) {
|
|
1801
|
+
state = "false";
|
|
1802
|
+
} else {
|
|
1803
|
+
const key = bl.raw.match(TS_GD_KEY_RE);
|
|
1804
|
+
if (key) {
|
|
1805
|
+
state = "unresolved";
|
|
1806
|
+
value = key[1];
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
depth += (bl.raw.match(/\{/g) ?? []).length;
|
|
1811
|
+
depth -= (bl.raw.match(/\}/g) ?? []).length;
|
|
1812
|
+
if (depth > 0) started = true;
|
|
1813
|
+
}
|
|
1814
|
+
if (started && depth <= 0) break;
|
|
1815
|
+
}
|
|
1816
|
+
sites.push({ state, value, anchor });
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
return sites;
|
|
1820
|
+
}
|
|
1821
|
+
var dExt2 = {
|
|
1822
|
+
id: "DExt2 graceful-degradation",
|
|
1823
|
+
run(tree) {
|
|
1824
|
+
const controls = dExt2PositiveControls();
|
|
1825
|
+
if (!controls.gracefulDegradation.acceptsTrue || !controls.gracefulDegradation.rejectsFalse) {
|
|
1826
|
+
throw new Error(
|
|
1827
|
+
`DExt2 graceful-degradation control FAILED: accept(\`${GD_POSITIVE}\`)=${controls.gracefulDegradation.acceptsTrue} reject(\`${GD_NEGATIVE}\`)=${controls.gracefulDegradation.rejectsFalse} \u2014 matcher broken, verdict untrustworthy`
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
const manifests = findExtensionManifests2(tree.root);
|
|
1831
|
+
for (const manifestPath of manifests) {
|
|
1832
|
+
const shortPath = manifestPath.split("/").slice(-2).join("/");
|
|
1833
|
+
let parsed;
|
|
1834
|
+
try {
|
|
1835
|
+
parsed = JSON.parse(readFileSync4(manifestPath, "utf8"));
|
|
1836
|
+
} catch {
|
|
1837
|
+
throw new Error(
|
|
1838
|
+
`DExt2 could not parse ${shortPath} as JSON \u2014 a present manifest silent on graceful degradation cannot pass (Extension Rule 2). Fail-closed, not a silent present.`
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
if (!("graceful_degradation" in parsed)) {
|
|
1842
|
+
throw new Error(
|
|
1843
|
+
`DExt2 found a present manifest ${shortPath} with no "graceful_degradation" field \u2014 Extension Rule 2 requires it to be true. Fail-closed, not a silent present.`
|
|
1844
|
+
);
|
|
1845
|
+
}
|
|
1846
|
+
const gdValue = parsed.graceful_degradation;
|
|
1847
|
+
if (gdValue !== true) {
|
|
1848
|
+
return {
|
|
1849
|
+
id: this.id,
|
|
1850
|
+
anchor: shortPath,
|
|
1851
|
+
state: "non-conforming",
|
|
1852
|
+
verdict: `manifest "graceful_degradation" is ${JSON.stringify(gdValue)}, not true (positive-control OK) -> set "graceful_degradation": true, an extension never breaks the host server (Extension Rule 2)`
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
const sites = findTsFactorySites(tree);
|
|
1857
|
+
for (const site of sites) {
|
|
1858
|
+
if (site.state === "unresolved") {
|
|
1859
|
+
throw new Error(
|
|
1860
|
+
`DExt2 cannot read the gracefulDegradation value at ${site.anchor} (got \`${site.value}\`) \u2014 computed/imported/template, graceful degradation unverifiable (Extension Rule 2). Fail-closed, not a silent present.`
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
if (site.state === "false") {
|
|
1864
|
+
return {
|
|
1865
|
+
id: this.id,
|
|
1866
|
+
anchor: site.anchor,
|
|
1867
|
+
state: "non-conforming",
|
|
1868
|
+
verdict: "gracefulDegradation is false, not true (positive-control OK) -> an extension never breaks the host server (Extension Rule 2)"
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
if (site.state === "absent") {
|
|
1872
|
+
return {
|
|
1873
|
+
id: this.id,
|
|
1874
|
+
anchor: site.anchor,
|
|
1875
|
+
state: "non-conforming",
|
|
1876
|
+
verdict: "defineExtension block has no gracefulDegradation flag (positive-control OK) -> declare gracefulDegradation: true (Extension Rule 2)"
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
const total = manifests.length + sites.length;
|
|
1881
|
+
if (total === 0) {
|
|
1882
|
+
return {
|
|
1883
|
+
id: this.id,
|
|
1884
|
+
anchor: tree.root.split("/").pop() ?? tree.root,
|
|
1885
|
+
state: "absent",
|
|
1886
|
+
verdict: "no extension graceful-degradation flag found (no extension.json manifest, no defineExtension factory) -> declare graceful_degradation: true (Extension Rule 2)"
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
return {
|
|
1890
|
+
id: this.id,
|
|
1891
|
+
anchor: sites.length > 0 ? sites[0].anchor : manifests[0].split("/").slice(-2).join("/"),
|
|
1892
|
+
state: "present",
|
|
1893
|
+
verdict: `${total} graceful-degradation flag(s) true (manifest=${manifests.length}, ts=${sites.length}, positive-control OK)`
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
};
|
|
1897
|
+
|
|
1898
|
+
// src/detectors/dExt3.ts
|
|
1899
|
+
import { readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
|
|
1900
|
+
import { join as join7 } from "path";
|
|
1901
|
+
var TYPE_MATCHERS = [
|
|
1902
|
+
{ type: "middleware", re: /\bmiddleware\b/ },
|
|
1903
|
+
{ type: "tool-extension", re: /\btool-ext(?:ension)?\b/ },
|
|
1904
|
+
{ type: "transport", re: /\btransport\b/ }
|
|
1905
|
+
];
|
|
1906
|
+
function typesIn(value) {
|
|
1907
|
+
const found = [];
|
|
1908
|
+
for (const { type, re } of TYPE_MATCHERS) {
|
|
1909
|
+
if (re.test(value)) found.push(type);
|
|
1910
|
+
}
|
|
1911
|
+
return found;
|
|
1912
|
+
}
|
|
1913
|
+
var TYPE_MIXED = "middleware|transport";
|
|
1914
|
+
var TYPE_SINGLE = "middleware";
|
|
1915
|
+
function dExt3PositiveControls() {
|
|
1916
|
+
return {
|
|
1917
|
+
type: {
|
|
1918
|
+
detectsMix: typesIn(TYPE_MIXED).length === 2,
|
|
1919
|
+
detectsSingle: typesIn(TYPE_SINGLE).length === 1
|
|
1920
|
+
}
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
var SKIP_DIRS3 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
1924
|
+
function findExtensionManifests3(root) {
|
|
1925
|
+
const out = [];
|
|
1926
|
+
let base;
|
|
1927
|
+
try {
|
|
1928
|
+
base = statSync4(root).isDirectory() ? root : join7(root, "..");
|
|
1929
|
+
} catch {
|
|
1930
|
+
return out;
|
|
1931
|
+
}
|
|
1932
|
+
const walk2 = (dir) => {
|
|
1933
|
+
let entries;
|
|
1934
|
+
try {
|
|
1935
|
+
entries = readdirSync3(dir);
|
|
1936
|
+
} catch {
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
for (const name of entries) {
|
|
1940
|
+
const full = join7(dir, name);
|
|
1941
|
+
let st;
|
|
1942
|
+
try {
|
|
1943
|
+
st = statSync4(full);
|
|
1944
|
+
} catch {
|
|
1945
|
+
continue;
|
|
1946
|
+
}
|
|
1947
|
+
if (st.isDirectory()) {
|
|
1948
|
+
if (SKIP_DIRS3.has(name)) continue;
|
|
1949
|
+
walk2(full);
|
|
1950
|
+
} else if (name === "extension.json") {
|
|
1951
|
+
out.push(full);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
};
|
|
1955
|
+
walk2(base);
|
|
1956
|
+
return out;
|
|
1957
|
+
}
|
|
1958
|
+
var DEFINE_EXTENSION_OPEN_RE2 = /\bdefineExtension\s*\(\s*\{/;
|
|
1959
|
+
var TS_TYPE_LITERAL_RE = /\btype:\s*["'`]([^"'`]*)["'`]/;
|
|
1960
|
+
var TS_TYPE_KEY_RE = /\btype:\s*[^\s"'`]/;
|
|
1961
|
+
function findTsTypeSites(tree) {
|
|
1962
|
+
const sites = [];
|
|
1963
|
+
for (const file of tree.files) {
|
|
1964
|
+
const lines = file.lines;
|
|
1965
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1966
|
+
const line = lines[i];
|
|
1967
|
+
if (line.comment) continue;
|
|
1968
|
+
if (!DEFINE_EXTENSION_OPEN_RE2.test(line.raw)) continue;
|
|
1969
|
+
const anchor = `${file.rel}:${line.n}`;
|
|
1970
|
+
let depth = 0;
|
|
1971
|
+
let started = false;
|
|
1972
|
+
let value;
|
|
1973
|
+
let unresolved = false;
|
|
1974
|
+
let found = false;
|
|
1975
|
+
for (let j = i; j < lines.length; j++) {
|
|
1976
|
+
const bl = lines[j];
|
|
1977
|
+
if (!bl.comment) {
|
|
1978
|
+
if (!found) {
|
|
1979
|
+
const lit = bl.raw.match(TS_TYPE_LITERAL_RE);
|
|
1980
|
+
if (lit) {
|
|
1981
|
+
value = lit[1];
|
|
1982
|
+
found = true;
|
|
1983
|
+
} else if (TS_TYPE_KEY_RE.test(bl.raw)) {
|
|
1984
|
+
unresolved = true;
|
|
1985
|
+
found = true;
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
depth += (bl.raw.match(/\{/g) ?? []).length;
|
|
1989
|
+
depth -= (bl.raw.match(/\}/g) ?? []).length;
|
|
1990
|
+
if (depth > 0) started = true;
|
|
1991
|
+
}
|
|
1992
|
+
if (started && depth <= 0) break;
|
|
1993
|
+
}
|
|
1994
|
+
sites.push({ value, anchor, unresolved: unresolved || !found });
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
return sites;
|
|
1998
|
+
}
|
|
1999
|
+
function coerceTypeValue(raw) {
|
|
2000
|
+
if (typeof raw === "string") return raw;
|
|
2001
|
+
if (Array.isArray(raw)) return raw.filter((x) => typeof x === "string").join("|");
|
|
2002
|
+
return null;
|
|
2003
|
+
}
|
|
2004
|
+
var dExt3 = {
|
|
2005
|
+
id: "DExt3 single-type",
|
|
2006
|
+
run(tree) {
|
|
2007
|
+
const controls = dExt3PositiveControls();
|
|
2008
|
+
if (!controls.type.detectsMix || !controls.type.detectsSingle) {
|
|
2009
|
+
throw new Error(
|
|
2010
|
+
`DExt3 single-type control FAILED: detectMix(\`${TYPE_MIXED}\`)=${controls.type.detectsMix} detectSingle(\`${TYPE_SINGLE}\`)=${controls.type.detectsSingle} \u2014 matcher broken, verdict untrustworthy`
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
const declared = /* @__PURE__ */ new Set();
|
|
2014
|
+
let firstAnchor2 = null;
|
|
2015
|
+
const manifests = findExtensionManifests3(tree.root);
|
|
2016
|
+
for (const manifestPath of manifests) {
|
|
2017
|
+
const shortPath = manifestPath.split("/").slice(-2).join("/");
|
|
2018
|
+
let parsed;
|
|
2019
|
+
try {
|
|
2020
|
+
parsed = JSON.parse(readFileSync5(manifestPath, "utf8"));
|
|
2021
|
+
} catch {
|
|
2022
|
+
throw new Error(
|
|
2023
|
+
`DExt3 could not parse ${shortPath} as JSON \u2014 a present manifest with an unreadable type cannot pass (Extension Rule 3). Fail-closed, not a silent present.`
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
2026
|
+
if (!("type" in parsed)) {
|
|
2027
|
+
throw new Error(
|
|
2028
|
+
`DExt3 found a present manifest ${shortPath} with no "type" field \u2014 Extension Rule 3 requires exactly one declared type. Fail-closed, not a silent present.`
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
const rawValue = coerceTypeValue(parsed.type);
|
|
2032
|
+
if (rawValue === null) {
|
|
2033
|
+
throw new Error(
|
|
2034
|
+
`DExt3 found a present manifest ${shortPath} whose "type" is neither string nor string[] (got ${typeof parsed.type}) \u2014 type undeterminable (Extension Rule 3). Fail-closed, not a silent present.`
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
const found = typesIn(rawValue);
|
|
2038
|
+
if (found.length === 0) {
|
|
2039
|
+
throw new Error(
|
|
2040
|
+
`DExt3 found a present manifest ${shortPath} whose "type" value ${JSON.stringify(parsed.type)} names no known category (middleware|tool-extension|transport) \u2014 type undeterminable (Extension Rule 3). Fail-closed, not a silent present.`
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
if (found.length > 1) {
|
|
2044
|
+
return {
|
|
2045
|
+
id: this.id,
|
|
2046
|
+
anchor: shortPath,
|
|
2047
|
+
state: "non-conforming",
|
|
2048
|
+
verdict: `manifest "type" ${JSON.stringify(parsed.type)} mixes ${found.join(" + ")} (positive-control OK) -> one extension = one type, split into ${found.length} extensions (Extension Rule 3)`
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
firstAnchor2 ??= shortPath;
|
|
2052
|
+
declared.add(found[0]);
|
|
2053
|
+
}
|
|
2054
|
+
const sites = findTsTypeSites(tree);
|
|
2055
|
+
for (const site of sites) {
|
|
2056
|
+
if (site.unresolved) {
|
|
2057
|
+
throw new Error(
|
|
2058
|
+
`DExt3 cannot read the type at ${site.anchor}${site.value === void 0 ? " (no `type:` literal in the defineExtension block)" : ` (got \`${site.value}\`)`} \u2014 computed/imported/template or absent, single-type unverifiable (Extension Rule 3). Fail-closed, not a silent present.`
|
|
2059
|
+
);
|
|
2060
|
+
}
|
|
2061
|
+
const raw = site.value ?? "";
|
|
2062
|
+
const found = typesIn(raw);
|
|
2063
|
+
if (found.length === 0) {
|
|
2064
|
+
throw new Error(
|
|
2065
|
+
`DExt3 found a defineExtension type: "${raw}" at ${site.anchor} naming no known category (middleware|tool-extension|transport) \u2014 type undeterminable (Extension Rule 3). Fail-closed, not a silent present.`
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
if (found.length > 1) {
|
|
2069
|
+
return {
|
|
2070
|
+
id: this.id,
|
|
2071
|
+
anchor: site.anchor,
|
|
2072
|
+
state: "non-conforming",
|
|
2073
|
+
verdict: `type: "${raw}" mixes ${found.join(" + ")} (positive-control OK) -> one extension = one type (Extension Rule 3)`
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
2076
|
+
firstAnchor2 ??= site.anchor;
|
|
2077
|
+
declared.add(found[0]);
|
|
2078
|
+
}
|
|
2079
|
+
const total = manifests.length + sites.length;
|
|
2080
|
+
if (total === 0) {
|
|
2081
|
+
return {
|
|
2082
|
+
id: this.id,
|
|
2083
|
+
anchor: tree.root.split("/").pop() ?? tree.root,
|
|
2084
|
+
state: "absent",
|
|
2085
|
+
verdict: "no extension type declared (no extension.json manifest, no defineExtension `type:` literal) -> declare exactly one type (Extension Rule 3)"
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
if (declared.size > 1) {
|
|
2089
|
+
return {
|
|
2090
|
+
id: this.id,
|
|
2091
|
+
anchor: firstAnchor2 ?? tree.root.split("/").pop() ?? tree.root,
|
|
2092
|
+
state: "non-conforming",
|
|
2093
|
+
verdict: `extension declares ${declared.size} types across its forms: ${[...declared].join(" + ")} (positive-control OK) -> one extension = one type (Extension Rule 3)`
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
return {
|
|
2097
|
+
id: this.id,
|
|
2098
|
+
anchor: firstAnchor2 ?? tree.root.split("/").pop() ?? tree.root,
|
|
2099
|
+
state: "present",
|
|
2100
|
+
verdict: `single type "${[...declared][0]}" declared across ${total} form(s) (manifest=${manifests.length}, ts=${sites.length}, positive-control OK)`
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
};
|
|
2104
|
+
|
|
2105
|
+
// src/detectors/dExt4.ts
|
|
2106
|
+
import { readFileSync as readFileSync6, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
|
|
2107
|
+
import { join as join8 } from "path";
|
|
2108
|
+
function canonical(name) {
|
|
2109
|
+
return name.replace(/[-_]/g, "").toLowerCase();
|
|
2110
|
+
}
|
|
2111
|
+
var HOOK_SNAKE = "before_call";
|
|
2112
|
+
var HOOK_KEBAB = "before-call";
|
|
2113
|
+
var HOOK_CAMEL = "beforeCall";
|
|
2114
|
+
var HOOK_OTHER = "on_error";
|
|
2115
|
+
function dExt4PositiveControls() {
|
|
2116
|
+
const a = canonical(HOOK_SNAKE);
|
|
2117
|
+
const b = canonical(HOOK_KEBAB);
|
|
2118
|
+
const c = canonical(HOOK_CAMEL);
|
|
2119
|
+
return {
|
|
2120
|
+
hooks: {
|
|
2121
|
+
unifiesSpellings: a === b && b === c,
|
|
2122
|
+
keepsDistinct: canonical(HOOK_SNAKE) !== canonical(HOOK_OTHER)
|
|
2123
|
+
}
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
var SKIP_DIRS4 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
2127
|
+
function findExtensionManifests4(root) {
|
|
2128
|
+
const out = [];
|
|
2129
|
+
let base;
|
|
2130
|
+
try {
|
|
2131
|
+
base = statSync5(root).isDirectory() ? root : join8(root, "..");
|
|
2132
|
+
} catch {
|
|
2133
|
+
return out;
|
|
2134
|
+
}
|
|
2135
|
+
const walk2 = (dir) => {
|
|
2136
|
+
let entries;
|
|
2137
|
+
try {
|
|
2138
|
+
entries = readdirSync4(dir);
|
|
2139
|
+
} catch {
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
for (const name of entries) {
|
|
2143
|
+
const full = join8(dir, name);
|
|
2144
|
+
let st;
|
|
2145
|
+
try {
|
|
2146
|
+
st = statSync5(full);
|
|
2147
|
+
} catch {
|
|
2148
|
+
continue;
|
|
2149
|
+
}
|
|
2150
|
+
if (st.isDirectory()) {
|
|
2151
|
+
if (SKIP_DIRS4.has(name)) continue;
|
|
2152
|
+
walk2(full);
|
|
2153
|
+
} else if (name === "extension.json") {
|
|
2154
|
+
out.push(full);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
};
|
|
2158
|
+
walk2(base);
|
|
2159
|
+
return out;
|
|
2160
|
+
}
|
|
2161
|
+
function findHooksDirs(root) {
|
|
2162
|
+
const out = [];
|
|
2163
|
+
let base;
|
|
2164
|
+
try {
|
|
2165
|
+
base = statSync5(root).isDirectory() ? root : join8(root, "..");
|
|
2166
|
+
} catch {
|
|
2167
|
+
return out;
|
|
2168
|
+
}
|
|
2169
|
+
const walk2 = (dir) => {
|
|
2170
|
+
let entries;
|
|
2171
|
+
try {
|
|
2172
|
+
entries = readdirSync4(dir);
|
|
2173
|
+
} catch {
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
for (const name of entries) {
|
|
2177
|
+
const full = join8(dir, name);
|
|
2178
|
+
let st;
|
|
2179
|
+
try {
|
|
2180
|
+
st = statSync5(full);
|
|
2181
|
+
} catch {
|
|
2182
|
+
continue;
|
|
2183
|
+
}
|
|
2184
|
+
if (!st.isDirectory()) continue;
|
|
2185
|
+
if (SKIP_DIRS4.has(name)) continue;
|
|
2186
|
+
if (name === "hooks") {
|
|
2187
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
2188
|
+
let files;
|
|
2189
|
+
try {
|
|
2190
|
+
files = readdirSync4(full);
|
|
2191
|
+
} catch {
|
|
2192
|
+
files = [];
|
|
2193
|
+
}
|
|
2194
|
+
for (const f of files) {
|
|
2195
|
+
if (!f.endsWith(".ts") || f.endsWith(".d.ts")) continue;
|
|
2196
|
+
const stem = f.replace(/\.ts$/, "");
|
|
2197
|
+
if (stem === "index") continue;
|
|
2198
|
+
tokens.add(canonical(stem));
|
|
2199
|
+
}
|
|
2200
|
+
out.push({ anchor: `${name}/`, tokens });
|
|
2201
|
+
}
|
|
2202
|
+
walk2(full);
|
|
2203
|
+
}
|
|
2204
|
+
};
|
|
2205
|
+
walk2(base);
|
|
2206
|
+
return out;
|
|
2207
|
+
}
|
|
2208
|
+
var LIFECYCLE_OBJ_RE = /\blifecycle\s*:\s*\{/;
|
|
2209
|
+
var LIFECYCLE_KEY_RE = /\blifecycle\s*:/;
|
|
2210
|
+
var OBJECT_KEY_RE = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*[:(]/;
|
|
2211
|
+
function findFactorySites(tree) {
|
|
2212
|
+
const sites = [];
|
|
2213
|
+
const unresolved = [];
|
|
2214
|
+
for (const file of tree.files) {
|
|
2215
|
+
const lines = file.lines;
|
|
2216
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2217
|
+
const line = lines[i];
|
|
2218
|
+
if (line.comment) continue;
|
|
2219
|
+
if (LIFECYCLE_OBJ_RE.test(line.raw)) {
|
|
2220
|
+
const anchor = `${file.rel}:${line.n}`;
|
|
2221
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
2222
|
+
let depth = 0;
|
|
2223
|
+
let started = false;
|
|
2224
|
+
for (let j = i; j < lines.length; j++) {
|
|
2225
|
+
const bl = lines[j];
|
|
2226
|
+
if (bl.comment) continue;
|
|
2227
|
+
const raw = bl.raw;
|
|
2228
|
+
if (started && depth === 1) {
|
|
2229
|
+
const k = raw.match(OBJECT_KEY_RE);
|
|
2230
|
+
if (k) tokens.add(canonical(k[1]));
|
|
2231
|
+
}
|
|
2232
|
+
depth += (raw.match(/\{/g) ?? []).length;
|
|
2233
|
+
depth -= (raw.match(/\}/g) ?? []).length;
|
|
2234
|
+
if (depth > 0) started = true;
|
|
2235
|
+
if (started && depth <= 0) break;
|
|
2236
|
+
}
|
|
2237
|
+
sites.push({ anchor, tokens });
|
|
2238
|
+
continue;
|
|
2239
|
+
}
|
|
2240
|
+
if (LIFECYCLE_KEY_RE.test(line.raw)) {
|
|
2241
|
+
unresolved.push(`${file.rel}:${line.n}`);
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
return { sites, unresolved };
|
|
2246
|
+
}
|
|
2247
|
+
function manifestHookTokens(raw) {
|
|
2248
|
+
const set = /* @__PURE__ */ new Set();
|
|
2249
|
+
if (!Array.isArray(raw)) {
|
|
2250
|
+
throw new Error("not-array");
|
|
2251
|
+
}
|
|
2252
|
+
for (const h of raw) {
|
|
2253
|
+
if (typeof h === "string") set.add(canonical(h));
|
|
2254
|
+
}
|
|
2255
|
+
return set;
|
|
2256
|
+
}
|
|
2257
|
+
var dExt4 = {
|
|
2258
|
+
id: "DExt4 lifecycle-hooks",
|
|
2259
|
+
run(tree) {
|
|
2260
|
+
const controls = dExt4PositiveControls();
|
|
2261
|
+
if (!controls.hooks.unifiesSpellings || !controls.hooks.keepsDistinct) {
|
|
2262
|
+
throw new Error(
|
|
2263
|
+
`DExt4 lifecycle-hooks control FAILED: unify(${HOOK_SNAKE}/${HOOK_KEBAB}/${HOOK_CAMEL})=${controls.hooks.unifiesSpellings} distinct(${HOOK_SNAKE}!=${HOOK_OTHER})=${controls.hooks.keepsDistinct} \u2014 normaliser broken, verdict untrustworthy`
|
|
2264
|
+
);
|
|
2265
|
+
}
|
|
2266
|
+
const hooksDirs = findHooksDirs(tree.root);
|
|
2267
|
+
const { sites, unresolved } = findFactorySites(tree);
|
|
2268
|
+
if (unresolved.length > 0) {
|
|
2269
|
+
throw new Error(
|
|
2270
|
+
`DExt4 cannot read the lifecycle implementation at ${unresolved.length} TS site(s): ${unresolved.join(", ")} \u2014 value is computed/imported/template, not an object literal, so the implemented hooks are unverifiable (Extension Rule 4). Fail-closed, not a silent present.`
|
|
2271
|
+
);
|
|
2272
|
+
}
|
|
2273
|
+
const implemented = /* @__PURE__ */ new Set();
|
|
2274
|
+
for (const d of hooksDirs) for (const t of d.tokens) implemented.add(t);
|
|
2275
|
+
for (const s of sites) for (const t of s.tokens) implemented.add(t);
|
|
2276
|
+
const implSurfacePresent = hooksDirs.length > 0 || sites.length > 0;
|
|
2277
|
+
const manifests = findExtensionManifests4(tree.root);
|
|
2278
|
+
let declaredAnyLifecycle = false;
|
|
2279
|
+
for (const manifestPath of manifests) {
|
|
2280
|
+
const shortPath = manifestPath.split("/").slice(-2).join("/");
|
|
2281
|
+
let parsed;
|
|
2282
|
+
try {
|
|
2283
|
+
parsed = JSON.parse(readFileSync6(manifestPath, "utf8"));
|
|
2284
|
+
} catch {
|
|
2285
|
+
throw new Error(
|
|
2286
|
+
`DExt4 could not parse ${shortPath} as JSON \u2014 a present manifest whose lifecycle cannot be read cannot pass (Extension Rule 4). Fail-closed, not a silent present.`
|
|
2287
|
+
);
|
|
2288
|
+
}
|
|
2289
|
+
if (!("lifecycle" in parsed)) continue;
|
|
2290
|
+
let declared;
|
|
2291
|
+
try {
|
|
2292
|
+
declared = manifestHookTokens(parsed.lifecycle);
|
|
2293
|
+
} catch {
|
|
2294
|
+
throw new Error(
|
|
2295
|
+
`DExt4 found a present manifest ${shortPath} whose "lifecycle" is not an array (got ${typeof parsed.lifecycle}) \u2014 the declared hooks are undeterminable (Extension Rule 4). Fail-closed, not a silent present.`
|
|
2296
|
+
);
|
|
2297
|
+
}
|
|
2298
|
+
if (declared.size === 0) continue;
|
|
2299
|
+
declaredAnyLifecycle = true;
|
|
2300
|
+
if (!implSurfacePresent) {
|
|
2301
|
+
throw new Error(
|
|
2302
|
+
`DExt4 found a present manifest ${shortPath} declaring lifecycle hooks while NO implementation surface exists (no hooks/ directory, no factory lifecycle object) \u2014 the parity is undeterminable (Extension Rule 4). Fail-closed, not a silent present.`
|
|
2303
|
+
);
|
|
2304
|
+
}
|
|
2305
|
+
const phantom = [...declared].filter((h) => !implemented.has(h));
|
|
2306
|
+
if (phantom.length > 0) {
|
|
2307
|
+
return {
|
|
2308
|
+
id: this.id,
|
|
2309
|
+
anchor: shortPath,
|
|
2310
|
+
state: "non-conforming",
|
|
2311
|
+
verdict: `manifest "lifecycle" declares hook(s) [${phantom.join(", ")}] with no implementation (implemented: [${[...implemented].join(", ") || "none"}], positive-control OK) -> a declared hook without implementation is a phantom hook (Extension Rule 4)`
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
if (!declaredAnyLifecycle) {
|
|
2316
|
+
if (manifests.length === 0 && !implSurfacePresent) {
|
|
2317
|
+
return {
|
|
2318
|
+
id: this.id,
|
|
2319
|
+
anchor: tree.root.split("/").pop() ?? tree.root,
|
|
2320
|
+
state: "absent",
|
|
2321
|
+
verdict: "no lifecycle hooks declared (no extension.json manifest `lifecycle` array) -> declare the hooks the extension implements (Extension Rule 4)"
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
return {
|
|
2325
|
+
id: this.id,
|
|
2326
|
+
anchor: manifests.length > 0 ? manifests[0].split("/").slice(-2).join("/") : tree.root.split("/").pop() ?? tree.root,
|
|
2327
|
+
state: "present",
|
|
2328
|
+
verdict: `no manifest lifecycle hooks declared, nothing to reconcile (implemented: [${[...implemented].join(", ") || "none"}], positive-control OK)`
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
return {
|
|
2332
|
+
id: this.id,
|
|
2333
|
+
anchor: manifests[0].split("/").slice(-2).join("/"),
|
|
2334
|
+
state: "present",
|
|
2335
|
+
verdict: `every declared lifecycle hook is implemented (declared \u2286 implemented: [${[...implemented].join(", ")}], hooksDir=${hooksDirs.length}, factory=${sites.length}, positive-control OK)`
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
};
|
|
2339
|
+
|
|
2340
|
+
// src/detectors/dExt5.ts
|
|
2341
|
+
import { existsSync as existsSync5, readFileSync as readFileSync7, readdirSync as readdirSync5, statSync as statSync6 } from "fs";
|
|
2342
|
+
import { dirname as dirname5, join as join9 } from "path";
|
|
2343
|
+
function nonEmptyString(v) {
|
|
2344
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
2345
|
+
}
|
|
2346
|
+
var FR_POSITIVE = "Outils meteo bilingues.";
|
|
2347
|
+
var FR_NEGATIVE = " ";
|
|
2348
|
+
function dExt5PositiveControls() {
|
|
2349
|
+
return {
|
|
2350
|
+
descriptionFr: {
|
|
2351
|
+
positive: nonEmptyString(FR_POSITIVE),
|
|
2352
|
+
negative: nonEmptyString(FR_NEGATIVE)
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
var SKIP_DIRS5 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
2357
|
+
function findExtensionManifests5(root) {
|
|
2358
|
+
const out = [];
|
|
2359
|
+
let base;
|
|
2360
|
+
try {
|
|
2361
|
+
base = statSync6(root).isDirectory() ? root : join9(root, "..");
|
|
2362
|
+
} catch {
|
|
2363
|
+
return out;
|
|
2364
|
+
}
|
|
2365
|
+
const walk2 = (dir) => {
|
|
2366
|
+
let entries;
|
|
2367
|
+
try {
|
|
2368
|
+
entries = readdirSync5(dir);
|
|
2369
|
+
} catch {
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
for (const name of entries) {
|
|
2373
|
+
const full = join9(dir, name);
|
|
2374
|
+
let st;
|
|
2375
|
+
try {
|
|
2376
|
+
st = statSync6(full);
|
|
2377
|
+
} catch {
|
|
2378
|
+
continue;
|
|
2379
|
+
}
|
|
2380
|
+
if (st.isDirectory()) {
|
|
2381
|
+
if (SKIP_DIRS5.has(name)) continue;
|
|
2382
|
+
walk2(full);
|
|
2383
|
+
} else if (name === "extension.json") {
|
|
2384
|
+
out.push(full);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
};
|
|
2388
|
+
walk2(base);
|
|
2389
|
+
return out;
|
|
2390
|
+
}
|
|
2391
|
+
function isToolExtension(type) {
|
|
2392
|
+
return typeof type === "string" && type.includes("tool-extension");
|
|
2393
|
+
}
|
|
2394
|
+
var dExt5 = {
|
|
2395
|
+
id: "DExt5 bilingual",
|
|
2396
|
+
run(tree) {
|
|
2397
|
+
const controls = dExt5PositiveControls();
|
|
2398
|
+
if (!controls.descriptionFr.positive || controls.descriptionFr.negative) {
|
|
2399
|
+
throw new Error(
|
|
2400
|
+
`DExt5 bilingual control FAILED: accept(\`${FR_POSITIVE}\`)=${controls.descriptionFr.positive} reject(empty)=${!controls.descriptionFr.negative} \u2014 FR-string matcher broken, absence verdict untrustworthy`
|
|
2401
|
+
);
|
|
2402
|
+
}
|
|
2403
|
+
const manifests = findExtensionManifests5(tree.root);
|
|
2404
|
+
if (manifests.length === 0) {
|
|
2405
|
+
return {
|
|
2406
|
+
id: this.id,
|
|
2407
|
+
anchor: tree.root.split("/").pop() ?? tree.root,
|
|
2408
|
+
state: "absent",
|
|
2409
|
+
verdict: "no extension.json manifest found -> declare a bilingual extension with description_fr + README.fr.md (Extension Rule 5)"
|
|
2410
|
+
};
|
|
2411
|
+
}
|
|
2412
|
+
for (const manifestPath of manifests) {
|
|
2413
|
+
const shortPath = manifestPath.split("/").slice(-2).join("/");
|
|
2414
|
+
const root = dirname5(manifestPath);
|
|
2415
|
+
const cannotRead = (reason) => ({
|
|
2416
|
+
id: this.id,
|
|
2417
|
+
anchor: shortPath,
|
|
2418
|
+
state: "could-not-judge",
|
|
2419
|
+
reason,
|
|
2420
|
+
verdict: `could not judge: ${reason}`
|
|
2421
|
+
});
|
|
2422
|
+
let parsed;
|
|
2423
|
+
try {
|
|
2424
|
+
parsed = JSON.parse(readFileSync7(manifestPath, "utf8"));
|
|
2425
|
+
} catch {
|
|
2426
|
+
return cannotRead(
|
|
2427
|
+
`${shortPath} does not parse as JSON \u2014 the FR surface of a present manifest cannot be read (Extension Rule 5)`
|
|
2428
|
+
);
|
|
2429
|
+
}
|
|
2430
|
+
const missing = [];
|
|
2431
|
+
if ("description_fr" in parsed && typeof parsed.description_fr !== "string") {
|
|
2432
|
+
return cannotRead(
|
|
2433
|
+
`${shortPath} top-level "description_fr" is not a string (got ${typeof parsed.description_fr}) \u2014 FR description surface undeterminable (Extension Rule 5)`
|
|
2434
|
+
);
|
|
2435
|
+
}
|
|
2436
|
+
if (!nonEmptyString(parsed.description_fr)) {
|
|
2437
|
+
missing.push("description_fr (extension.json)");
|
|
2438
|
+
}
|
|
2439
|
+
if (!existsSync5(join9(root, "README.fr.md"))) {
|
|
2440
|
+
missing.push("README.fr.md");
|
|
2441
|
+
}
|
|
2442
|
+
if (isToolExtension(parsed.type) && "tools" in parsed) {
|
|
2443
|
+
if (!Array.isArray(parsed.tools)) {
|
|
2444
|
+
return cannotRead(
|
|
2445
|
+
`${shortPath} "tools" is not an array (got ${typeof parsed.tools}) \u2014 per-tool FR surface undeterminable (Extension Rule 5)`
|
|
2446
|
+
);
|
|
2447
|
+
}
|
|
2448
|
+
for (let i = 0; i < parsed.tools.length; i++) {
|
|
2449
|
+
const tool = parsed.tools[i];
|
|
2450
|
+
if (tool === null || typeof tool !== "object") continue;
|
|
2451
|
+
const t = tool;
|
|
2452
|
+
if ("description_fr" in t && typeof t.description_fr !== "string") {
|
|
2453
|
+
return cannotRead(
|
|
2454
|
+
`${shortPath} tools[${i}] "description_fr" is not a string (got ${typeof t.description_fr}) \u2014 per-tool FR surface undeterminable (Extension Rule 5)`
|
|
2455
|
+
);
|
|
2456
|
+
}
|
|
2457
|
+
if (!nonEmptyString(t.description_fr)) {
|
|
2458
|
+
const label = typeof t.name === "string" ? t.name : `#${i}`;
|
|
2459
|
+
missing.push(`description_fr on tool "${label}"`);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
if (missing.length > 0) {
|
|
2464
|
+
return {
|
|
2465
|
+
id: this.id,
|
|
2466
|
+
anchor: shortPath,
|
|
2467
|
+
state: "absent",
|
|
2468
|
+
verdict: `bilingual FR localization missing: ${missing.join(", ")} (positive-control OK) -> no release without description_fr + README.fr.md (+ per-tool description_fr) (Extension Rule 5)`
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
return {
|
|
2473
|
+
id: this.id,
|
|
2474
|
+
anchor: manifests[0].split("/").slice(-2).join("/"),
|
|
2475
|
+
state: "present",
|
|
2476
|
+
verdict: `bilingual FR localization complete across ${manifests.length} manifest(s): description_fr + README.fr.md (+ per-tool description_fr where tool-extension) present (positive-control OK)`
|
|
2477
|
+
};
|
|
2478
|
+
}
|
|
2479
|
+
};
|
|
2480
|
+
|
|
2481
|
+
// src/detectors/dExt7.ts
|
|
2482
|
+
import { existsSync as existsSync6, readFileSync as readFileSync8, readdirSync as readdirSync6, statSync as statSync7 } from "fs";
|
|
2483
|
+
import { dirname as dirname6, join as join10 } from "path";
|
|
2484
|
+
var ABSENCE_FLAG_FIELDS = [
|
|
2485
|
+
"extensionAbsent",
|
|
2486
|
+
"extension_absent",
|
|
2487
|
+
"degradation",
|
|
2488
|
+
"withoutExtension",
|
|
2489
|
+
"without_extension",
|
|
2490
|
+
"graceful_degradation",
|
|
2491
|
+
"gracefulDegradation"
|
|
2492
|
+
];
|
|
2493
|
+
var SCENARIO_FIELDS = ["op", "mode", "scenario", "kind", "type"];
|
|
2494
|
+
var SCENARIO_TOKEN_RE = /^(?:degradation|degrade|extension[-_ ]?absent|without[-_ ]?extension|sans[-_ ]?extension)$/i;
|
|
2495
|
+
var LABEL_FIELDS = ["id", "name", "scenario", "title", "label"];
|
|
2496
|
+
var LABEL_LEXICON_RE = /(?:extension[-_ ]?absen|absence|d[eé]grad|without[-_ ]?ext|sans[-_ ]?ext)/i;
|
|
2497
|
+
function isAbsenceCase(c) {
|
|
2498
|
+
for (const f of ABSENCE_FLAG_FIELDS) {
|
|
2499
|
+
if (c[f] === true) return true;
|
|
2500
|
+
}
|
|
2501
|
+
for (const f of SCENARIO_FIELDS) {
|
|
2502
|
+
const v = c[f];
|
|
2503
|
+
if (typeof v === "string" && SCENARIO_TOKEN_RE.test(v.trim())) return true;
|
|
2504
|
+
}
|
|
2505
|
+
for (const f of LABEL_FIELDS) {
|
|
2506
|
+
const v = c[f];
|
|
2507
|
+
if (typeof v === "string" && LABEL_LEXICON_RE.test(v)) return true;
|
|
2508
|
+
}
|
|
2509
|
+
return false;
|
|
2510
|
+
}
|
|
2511
|
+
var ABSENCE_POSITIVE_FLAG = { id: "flag", degradation: true };
|
|
2512
|
+
var ABSENCE_POSITIVE_TOKEN = { id: "tok", op: "degradation" };
|
|
2513
|
+
var ABSENCE_POSITIVE_LABEL = {
|
|
2514
|
+
id: "extension-absente-1",
|
|
2515
|
+
op: "callTool"
|
|
2516
|
+
};
|
|
2517
|
+
var ABSENCE_NEGATIVE = { id: "ping-1", op: "callTool", tool: "ping" };
|
|
2518
|
+
function dExt7PositiveControls() {
|
|
2519
|
+
return {
|
|
2520
|
+
flag: isAbsenceCase(ABSENCE_POSITIVE_FLAG),
|
|
2521
|
+
token: isAbsenceCase(ABSENCE_POSITIVE_TOKEN),
|
|
2522
|
+
label: isAbsenceCase(ABSENCE_POSITIVE_LABEL),
|
|
2523
|
+
negative: isAbsenceCase(ABSENCE_NEGATIVE)
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
var SKIP_DIRS6 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
2527
|
+
function findExtensionManifests6(root) {
|
|
2528
|
+
const out = [];
|
|
2529
|
+
let base;
|
|
2530
|
+
try {
|
|
2531
|
+
base = statSync7(root).isDirectory() ? root : join10(root, "..");
|
|
2532
|
+
} catch {
|
|
2533
|
+
return out;
|
|
2534
|
+
}
|
|
2535
|
+
const walk2 = (dir) => {
|
|
2536
|
+
let entries;
|
|
2537
|
+
try {
|
|
2538
|
+
entries = readdirSync6(dir);
|
|
2539
|
+
} catch {
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2542
|
+
for (const name of entries) {
|
|
2543
|
+
const full = join10(dir, name);
|
|
2544
|
+
let st;
|
|
2545
|
+
try {
|
|
2546
|
+
st = statSync7(full);
|
|
2547
|
+
} catch {
|
|
2548
|
+
continue;
|
|
2549
|
+
}
|
|
2550
|
+
if (st.isDirectory()) {
|
|
2551
|
+
if (SKIP_DIRS6.has(name)) continue;
|
|
2552
|
+
walk2(full);
|
|
2553
|
+
} else if (name === "extension.json") {
|
|
2554
|
+
out.push(full);
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
};
|
|
2558
|
+
walk2(base);
|
|
2559
|
+
return out;
|
|
2560
|
+
}
|
|
2561
|
+
var dExt7 = {
|
|
2562
|
+
id: "DExt7 eval-coverage",
|
|
2563
|
+
run(tree) {
|
|
2564
|
+
const controls = dExt7PositiveControls();
|
|
2565
|
+
if (!controls.flag || !controls.token || !controls.label || controls.negative) {
|
|
2566
|
+
throw new Error(
|
|
2567
|
+
`DExt7 absence-matcher control FAILED: flag=${controls.flag} token=${controls.token} label=${controls.label} reject(normal)=${!controls.negative} \u2014 absence matcher broken, eval-coverage verdict untrustworthy`
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
const manifests = findExtensionManifests6(tree.root);
|
|
2571
|
+
if (manifests.length === 0) {
|
|
2572
|
+
return {
|
|
2573
|
+
id: this.id,
|
|
2574
|
+
anchor: tree.root.split("/").pop() ?? tree.root,
|
|
2575
|
+
state: "absent",
|
|
2576
|
+
verdict: "no extension.json manifest found -> declare an extension with an eval suite (>=3 cases incl. 1 'extension absente') (Extension Rule 7)"
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
for (const manifestPath of manifests) {
|
|
2580
|
+
const extDir = dirname6(manifestPath);
|
|
2581
|
+
const extLabel = extDir.split("/").pop() ?? extDir;
|
|
2582
|
+
const evalsPath = join10(extDir, "evals", "evals.json");
|
|
2583
|
+
const shortPath = `${extLabel}/evals/evals.json`;
|
|
2584
|
+
if (!existsSync6(evalsPath)) {
|
|
2585
|
+
return {
|
|
2586
|
+
id: this.id,
|
|
2587
|
+
anchor: `${extLabel}/extension.json`,
|
|
2588
|
+
state: "non-conforming",
|
|
2589
|
+
verdict: `no evals/evals.json for extension "${extLabel}" -> add an eval suite with >=3 cases incl. 1 'extension absente' (Extension Rule 7)`
|
|
2590
|
+
};
|
|
2591
|
+
}
|
|
2592
|
+
let cases;
|
|
2593
|
+
try {
|
|
2594
|
+
const raw = readFileSync8(evalsPath, "utf8");
|
|
2595
|
+
const parsed = JSON.parse(raw);
|
|
2596
|
+
if (!Array.isArray(parsed.cases)) {
|
|
2597
|
+
throw new Error("`cases` is not an array");
|
|
2598
|
+
}
|
|
2599
|
+
cases = parsed.cases;
|
|
2600
|
+
} catch (err) {
|
|
2601
|
+
throw new Error(
|
|
2602
|
+
`DExt7 could not parse ${shortPath}: ${err instanceof Error ? err.message : String(err)} \u2014 a present eval surface that cannot be read cannot pass (Extension Rule 7). Fail-closed, not a silent present.`
|
|
2603
|
+
);
|
|
2604
|
+
}
|
|
2605
|
+
cases.forEach((c, i) => {
|
|
2606
|
+
if (c === null || typeof c !== "object" || Array.isArray(c)) {
|
|
2607
|
+
throw new Error(
|
|
2608
|
+
`DExt7 found ${shortPath} cases[${i}] is not an object (got ${c === null ? "null" : Array.isArray(c) ? "array" : typeof c}) \u2014 case shape undeterminable, absence-case presence cannot be decided (Extension Rule 7). Fail-closed, not a silent present.`
|
|
2609
|
+
);
|
|
2610
|
+
}
|
|
2611
|
+
});
|
|
2612
|
+
const count = cases.length;
|
|
2613
|
+
const hasAbsence = cases.some(isAbsenceCase);
|
|
2614
|
+
const failures = [];
|
|
2615
|
+
if (count < 3) failures.push(`only ${count} case(s) (need >=3)`);
|
|
2616
|
+
if (!hasAbsence) failures.push("no 'extension absente'/degradation case");
|
|
2617
|
+
if (failures.length > 0) {
|
|
2618
|
+
return {
|
|
2619
|
+
id: this.id,
|
|
2620
|
+
anchor: `${shortPath}`,
|
|
2621
|
+
state: "non-conforming",
|
|
2622
|
+
verdict: `eval suite for "${extLabel}": ${failures.join("; ")} (positive-control OK) -> Extension Rule 7 needs >=3 cases incl. 1 'extension absente'`
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
const firstLabel = manifests[0].split("/").slice(-2, -1)[0] ?? "ext";
|
|
2627
|
+
return {
|
|
2628
|
+
id: this.id,
|
|
2629
|
+
anchor: `${firstLabel}/evals/evals.json`,
|
|
2630
|
+
state: "present",
|
|
2631
|
+
verdict: `eval suite complete across ${manifests.length} extension(s): >=3 cases incl. an 'extension absente'/degradation case (positive-control OK)`
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
2634
|
+
};
|
|
2635
|
+
|
|
2636
|
+
// src/detectors/dExt8.ts
|
|
2637
|
+
import { existsSync as existsSync7, readFileSync as readFileSync9, readdirSync as readdirSync7, statSync as statSync8 } from "fs";
|
|
2638
|
+
import { dirname as dirname7, join as join11 } from "path";
|
|
2639
|
+
var SEMVER_RE2 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
2640
|
+
var POSITIVE_CONTROL5 = "1.2.3-beta.1+build.7";
|
|
2641
|
+
function dExt8PositiveControl() {
|
|
2642
|
+
return {
|
|
2643
|
+
pattern: SEMVER_RE2.source,
|
|
2644
|
+
sample: POSITIVE_CONTROL5,
|
|
2645
|
+
matched: SEMVER_RE2.test(POSITIVE_CONTROL5)
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
var SKIP_DIRS7 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
2649
|
+
function findExtensionManifests7(root) {
|
|
2650
|
+
const out = [];
|
|
2651
|
+
let base;
|
|
2652
|
+
try {
|
|
2653
|
+
base = statSync8(root).isDirectory() ? root : join11(root, "..");
|
|
2654
|
+
} catch {
|
|
2655
|
+
return out;
|
|
2656
|
+
}
|
|
2657
|
+
const walk2 = (dir) => {
|
|
2658
|
+
let entries;
|
|
2659
|
+
try {
|
|
2660
|
+
entries = readdirSync7(dir);
|
|
2661
|
+
} catch {
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2664
|
+
for (const name of entries) {
|
|
2665
|
+
const full = join11(dir, name);
|
|
2666
|
+
let st;
|
|
2667
|
+
try {
|
|
2668
|
+
st = statSync8(full);
|
|
2669
|
+
} catch {
|
|
2670
|
+
continue;
|
|
2671
|
+
}
|
|
2672
|
+
if (st.isDirectory()) {
|
|
2673
|
+
if (SKIP_DIRS7.has(name)) continue;
|
|
2674
|
+
walk2(full);
|
|
2675
|
+
} else if (name === "extension.json") {
|
|
2676
|
+
out.push(full);
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
};
|
|
2680
|
+
walk2(base);
|
|
2681
|
+
return out;
|
|
2682
|
+
}
|
|
2683
|
+
var dExt8 = {
|
|
2684
|
+
id: "DExt8 semver+changelog",
|
|
2685
|
+
run(tree) {
|
|
2686
|
+
const control = dExt8PositiveControl();
|
|
2687
|
+
if (!control.matched) {
|
|
2688
|
+
throw new Error(
|
|
2689
|
+
`DExt8 positive control FAILED: pattern /${control.pattern}/ did not match sample \`${control.sample}\` \u2014 matcher broken, verdict untrustworthy`
|
|
2690
|
+
);
|
|
2691
|
+
}
|
|
2692
|
+
const manifests = findExtensionManifests7(tree.root);
|
|
2693
|
+
if (manifests.length === 0) {
|
|
2694
|
+
return {
|
|
2695
|
+
id: this.id,
|
|
2696
|
+
anchor: tree.root.split("/").pop() ?? tree.root,
|
|
2697
|
+
state: "absent",
|
|
2698
|
+
verdict: "no extension.json manifest found -> declare an extension with a valid SemVer version + CHANGELOG.md (Extension Rule 8)"
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
for (const manifestPath of manifests) {
|
|
2702
|
+
const extDir = dirname7(manifestPath);
|
|
2703
|
+
const extLabel = extDir.split("/").pop() ?? extDir;
|
|
2704
|
+
const pkgPath = join11(extDir, "package.json");
|
|
2705
|
+
const shortPkgPath = `${extLabel}/package.json`;
|
|
2706
|
+
if (!existsSync7(pkgPath)) {
|
|
2707
|
+
throw new Error(
|
|
2708
|
+
`DExt8 could not locate ${shortPkgPath} for extension "${extLabel}" \u2014 cannot verify SemVer/CHANGELOG (Extension Rule 8). Fail-closed, not a silent present.`
|
|
2709
|
+
);
|
|
2710
|
+
}
|
|
2711
|
+
let pkgJson;
|
|
2712
|
+
try {
|
|
2713
|
+
pkgJson = JSON.parse(readFileSync9(pkgPath, "utf8"));
|
|
2714
|
+
} catch {
|
|
2715
|
+
throw new Error(
|
|
2716
|
+
`DExt8 could not parse ${shortPkgPath} as JSON for extension "${extLabel}" \u2014 cannot verify SemVer/CHANGELOG (Extension Rule 8). Fail-closed, not a silent present.`
|
|
2717
|
+
);
|
|
2718
|
+
}
|
|
2719
|
+
const version = pkgJson.version;
|
|
2720
|
+
if ("version" in pkgJson && typeof version !== "string") {
|
|
2721
|
+
throw new Error(
|
|
2722
|
+
`DExt8 found ${shortPkgPath} whose "version" is not a string (got ${typeof version}) for extension "${extLabel}" \u2014 cannot verify SemVer (Extension Rule 8). Fail-closed, not a silent present.`
|
|
2723
|
+
);
|
|
2724
|
+
}
|
|
2725
|
+
if (typeof version !== "string" || version.trim().length === 0) {
|
|
2726
|
+
return {
|
|
2727
|
+
id: this.id,
|
|
2728
|
+
anchor: shortPkgPath,
|
|
2729
|
+
state: "non-conforming",
|
|
2730
|
+
verdict: `no "version" field in ${shortPkgPath} for extension "${extLabel}" -> set a valid SemVer version (Extension Rule 8)`
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
const validSemver = SEMVER_RE2.test(version);
|
|
2734
|
+
const changelogPath = join11(extDir, "CHANGELOG.md");
|
|
2735
|
+
const hasChangelog = existsSync7(changelogPath);
|
|
2736
|
+
if (!validSemver) {
|
|
2737
|
+
return {
|
|
2738
|
+
id: this.id,
|
|
2739
|
+
anchor: shortPkgPath,
|
|
2740
|
+
state: "non-conforming",
|
|
2741
|
+
verdict: `"version": "${version}" is not valid SemVer for extension "${extLabel}" (positive-control OK) -> use MAJOR.MINOR.PATCH (Extension Rule 8)`
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
if (!hasChangelog) {
|
|
2745
|
+
return {
|
|
2746
|
+
id: this.id,
|
|
2747
|
+
anchor: shortPkgPath,
|
|
2748
|
+
state: "absent",
|
|
2749
|
+
verdict: `no CHANGELOG.md next to ${shortPkgPath} for extension "${extLabel}" (positive-control OK) -> add CHANGELOG.md, update it each release (Extension Rule 8)`
|
|
2750
|
+
};
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
const firstLabel = manifests[0].split("/").slice(-2, -1)[0] ?? "ext";
|
|
2754
|
+
return {
|
|
2755
|
+
id: this.id,
|
|
2756
|
+
anchor: `${firstLabel}/package.json`,
|
|
2757
|
+
state: "present",
|
|
2758
|
+
verdict: `SemVer valid + CHANGELOG.md present across ${manifests.length} extension(s) (positive-control OK)`
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
};
|
|
2762
|
+
|
|
2763
|
+
// src/detectors/dExt9.ts
|
|
2764
|
+
import { readFileSync as readFileSync10, readdirSync as readdirSync8, statSync as statSync9 } from "fs";
|
|
2765
|
+
import { join as join12 } from "path";
|
|
2766
|
+
var SKIP_DIRS8 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
2767
|
+
function collectHooksDirSources(root) {
|
|
2768
|
+
const out = [];
|
|
2769
|
+
let base;
|
|
2770
|
+
try {
|
|
2771
|
+
base = statSync9(root).isDirectory() ? root : join12(root, "..");
|
|
2772
|
+
} catch {
|
|
2773
|
+
return out;
|
|
2774
|
+
}
|
|
2775
|
+
const walk2 = (dir) => {
|
|
2776
|
+
let entries;
|
|
2777
|
+
try {
|
|
2778
|
+
entries = readdirSync8(dir);
|
|
2779
|
+
} catch {
|
|
2780
|
+
return;
|
|
2781
|
+
}
|
|
2782
|
+
for (const name of entries) {
|
|
2783
|
+
const full = join12(dir, name);
|
|
2784
|
+
let st;
|
|
2785
|
+
try {
|
|
2786
|
+
st = statSync9(full);
|
|
2787
|
+
} catch {
|
|
2788
|
+
continue;
|
|
2789
|
+
}
|
|
2790
|
+
if (!st.isDirectory()) continue;
|
|
2791
|
+
if (SKIP_DIRS8.has(name)) continue;
|
|
2792
|
+
if (name === "hooks") {
|
|
2793
|
+
let files;
|
|
2794
|
+
try {
|
|
2795
|
+
files = readdirSync8(full);
|
|
2796
|
+
} catch {
|
|
2797
|
+
files = [];
|
|
2798
|
+
}
|
|
2799
|
+
for (const f of files) {
|
|
2800
|
+
if (!f.endsWith(".ts") || f.endsWith(".d.ts")) continue;
|
|
2801
|
+
const stem = f.replace(/\.ts$/, "");
|
|
2802
|
+
if (stem === "index") continue;
|
|
2803
|
+
const filePath = join12(full, f);
|
|
2804
|
+
let body;
|
|
2805
|
+
try {
|
|
2806
|
+
body = readFileSync10(filePath, "utf8");
|
|
2807
|
+
} catch {
|
|
2808
|
+
throw new Error(
|
|
2809
|
+
`DExt9 could not read hook implementation ${filePath.split("/").slice(-2).join("/")} \u2014 timeout guard undeterminable for hook "${stem}" (Extension Rule 9). Fail-closed, not a silent absent.`
|
|
2810
|
+
);
|
|
2811
|
+
}
|
|
2812
|
+
out.push({
|
|
2813
|
+
token: canonical(stem),
|
|
2814
|
+
anchor: filePath.split("/").slice(-2).join("/"),
|
|
2815
|
+
body
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
walk2(full);
|
|
2820
|
+
}
|
|
2821
|
+
};
|
|
2822
|
+
walk2(base);
|
|
2823
|
+
return out;
|
|
2824
|
+
}
|
|
2825
|
+
var LIFECYCLE_OBJ_RE2 = /\blifecycle\s*:\s*\{/;
|
|
2826
|
+
var OBJECT_KEY_RE2 = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*[:(]/;
|
|
2827
|
+
function collectFactorySources(tree) {
|
|
2828
|
+
const out = [];
|
|
2829
|
+
for (const file of tree.files) {
|
|
2830
|
+
const lines = file.lines;
|
|
2831
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2832
|
+
const line = lines[i];
|
|
2833
|
+
if (line.comment) continue;
|
|
2834
|
+
if (!LIFECYCLE_OBJ_RE2.test(line.raw)) continue;
|
|
2835
|
+
let depth = 0;
|
|
2836
|
+
let started = false;
|
|
2837
|
+
let curKey = null;
|
|
2838
|
+
const keyBodies = /* @__PURE__ */ new Map();
|
|
2839
|
+
for (let j = i; j < lines.length; j++) {
|
|
2840
|
+
const bl = lines[j];
|
|
2841
|
+
if (bl.comment) continue;
|
|
2842
|
+
const raw = bl.raw;
|
|
2843
|
+
if (started && depth === 1) {
|
|
2844
|
+
const k = raw.match(OBJECT_KEY_RE2);
|
|
2845
|
+
if (k) {
|
|
2846
|
+
curKey = canonical(k[1]);
|
|
2847
|
+
keyBodies.set(curKey, []);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
if (curKey && depth >= 1) {
|
|
2851
|
+
keyBodies.get(curKey)?.push(raw);
|
|
2852
|
+
}
|
|
2853
|
+
depth += (raw.match(/\{/g) ?? []).length;
|
|
2854
|
+
depth -= (raw.match(/\}/g) ?? []).length;
|
|
2855
|
+
if (depth > 0) started = true;
|
|
2856
|
+
if (started && depth <= 0) break;
|
|
2857
|
+
}
|
|
2858
|
+
for (const [token, bodyLines] of keyBodies) {
|
|
2859
|
+
out.push({ token, anchor: `${file.rel}:${i + 1}`, body: bodyLines.join("\n") });
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
return out;
|
|
2864
|
+
}
|
|
2865
|
+
var SETTIMEOUT_RE = /\bsetTimeout\s*\(/;
|
|
2866
|
+
var ABORTCONTROLLER_RE = /\bAbortController\b/;
|
|
2867
|
+
function hasImplTimeoutGuard(body) {
|
|
2868
|
+
return SETTIMEOUT_RE.test(body) || ABORTCONTROLLER_RE.test(body);
|
|
2869
|
+
}
|
|
2870
|
+
var CTRL_SETTIMEOUT_BODY = "const t = setTimeout(() => controller.abort(), 1000);";
|
|
2871
|
+
var CTRL_ABORT_BODY = "const controller = new AbortController();\nfetch(url, { signal: controller.signal });";
|
|
2872
|
+
var CTRL_NEGATIVE_BODY = "let retries = 3;\nwhile (retries > 0) {\n retries--;\n}";
|
|
2873
|
+
function dExt9PositiveControls() {
|
|
2874
|
+
return {
|
|
2875
|
+
setTimeoutForm: hasImplTimeoutGuard(CTRL_SETTIMEOUT_BODY),
|
|
2876
|
+
abortForm: hasImplTimeoutGuard(CTRL_ABORT_BODY),
|
|
2877
|
+
negative: hasImplTimeoutGuard(CTRL_NEGATIVE_BODY)
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2880
|
+
function manifestTimeoutTokens(raw, shortPath) {
|
|
2881
|
+
const map = /* @__PURE__ */ new Map();
|
|
2882
|
+
if (raw === void 0) return map;
|
|
2883
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
2884
|
+
throw new Error(
|
|
2885
|
+
`DExt9 found ${shortPath} whose "timeouts" field is present but not an object (got ${Array.isArray(raw) ? "array" : typeof raw}) \u2014 declared timeout guards are undeterminable (Extension Rule 9). Fail-closed, not a silent absent.`
|
|
2886
|
+
);
|
|
2887
|
+
}
|
|
2888
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
2889
|
+
if (typeof value === "number" && value > 0) {
|
|
2890
|
+
map.set(canonical(key), value);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
return map;
|
|
2894
|
+
}
|
|
2895
|
+
var dExt9 = {
|
|
2896
|
+
id: "DExt9 hook-timeout",
|
|
2897
|
+
run(tree) {
|
|
2898
|
+
const controls = dExt9PositiveControls();
|
|
2899
|
+
if (!controls.setTimeoutForm || !controls.abortForm || controls.negative) {
|
|
2900
|
+
throw new Error(
|
|
2901
|
+
`DExt9 hook-timeout control FAILED: setTimeoutForm=${controls.setTimeoutForm} abortForm=${controls.abortForm} negative=${controls.negative} \u2014 impl-pattern matcher broken, verdict untrustworthy`
|
|
2902
|
+
);
|
|
2903
|
+
}
|
|
2904
|
+
const hookSources = [...collectHooksDirSources(tree.root), ...collectFactorySources(tree)];
|
|
2905
|
+
const implByToken = /* @__PURE__ */ new Map();
|
|
2906
|
+
for (const s of hookSources) {
|
|
2907
|
+
const list = implByToken.get(s.token) ?? [];
|
|
2908
|
+
list.push(s);
|
|
2909
|
+
implByToken.set(s.token, list);
|
|
2910
|
+
}
|
|
2911
|
+
const manifests = findExtensionManifests4(tree.root);
|
|
2912
|
+
let declaredAnyLifecycle = false;
|
|
2913
|
+
for (const manifestPath of manifests) {
|
|
2914
|
+
const shortPath = manifestPath.split("/").slice(-2).join("/");
|
|
2915
|
+
let parsed;
|
|
2916
|
+
try {
|
|
2917
|
+
parsed = JSON.parse(readFileSync10(manifestPath, "utf8"));
|
|
2918
|
+
} catch {
|
|
2919
|
+
throw new Error(
|
|
2920
|
+
`DExt9 could not parse ${shortPath} as JSON \u2014 timeout guards undeterminable (Extension Rule 9). Fail-closed, not a silent absent.`
|
|
2921
|
+
);
|
|
2922
|
+
}
|
|
2923
|
+
if (!("lifecycle" in parsed)) continue;
|
|
2924
|
+
let declared;
|
|
2925
|
+
try {
|
|
2926
|
+
declared = manifestHookTokens(parsed.lifecycle);
|
|
2927
|
+
} catch {
|
|
2928
|
+
throw new Error(
|
|
2929
|
+
`DExt9 found a present manifest ${shortPath} whose "lifecycle" is not an array (got ${typeof parsed.lifecycle}) \u2014 declared hooks are undeterminable (Extension Rule 9). Fail-closed, not a silent absent.`
|
|
2930
|
+
);
|
|
2931
|
+
}
|
|
2932
|
+
if (declared.size === 0) continue;
|
|
2933
|
+
declaredAnyLifecycle = true;
|
|
2934
|
+
const timeouts = manifestTimeoutTokens(parsed.timeouts, shortPath);
|
|
2935
|
+
const noTimeout = [];
|
|
2936
|
+
for (const hook of declared) {
|
|
2937
|
+
const hasManifestTimeout = timeouts.has(hook);
|
|
2938
|
+
const implSources = implByToken.get(hook) ?? [];
|
|
2939
|
+
const hasImplTimeout = implSources.some((s) => hasImplTimeoutGuard(s.body));
|
|
2940
|
+
if (!hasManifestTimeout && !hasImplTimeout) noTimeout.push(hook);
|
|
2941
|
+
}
|
|
2942
|
+
if (noTimeout.length > 0) {
|
|
2943
|
+
return {
|
|
2944
|
+
id: this.id,
|
|
2945
|
+
anchor: shortPath,
|
|
2946
|
+
state: "non-conforming",
|
|
2947
|
+
verdict: `hook(s) [${noTimeout.join(", ")}] declare NO timeout guard in either form (manifest "timeouts.<hook>" positive number, or impl-side setTimeout/AbortController cutoff) -> a hook exceeding its timeout must be skipped, not a server blocker (Extension Rule 9)`
|
|
2948
|
+
};
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
if (!declaredAnyLifecycle) {
|
|
2952
|
+
return {
|
|
2953
|
+
id: this.id,
|
|
2954
|
+
anchor: manifests.length > 0 ? manifests[0].split("/").slice(-2).join("/") : tree.root.split("/").pop() ?? tree.root,
|
|
2955
|
+
state: "absent",
|
|
2956
|
+
verdict: "no lifecycle hooks declared, nothing to guard with a timeout (Extension Rule 9)"
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2959
|
+
return {
|
|
2960
|
+
id: this.id,
|
|
2961
|
+
anchor: manifests[0].split("/").slice(-2).join("/"),
|
|
2962
|
+
state: "present",
|
|
2963
|
+
verdict: "every declared lifecycle hook has a timeout guard (manifest timeouts.<hook>, or impl-side cutoff), positive-control OK (Extension Rule 9)"
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
};
|
|
2967
|
+
|
|
2968
|
+
// src/detectors/dServer2.ts
|
|
2969
|
+
var TOOL_SNAKE_RE = /^[a-z0-9]+(?:_[a-z0-9]+)*$/;
|
|
2970
|
+
var PKG_KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2971
|
+
var TOOL_SNAKE_POSITIVE = "list_customers";
|
|
2972
|
+
var TOOL_SNAKE_NEGATIVE = "listCustomers";
|
|
2973
|
+
var PKG_KEBAB_POSITIVE = "mcp-doctor";
|
|
2974
|
+
var PKG_KEBAB_NEGATIVE = "MCP_Doctor";
|
|
2975
|
+
function dServer2PositiveControls() {
|
|
2976
|
+
return {
|
|
2977
|
+
toolSnake: {
|
|
2978
|
+
pattern: TOOL_SNAKE_RE.source,
|
|
2979
|
+
positive: TOOL_SNAKE_RE.test(TOOL_SNAKE_POSITIVE),
|
|
2980
|
+
negative: TOOL_SNAKE_RE.test(TOOL_SNAKE_NEGATIVE)
|
|
2981
|
+
},
|
|
2982
|
+
pkgKebab: {
|
|
2983
|
+
pattern: PKG_KEBAB_RE.source,
|
|
2984
|
+
positive: PKG_KEBAB_RE.test(PKG_KEBAB_POSITIVE),
|
|
2985
|
+
negative: PKG_KEBAB_RE.test(PKG_KEBAB_NEGATIVE)
|
|
2986
|
+
}
|
|
2987
|
+
};
|
|
2988
|
+
}
|
|
2989
|
+
function unscopePackageName(name) {
|
|
2990
|
+
const m = name.match(/^@[^/]+\/(.+)$/);
|
|
2991
|
+
return m ? m[1] : name;
|
|
2992
|
+
}
|
|
2993
|
+
var dServer2 = {
|
|
2994
|
+
id: "DServer2 naming",
|
|
2995
|
+
run(tree) {
|
|
2996
|
+
const controls = dServer2PositiveControls();
|
|
2997
|
+
if (!controls.toolSnake.positive || controls.toolSnake.negative) {
|
|
2998
|
+
throw new Error(
|
|
2999
|
+
`DServer2 tool-name control FAILED: /${controls.toolSnake.pattern}/ accept(\`${TOOL_SNAKE_POSITIVE}\`)=${controls.toolSnake.positive} reject(\`${TOOL_SNAKE_NEGATIVE}\`)=${!controls.toolSnake.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
3000
|
+
);
|
|
3001
|
+
}
|
|
3002
|
+
if (!controls.pkgKebab.positive || controls.pkgKebab.negative) {
|
|
3003
|
+
throw new Error(
|
|
3004
|
+
`DServer2 package-name control FAILED: /${controls.pkgKebab.pattern}/ accept(\`${PKG_KEBAB_POSITIVE}\`)=${controls.pkgKebab.positive} reject(\`${PKG_KEBAB_NEGATIVE}\`)=${!controls.pkgKebab.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
3005
|
+
);
|
|
3006
|
+
}
|
|
3007
|
+
const unresolved = findUnresolvedToolSites(tree);
|
|
3008
|
+
if (unresolved.length > 0) {
|
|
3009
|
+
const reason = `tool name at ${unresolved.length} registration site(s) is computed/imported/template (${unresolved.join(", ")}) \u2014 snake_case unverifiable statically (Server Rule 2)`;
|
|
3010
|
+
return {
|
|
3011
|
+
id: this.id,
|
|
3012
|
+
anchor: unresolved[0],
|
|
3013
|
+
state: "could-not-judge",
|
|
3014
|
+
reason,
|
|
3015
|
+
verdict: `could not judge: ${reason}`
|
|
3016
|
+
};
|
|
3017
|
+
}
|
|
3018
|
+
const tools = findRegisteredTools(tree);
|
|
3019
|
+
for (const t of tools) {
|
|
3020
|
+
if (!TOOL_SNAKE_RE.test(t.name)) {
|
|
3021
|
+
return {
|
|
3022
|
+
id: this.id,
|
|
3023
|
+
anchor: t.anchor,
|
|
3024
|
+
state: "non-conforming",
|
|
3025
|
+
verdict: `tool name "${t.name}" is not snake_case (positive-control OK) -> use lowercase_snake_case (Server Rule 2)`
|
|
3026
|
+
};
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
if (!tree.packageJson) {
|
|
3030
|
+
const reason = "could not locate package.json anywhere above the scanned root \u2014 package-name kebab-case unverifiable (Server Rule 2)";
|
|
3031
|
+
return {
|
|
3032
|
+
id: this.id,
|
|
3033
|
+
anchor: tree.root,
|
|
3034
|
+
state: "could-not-judge",
|
|
3035
|
+
reason,
|
|
3036
|
+
verdict: `could not judge: ${reason}`
|
|
3037
|
+
};
|
|
3038
|
+
}
|
|
3039
|
+
const { path: pkgPath, json: pkgJson } = tree.packageJson;
|
|
3040
|
+
if (!pkgJson || Object.keys(pkgJson).length === 0) {
|
|
3041
|
+
const reason = `could not parse ${pkgPath} as JSON \u2014 package-name kebab-case unverifiable (Server Rule 2)`;
|
|
3042
|
+
return {
|
|
3043
|
+
id: this.id,
|
|
3044
|
+
anchor: pkgPath.split("/").slice(-2).join("/"),
|
|
3045
|
+
state: "could-not-judge",
|
|
3046
|
+
reason,
|
|
3047
|
+
verdict: `could not judge: ${reason}`
|
|
3048
|
+
};
|
|
3049
|
+
}
|
|
3050
|
+
const pkgName = pkgJson.name;
|
|
3051
|
+
if (typeof pkgName !== "string") {
|
|
3052
|
+
const reason = `no string "name" field in ${pkgPath} \u2014 package-name kebab-case unverifiable (Server Rule 2)`;
|
|
3053
|
+
return {
|
|
3054
|
+
id: this.id,
|
|
3055
|
+
anchor: pkgPath.split("/").slice(-2).join("/"),
|
|
3056
|
+
state: "could-not-judge",
|
|
3057
|
+
reason,
|
|
3058
|
+
verdict: `could not judge: ${reason}`
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
const bareName = unscopePackageName(pkgName);
|
|
3062
|
+
const anchorPkg = pkgPath.split("/").slice(-2).join("/");
|
|
3063
|
+
if (!PKG_KEBAB_RE.test(bareName)) {
|
|
3064
|
+
return {
|
|
3065
|
+
id: this.id,
|
|
3066
|
+
anchor: anchorPkg,
|
|
3067
|
+
state: "non-conforming",
|
|
3068
|
+
verdict: `package name "${pkgName}" is not kebab-case (positive-control OK) -> use lowercase-kebab-case (Server Rule 2)`
|
|
3069
|
+
};
|
|
3070
|
+
}
|
|
3071
|
+
const toolNote = tools.length > 0 ? `${tools.length} tool name(s) snake_case` : "no tools registered";
|
|
3072
|
+
return {
|
|
3073
|
+
id: this.id,
|
|
3074
|
+
anchor: tools.length > 0 ? tools[0].anchor : anchorPkg,
|
|
3075
|
+
state: "present",
|
|
3076
|
+
verdict: `${toolNote}, package name "${pkgName}" kebab-case (positive-control OK)`
|
|
3077
|
+
};
|
|
3078
|
+
},
|
|
3079
|
+
/**
|
|
3080
|
+
* DYNAMIC — judge the RUNTIME tool names the server actually advertises over
|
|
3081
|
+
* `tools/list`. This sees what the static scan cannot: names computed at
|
|
3082
|
+
* startup, or tools shipped only as built `dist`. Same snake_case rule, same
|
|
3083
|
+
* bipolar positive control. A server that could not be started is
|
|
3084
|
+
* `could-not-judge` naming the launch failure — never absent, never a pass.
|
|
3085
|
+
*/
|
|
3086
|
+
runDynamic(runtime) {
|
|
3087
|
+
const controls = dServer2PositiveControls();
|
|
3088
|
+
if (!controls.toolSnake.positive || controls.toolSnake.negative) {
|
|
3089
|
+
throw new Error(
|
|
3090
|
+
`DServer2 tool-name control FAILED: /${controls.toolSnake.pattern}/ accept(\`${TOOL_SNAKE_POSITIVE}\`)=${controls.toolSnake.positive} reject(\`${TOOL_SNAKE_NEGATIVE}\`)=${!controls.toolSnake.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
3091
|
+
);
|
|
3092
|
+
}
|
|
3093
|
+
if (runtime.launchError) {
|
|
3094
|
+
const reason = `server could not be started/introspected \u2014 runtime tool naming unverifiable: ${runtime.launchError} (Server Rule 2)`;
|
|
3095
|
+
return {
|
|
3096
|
+
id: this.id,
|
|
3097
|
+
anchor: `${runtime.root} (runtime)`,
|
|
3098
|
+
state: "could-not-judge",
|
|
3099
|
+
reason,
|
|
3100
|
+
verdict: `could not judge: ${reason}`
|
|
3101
|
+
};
|
|
3102
|
+
}
|
|
3103
|
+
for (const t of runtime.tools) {
|
|
3104
|
+
if (!TOOL_SNAKE_RE.test(t.name)) {
|
|
3105
|
+
return {
|
|
3106
|
+
id: this.id,
|
|
3107
|
+
anchor: `${runtime.root} (runtime tools/list)`,
|
|
3108
|
+
state: "non-conforming",
|
|
3109
|
+
verdict: `runtime tool name "${t.name}" is not snake_case (positive-control OK) -> use lowercase_snake_case (Server Rule 2)`
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
const toolNote = runtime.tools.length > 0 ? `${runtime.tools.length} runtime tool name(s) snake_case` : "server advertises no tools at runtime";
|
|
3114
|
+
return {
|
|
3115
|
+
id: this.id,
|
|
3116
|
+
anchor: `${runtime.root} (runtime tools/list)`,
|
|
3117
|
+
state: "present",
|
|
3118
|
+
verdict: `${toolNote} (positive-control OK)`
|
|
3119
|
+
};
|
|
3120
|
+
}
|
|
3121
|
+
};
|
|
3122
|
+
|
|
3123
|
+
// src/detectors/dServer6.ts
|
|
3124
|
+
var LOG_OPENER_RE = /\b(?:console|logger|log)\.(?:log|info|warn|error|debug|trace)\s*\(/;
|
|
3125
|
+
var SECRET_RE = /\b(?:api[_-]?key|token|secret|passwd|password|authorization|auth[_-]?token|bearer|access[_-]?key|client[_-]?secret|private[_-]?key)\b/i;
|
|
3126
|
+
var STACK_RE = /\b(?:error|err|e|ex|exception)\.stack\b/;
|
|
3127
|
+
var CLIENT_SINK_RE = /\b(?:content|text|message|body)\s*:|(?:\breturn\b|\bthrow\b|JSON\.stringify)/;
|
|
3128
|
+
var SPREAD_RE = /\.\.\.[A-Za-z_$]/;
|
|
3129
|
+
var SECRET_POSITIVE = "apiKey";
|
|
3130
|
+
var SECRET_NEGATIVE = "customerId";
|
|
3131
|
+
var STACK_POSITIVE = "error.stack";
|
|
3132
|
+
var STACK_NEGATIVE = "error.message";
|
|
3133
|
+
function dServer6PositiveControls() {
|
|
3134
|
+
return {
|
|
3135
|
+
secret: {
|
|
3136
|
+
pattern: SECRET_RE.source,
|
|
3137
|
+
positive: SECRET_RE.test(SECRET_POSITIVE),
|
|
3138
|
+
negative: SECRET_RE.test(SECRET_NEGATIVE)
|
|
3139
|
+
},
|
|
3140
|
+
stack: {
|
|
3141
|
+
pattern: STACK_RE.source,
|
|
3142
|
+
positive: STACK_RE.test(STACK_POSITIVE),
|
|
3143
|
+
negative: STACK_RE.test(STACK_NEGATIVE)
|
|
3144
|
+
}
|
|
3145
|
+
};
|
|
3146
|
+
}
|
|
3147
|
+
function referencedIdentifiers(argText) {
|
|
3148
|
+
const interps = [...argText.matchAll(/\$\{([^}]*)\}/g)].map((m) => m[1]).join(" ");
|
|
3149
|
+
const noStrings = argText.replace(/`(?:[^`\\]|\\.)*`/g, " ").replace(/'(?:[^'\\]|\\.)*'/g, " ").replace(/"(?:[^"\\]|\\.)*"/g, " ");
|
|
3150
|
+
return `${noStrings} ${interps}`;
|
|
3151
|
+
}
|
|
3152
|
+
function collectLogCalls(file) {
|
|
3153
|
+
const calls = [];
|
|
3154
|
+
const unbalanced = [];
|
|
3155
|
+
const code = file.lines.filter((l) => !l.comment);
|
|
3156
|
+
for (let i = 0; i < code.length; i++) {
|
|
3157
|
+
const line = code[i];
|
|
3158
|
+
const m = line.raw.match(LOG_OPENER_RE);
|
|
3159
|
+
if (!m) continue;
|
|
3160
|
+
const openIdx = line.raw.indexOf("(", m.index ?? 0);
|
|
3161
|
+
let depth = 0;
|
|
3162
|
+
let argText = "";
|
|
3163
|
+
let closed = false;
|
|
3164
|
+
for (let j = i; j < code.length && !closed; j++) {
|
|
3165
|
+
const raw = j === i ? line.raw.slice(openIdx) : code[j].raw;
|
|
3166
|
+
for (const ch of raw) {
|
|
3167
|
+
if (ch === "(") {
|
|
3168
|
+
depth++;
|
|
3169
|
+
if (depth === 1) continue;
|
|
3170
|
+
} else if (ch === ")") {
|
|
3171
|
+
depth--;
|
|
3172
|
+
if (depth === 0) {
|
|
3173
|
+
closed = true;
|
|
3174
|
+
break;
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
if (depth >= 1) argText += ch;
|
|
3178
|
+
}
|
|
3179
|
+
if (!closed) argText += " ";
|
|
3180
|
+
}
|
|
3181
|
+
if (!closed) {
|
|
3182
|
+
unbalanced.push(`${file.rel}:${line.n}`);
|
|
3183
|
+
} else {
|
|
3184
|
+
calls.push({ argText, anchor: `${file.rel}:${line.n}`, line: line.n });
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
return { calls, unbalanced };
|
|
3188
|
+
}
|
|
3189
|
+
var dServer6 = {
|
|
3190
|
+
id: "DServer6 secret-hygiene",
|
|
3191
|
+
run(tree) {
|
|
3192
|
+
const controls = dServer6PositiveControls();
|
|
3193
|
+
if (!controls.secret.positive || controls.secret.negative) {
|
|
3194
|
+
throw new Error(
|
|
3195
|
+
`DServer6 secret control FAILED: /${controls.secret.pattern}/ accept(\`${SECRET_POSITIVE}\`)=${controls.secret.positive} reject(\`${SECRET_NEGATIVE}\`)=${!controls.secret.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
3196
|
+
);
|
|
3197
|
+
}
|
|
3198
|
+
if (!controls.stack.positive || controls.stack.negative) {
|
|
3199
|
+
throw new Error(
|
|
3200
|
+
`DServer6 stack control FAILED: /${controls.stack.pattern}/ accept(\`${STACK_POSITIVE}\`)=${controls.stack.positive} reject(\`${STACK_NEGATIVE}\`)=${!controls.stack.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
3201
|
+
);
|
|
3202
|
+
}
|
|
3203
|
+
for (const file of tree.files) {
|
|
3204
|
+
const { calls, unbalanced } = collectLogCalls(file);
|
|
3205
|
+
if (unbalanced.length > 0) {
|
|
3206
|
+
throw new Error(
|
|
3207
|
+
`DServer6 could not read the argument list of ${unbalanced.length} log call(s): ${unbalanced.join(", ")} \u2014 parentheses never balance, secret content unverifiable (Server Rule 6). Fail-closed, not a silent present.`
|
|
3208
|
+
);
|
|
3209
|
+
}
|
|
3210
|
+
for (const call of calls) {
|
|
3211
|
+
if (SPREAD_RE.test(call.argText)) {
|
|
3212
|
+
throw new Error(
|
|
3213
|
+
`DServer6 cannot inspect the spread argument of the log call at ${call.anchor} \u2014 a spread hides arbitrary content, secret presence unverifiable (Server Rule 6). Fail-closed, not a silent present.`
|
|
3214
|
+
);
|
|
3215
|
+
}
|
|
3216
|
+
if (SECRET_RE.test(referencedIdentifiers(call.argText))) {
|
|
3217
|
+
return {
|
|
3218
|
+
id: this.id,
|
|
3219
|
+
anchor: call.anchor,
|
|
3220
|
+
state: "non-conforming",
|
|
3221
|
+
verdict: "Form A \u2014 log call references a secret-like identifier (positive-control OK) -> never log secrets; redact before logging (Server Rule 6)"
|
|
3222
|
+
};
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
for (const file of tree.files) {
|
|
3227
|
+
for (const line of file.lines) {
|
|
3228
|
+
if (line.comment) continue;
|
|
3229
|
+
if (STACK_RE.test(line.raw) && CLIENT_SINK_RE.test(line.raw)) {
|
|
3230
|
+
return {
|
|
3231
|
+
id: this.id,
|
|
3232
|
+
anchor: `${file.rel}:${line.n}`,
|
|
3233
|
+
state: "non-conforming",
|
|
3234
|
+
verdict: "Form B \u2014 raw error.stack serialized into a client-facing response (positive-control OK) -> return an McpError message, never the raw stack (Server Rule 6)"
|
|
3235
|
+
};
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
return {
|
|
3240
|
+
id: this.id,
|
|
3241
|
+
anchor: tree.files.length > 0 ? `${tree.files[0].rel}:1` : tree.root,
|
|
3242
|
+
state: "present",
|
|
3243
|
+
verdict: "no secret referenced in any log call; no raw error.stack exposed to the client (both positive-controls OK)"
|
|
3244
|
+
};
|
|
3245
|
+
}
|
|
3246
|
+
};
|
|
3247
|
+
|
|
3248
|
+
// src/detectors/dServer8.ts
|
|
3249
|
+
var CONTENT_OPEN_RE = /content\s*:\s*\[/;
|
|
3250
|
+
var KIT_RE = /renderUiResource\s*\(/;
|
|
3251
|
+
var INLINE_RESOURCE_RE = /type\s*:\s*["'`]resource["'`]/;
|
|
3252
|
+
var UI_URI_RE = /ui:\/\//;
|
|
3253
|
+
var TEXT_RE = /type\s*:\s*["'`]text["'`]/;
|
|
3254
|
+
var MARKDOWN_RE = /type\s*:\s*["'`]markdown["'`]/;
|
|
3255
|
+
var EMISSION_POSITIVE = 'type: "resource"';
|
|
3256
|
+
var EMISSION_NEGATIVE = 'type: "text"';
|
|
3257
|
+
var TEXT_POSITIVE = 'type: "text"';
|
|
3258
|
+
var TEXT_NEGATIVE = 'type: "resource"';
|
|
3259
|
+
function dServer8PositiveControls() {
|
|
3260
|
+
return {
|
|
3261
|
+
emission: {
|
|
3262
|
+
pattern: INLINE_RESOURCE_RE.source,
|
|
3263
|
+
positive: INLINE_RESOURCE_RE.test(EMISSION_POSITIVE),
|
|
3264
|
+
negative: INLINE_RESOURCE_RE.test(EMISSION_NEGATIVE)
|
|
3265
|
+
},
|
|
3266
|
+
text: {
|
|
3267
|
+
pattern: TEXT_RE.source,
|
|
3268
|
+
positive: TEXT_RE.test(TEXT_POSITIVE),
|
|
3269
|
+
negative: TEXT_RE.test(TEXT_NEGATIVE)
|
|
3270
|
+
}
|
|
3271
|
+
};
|
|
3272
|
+
}
|
|
3273
|
+
function collectContentArrays(file) {
|
|
3274
|
+
const blocks = [];
|
|
3275
|
+
const unbalanced = [];
|
|
3276
|
+
const code = file.lines.filter((l) => !l.comment);
|
|
3277
|
+
for (let i = 0; i < code.length; i++) {
|
|
3278
|
+
const line = code[i];
|
|
3279
|
+
const m = line.raw.match(CONTENT_OPEN_RE);
|
|
3280
|
+
if (!m) continue;
|
|
3281
|
+
const openIdx = line.raw.indexOf("[", m.index ?? 0);
|
|
3282
|
+
let depth = 0;
|
|
3283
|
+
let text = "";
|
|
3284
|
+
let closed = false;
|
|
3285
|
+
let endLine = line.n;
|
|
3286
|
+
for (let j = i; j < code.length && !closed; j++) {
|
|
3287
|
+
const raw = j === i ? code[j].raw.slice(openIdx) : code[j].raw;
|
|
3288
|
+
endLine = code[j].n;
|
|
3289
|
+
for (const ch of raw) {
|
|
3290
|
+
if (ch === "[") {
|
|
3291
|
+
depth++;
|
|
3292
|
+
if (depth === 1) continue;
|
|
3293
|
+
} else if (ch === "]") {
|
|
3294
|
+
depth--;
|
|
3295
|
+
if (depth === 0) {
|
|
3296
|
+
closed = true;
|
|
3297
|
+
break;
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
if (depth >= 1) text += ch;
|
|
3301
|
+
}
|
|
3302
|
+
if (!closed) text += " ";
|
|
3303
|
+
}
|
|
3304
|
+
if (!closed) {
|
|
3305
|
+
unbalanced.push(`${file.rel}:${line.n}`);
|
|
3306
|
+
} else {
|
|
3307
|
+
blocks.push({ file, text, startLine: line.n, endLine, anchor: `${file.rel}:${line.n}` });
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
return { blocks, unbalanced };
|
|
3311
|
+
}
|
|
3312
|
+
var dServer8 = {
|
|
3313
|
+
id: "DServer8 ui-fallback",
|
|
3314
|
+
run(tree) {
|
|
3315
|
+
const controls = dServer8PositiveControls();
|
|
3316
|
+
if (!controls.emission.positive || controls.emission.negative) {
|
|
3317
|
+
throw new Error(
|
|
3318
|
+
`DServer8 emission control FAILED: /${controls.emission.pattern}/ accept(\`${EMISSION_POSITIVE}\`)=${controls.emission.positive} reject(\`${EMISSION_NEGATIVE}\`)=${!controls.emission.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
3319
|
+
);
|
|
3320
|
+
}
|
|
3321
|
+
if (!controls.text.positive || controls.text.negative) {
|
|
3322
|
+
throw new Error(
|
|
3323
|
+
`DServer8 text control FAILED: /${controls.text.pattern}/ accept(\`${TEXT_POSITIVE}\`)=${controls.text.positive} reject(\`${TEXT_NEGATIVE}\`)=${!controls.text.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
3324
|
+
);
|
|
3325
|
+
}
|
|
3326
|
+
const blocks = [];
|
|
3327
|
+
for (const file of tree.files) {
|
|
3328
|
+
const { blocks: fileBlocks, unbalanced } = collectContentArrays(file);
|
|
3329
|
+
if (unbalanced.length > 0) {
|
|
3330
|
+
throw new Error(
|
|
3331
|
+
`DServer8 could not read the content[] array at ${unbalanced.join(", ")} \u2014 brackets never balance, a ui:// resource emission could hide inside it (Server Rule 8). Fail-closed, not a silent present.`
|
|
3332
|
+
);
|
|
3333
|
+
}
|
|
3334
|
+
blocks.push(...fileBlocks);
|
|
3335
|
+
}
|
|
3336
|
+
for (const file of tree.files) {
|
|
3337
|
+
const fileBlocks = blocks.filter((b) => b.file === file);
|
|
3338
|
+
for (const line of file.lines) {
|
|
3339
|
+
if (line.comment) continue;
|
|
3340
|
+
if (!KIT_RE.test(line.raw)) continue;
|
|
3341
|
+
const inside = fileBlocks.some((b) => line.n >= b.startLine && line.n <= b.endLine);
|
|
3342
|
+
if (!inside) {
|
|
3343
|
+
throw new Error(
|
|
3344
|
+
`DServer8 found a renderUiResource(\u2026) ui:// emission at ${file.rel}:${line.n} outside any content[] array \u2014 cannot associate it with a sibling text fallback (Server Rule 8). Fail-closed, not a silent present.`
|
|
3345
|
+
);
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
let sawEmission = false;
|
|
3350
|
+
for (const block of blocks) {
|
|
3351
|
+
const inlineEmission = INLINE_RESOURCE_RE.test(block.text) && UI_URI_RE.test(block.text);
|
|
3352
|
+
const kitEmission = KIT_RE.test(block.text);
|
|
3353
|
+
if (!inlineEmission && !kitEmission) continue;
|
|
3354
|
+
sawEmission = true;
|
|
3355
|
+
const hasText = TEXT_RE.test(block.text) || MARKDOWN_RE.test(block.text);
|
|
3356
|
+
if (!hasText) {
|
|
3357
|
+
return {
|
|
3358
|
+
id: this.id,
|
|
3359
|
+
anchor: block.anchor,
|
|
3360
|
+
state: "non-conforming",
|
|
3361
|
+
verdict: `ui:// resource emitted in content[] with no text fallback beside ui:// in the same array (positive-control OK) -> add a sibling { type: "text" } / { type: "markdown" } entry (Server Rule 8)`
|
|
3362
|
+
};
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
if (!sawEmission) {
|
|
3366
|
+
return {
|
|
3367
|
+
id: this.id,
|
|
3368
|
+
anchor: tree.files.length > 0 ? `${tree.files[0].rel}:1` : tree.root,
|
|
3369
|
+
state: "absent",
|
|
3370
|
+
verdict: "no ui:// resource emission in any content[] array (n/a for Server Rule 8)"
|
|
3371
|
+
};
|
|
3372
|
+
}
|
|
3373
|
+
return {
|
|
3374
|
+
id: this.id,
|
|
3375
|
+
anchor: blocks.length > 0 ? blocks[0].anchor : tree.root,
|
|
3376
|
+
state: "present",
|
|
3377
|
+
verdict: "every ui:// resource emission has a sibling text/markdown fallback in the same content[] (positive-control OK, Server Rule 8)"
|
|
3378
|
+
};
|
|
3379
|
+
}
|
|
3380
|
+
};
|
|
3381
|
+
|
|
3382
|
+
// src/detectors/dServer14.ts
|
|
3383
|
+
var WWW_AUTH_RE = /WWW-Authenticate/i;
|
|
3384
|
+
var CHALLENGE_PARAM_RE = /resource_metadata\s*=/;
|
|
3385
|
+
var STATUS_401_RE = /\b401\b/;
|
|
3386
|
+
var BEARER_RE = /Bearer/;
|
|
3387
|
+
var PRM_ROUTE_RE = /(?:app|router|server)\.(?:get|route|all)\s*\(\s*["'`][^"'`]*\.well-known\/oauth-protected-resource/;
|
|
3388
|
+
var CLERK_RE = /clerk/i;
|
|
3389
|
+
var AS_DISCOVERY_RE = /authorizationServers|authorization_servers|openid-configuration|oauth-authorization-server/i;
|
|
3390
|
+
var JWKS_RE = /jwks|createRemoteJWKSet|createLocalJWKSet/i;
|
|
3391
|
+
var RS256_RE = /RS256|jwtVerify|verifyMcpBearer|verifyBearer/;
|
|
3392
|
+
var AUDIENCE_RE = /audience|\baud\b/i;
|
|
3393
|
+
var ISSUER_RE = /issuer/i;
|
|
3394
|
+
var REUSE_RE = /@vantageos\/cloud-identity/;
|
|
3395
|
+
var COMPUTED_CHALLENGE_RE = /WWW-Authenticate["'`]?\s*(?:,|:|\])\s*[A-Za-z_$][\w.]*\s*\(/;
|
|
3396
|
+
var CONTROLS4 = [
|
|
3397
|
+
{
|
|
3398
|
+
key: "challenge",
|
|
3399
|
+
re: CHALLENGE_PARAM_RE,
|
|
3400
|
+
positive: 'resource_metadata="https://x"',
|
|
3401
|
+
negative: "content_type=json"
|
|
3402
|
+
},
|
|
3403
|
+
{
|
|
3404
|
+
key: "prmRoute",
|
|
3405
|
+
re: PRM_ROUTE_RE,
|
|
3406
|
+
positive: 'app.get("/.well-known/oauth-protected-resource"',
|
|
3407
|
+
negative: 'app.get("/health"'
|
|
3408
|
+
},
|
|
3409
|
+
{ key: "clerk", re: CLERK_RE, positive: "clerk.accounts.dev", negative: "auth0.example.com" },
|
|
3410
|
+
{
|
|
3411
|
+
key: "jwks",
|
|
3412
|
+
re: JWKS_RE,
|
|
3413
|
+
positive: "createRemoteJWKSet(new URL(u))",
|
|
3414
|
+
negative: "sharedSecretCompare(x)"
|
|
3415
|
+
},
|
|
3416
|
+
{ key: "rs256", re: RS256_RE, positive: "alg: RS256", negative: "alg: HS256" },
|
|
3417
|
+
{ key: "issuer", re: ISSUER_RE, positive: "issuer: env.ISS", negative: "subject: env.SUB" },
|
|
3418
|
+
{
|
|
3419
|
+
key: "reuse",
|
|
3420
|
+
re: REUSE_RE,
|
|
3421
|
+
positive: 'from "@vantageos/cloud-identity"',
|
|
3422
|
+
negative: 'from "@vantageos/other-kit"'
|
|
3423
|
+
}
|
|
3424
|
+
];
|
|
3425
|
+
function dServer14PositiveControls() {
|
|
3426
|
+
return CONTROLS4.map((c) => ({
|
|
3427
|
+
key: c.key,
|
|
3428
|
+
pattern: c.re.source,
|
|
3429
|
+
positive: c.re.test(c.positive),
|
|
3430
|
+
negative: c.re.test(c.negative)
|
|
3431
|
+
}));
|
|
3432
|
+
}
|
|
3433
|
+
function codeBlob(tree) {
|
|
3434
|
+
const parts = [];
|
|
3435
|
+
for (const file of tree.files) {
|
|
3436
|
+
for (const line of file.lines) {
|
|
3437
|
+
if (!line.comment) parts.push(line.raw);
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
return parts.join("\n");
|
|
3441
|
+
}
|
|
3442
|
+
function firstAnchor(tree, re) {
|
|
3443
|
+
for (const file of tree.files) {
|
|
3444
|
+
for (const line of file.lines) {
|
|
3445
|
+
if (line.comment) continue;
|
|
3446
|
+
if (re.test(line.raw)) return `${file.rel}:${line.n}`;
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
return null;
|
|
3450
|
+
}
|
|
3451
|
+
function verificationSiteAnchor(tree) {
|
|
3452
|
+
let verifyOnly = null;
|
|
3453
|
+
for (const file of tree.files) {
|
|
3454
|
+
for (const line of file.lines) {
|
|
3455
|
+
if (line.comment) continue;
|
|
3456
|
+
if (!RS256_RE.test(line.raw)) continue;
|
|
3457
|
+
if (AUDIENCE_RE.test(line.raw)) return `${file.rel}:${line.n}`;
|
|
3458
|
+
if (verifyOnly === null) verifyOnly = `${file.rel}:${line.n}`;
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
return verifyOnly;
|
|
3462
|
+
}
|
|
3463
|
+
var dServer14 = {
|
|
3464
|
+
id: "DServer14 oauth-resource-server",
|
|
3465
|
+
run(tree) {
|
|
3466
|
+
const controls = dServer14PositiveControls();
|
|
3467
|
+
for (const c of controls) {
|
|
3468
|
+
if (!c.positive || c.negative) {
|
|
3469
|
+
throw new Error(
|
|
3470
|
+
`DServer14 ${c.key} control FAILED: /${c.pattern}/ accept=${c.positive} reject=${!c.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
3471
|
+
);
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
const blob = codeBlob(tree);
|
|
3475
|
+
const surfacePresent = WWW_AUTH_RE.test(blob) || PRM_ROUTE_RE.test(blob) || JWKS_RE.test(blob) || CLERK_RE.test(blob) || REUSE_RE.test(blob);
|
|
3476
|
+
if (!surfacePresent) {
|
|
3477
|
+
return {
|
|
3478
|
+
id: this.id,
|
|
3479
|
+
anchor: tree.files.length > 0 ? `${tree.files[0].rel}:1` : tree.root,
|
|
3480
|
+
state: "absent",
|
|
3481
|
+
verdict: "no OAuth 2.1 resource-server surface detected (n/a for Server Rule 14)"
|
|
3482
|
+
};
|
|
3483
|
+
}
|
|
3484
|
+
if (!CHALLENGE_PARAM_RE.test(blob)) {
|
|
3485
|
+
const computedAnchor = firstAnchor(tree, COMPUTED_CHALLENGE_RE);
|
|
3486
|
+
if (computedAnchor) {
|
|
3487
|
+
const reason = `WWW-Authenticate challenge at ${computedAnchor} is computed \u2014 the resource_metadata parameter is unverifiable statically (Server Rule 14)`;
|
|
3488
|
+
return {
|
|
3489
|
+
id: this.id,
|
|
3490
|
+
anchor: computedAnchor,
|
|
3491
|
+
state: "could-not-judge",
|
|
3492
|
+
reason,
|
|
3493
|
+
verdict: `could not judge: ${reason}`
|
|
3494
|
+
};
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
const elements = [
|
|
3498
|
+
{
|
|
3499
|
+
name: "Element 1 CHALLENGE (401 + WWW-Authenticate: Bearer \u2026 resource_metadata=\u2026)",
|
|
3500
|
+
present: WWW_AUTH_RE.test(blob) && CHALLENGE_PARAM_RE.test(blob) && STATUS_401_RE.test(blob) && BEARER_RE.test(blob),
|
|
3501
|
+
probe: WWW_AUTH_RE,
|
|
3502
|
+
fix: "answer 401 with WWW-Authenticate: Bearer \u2026 resource_metadata=\u2026 (RFC 9728)"
|
|
3503
|
+
},
|
|
3504
|
+
{
|
|
3505
|
+
name: "Element 2 PRM ROUTE (/.well-known/oauth-protected-resource)",
|
|
3506
|
+
present: PRM_ROUTE_RE.test(blob),
|
|
3507
|
+
probe: PRM_ROUTE_RE,
|
|
3508
|
+
fix: "serve /.well-known/oauth-protected-resource (Protected Resource Metadata, RFC 9728)"
|
|
3509
|
+
},
|
|
3510
|
+
{
|
|
3511
|
+
name: "Element 3 OIDC/8414 CLERK-AS (authorization-server discovery pointing to Clerk)",
|
|
3512
|
+
present: CLERK_RE.test(blob) && AS_DISCOVERY_RE.test(blob),
|
|
3513
|
+
probe: CLERK_RE,
|
|
3514
|
+
fix: "point authorization-server discovery at Clerk as the AS (RFC 8414 / OIDC)"
|
|
3515
|
+
},
|
|
3516
|
+
{
|
|
3517
|
+
name: "Element 4 JWKS/RS256 (asymmetric JWKS verification, not a shared secret)",
|
|
3518
|
+
present: JWKS_RE.test(blob) && RS256_RE.test(blob),
|
|
3519
|
+
probe: JWKS_RE,
|
|
3520
|
+
fix: "verify the bearer JWT against a JWKS with RS256, never a shared-secret comparison"
|
|
3521
|
+
},
|
|
3522
|
+
{
|
|
3523
|
+
name: "Element 5 AUD/ISS BINDING (audience + issuer)",
|
|
3524
|
+
present: AUDIENCE_RE.test(blob) && ISSUER_RE.test(blob),
|
|
3525
|
+
probe: ISSUER_RE,
|
|
3526
|
+
fix: "bind both audience (aud) and issuer during verification",
|
|
3527
|
+
anchorFn: verificationSiteAnchor
|
|
3528
|
+
},
|
|
3529
|
+
{
|
|
3530
|
+
name: "Element 6 REUSE (@vantageos/cloud-identity imported, verification not hand-rolled)",
|
|
3531
|
+
present: REUSE_RE.test(blob),
|
|
3532
|
+
probe: REUSE_RE,
|
|
3533
|
+
fix: "import @vantageos/cloud-identity and reuse its verification, do not re-implement"
|
|
3534
|
+
}
|
|
3535
|
+
];
|
|
3536
|
+
for (const el of elements) {
|
|
3537
|
+
if (!el.present) {
|
|
3538
|
+
const anchor = el.anchorFn?.(tree) ?? firstAnchor(tree, el.probe) ?? (tree.files.length > 0 ? `${tree.files[0].rel}:1` : tree.root);
|
|
3539
|
+
return {
|
|
3540
|
+
id: this.id,
|
|
3541
|
+
anchor,
|
|
3542
|
+
state: "non-conforming",
|
|
3543
|
+
verdict: `${el.name} missing (positive-control OK) -> ${el.fix} (Server Rule 14)`
|
|
3544
|
+
};
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
return {
|
|
3548
|
+
id: this.id,
|
|
3549
|
+
anchor: firstAnchor(tree, REUSE_RE) ?? (tree.files.length > 0 ? `${tree.files[0].rel}:1` : tree.root),
|
|
3550
|
+
state: "present",
|
|
3551
|
+
verdict: "OAuth 2.1 resource-server chain complete \u2014 401/WWW-Authenticate challenge, PRM route, Clerk-as-AS discovery, JWKS/RS256 verification, aud/iss binding, reusing @vantageos/cloud-identity (all positive-controls OK, Server Rule 14)"
|
|
3552
|
+
};
|
|
3553
|
+
}
|
|
3554
|
+
};
|
|
3555
|
+
|
|
3556
|
+
// src/detectors/index.ts
|
|
3557
|
+
var detectors = [
|
|
3558
|
+
d2,
|
|
3559
|
+
d3,
|
|
3560
|
+
d4,
|
|
3561
|
+
d5,
|
|
3562
|
+
d6,
|
|
3563
|
+
d7,
|
|
3564
|
+
d8,
|
|
3565
|
+
d9,
|
|
3566
|
+
d10,
|
|
3567
|
+
d11,
|
|
3568
|
+
d12,
|
|
3569
|
+
d15,
|
|
3570
|
+
d16,
|
|
3571
|
+
dServer2,
|
|
3572
|
+
dServer6,
|
|
3573
|
+
dDebugOptIn,
|
|
3574
|
+
dServer8,
|
|
3575
|
+
dServer14,
|
|
3576
|
+
dAppI18n,
|
|
3577
|
+
dExt1,
|
|
3578
|
+
dExt2,
|
|
3579
|
+
dExt3,
|
|
3580
|
+
dExt4,
|
|
3581
|
+
dExt5,
|
|
3582
|
+
dExt7,
|
|
3583
|
+
dExt8,
|
|
3584
|
+
dExt9
|
|
3585
|
+
];
|
|
3586
|
+
|
|
3587
|
+
// src/runtime/launch.ts
|
|
3588
|
+
import { spawnSync } from "child_process";
|
|
3589
|
+
import { existsSync as existsSync8, readFileSync as readFileSync11, statSync as statSync10 } from "fs";
|
|
3590
|
+
import { dirname as dirname8, isAbsolute, join as join13, resolve } from "path";
|
|
3591
|
+
function findPackageDir(target) {
|
|
3592
|
+
let dir;
|
|
3593
|
+
try {
|
|
3594
|
+
dir = statSync10(target).isDirectory() ? target : dirname8(target);
|
|
3595
|
+
} catch {
|
|
3596
|
+
return null;
|
|
3597
|
+
}
|
|
3598
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
3599
|
+
if (existsSync8(join13(dir, "package.json"))) return dir;
|
|
3600
|
+
const parent = dirname8(dir);
|
|
3601
|
+
if (parent === dir) break;
|
|
3602
|
+
dir = parent;
|
|
3603
|
+
}
|
|
3604
|
+
return null;
|
|
3605
|
+
}
|
|
3606
|
+
function firstBinEntry(bin) {
|
|
3607
|
+
if (typeof bin === "string") return bin;
|
|
3608
|
+
if (bin && typeof bin === "object") {
|
|
3609
|
+
for (const v of Object.values(bin)) {
|
|
3610
|
+
if (typeof v === "string") return v;
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
return null;
|
|
3614
|
+
}
|
|
3615
|
+
function deriveLaunchPlan(target) {
|
|
3616
|
+
const pkgDir = findPackageDir(target);
|
|
3617
|
+
if (!pkgDir) {
|
|
3618
|
+
return {
|
|
3619
|
+
error: `no package.json found at or above ${target} \u2014 cannot derive how to start the server`
|
|
3620
|
+
};
|
|
3621
|
+
}
|
|
3622
|
+
let pkg;
|
|
3623
|
+
try {
|
|
3624
|
+
pkg = JSON.parse(readFileSync11(join13(pkgDir, "package.json"), "utf8"));
|
|
3625
|
+
} catch (err) {
|
|
3626
|
+
return {
|
|
3627
|
+
error: `could not parse ${join13(pkgDir, "package.json")}: ${err instanceof Error ? err.message : String(err)}`
|
|
3628
|
+
};
|
|
3629
|
+
}
|
|
3630
|
+
const bin = firstBinEntry(pkg.bin);
|
|
3631
|
+
if (bin) {
|
|
3632
|
+
return {
|
|
3633
|
+
command: process.execPath,
|
|
3634
|
+
args: [isAbsolute(bin) ? bin : resolve(pkgDir, bin)],
|
|
3635
|
+
cwd: pkgDir
|
|
3636
|
+
};
|
|
3637
|
+
}
|
|
3638
|
+
const scripts = pkg.scripts;
|
|
3639
|
+
const startScript = scripts && typeof scripts.start === "string" ? scripts.start : null;
|
|
3640
|
+
if (startScript) {
|
|
3641
|
+
return { command: "sh", args: ["-c", startScript], cwd: pkgDir };
|
|
3642
|
+
}
|
|
3643
|
+
const main2 = typeof pkg.main === "string" ? pkg.main : null;
|
|
3644
|
+
if (main2) {
|
|
3645
|
+
return {
|
|
3646
|
+
command: process.execPath,
|
|
3647
|
+
args: [isAbsolute(main2) ? main2 : resolve(pkgDir, main2)],
|
|
3648
|
+
cwd: pkgDir
|
|
3649
|
+
};
|
|
3650
|
+
}
|
|
3651
|
+
return {
|
|
3652
|
+
error: `package.json at ${pkgDir} declares no bin, scripts.start, or main \u2014 cannot derive how to start the server`
|
|
3653
|
+
};
|
|
3654
|
+
}
|
|
3655
|
+
var INTROSPECTOR = String.raw`
|
|
3656
|
+
import { spawn } from "node:child_process";
|
|
3657
|
+
const plan = JSON.parse(process.env.MCP_DOCTOR_PLAN);
|
|
3658
|
+
const done = (o) => { process.stdout.write(JSON.stringify(o)); process.exit(0); };
|
|
3659
|
+
let child;
|
|
3660
|
+
try {
|
|
3661
|
+
child = spawn(plan.command, plan.args, { cwd: plan.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
3662
|
+
} catch (e) {
|
|
3663
|
+
done({ launchError: "spawn failed: " + (e && e.message ? e.message : String(e)) });
|
|
3664
|
+
}
|
|
3665
|
+
let settled = false;
|
|
3666
|
+
const fail = (msg) => { if (settled) return; settled = true; try { child.kill(); } catch {} done({ launchError: msg }); };
|
|
3667
|
+
const to = setTimeout(() => fail("timeout: server did not answer initialize+tools/list within the deadline"), 12000);
|
|
3668
|
+
child.on("error", (e) => fail("spawn error: " + (e && e.message ? e.message : String(e))));
|
|
3669
|
+
child.on("exit", (code) => fail("server exited (code " + code + ") before completing the MCP handshake"));
|
|
3670
|
+
const send = (o) => { try { child.stdin.write(JSON.stringify(o) + "\n"); } catch (e) { fail("write failed: " + e.message); } };
|
|
3671
|
+
let buf = "";
|
|
3672
|
+
child.stdout.on("data", (d) => {
|
|
3673
|
+
buf += d.toString();
|
|
3674
|
+
let i;
|
|
3675
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
3676
|
+
const line = buf.slice(0, i); buf = buf.slice(i + 1);
|
|
3677
|
+
if (!line.trim()) continue;
|
|
3678
|
+
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
3679
|
+
if (msg.id === 1) {
|
|
3680
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
3681
|
+
send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
|
|
3682
|
+
} else if (msg.id === 2) {
|
|
3683
|
+
if (settled) return; settled = true; clearTimeout(to);
|
|
3684
|
+
const tools = (msg.result && Array.isArray(msg.result.tools) ? msg.result.tools : []).map((t) => ({ name: String(t && t.name) }));
|
|
3685
|
+
try { child.kill(); } catch {}
|
|
3686
|
+
done({ tools });
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
});
|
|
3690
|
+
send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "mcp-doctor", version: "0" } } });
|
|
3691
|
+
`;
|
|
3692
|
+
function readEvalCounts(target) {
|
|
3693
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3694
|
+
const pkgDir = findPackageDir(target) ?? (statSyncSafe(target) ? target : null);
|
|
3695
|
+
if (!pkgDir) return counts;
|
|
3696
|
+
let dir = pkgDir;
|
|
3697
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
3698
|
+
const candidate = join13(dir, "evals", "evals.json");
|
|
3699
|
+
if (existsSync8(candidate)) {
|
|
3700
|
+
try {
|
|
3701
|
+
const parsed = JSON.parse(readFileSync11(candidate, "utf8"));
|
|
3702
|
+
for (const c of parsed.cases ?? []) {
|
|
3703
|
+
if (c && typeof c.tool === "string") counts.set(c.tool, (counts.get(c.tool) ?? 0) + 1);
|
|
3704
|
+
}
|
|
3705
|
+
} catch {
|
|
3706
|
+
}
|
|
3707
|
+
return counts;
|
|
3708
|
+
}
|
|
3709
|
+
const parent = dirname8(dir);
|
|
3710
|
+
if (parent === dir) break;
|
|
3711
|
+
dir = parent;
|
|
3712
|
+
}
|
|
3713
|
+
return counts;
|
|
3714
|
+
}
|
|
3715
|
+
function statSyncSafe(p) {
|
|
3716
|
+
try {
|
|
3717
|
+
return statSync10(p).isDirectory();
|
|
3718
|
+
} catch {
|
|
3719
|
+
return false;
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
function launchAndIntrospect(target) {
|
|
3723
|
+
const plan = deriveLaunchPlan(target);
|
|
3724
|
+
if ("error" in plan) {
|
|
3725
|
+
return { root: target, tools: [], launchError: plan.error };
|
|
3726
|
+
}
|
|
3727
|
+
const proc = spawnSync(process.execPath, ["--input-type=module", "-e", INTROSPECTOR], {
|
|
3728
|
+
env: { ...process.env, MCP_DOCTOR_PLAN: JSON.stringify(plan) },
|
|
3729
|
+
encoding: "utf8",
|
|
3730
|
+
timeout: 2e4,
|
|
3731
|
+
maxBuffer: 8 * 1024 * 1024
|
|
3732
|
+
});
|
|
3733
|
+
if (proc.error) {
|
|
3734
|
+
return {
|
|
3735
|
+
root: target,
|
|
3736
|
+
tools: [],
|
|
3737
|
+
launchError: `introspector failed to run: ${proc.error.message}`
|
|
3738
|
+
};
|
|
3739
|
+
}
|
|
3740
|
+
const out = (proc.stdout ?? "").trim();
|
|
3741
|
+
if (!out) {
|
|
3742
|
+
const stderr = (proc.stderr ?? "").trim();
|
|
3743
|
+
return {
|
|
3744
|
+
root: target,
|
|
3745
|
+
tools: [],
|
|
3746
|
+
launchError: `introspector produced no result${stderr ? ` (stderr: ${stderr.slice(0, 300)})` : ""}`
|
|
3747
|
+
};
|
|
3748
|
+
}
|
|
3749
|
+
let parsed;
|
|
3750
|
+
try {
|
|
3751
|
+
parsed = JSON.parse(out);
|
|
3752
|
+
} catch {
|
|
3753
|
+
return {
|
|
3754
|
+
root: target,
|
|
3755
|
+
tools: [],
|
|
3756
|
+
launchError: `introspector emitted non-JSON output: ${out.slice(0, 300)}`
|
|
3757
|
+
};
|
|
3758
|
+
}
|
|
3759
|
+
if (parsed.launchError) {
|
|
3760
|
+
return { root: target, tools: [], launchError: parsed.launchError };
|
|
3761
|
+
}
|
|
3762
|
+
const evalCounts = readEvalCounts(target);
|
|
3763
|
+
const tools = (parsed.tools ?? []).map((t) => {
|
|
3764
|
+
const name = t.name;
|
|
3765
|
+
const count = evalCounts.get(name);
|
|
3766
|
+
return count === void 0 ? { name } : { name, evalCaseCount: count };
|
|
3767
|
+
});
|
|
3768
|
+
return { root: target, tools };
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
// src/scan.ts
|
|
3772
|
+
import { existsSync as existsSync9, readFileSync as readFileSync12, readdirSync as readdirSync9, statSync as statSync11 } from "fs";
|
|
3773
|
+
import { dirname as dirname9, join as join14, relative as relative2 } from "path";
|
|
3774
|
+
var SKIP_DIRS9 = /* @__PURE__ */ new Set([
|
|
3775
|
+
"node_modules",
|
|
3776
|
+
"dist",
|
|
3777
|
+
"build",
|
|
3778
|
+
".git",
|
|
3779
|
+
"tests",
|
|
3780
|
+
"__tests__",
|
|
3781
|
+
"coverage"
|
|
3782
|
+
]);
|
|
3783
|
+
function isCommentLine(raw) {
|
|
3784
|
+
const t = raw.trim();
|
|
3785
|
+
if (t === "") return false;
|
|
3786
|
+
return t.startsWith("*") || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*/");
|
|
3787
|
+
}
|
|
3788
|
+
function walk(dir, out) {
|
|
3789
|
+
let entries;
|
|
3790
|
+
try {
|
|
3791
|
+
entries = readdirSync9(dir);
|
|
3792
|
+
} catch {
|
|
3793
|
+
return;
|
|
3794
|
+
}
|
|
3795
|
+
for (const name of entries) {
|
|
3796
|
+
const full = join14(dir, name);
|
|
3797
|
+
let st;
|
|
3798
|
+
try {
|
|
3799
|
+
st = statSync11(full);
|
|
3800
|
+
} catch {
|
|
3801
|
+
continue;
|
|
3802
|
+
}
|
|
3803
|
+
if (st.isDirectory()) {
|
|
3804
|
+
if (SKIP_DIRS9.has(name)) continue;
|
|
3805
|
+
walk(full, out);
|
|
3806
|
+
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
|
|
3807
|
+
if (name.endsWith(".d.ts") || name.endsWith(".test.ts")) continue;
|
|
3808
|
+
out.push(full);
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
function loadFile(path, root) {
|
|
3813
|
+
const raw = readFileSync12(path, "utf8");
|
|
3814
|
+
const lines = raw.split("\n").map((r, i) => ({ n: i + 1, raw: r, comment: isCommentLine(r) }));
|
|
3815
|
+
return { path, rel: relative2(root, path) || path, lines };
|
|
3816
|
+
}
|
|
3817
|
+
function findPackageJson(startDir) {
|
|
3818
|
+
let dir = startDir;
|
|
3819
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
3820
|
+
const candidate = join14(dir, "package.json");
|
|
3821
|
+
if (existsSync9(candidate)) {
|
|
3822
|
+
try {
|
|
3823
|
+
return { path: candidate, json: JSON.parse(readFileSync12(candidate, "utf8")) };
|
|
3824
|
+
} catch {
|
|
3825
|
+
return { path: candidate, json: {} };
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
const parent = dirname9(dir);
|
|
3829
|
+
if (parent === dir) break;
|
|
3830
|
+
dir = parent;
|
|
3831
|
+
}
|
|
3832
|
+
return null;
|
|
3833
|
+
}
|
|
3834
|
+
function loadTree(root) {
|
|
3835
|
+
const st = statSync11(root);
|
|
3836
|
+
const baseDir = st.isDirectory() ? root : dirname9(root);
|
|
3837
|
+
const filePaths = [];
|
|
3838
|
+
if (st.isDirectory()) walk(root, filePaths);
|
|
3839
|
+
else if (root.endsWith(".ts") || root.endsWith(".tsx")) filePaths.push(root);
|
|
3840
|
+
const files = filePaths.map((p) => loadFile(p, baseDir));
|
|
3841
|
+
return { root, files, packageJson: findPackageJson(baseDir) };
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3844
|
+
// src/types.ts
|
|
3845
|
+
function isLiveDetector(d) {
|
|
3846
|
+
return typeof d.runLive === "function";
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3849
|
+
// src/doctor.ts
|
|
3850
|
+
function pad(s, w) {
|
|
3851
|
+
return s.length >= w ? s : s + " ".repeat(w - s.length);
|
|
3852
|
+
}
|
|
3853
|
+
function invokeChecked(id, phase, fn) {
|
|
3854
|
+
let r;
|
|
3855
|
+
try {
|
|
3856
|
+
r = fn();
|
|
3857
|
+
} catch (err) {
|
|
3858
|
+
const detail = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
3859
|
+
throw new Error(
|
|
3860
|
+
`mcp-doctor: detector "${id}" (${phase}) threw an UNEXPECTED error \u2014 the run is not trustworthy and will NOT report on the remaining detectors as if it were whole. Deliberate undecidability must be returned in-band as could-not-judge, never thrown. Underlying error:
|
|
3861
|
+
${detail}`
|
|
3862
|
+
);
|
|
3863
|
+
}
|
|
3864
|
+
if (!r || typeof r.state !== "string") {
|
|
3865
|
+
throw new Error(
|
|
3866
|
+
`mcp-doctor: detector "${id}" (${phase}) did not produce a well-formed result.`
|
|
3867
|
+
);
|
|
3868
|
+
}
|
|
3869
|
+
if (r.state === "could-not-judge" && !(typeof r.reason === "string" && r.reason.trim())) {
|
|
3870
|
+
throw new Error(
|
|
3871
|
+
`mcp-doctor: detector "${id}" (${phase}) returned could-not-judge with no reason \u2014 a state with no reason is a shrug. Set result.reason to the machine-readable reason it could not decide.`
|
|
3872
|
+
);
|
|
3873
|
+
}
|
|
3874
|
+
return r;
|
|
3875
|
+
}
|
|
3876
|
+
function reconcile(id, staticR, dynamicR) {
|
|
3877
|
+
const reason = `static and dynamic verdicts DISAGREE for "${id}": static=${staticR.state} @ ${staticR.anchor} ("${staticR.verdict}") vs dynamic=${dynamicR.state} @ ${dynamicR.anchor} ("${dynamicR.verdict}"). The modes must never disagree silently \u2014 resolve which surface is authoritative for this rule.`;
|
|
3878
|
+
return {
|
|
3879
|
+
id: `${id} [static\u2194dynamic disagreement]`,
|
|
3880
|
+
anchor: `${staticR.anchor} | ${dynamicR.anchor}`,
|
|
3881
|
+
state: "non-conforming",
|
|
3882
|
+
verdict: reason,
|
|
3883
|
+
reason
|
|
3884
|
+
};
|
|
3885
|
+
}
|
|
3886
|
+
function runDoctor(target, detectorList = detectors, opts = {}) {
|
|
3887
|
+
const mode = opts.mode ?? "static";
|
|
3888
|
+
const tree = loadTree(target);
|
|
3889
|
+
const needsRuntime = mode !== "static" && detectorList.some((d) => typeof d.runDynamic === "function");
|
|
3890
|
+
const runtime = needsRuntime ? launchAndIntrospect(target) : null;
|
|
3891
|
+
const results = [];
|
|
3892
|
+
for (const d of detectorList) {
|
|
3893
|
+
const hasDynamic = typeof d.runDynamic === "function";
|
|
3894
|
+
if (mode === "static" || !hasDynamic) {
|
|
3895
|
+
results.push(invokeChecked(d.id, "static", () => d.run(tree)));
|
|
3896
|
+
continue;
|
|
3897
|
+
}
|
|
3898
|
+
const rt = runtime;
|
|
3899
|
+
if (mode === "dynamic") {
|
|
3900
|
+
results.push(
|
|
3901
|
+
invokeChecked(
|
|
3902
|
+
d.id,
|
|
3903
|
+
"dynamic",
|
|
3904
|
+
() => d.runDynamic(rt)
|
|
3905
|
+
)
|
|
3906
|
+
);
|
|
3907
|
+
continue;
|
|
3908
|
+
}
|
|
3909
|
+
const staticR = invokeChecked(d.id, "static", () => d.run(tree));
|
|
3910
|
+
const dynamicR = invokeChecked(
|
|
3911
|
+
d.id,
|
|
3912
|
+
"dynamic",
|
|
3913
|
+
() => d.runDynamic(rt)
|
|
3914
|
+
);
|
|
3915
|
+
results.push(staticR);
|
|
3916
|
+
results.push(dynamicR);
|
|
3917
|
+
if (staticR.state !== dynamicR.state) {
|
|
3918
|
+
results.push(reconcile(d.id, staticR, dynamicR));
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
return results;
|
|
3922
|
+
}
|
|
3923
|
+
async function invokeLiveChecked(d, session) {
|
|
3924
|
+
if (!session) {
|
|
3925
|
+
return {
|
|
3926
|
+
id: d.id,
|
|
3927
|
+
anchor: "<live session>",
|
|
3928
|
+
state: "could-not-judge",
|
|
3929
|
+
verdict: `no running session supplied \u2014 the live control for "${d.id}" could not connect.`,
|
|
3930
|
+
reason: `live detector "${d.id}" requires a running MCP session; none was passed to runDoctorAsync.`
|
|
3931
|
+
};
|
|
3932
|
+
}
|
|
3933
|
+
let r;
|
|
3934
|
+
try {
|
|
3935
|
+
r = await d.runLive(session);
|
|
3936
|
+
} catch (err) {
|
|
3937
|
+
const detail = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
3938
|
+
return {
|
|
3939
|
+
id: d.id,
|
|
3940
|
+
anchor: "<live session>",
|
|
3941
|
+
state: "could-not-judge",
|
|
3942
|
+
verdict: `the running session could not be read for "${d.id}".`,
|
|
3943
|
+
reason: `live detector "${d.id}" could not complete its session round-trip (the session threw/timed out/refused before a verdict could be formed): ${detail}`
|
|
3944
|
+
};
|
|
3945
|
+
}
|
|
3946
|
+
if (!r || typeof r.state !== "string") {
|
|
3947
|
+
throw new Error(
|
|
3948
|
+
`mcp-doctor: live detector "${d.id}" (live) did not produce a well-formed result.`
|
|
3949
|
+
);
|
|
3950
|
+
}
|
|
3951
|
+
if (r.state === "could-not-judge" && !(typeof r.reason === "string" && r.reason.trim())) {
|
|
3952
|
+
throw new Error(
|
|
3953
|
+
`mcp-doctor: live detector "${d.id}" (live) returned could-not-judge with no reason \u2014 a state with no reason is a shrug. Set result.reason to the machine-readable reason it could not decide.`
|
|
3954
|
+
);
|
|
3955
|
+
}
|
|
3956
|
+
return r;
|
|
3957
|
+
}
|
|
3958
|
+
async function runDoctorAsync(target, opts = {}) {
|
|
3959
|
+
const list = opts.detectorList ?? detectors;
|
|
3960
|
+
const tree = loadTree(target);
|
|
3961
|
+
const results = [];
|
|
3962
|
+
for (const d of list) {
|
|
3963
|
+
if (isLiveDetector(d)) {
|
|
3964
|
+
results.push(await invokeLiveChecked(d, opts.session));
|
|
3965
|
+
} else {
|
|
3966
|
+
results.push(invokeChecked(d.id, "static", () => d.run(tree)));
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
if (results.length !== list.length) {
|
|
3970
|
+
throw new Error(
|
|
3971
|
+
`mcp-doctor: runDoctorAsync produced ${results.length} results for ${list.length} detectors \u2014 the run is not whole and will not report as if it were.`
|
|
3972
|
+
);
|
|
3973
|
+
}
|
|
3974
|
+
return results;
|
|
3975
|
+
}
|
|
3976
|
+
function renderTable(surface, results) {
|
|
3977
|
+
const cols = { item: 24, anchor: 26, state: 15 };
|
|
3978
|
+
const header = `${pad("item", cols.item)} | ${pad("file:line", cols.anchor)} | ${pad("state", cols.state)} | verdict`;
|
|
3979
|
+
const sep = "-".repeat(header.length);
|
|
3980
|
+
const rows = results.map(
|
|
3981
|
+
(r) => `${pad(r.id, cols.item)} | ${pad(r.anchor, cols.anchor)} | ${pad(r.state, cols.state)} | ${r.verdict}`
|
|
3982
|
+
);
|
|
3983
|
+
const table = [`surface: ${surface}`, header, sep, ...rows].join("\n");
|
|
3984
|
+
const unjudged = results.filter((r) => r.state === "could-not-judge");
|
|
3985
|
+
if (unjudged.length === 0) return table;
|
|
3986
|
+
const cnjHeader = `could-not-judge (${unjudged.length}) \u2014 the doctor could NOT read these; this is NOT a clean bill:`;
|
|
3987
|
+
const cnjSep = "-".repeat(cnjHeader.length);
|
|
3988
|
+
const cnjRows = unjudged.map(
|
|
3989
|
+
(r) => `${pad(r.id, cols.item)} | ${pad(r.anchor, cols.anchor)} | ${r.reason ?? r.verdict}`
|
|
3990
|
+
);
|
|
3991
|
+
return [table, "", cnjHeader, cnjSep, ...cnjRows].join("\n");
|
|
3992
|
+
}
|
|
3993
|
+
function computeExitCode(results) {
|
|
3994
|
+
if (results.some((r) => r.state === "non-conforming")) return 1;
|
|
3995
|
+
if (results.some((r) => r.state === "could-not-judge")) return 2;
|
|
3996
|
+
return 0;
|
|
3997
|
+
}
|
|
3998
|
+
function main() {
|
|
3999
|
+
const args = process.argv.slice(2);
|
|
4000
|
+
const modeArg = args.find((a) => a.startsWith("--mode="));
|
|
4001
|
+
const target = args.find((a) => !a.startsWith("--"));
|
|
4002
|
+
if (!target) {
|
|
4003
|
+
console.error("usage: tsx src/doctor.ts <source-tree-path> [--mode=static|dynamic|both]");
|
|
4004
|
+
process.exit(1);
|
|
4005
|
+
}
|
|
4006
|
+
const mode = modeArg ? modeArg.slice("--mode=".length) : "static";
|
|
4007
|
+
if (mode !== "static" && mode !== "dynamic" && mode !== "both") {
|
|
4008
|
+
console.error(`unknown --mode "${mode}" \u2014 expected static|dynamic|both`);
|
|
384
4009
|
process.exit(1);
|
|
385
4010
|
}
|
|
386
|
-
const results = runDoctor(target);
|
|
4011
|
+
const results = runDoctor(target, void 0, { mode });
|
|
387
4012
|
console.log(renderTable(target, results));
|
|
4013
|
+
process.exitCode = computeExitCode(results);
|
|
388
4014
|
}
|
|
389
4015
|
if (import.meta.url === `file://${process.argv[1]}`) main();
|
|
390
4016
|
export {
|
|
4017
|
+
computeExitCode,
|
|
4018
|
+
isLiveDetector,
|
|
391
4019
|
renderTable,
|
|
392
|
-
runDoctor
|
|
4020
|
+
runDoctor,
|
|
4021
|
+
runDoctorAsync
|
|
393
4022
|
};
|