@vantageos/mcp-doctor 0.2.0 → 0.3.1
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 +68 -0
- package/README.md +72 -7
- package/dist/doctor.d.ts +44 -0
- package/dist/doctor.js +2932 -273
- package/package.json +3 -7
package/dist/doctor.js
CHANGED
|
@@ -1,31 +1,133 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
// src/doctor.ts
|
|
4
|
+
import { readFileSync as readFileSync19, realpathSync } from "fs";
|
|
5
|
+
import { dirname as dirname11, join as join21, resolve as resolve2 } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
|
|
8
|
+
// src/detectors/c7-artifact-verifiable.ts
|
|
9
|
+
import { existsSync, readFileSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
var PIN = "a74a042b53c71a095ab88b3073bd47728eeda814";
|
|
12
|
+
var DOC_FILES = ["README.md", "README.fr.md"];
|
|
13
|
+
var INSTALL_RE = /\b(?:npm\s+(?:install|i)|npx|pnpm\s+add|yarn\s+add)\s+(@?[\w./-]+)/i;
|
|
14
|
+
var PIN_VERSION_RE = /@\d+\.\d+\.\d+/;
|
|
15
|
+
var PROVENANCE_RE = /\b(?:provenance|sha-?256|sha-?512|checksum|gpg\s+signature|sigstore|cosign|--provenance)\b/i;
|
|
16
|
+
var SECOND_CHANNEL_RE = /\bgithub\s+release|releases\/tag|releases\/download\b/i;
|
|
17
|
+
function c7PositiveControls() {
|
|
18
|
+
const installSample = "npm install @vantageos/mcp-doctor";
|
|
19
|
+
const pinSample = "npm install @vantageos/mcp-doctor@1.2.3";
|
|
20
|
+
const bareSample = "npm install @vantageos/mcp-doctor";
|
|
21
|
+
const provenanceSample = "published with npm publish --provenance";
|
|
22
|
+
const channelSample = "also available as a GitHub release";
|
|
23
|
+
return {
|
|
24
|
+
installMatcher: {
|
|
25
|
+
pattern: INSTALL_RE.source,
|
|
26
|
+
sample: installSample,
|
|
27
|
+
matched: INSTALL_RE.test(installSample)
|
|
28
|
+
},
|
|
29
|
+
pinMatcher: {
|
|
30
|
+
pattern: PIN_VERSION_RE.source,
|
|
31
|
+
sample: pinSample,
|
|
32
|
+
matched: PIN_VERSION_RE.test(pinSample),
|
|
33
|
+
rejectsBare: !PIN_VERSION_RE.test(bareSample)
|
|
34
|
+
},
|
|
35
|
+
provenanceMatcher: {
|
|
36
|
+
pattern: PROVENANCE_RE.source,
|
|
37
|
+
sample: provenanceSample,
|
|
38
|
+
matched: PROVENANCE_RE.test(provenanceSample)
|
|
39
|
+
},
|
|
40
|
+
channelMatcher: {
|
|
41
|
+
pattern: SECOND_CHANNEL_RE.source,
|
|
42
|
+
sample: channelSample,
|
|
43
|
+
matched: SECOND_CHANNEL_RE.test(channelSample)
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function readDocText(root) {
|
|
48
|
+
for (const name of DOC_FILES) {
|
|
49
|
+
const path = join(root, name);
|
|
50
|
+
if (!existsSync(path)) continue;
|
|
51
|
+
try {
|
|
52
|
+
return { text: readFileSync(path, "utf8"), file: name };
|
|
53
|
+
} catch {
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
var c7ArtifactVerifiable = {
|
|
59
|
+
id: "C7 artifact-verifiable",
|
|
60
|
+
rule: "C-7",
|
|
61
|
+
pin: PIN,
|
|
7
62
|
run(tree) {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
63
|
+
const controls = c7PositiveControls();
|
|
64
|
+
if (!controls.installMatcher.matched) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`C7 install-matcher control FAILED on \`${controls.installMatcher.sample}\` \u2014 matcher broken, verdict untrustworthy`
|
|
67
|
+
);
|
|
14
68
|
}
|
|
15
|
-
if (
|
|
16
|
-
|
|
69
|
+
if (!controls.pinMatcher.matched || !controls.pinMatcher.rejectsBare) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`C7 pin-matcher control FAILED: accept(\`${controls.pinMatcher.sample}\`)=${controls.pinMatcher.matched} rejectsBare=${controls.pinMatcher.rejectsBare} \u2014 matcher broken, verdict untrustworthy`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (!controls.provenanceMatcher.matched) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`C7 provenance-matcher control FAILED on \`${controls.provenanceMatcher.sample}\` \u2014 matcher broken, verdict untrustworthy`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!controls.channelMatcher.matched) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`C7 channel-matcher control FAILED on \`${controls.channelMatcher.sample}\` \u2014 matcher broken, verdict untrustworthy`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const doc = readDocText(tree.root);
|
|
85
|
+
if (!doc) {
|
|
17
86
|
return {
|
|
18
87
|
id: this.id,
|
|
19
|
-
anchor,
|
|
88
|
+
anchor: tree.root,
|
|
20
89
|
state: "non-conforming",
|
|
21
|
-
verdict: "
|
|
90
|
+
verdict: "no README.md/README.fr.md found -> a trunk rule governs every published object; no install-command surface to read (C-7)"
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const installMatch = doc.text.match(INSTALL_RE);
|
|
94
|
+
if (!installMatch) {
|
|
95
|
+
return {
|
|
96
|
+
id: this.id,
|
|
97
|
+
anchor: `${doc.file}:1`,
|
|
98
|
+
state: "non-conforming",
|
|
99
|
+
verdict: `no one-command install naming a registry found in ${doc.file} -> add \`npm install <pkg>\`/\`npx <pkg>\` naming the registry (C-7a)`
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (!PIN_VERSION_RE.test(doc.text)) {
|
|
103
|
+
return {
|
|
104
|
+
id: this.id,
|
|
105
|
+
anchor: `${doc.file}:1`,
|
|
106
|
+
state: "non-conforming",
|
|
107
|
+
verdict: `install command present but no version-pin shape (\`@<version>\`) found in ${doc.file} -> document that an exact version can be pinned and rolled back (C-7b)`
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (!SECOND_CHANNEL_RE.test(doc.text)) {
|
|
111
|
+
return {
|
|
112
|
+
id: this.id,
|
|
113
|
+
anchor: `${doc.file}:1`,
|
|
114
|
+
state: "non-conforming",
|
|
115
|
+
verdict: `only one obtain channel found in ${doc.file} -> cite a second independent channel (e.g. GitHub release) alongside the registry (C-7d)`
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (!PROVENANCE_RE.test(doc.text)) {
|
|
119
|
+
return {
|
|
120
|
+
id: this.id,
|
|
121
|
+
anchor: `${doc.file}:1`,
|
|
122
|
+
state: "non-conforming",
|
|
123
|
+
verdict: `no provenance/checksum signal cited in ${doc.file} -> cite npm provenance or a published checksum (C-7c)`
|
|
22
124
|
};
|
|
23
125
|
}
|
|
24
126
|
return {
|
|
25
127
|
id: this.id,
|
|
26
|
-
anchor:
|
|
128
|
+
anchor: `${doc.file}:1`,
|
|
27
129
|
state: "present",
|
|
28
|
-
verdict:
|
|
130
|
+
verdict: `${doc.file} declares a one-command pinned install, >=2 obtain channels, and a provenance/checksum signal (C-7a/b/c/d complete). Whether the cited signal actually RESOLVES against what the registry serves is the separate gate check at publish time \u2014 this static run asserts the declaration is complete, not that the gate has resolved it.`
|
|
29
131
|
};
|
|
30
132
|
}
|
|
31
133
|
};
|
|
@@ -224,6 +326,208 @@ function sdkFirstAnchor(tree) {
|
|
|
224
326
|
}
|
|
225
327
|
return null;
|
|
226
328
|
}
|
|
329
|
+
var VERB_FIELD_RE = /\bverb\s*:\s*["'`]([A-Za-z][A-Za-z-]*)["'`]/;
|
|
330
|
+
var TABLE_FIELD_RE = /\btable\s*:\s*["'`]([A-Za-z0-9_.-]+)["'`]/;
|
|
331
|
+
var MECHANISM_FIELD_RE = /\b(?:searchMechanism|mechanism)\s*:\s*["'`]([A-Za-z0-9_-]+(?:\([^)]*\))?)["'`]/;
|
|
332
|
+
var AUTH_READ_FIELD_RE = /\bauthRead\s*:\s*["'`]([A-Za-z-]+)["'`]/;
|
|
333
|
+
var AUTH_WRITE_FIELD_RE = /\bauthWrite\s*:\s*["'`]([A-Za-z-]+)["'`]/;
|
|
334
|
+
var AUTH_FOLDED_FIELD_RE = /\bauth\s*:\s*["'`]([A-Za-z-]+)["'`]/;
|
|
335
|
+
var READ_ONLY_HINT_RE = /\breadOnlyHint\s*:\s*(?:true|false)\b/;
|
|
336
|
+
var DESTRUCTIVE_HINT_RE = /\bdestructiveHint\s*:\s*(?:true|false)\b/;
|
|
337
|
+
var IDEMPOTENT_HINT_RE = /\bidempotentHint\s*:\s*(?:true|false)\b/;
|
|
338
|
+
var OPEN_WORLD_HINT_RE = /\bopenWorldHint\s*:\s*(?:true|false)\b/;
|
|
339
|
+
var TOKENS_FIELD_RE = /\btokensEstimeContexte\s*:\s*(\d+)\b/;
|
|
340
|
+
var PAGINATION_CURSOR_RE = /\bpaginationCursor\s*:\s*(?:true|false)\b/;
|
|
341
|
+
var PAGINATION_DEFAULT_RE = /\bpaginationDefault\s*:\s*(\d+)\b/;
|
|
342
|
+
var PAGINATION_MAX_RE = /\bpaginationMax\s*:\s*(\d+)\b/;
|
|
343
|
+
function toolAnnotationFieldControls() {
|
|
344
|
+
const verbSample = 'annotations: { verb: "create", table: "customers" },';
|
|
345
|
+
const tableSample = 'annotations: { verb: "create", table: "customers" },';
|
|
346
|
+
const mechSample = 'annotations: { searchMechanism: "indexed-bm25" },';
|
|
347
|
+
const authPairSample = 'annotations: { authRead: "public", authWrite: "org-admin" },';
|
|
348
|
+
const authFoldedSample = 'annotations: { auth: "org-admin" },';
|
|
349
|
+
const hintsSample = "annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },";
|
|
350
|
+
const tokensSample = "annotations: { tokensEstimeContexte: 120 },";
|
|
351
|
+
const paginationSample = "annotations: { paginationCursor: true, paginationDefault: 20, paginationMax: 100 },";
|
|
352
|
+
const paginationUnboundedSample = "annotations: { paginationMax: Infinity },";
|
|
353
|
+
return {
|
|
354
|
+
verb: {
|
|
355
|
+
pattern: VERB_FIELD_RE.source,
|
|
356
|
+
sample: verbSample,
|
|
357
|
+
matched: VERB_FIELD_RE.test(verbSample)
|
|
358
|
+
},
|
|
359
|
+
table: {
|
|
360
|
+
pattern: TABLE_FIELD_RE.source,
|
|
361
|
+
sample: tableSample,
|
|
362
|
+
matched: TABLE_FIELD_RE.test(tableSample)
|
|
363
|
+
},
|
|
364
|
+
mechanism: {
|
|
365
|
+
pattern: MECHANISM_FIELD_RE.source,
|
|
366
|
+
sample: mechSample,
|
|
367
|
+
matched: MECHANISM_FIELD_RE.test(mechSample)
|
|
368
|
+
},
|
|
369
|
+
authRead: {
|
|
370
|
+
pattern: AUTH_READ_FIELD_RE.source,
|
|
371
|
+
sample: authPairSample,
|
|
372
|
+
matched: AUTH_READ_FIELD_RE.test(authPairSample)
|
|
373
|
+
},
|
|
374
|
+
authWrite: {
|
|
375
|
+
pattern: AUTH_WRITE_FIELD_RE.source,
|
|
376
|
+
sample: authPairSample,
|
|
377
|
+
matched: AUTH_WRITE_FIELD_RE.test(authPairSample)
|
|
378
|
+
},
|
|
379
|
+
authFolded: {
|
|
380
|
+
pattern: AUTH_FOLDED_FIELD_RE.source,
|
|
381
|
+
sample: authFoldedSample,
|
|
382
|
+
matched: AUTH_FOLDED_FIELD_RE.test(authFoldedSample) && !AUTH_READ_FIELD_RE.test(authFoldedSample)
|
|
383
|
+
},
|
|
384
|
+
behavioralHints: {
|
|
385
|
+
pattern: [
|
|
386
|
+
READ_ONLY_HINT_RE.source,
|
|
387
|
+
DESTRUCTIVE_HINT_RE.source,
|
|
388
|
+
IDEMPOTENT_HINT_RE.source,
|
|
389
|
+
OPEN_WORLD_HINT_RE.source
|
|
390
|
+
].join(" & "),
|
|
391
|
+
sample: hintsSample,
|
|
392
|
+
matched: READ_ONLY_HINT_RE.test(hintsSample) && DESTRUCTIVE_HINT_RE.test(hintsSample) && IDEMPOTENT_HINT_RE.test(hintsSample) && OPEN_WORLD_HINT_RE.test(hintsSample)
|
|
393
|
+
},
|
|
394
|
+
tokensCtx: {
|
|
395
|
+
pattern: TOKENS_FIELD_RE.source,
|
|
396
|
+
sample: tokensSample,
|
|
397
|
+
matched: TOKENS_FIELD_RE.test(tokensSample)
|
|
398
|
+
},
|
|
399
|
+
pagination: {
|
|
400
|
+
pattern: [
|
|
401
|
+
PAGINATION_CURSOR_RE.source,
|
|
402
|
+
PAGINATION_DEFAULT_RE.source,
|
|
403
|
+
PAGINATION_MAX_RE.source
|
|
404
|
+
].join(" & "),
|
|
405
|
+
sample: paginationSample,
|
|
406
|
+
matched: PAGINATION_CURSOR_RE.test(paginationSample) && PAGINATION_DEFAULT_RE.test(paginationSample) && PAGINATION_MAX_RE.test(paginationSample),
|
|
407
|
+
rejectsUnbounded: !PAGINATION_MAX_RE.test(paginationUnboundedSample)
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
var CALL_OPENER_LOOKBACK = 10;
|
|
412
|
+
function callOpenerLine(file, siteLine) {
|
|
413
|
+
for (let n = siteLine; n >= Math.max(1, siteLine - CALL_OPENER_LOOKBACK); n--) {
|
|
414
|
+
const line = file.lines[n - 1];
|
|
415
|
+
if (!line || line.comment) continue;
|
|
416
|
+
if (TOOL_INLINE_LITERAL_RE.test(line.raw) || TOOL_INLINE_IDENT_RE.test(line.raw) || OPENER_RE.test(line.raw)) {
|
|
417
|
+
return line.n;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return siteLine;
|
|
421
|
+
}
|
|
422
|
+
function ownCallEnd(file, siteLine) {
|
|
423
|
+
const startLine = callOpenerLine(file, siteLine);
|
|
424
|
+
let depth = 0;
|
|
425
|
+
for (const line of file.lines) {
|
|
426
|
+
if (line.n < startLine) continue;
|
|
427
|
+
if (!line.comment) {
|
|
428
|
+
depth += (line.raw.match(/\(/g) ?? []).length - (line.raw.match(/\)/g) ?? []).length;
|
|
429
|
+
}
|
|
430
|
+
if (line.n >= startLine && depth <= 0 && line.n > startLine) return line.n;
|
|
431
|
+
if (line.n === startLine && depth <= 0) {
|
|
432
|
+
return line.n;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return file.lines.length;
|
|
436
|
+
}
|
|
437
|
+
function findToolAnnotations(tree) {
|
|
438
|
+
const allSites = findRegisteredTools(tree);
|
|
439
|
+
const out = [];
|
|
440
|
+
for (const file of tree.files) {
|
|
441
|
+
const prefix = `${file.rel}:`;
|
|
442
|
+
const sitesInFile = allSites.filter((s) => s.anchor.startsWith(prefix)).map((s) => ({ ...s, line: Number.parseInt(s.anchor.slice(prefix.length), 10) })).sort((a, b) => a.line - b.line).map((s) => ({ ...s, ownEnd: ownCallEnd(file, s.line) }));
|
|
443
|
+
for (let i = 0; i < sitesInFile.length; i++) {
|
|
444
|
+
const site = sitesInFile[i];
|
|
445
|
+
const windowStart = i === 0 ? 0 : sitesInFile[i - 1].ownEnd;
|
|
446
|
+
const windowEnd = site.ownEnd;
|
|
447
|
+
let verb;
|
|
448
|
+
let table;
|
|
449
|
+
let searchMechanism;
|
|
450
|
+
let authRead;
|
|
451
|
+
let authWrite;
|
|
452
|
+
let authFolded = false;
|
|
453
|
+
let readOnlyHintDeclared = false;
|
|
454
|
+
let destructiveHintDeclared = false;
|
|
455
|
+
let idempotentHintDeclared = false;
|
|
456
|
+
let openWorldHintDeclared = false;
|
|
457
|
+
let tokensEstimeContexte;
|
|
458
|
+
let paginationCursorDeclared = false;
|
|
459
|
+
let paginationDefault;
|
|
460
|
+
let paginationMax;
|
|
461
|
+
for (const line of file.lines) {
|
|
462
|
+
if (line.n <= windowStart || line.n > windowEnd) continue;
|
|
463
|
+
if (line.comment) continue;
|
|
464
|
+
const raw = line.raw;
|
|
465
|
+
if (!verb) {
|
|
466
|
+
const m = raw.match(VERB_FIELD_RE);
|
|
467
|
+
if (m) verb = m[1];
|
|
468
|
+
}
|
|
469
|
+
if (!table) {
|
|
470
|
+
const m = raw.match(TABLE_FIELD_RE);
|
|
471
|
+
if (m) table = m[1];
|
|
472
|
+
}
|
|
473
|
+
if (!searchMechanism) {
|
|
474
|
+
const m = raw.match(MECHANISM_FIELD_RE);
|
|
475
|
+
if (m) searchMechanism = m[1];
|
|
476
|
+
}
|
|
477
|
+
if (!authRead) {
|
|
478
|
+
const m = raw.match(AUTH_READ_FIELD_RE);
|
|
479
|
+
if (m) authRead = m[1];
|
|
480
|
+
}
|
|
481
|
+
if (!authWrite) {
|
|
482
|
+
const m = raw.match(AUTH_WRITE_FIELD_RE);
|
|
483
|
+
if (m) authWrite = m[1];
|
|
484
|
+
}
|
|
485
|
+
if (!authFolded && AUTH_FOLDED_FIELD_RE.test(raw) && !/\bauth(?:Read|Write)\s*:/.test(raw)) {
|
|
486
|
+
authFolded = true;
|
|
487
|
+
}
|
|
488
|
+
if (!readOnlyHintDeclared && READ_ONLY_HINT_RE.test(raw)) readOnlyHintDeclared = true;
|
|
489
|
+
if (!destructiveHintDeclared && DESTRUCTIVE_HINT_RE.test(raw))
|
|
490
|
+
destructiveHintDeclared = true;
|
|
491
|
+
if (!idempotentHintDeclared && IDEMPOTENT_HINT_RE.test(raw)) idempotentHintDeclared = true;
|
|
492
|
+
if (!openWorldHintDeclared && OPEN_WORLD_HINT_RE.test(raw)) openWorldHintDeclared = true;
|
|
493
|
+
if (tokensEstimeContexte === void 0) {
|
|
494
|
+
const m = raw.match(TOKENS_FIELD_RE);
|
|
495
|
+
if (m) tokensEstimeContexte = Number.parseInt(m[1], 10);
|
|
496
|
+
}
|
|
497
|
+
if (!paginationCursorDeclared && PAGINATION_CURSOR_RE.test(raw)) {
|
|
498
|
+
paginationCursorDeclared = true;
|
|
499
|
+
}
|
|
500
|
+
if (paginationDefault === void 0) {
|
|
501
|
+
const m = raw.match(PAGINATION_DEFAULT_RE);
|
|
502
|
+
if (m) paginationDefault = Number.parseInt(m[1], 10);
|
|
503
|
+
}
|
|
504
|
+
if (paginationMax === void 0) {
|
|
505
|
+
const m = raw.match(PAGINATION_MAX_RE);
|
|
506
|
+
if (m) paginationMax = Number.parseInt(m[1], 10);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
out.push({
|
|
510
|
+
name: site.name,
|
|
511
|
+
anchor: site.anchor,
|
|
512
|
+
verb,
|
|
513
|
+
table,
|
|
514
|
+
searchMechanism,
|
|
515
|
+
authRead,
|
|
516
|
+
authWrite,
|
|
517
|
+
authFolded,
|
|
518
|
+
readOnlyHintDeclared,
|
|
519
|
+
destructiveHintDeclared,
|
|
520
|
+
idempotentHintDeclared,
|
|
521
|
+
openWorldHintDeclared,
|
|
522
|
+
tokensEstimeContexte,
|
|
523
|
+
paginationCursorDeclared,
|
|
524
|
+
paginationDefault,
|
|
525
|
+
paginationMax
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
227
531
|
function countKeywordInCode(tree, re) {
|
|
228
532
|
let count = 0;
|
|
229
533
|
let firstFile;
|
|
@@ -240,8 +544,2180 @@ function countKeywordInCode(tree, re) {
|
|
|
240
544
|
}
|
|
241
545
|
}
|
|
242
546
|
}
|
|
243
|
-
return { count, firstFile, firstLine };
|
|
244
|
-
}
|
|
547
|
+
return { count, firstFile, firstLine };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// src/detectors/c8-authority-declared.ts
|
|
551
|
+
var TIER_SET = /* @__PURE__ */ new Set(["public", "org-member", "org-admin", "fleet-internal", "master"]);
|
|
552
|
+
function c8PositiveControls() {
|
|
553
|
+
const controls = toolAnnotationFieldControls();
|
|
554
|
+
return {
|
|
555
|
+
authReadMatcher: controls.authRead,
|
|
556
|
+
authWriteMatcher: controls.authWrite,
|
|
557
|
+
authFoldedMatcher: controls.authFolded,
|
|
558
|
+
setAcceptsPublic: TIER_SET.has("public"),
|
|
559
|
+
setRejectsOutOfSet: !TIER_SET.has("superadmin")
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function isFolded(t) {
|
|
563
|
+
return t.authFolded === true && t.authRead === void 0 && t.authWrite === void 0;
|
|
564
|
+
}
|
|
565
|
+
var c8AuthorityDeclared = {
|
|
566
|
+
id: "C8 authority-declared",
|
|
567
|
+
rule: "C-8",
|
|
568
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
569
|
+
run(tree) {
|
|
570
|
+
const controls = c8PositiveControls();
|
|
571
|
+
if (!controls.authReadMatcher.matched || !controls.authWriteMatcher.matched || !controls.authFoldedMatcher.matched) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`C8 positive control FAILED: authRead=${controls.authReadMatcher.matched} authWrite=${controls.authWriteMatcher.matched} folded=${controls.authFoldedMatcher.matched} \u2014 matcher broken, verdict untrustworthy`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
if (!controls.setAcceptsPublic || !controls.setRejectsOutOfSet) {
|
|
577
|
+
throw new Error(
|
|
578
|
+
"C8 closed-set control FAILED: TIER_SET must accept a legitimate tier (public) and reject an out-of-set value (superadmin) \u2014 verdict untrustworthy"
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const unresolved = findUnresolvedToolSites(tree);
|
|
582
|
+
if (unresolved.length > 0) {
|
|
583
|
+
const reason = `tool name at ${unresolved.length} registration site(s) is computed/imported/template (${unresolved.join(", ")}) \u2014 declared authority unattributable statically (C-8)`;
|
|
584
|
+
return {
|
|
585
|
+
id: this.id,
|
|
586
|
+
anchor: unresolved[0],
|
|
587
|
+
state: "could-not-judge",
|
|
588
|
+
reason,
|
|
589
|
+
verdict: `could not judge: ${reason}`
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
const tools = findToolAnnotations(tree);
|
|
593
|
+
if (tools.length === 0) {
|
|
594
|
+
return {
|
|
595
|
+
id: this.id,
|
|
596
|
+
anchor: "-",
|
|
597
|
+
state: "absent",
|
|
598
|
+
verdict: "no tool registrations found; authority-declared check n/a"
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
for (const t of tools) {
|
|
602
|
+
if (isFolded(t)) {
|
|
603
|
+
return {
|
|
604
|
+
id: this.id,
|
|
605
|
+
anchor: t.anchor,
|
|
606
|
+
state: "non-conforming",
|
|
607
|
+
verdict: `tool "${t.name}" declares a single folded "auth" field instead of the (authRead, authWrite) pair \u2014 folding erases the read/write divergence (positive-control OK) -> declare authRead and authWrite separately from the closed 5-tier set (C-8)`
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
if (!t.authRead || !t.authWrite) {
|
|
611
|
+
return {
|
|
612
|
+
id: this.id,
|
|
613
|
+
anchor: t.anchor,
|
|
614
|
+
state: "non-conforming",
|
|
615
|
+
verdict: `tool "${t.name}" is missing ${!t.authRead ? "authRead" : "authWrite"} (positive-control OK) -> declare the full (authRead, authWrite) pair from public/org-member/org-admin/fleet-internal/master (C-8)`
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
if (!TIER_SET.has(t.authRead)) {
|
|
619
|
+
return {
|
|
620
|
+
id: this.id,
|
|
621
|
+
anchor: t.anchor,
|
|
622
|
+
state: "non-conforming",
|
|
623
|
+
verdict: `tool "${t.name}" declares authRead "${t.authRead}", outside the closed 5-tier set (positive-control OK) -> use public/org-member/org-admin/fleet-internal/master (C-8)`
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
if (!TIER_SET.has(t.authWrite)) {
|
|
627
|
+
return {
|
|
628
|
+
id: this.id,
|
|
629
|
+
anchor: t.anchor,
|
|
630
|
+
state: "non-conforming",
|
|
631
|
+
verdict: `tool "${t.name}" declares authWrite "${t.authWrite}", outside the closed 5-tier set (positive-control OK) -> use public/org-member/org-admin/fleet-internal/master (C-8)`
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
id: this.id,
|
|
637
|
+
anchor: tools[0].anchor,
|
|
638
|
+
state: "present",
|
|
639
|
+
verdict: `${tools.length} tool(s) each declare a distinct (authRead, authWrite) pair in the closed 5-tier set (positive-control OK)`
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
// src/detectors/c9-verb-declared.ts
|
|
645
|
+
var VERB_SET = /* @__PURE__ */ new Set([
|
|
646
|
+
"list",
|
|
647
|
+
"get",
|
|
648
|
+
"search",
|
|
649
|
+
"aggregate",
|
|
650
|
+
"create",
|
|
651
|
+
"update",
|
|
652
|
+
"delete",
|
|
653
|
+
"upsert",
|
|
654
|
+
"transition",
|
|
655
|
+
"bulk-write",
|
|
656
|
+
"pure",
|
|
657
|
+
"introspect",
|
|
658
|
+
"external-effect"
|
|
659
|
+
]);
|
|
660
|
+
function c9PositiveControls() {
|
|
661
|
+
const field = toolAnnotationFieldControls().verb;
|
|
662
|
+
return {
|
|
663
|
+
fieldMatcher: field,
|
|
664
|
+
setAcceptsCreate: VERB_SET.has("create"),
|
|
665
|
+
setRejectsRpc: !VERB_SET.has("rpc")
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
var c9VerbDeclared = {
|
|
669
|
+
id: "C9 verb-declared",
|
|
670
|
+
rule: "C-9",
|
|
671
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
672
|
+
run(tree) {
|
|
673
|
+
const controls = c9PositiveControls();
|
|
674
|
+
if (!controls.fieldMatcher.matched) {
|
|
675
|
+
throw new Error(
|
|
676
|
+
`C9 positive control FAILED: pattern /${controls.fieldMatcher.pattern}/ did not match sample \`${controls.fieldMatcher.sample}\` \u2014 matcher broken, verdict untrustworthy`
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
if (!controls.setAcceptsCreate || !controls.setRejectsRpc) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
"C9 closed-set control FAILED: VERB_SET must accept a legitimate verb (create) and reject the banned rpc sentinel \u2014 verdict untrustworthy"
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
const unresolved = findUnresolvedToolSites(tree);
|
|
685
|
+
if (unresolved.length > 0) {
|
|
686
|
+
const reason = `tool name at ${unresolved.length} registration site(s) is computed/imported/template (${unresolved.join(", ")}) \u2014 declared verb unattributable statically (C-9)`;
|
|
687
|
+
return {
|
|
688
|
+
id: this.id,
|
|
689
|
+
anchor: unresolved[0],
|
|
690
|
+
state: "could-not-judge",
|
|
691
|
+
reason,
|
|
692
|
+
verdict: `could not judge: ${reason}`
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
const tools = findToolAnnotations(tree);
|
|
696
|
+
if (tools.length === 0) {
|
|
697
|
+
return {
|
|
698
|
+
id: this.id,
|
|
699
|
+
anchor: "-",
|
|
700
|
+
state: "absent",
|
|
701
|
+
verdict: "no tool registrations found; verb-declared check n/a"
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
for (const t of tools) {
|
|
705
|
+
if (!t.verb) {
|
|
706
|
+
return {
|
|
707
|
+
id: this.id,
|
|
708
|
+
anchor: t.anchor,
|
|
709
|
+
state: "non-conforming",
|
|
710
|
+
verdict: `tool "${t.name}" declares no operation verb (positive-control OK) -> add annotations.verb from the closed CRUD-and-beyond set (C-9)`
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
if (t.verb === "rpc") {
|
|
714
|
+
return {
|
|
715
|
+
id: this.id,
|
|
716
|
+
anchor: t.anchor,
|
|
717
|
+
state: "non-conforming",
|
|
718
|
+
verdict: `tool "${t.name}" declares verb "rpc" \u2014 rpc is the absence of a verb and never publishes (positive-control OK) -> declare a real verb (C-9)`
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
if (!VERB_SET.has(t.verb)) {
|
|
722
|
+
return {
|
|
723
|
+
id: this.id,
|
|
724
|
+
anchor: t.anchor,
|
|
725
|
+
state: "non-conforming",
|
|
726
|
+
verdict: `tool "${t.name}" declares verb "${t.verb}", outside the closed set (positive-control OK) -> use list/get/search/aggregate/create/update/delete/upsert/transition/bulk-write/pure/introspect/external-effect (C-9)`
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
id: this.id,
|
|
732
|
+
anchor: tools[0].anchor,
|
|
733
|
+
state: "present",
|
|
734
|
+
verdict: `${tools.length} tool(s) each declare a verb in the closed set (positive-control OK)`
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
// src/detectors/c10-mechanism-declared.ts
|
|
740
|
+
var MECHANISM_BASE_SET = /* @__PURE__ */ new Set([
|
|
741
|
+
"indexed-range",
|
|
742
|
+
"indexed-bm25",
|
|
743
|
+
"indexed-vector",
|
|
744
|
+
"indexed-hybrid-rrf"
|
|
745
|
+
]);
|
|
746
|
+
function baseOf(mechanism) {
|
|
747
|
+
const paren = mechanism.indexOf("(");
|
|
748
|
+
return paren === -1 ? mechanism : mechanism.slice(0, paren);
|
|
749
|
+
}
|
|
750
|
+
function isListOrSearch(t) {
|
|
751
|
+
if (t.verb) return t.verb === "list" || t.verb === "search";
|
|
752
|
+
return /^(list|search)/i.test(t.name);
|
|
753
|
+
}
|
|
754
|
+
function c10PositiveControls() {
|
|
755
|
+
const field = toolAnnotationFieldControls().mechanism;
|
|
756
|
+
return {
|
|
757
|
+
fieldMatcher: field,
|
|
758
|
+
setAcceptsIndexedBm25: MECHANISM_BASE_SET.has("indexed-bm25"),
|
|
759
|
+
setRejectsUnindexedScan: !MECHANISM_BASE_SET.has("unindexed-scan"),
|
|
760
|
+
vectorParamsStripped: baseOf("indexed-vector(cosine, 1536, 20)") === "indexed-vector"
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
var c10MechanismDeclared = {
|
|
764
|
+
id: "C10 mechanism-declared",
|
|
765
|
+
rule: "C-10",
|
|
766
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
767
|
+
run(tree) {
|
|
768
|
+
const controls = c10PositiveControls();
|
|
769
|
+
if (!controls.fieldMatcher.matched) {
|
|
770
|
+
throw new Error(
|
|
771
|
+
`C10 positive control FAILED: pattern /${controls.fieldMatcher.pattern}/ did not match sample \`${controls.fieldMatcher.sample}\` \u2014 matcher broken, verdict untrustworthy`
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
if (!controls.setAcceptsIndexedBm25 || !controls.setRejectsUnindexedScan || !controls.vectorParamsStripped) {
|
|
775
|
+
throw new Error(
|
|
776
|
+
"C10 closed-set control FAILED: MECHANISM_BASE_SET must accept indexed-bm25, reject unindexed-scan, and strip indexed-vector(...) params \u2014 verdict untrustworthy"
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
const unresolved = findUnresolvedToolSites(tree);
|
|
780
|
+
if (unresolved.length > 0) {
|
|
781
|
+
const reason = `tool name at ${unresolved.length} registration site(s) is computed/imported/template (${unresolved.join(", ")}) \u2014 declared mechanism unattributable statically (C-10)`;
|
|
782
|
+
return {
|
|
783
|
+
id: this.id,
|
|
784
|
+
anchor: unresolved[0],
|
|
785
|
+
state: "could-not-judge",
|
|
786
|
+
reason,
|
|
787
|
+
verdict: `could not judge: ${reason}`
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
const applicable = findToolAnnotations(tree).filter(isListOrSearch);
|
|
791
|
+
if (applicable.length === 0) {
|
|
792
|
+
return {
|
|
793
|
+
id: this.id,
|
|
794
|
+
anchor: "-",
|
|
795
|
+
state: "absent",
|
|
796
|
+
verdict: "no list/search tool found; mechanism-declared check n/a"
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
for (const t of applicable) {
|
|
800
|
+
if (!t.searchMechanism) {
|
|
801
|
+
return {
|
|
802
|
+
id: this.id,
|
|
803
|
+
anchor: t.anchor,
|
|
804
|
+
state: "non-conforming",
|
|
805
|
+
verdict: `list/search tool "${t.name}" declares no search mechanism (positive-control OK) -> add annotations.searchMechanism from the closed indexing-strategy set (C-10)`
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
const base = baseOf(t.searchMechanism);
|
|
809
|
+
if (base === "unindexed-scan") {
|
|
810
|
+
return {
|
|
811
|
+
id: this.id,
|
|
812
|
+
anchor: t.anchor,
|
|
813
|
+
state: "non-conforming",
|
|
814
|
+
verdict: `list/search tool "${t.name}" declares "unindexed-scan" \u2014 refused at publication (positive-control OK) -> declare an indexed mechanism (C-10)`
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
if (!MECHANISM_BASE_SET.has(base)) {
|
|
818
|
+
return {
|
|
819
|
+
id: this.id,
|
|
820
|
+
anchor: t.anchor,
|
|
821
|
+
state: "non-conforming",
|
|
822
|
+
verdict: `list/search tool "${t.name}" declares mechanism "${t.searchMechanism}", outside the closed set (positive-control OK) -> use indexed-range/indexed-bm25/indexed-vector/indexed-hybrid-rrf (C-10)`
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return {
|
|
827
|
+
id: this.id,
|
|
828
|
+
anchor: applicable[0].anchor,
|
|
829
|
+
state: "present",
|
|
830
|
+
verdict: `${applicable.length} list/search tool(s) each declare a mechanism in the closed set (positive-control OK)`
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
// src/detectors/c11-pagination.ts
|
|
836
|
+
var PIN2 = "a74a042b53c71a095ab88b3073bd47728eeda814";
|
|
837
|
+
var PAGINABLE_RE = LIST_READ_RE;
|
|
838
|
+
function c11PositiveControls() {
|
|
839
|
+
const field = toolAnnotationFieldControls().pagination;
|
|
840
|
+
return { fieldMatcher: field };
|
|
841
|
+
}
|
|
842
|
+
var c11Pagination = {
|
|
843
|
+
id: "C11 pagination-declared-applied",
|
|
844
|
+
rule: "C-11",
|
|
845
|
+
pin: PIN2,
|
|
846
|
+
run(tree) {
|
|
847
|
+
const controls = c11PositiveControls();
|
|
848
|
+
if (!controls.fieldMatcher.matched || !controls.fieldMatcher.rejectsUnbounded) {
|
|
849
|
+
throw new Error(
|
|
850
|
+
`C11 positive control FAILED: pattern /${controls.fieldMatcher.pattern}/ matched=${controls.fieldMatcher.matched} rejectsUnbounded=${controls.fieldMatcher.rejectsUnbounded} \u2014 matcher broken, verdict untrustworthy`
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
const unresolved = findUnresolvedToolSites(tree);
|
|
854
|
+
if (unresolved.length > 0) {
|
|
855
|
+
const reason = `tool name at ${unresolved.length} registration site(s) is computed/imported/template (${unresolved.join(", ")}) \u2014 paginable-shape unattributable statically (C-11)`;
|
|
856
|
+
return {
|
|
857
|
+
id: this.id,
|
|
858
|
+
anchor: unresolved[0],
|
|
859
|
+
state: "could-not-judge",
|
|
860
|
+
reason,
|
|
861
|
+
verdict: `could not judge: ${reason}`
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
const tools = findToolAnnotations(tree).filter((t) => PAGINABLE_RE.test(t.name));
|
|
865
|
+
if (tools.length === 0) {
|
|
866
|
+
return {
|
|
867
|
+
id: this.id,
|
|
868
|
+
anchor: "-",
|
|
869
|
+
state: "absent",
|
|
870
|
+
verdict: "no list/read/get/search tool registrations found; pagination declaration check n/a"
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
for (const t of tools) {
|
|
874
|
+
const missing = [];
|
|
875
|
+
if (!t.paginationCursorDeclared) missing.push("paginationCursor");
|
|
876
|
+
if (t.paginationDefault === void 0) missing.push("paginationDefault");
|
|
877
|
+
if (t.paginationMax === void 0) missing.push("paginationMax (finite)");
|
|
878
|
+
if (missing.length > 0) {
|
|
879
|
+
return {
|
|
880
|
+
id: this.id,
|
|
881
|
+
anchor: t.anchor,
|
|
882
|
+
state: "non-conforming",
|
|
883
|
+
verdict: `tool "${t.name}" is missing pagination declaration(s): ${missing.join(", ")} (positive-control OK) -> declare annotations.paginationCursor/paginationDefault/paginationMax with a FINITE max \u2014 an unbounded max never publishes (C-11)`
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return {
|
|
888
|
+
id: this.id,
|
|
889
|
+
anchor: tools[0].anchor,
|
|
890
|
+
state: "present",
|
|
891
|
+
verdict: `${tools.length} paginable tool(s) each declare cursor + default + finite max (positive-control OK)`
|
|
892
|
+
};
|
|
893
|
+
},
|
|
894
|
+
/**
|
|
895
|
+
* DYNAMIC — confirms the tool(s) the static shape considers paginable are
|
|
896
|
+
* genuinely SERVED by the running server (a real runtime fact from the
|
|
897
|
+
* tools/list snapshot). Does NOT perform the invalid-cursor -> -32602
|
|
898
|
+
* round trip: the one-shot RuntimeContext snapshot carries no per-tool
|
|
899
|
+
* schema and no `callTool` verb — that half needs `Session.callTool` with a
|
|
900
|
+
* cursor argument (documented gap, types.ts `Session`). The verdict names
|
|
901
|
+
* this explicitly rather than folding it into a silent full "present".
|
|
902
|
+
*/
|
|
903
|
+
runDynamic(runtime) {
|
|
904
|
+
if (runtime.launchError) {
|
|
905
|
+
const reason = `server could not be started/introspected \u2014 runtime pagination applicability unverifiable: ${runtime.launchError} (C-11)`;
|
|
906
|
+
return {
|
|
907
|
+
id: this.id,
|
|
908
|
+
anchor: `${runtime.root} (runtime)`,
|
|
909
|
+
state: "could-not-judge",
|
|
910
|
+
reason,
|
|
911
|
+
verdict: `could not judge: ${reason}`
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
const paginable = runtime.tools.filter((t) => PAGINABLE_RE.test(t.name));
|
|
915
|
+
if (paginable.length === 0) {
|
|
916
|
+
return {
|
|
917
|
+
id: this.id,
|
|
918
|
+
anchor: `${runtime.root} (runtime tools/list)`,
|
|
919
|
+
state: "absent",
|
|
920
|
+
verdict: "server advertises no list/read/get/search tool at runtime; pagination check n/a"
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
const names = paginable.map((t) => t.name).join(", ");
|
|
924
|
+
return {
|
|
925
|
+
id: this.id,
|
|
926
|
+
anchor: `${runtime.root} (runtime tools/list)`,
|
|
927
|
+
state: "present",
|
|
928
|
+
verdict: `runtime confirms paginable tool(s) actually served: ${names}. The invalid-cursor -> -32602 round trip is NOT performed by this snapshot surface (needs Session.callTool with a cursor argument, not yet exposed \u2014 see types.ts Session doc); this dynamic half asserts service reachability, not the full cursor-handling guarantee.`
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
// src/detectors/c12-no-duplicates.ts
|
|
934
|
+
function tripletKey(t) {
|
|
935
|
+
return `${t.verb}::${t.table}::${t.searchMechanism ?? ""}`;
|
|
936
|
+
}
|
|
937
|
+
function c12PositiveControls() {
|
|
938
|
+
const a = { name: "get_customer", anchor: "a", verb: "get", table: "customers" };
|
|
939
|
+
const b = {
|
|
940
|
+
name: "fetch_customer",
|
|
941
|
+
anchor: "b",
|
|
942
|
+
verb: "get",
|
|
943
|
+
table: "customers"
|
|
944
|
+
};
|
|
945
|
+
const c = { name: "get_order", anchor: "c", verb: "get", table: "orders" };
|
|
946
|
+
return {
|
|
947
|
+
fieldMatcher: toolAnnotationFieldControls().table,
|
|
948
|
+
detectsDuplicate: tripletKey(a) === tripletKey(b),
|
|
949
|
+
clearsDistinctTable: tripletKey(a) !== tripletKey(c)
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
var c12NoDuplicates = {
|
|
953
|
+
id: "C12 no-duplicates",
|
|
954
|
+
rule: "C-12",
|
|
955
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
956
|
+
run(tree) {
|
|
957
|
+
const controls = c12PositiveControls();
|
|
958
|
+
if (!controls.fieldMatcher.matched) {
|
|
959
|
+
throw new Error(
|
|
960
|
+
`C12 positive control FAILED: pattern /${controls.fieldMatcher.pattern}/ did not match sample \`${controls.fieldMatcher.sample}\` \u2014 matcher broken, verdict untrustworthy`
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
if (!controls.detectsDuplicate || !controls.clearsDistinctTable) {
|
|
964
|
+
throw new Error(
|
|
965
|
+
"C12 dedupe-key control FAILED: must match on an identical (verb, table) pair and differ on a distinct table \u2014 verdict untrustworthy"
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
const unresolved = findUnresolvedToolSites(tree);
|
|
969
|
+
if (unresolved.length > 0) {
|
|
970
|
+
const reason = `tool name at ${unresolved.length} registration site(s) is computed/imported/template (${unresolved.join(", ")}) \u2014 triplet unattributable statically (C-12)`;
|
|
971
|
+
return {
|
|
972
|
+
id: this.id,
|
|
973
|
+
anchor: unresolved[0],
|
|
974
|
+
state: "could-not-judge",
|
|
975
|
+
reason,
|
|
976
|
+
verdict: `could not judge: ${reason}`
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
const tools = findToolAnnotations(tree);
|
|
980
|
+
if (tools.length === 0) {
|
|
981
|
+
return {
|
|
982
|
+
id: this.id,
|
|
983
|
+
anchor: "-",
|
|
984
|
+
state: "absent",
|
|
985
|
+
verdict: "no tool registrations found; no-duplicates check n/a"
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
const considered = tools.filter((t) => t.verb && t.table);
|
|
989
|
+
if (considered.length === 0) {
|
|
990
|
+
const reason = "no tool declares both a verb and a table \u2014 duplicate-triplet check unverifiable tree-wide (C-12; see C-9 for missing-verb conformance separately)";
|
|
991
|
+
return {
|
|
992
|
+
id: this.id,
|
|
993
|
+
anchor: tools[0].anchor,
|
|
994
|
+
state: "could-not-judge",
|
|
995
|
+
reason,
|
|
996
|
+
verdict: `could not judge: ${reason}`
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1000
|
+
for (const t of considered) {
|
|
1001
|
+
const key = tripletKey(t);
|
|
1002
|
+
const list = groups.get(key) ?? [];
|
|
1003
|
+
list.push(t);
|
|
1004
|
+
groups.set(key, list);
|
|
1005
|
+
}
|
|
1006
|
+
for (const [key, members] of groups) {
|
|
1007
|
+
if (members.length >= 2) {
|
|
1008
|
+
const anchors = members.map((m) => `${m.name}@${m.anchor}`).join(", ");
|
|
1009
|
+
return {
|
|
1010
|
+
id: this.id,
|
|
1011
|
+
anchor: members[0].anchor,
|
|
1012
|
+
state: "non-conforming",
|
|
1013
|
+
verdict: `duplicate triplet (verb::table::mechanism = "${key}") on ${members.length} tools (positive-control OK): ${anchors} -> collapse to one tool, express the other as documentation (C-12)`
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return {
|
|
1018
|
+
id: this.id,
|
|
1019
|
+
anchor: considered[0].anchor,
|
|
1020
|
+
state: "present",
|
|
1021
|
+
verdict: `${considered.length} tool(s) with a declared (verb, table) checked, no duplicate triplet found (positive-control OK)`
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
// src/detectors/c13-context-ceiling.ts
|
|
1027
|
+
var CEILING_GLOBAL_RE = /\b(?:CONTEXT_CEILING|contextCeiling)\b[\s\S]{0,80}?\bglobal\s*:\s*(\d+)\b/;
|
|
1028
|
+
var CEILING_PER_TOOL_RE = /\b(?:CONTEXT_CEILING|contextCeiling)\b[\s\S]{0,80}?\bperTool\s*:\s*(\d+)\b/;
|
|
1029
|
+
function findCeiling(tree) {
|
|
1030
|
+
for (const file of tree.files) {
|
|
1031
|
+
for (const line of file.lines) {
|
|
1032
|
+
if (line.comment) continue;
|
|
1033
|
+
if (/\b(?:CONTEXT_CEILING|contextCeiling)\b/.test(line.raw)) {
|
|
1034
|
+
const start = line.n - 1;
|
|
1035
|
+
const windowLines = file.lines.slice(start, start + 6).map((l) => l.raw);
|
|
1036
|
+
const window = windowLines.join("\n");
|
|
1037
|
+
const g = window.match(CEILING_GLOBAL_RE);
|
|
1038
|
+
const p = window.match(CEILING_PER_TOOL_RE);
|
|
1039
|
+
if (g && p) {
|
|
1040
|
+
return {
|
|
1041
|
+
global: Number.parseInt(g[1], 10),
|
|
1042
|
+
perTool: Number.parseInt(p[1], 10),
|
|
1043
|
+
anchor: `${file.rel}:${line.n}`
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
function c13PositiveControls() {
|
|
1052
|
+
const complete = "const CONTEXT_CEILING = { global: 8000, perTool: 800 };";
|
|
1053
|
+
const partial = "const CONTEXT_CEILING = { global: 8000 };";
|
|
1054
|
+
return {
|
|
1055
|
+
tokensMatcher: toolAnnotationFieldControls().tokensCtx,
|
|
1056
|
+
ceilingAcceptsComplete: CEILING_GLOBAL_RE.test(complete) && CEILING_PER_TOOL_RE.test(complete),
|
|
1057
|
+
ceilingRejectsPartial: !(CEILING_GLOBAL_RE.test(partial) && CEILING_PER_TOOL_RE.test(partial))
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
var c13ContextCeiling = {
|
|
1061
|
+
id: "C13 context-ceiling",
|
|
1062
|
+
rule: "C-13",
|
|
1063
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
1064
|
+
run(tree) {
|
|
1065
|
+
const controls = c13PositiveControls();
|
|
1066
|
+
if (!controls.tokensMatcher.matched) {
|
|
1067
|
+
throw new Error(
|
|
1068
|
+
`C13 positive control FAILED: pattern /${controls.tokensMatcher.pattern}/ did not match sample \`${controls.tokensMatcher.sample}\` \u2014 matcher broken, verdict untrustworthy`
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
if (!controls.ceilingAcceptsComplete || !controls.ceilingRejectsPartial) {
|
|
1072
|
+
throw new Error(
|
|
1073
|
+
"C13 ceiling control FAILED: must accept a complete (global, perTool) declaration and reject a partial one \u2014 verdict untrustworthy"
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
const unresolved = findUnresolvedToolSites(tree);
|
|
1077
|
+
if (unresolved.length > 0) {
|
|
1078
|
+
const reason = `tool name at ${unresolved.length} registration site(s) is computed/imported/template (${unresolved.join(", ")}) \u2014 declared context cost unattributable statically (C-13)`;
|
|
1079
|
+
return {
|
|
1080
|
+
id: this.id,
|
|
1081
|
+
anchor: unresolved[0],
|
|
1082
|
+
state: "could-not-judge",
|
|
1083
|
+
reason,
|
|
1084
|
+
verdict: `could not judge: ${reason}`
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
const tools = findToolAnnotations(tree);
|
|
1088
|
+
if (tools.length === 0) {
|
|
1089
|
+
return {
|
|
1090
|
+
id: this.id,
|
|
1091
|
+
anchor: "-",
|
|
1092
|
+
state: "absent",
|
|
1093
|
+
verdict: "no tool registrations found; context-ceiling check n/a"
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
const ceiling = findCeiling(tree);
|
|
1097
|
+
if (!ceiling) {
|
|
1098
|
+
return {
|
|
1099
|
+
id: this.id,
|
|
1100
|
+
anchor: tools[0].anchor,
|
|
1101
|
+
state: "non-conforming",
|
|
1102
|
+
verdict: `${tools.length} tool(s) registered but no (global, perTool) context ceiling declared anywhere (positive-control OK) -> declare CONTEXT_CEILING = { global, perTool } (C-13)`
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
let sum = 0;
|
|
1106
|
+
for (const t of tools) {
|
|
1107
|
+
if (t.tokensEstimeContexte === void 0) {
|
|
1108
|
+
return {
|
|
1109
|
+
id: this.id,
|
|
1110
|
+
anchor: t.anchor,
|
|
1111
|
+
state: "non-conforming",
|
|
1112
|
+
verdict: `tool "${t.name}" declares no tokensEstimeContexte (positive-control OK, ceiling declared at ${ceiling.anchor}) -> declare tokensEstimeContexte on every tool (C-13)`
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
if (t.tokensEstimeContexte > ceiling.perTool) {
|
|
1116
|
+
return {
|
|
1117
|
+
id: this.id,
|
|
1118
|
+
anchor: t.anchor,
|
|
1119
|
+
state: "non-conforming",
|
|
1120
|
+
verdict: `tool "${t.name}" declares tokensEstimeContexte=${t.tokensEstimeContexte}, above the declared per-tool ceiling ${ceiling.perTool} (${ceiling.anchor}) -> stage the schema, publish by tiers (C-13)`
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
sum += t.tokensEstimeContexte;
|
|
1124
|
+
}
|
|
1125
|
+
if (sum > ceiling.global) {
|
|
1126
|
+
return {
|
|
1127
|
+
id: this.id,
|
|
1128
|
+
anchor: ceiling.anchor,
|
|
1129
|
+
state: "non-conforming",
|
|
1130
|
+
verdict: `declared per-tool costs sum to ${sum}, above the declared global ceiling ${ceiling.global} (${ceiling.anchor}) -> stage publication by tiers (C-13)`
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
return {
|
|
1134
|
+
id: this.id,
|
|
1135
|
+
anchor: ceiling.anchor,
|
|
1136
|
+
state: "present",
|
|
1137
|
+
verdict: `${tools.length} tool(s) each declare tokensEstimeContexte within the declared per-tool ceiling (${ceiling.perTool}); sum ${sum} within global ceiling ${ceiling.global} (positive-control OK)`
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
// src/detectors/c14-catalog-registration.ts
|
|
1143
|
+
var PIN3 = "a74a042b53c71a095ab88b3073bd47728eeda814";
|
|
1144
|
+
var REQUIRED_FIELDS = [
|
|
1145
|
+
"name",
|
|
1146
|
+
"version",
|
|
1147
|
+
"description",
|
|
1148
|
+
"description_fr",
|
|
1149
|
+
"repo_url",
|
|
1150
|
+
"npm_url",
|
|
1151
|
+
"keywords",
|
|
1152
|
+
"auth_type",
|
|
1153
|
+
"transport",
|
|
1154
|
+
"bilingual"
|
|
1155
|
+
];
|
|
1156
|
+
function readCatalogBlock(tree) {
|
|
1157
|
+
if (!tree.packageJson) return null;
|
|
1158
|
+
const raw = tree.packageJson.json.mcpCatalog;
|
|
1159
|
+
if (!raw || typeof raw !== "object") return null;
|
|
1160
|
+
return { fields: raw, anchor: `${tree.packageJson.path}:mcpCatalog` };
|
|
1161
|
+
}
|
|
1162
|
+
function c14PositiveControls() {
|
|
1163
|
+
const complete = {
|
|
1164
|
+
name: "x",
|
|
1165
|
+
version: "1.0.0",
|
|
1166
|
+
description: "en",
|
|
1167
|
+
description_fr: "fr",
|
|
1168
|
+
repo_url: "https://github.com/x/y",
|
|
1169
|
+
npm_url: "https://npmjs.com/package/x",
|
|
1170
|
+
keywords: ["mcp"],
|
|
1171
|
+
auth_type: "public",
|
|
1172
|
+
transport: "stdio",
|
|
1173
|
+
bilingual: true
|
|
1174
|
+
};
|
|
1175
|
+
const partial = { ...complete };
|
|
1176
|
+
partial.repo_url = void 0;
|
|
1177
|
+
const missing = (block) => REQUIRED_FIELDS.find((f) => block[f] === void 0 || block[f] === null);
|
|
1178
|
+
return {
|
|
1179
|
+
acceptsComplete: missing(complete) === void 0,
|
|
1180
|
+
rejectsPartial: missing(partial) !== void 0
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
var c14CatalogRegistration = {
|
|
1184
|
+
id: "C14 catalog-registration",
|
|
1185
|
+
rule: "C-14",
|
|
1186
|
+
pin: PIN3,
|
|
1187
|
+
run(tree) {
|
|
1188
|
+
const controls = c14PositiveControls();
|
|
1189
|
+
if (!controls.acceptsComplete || !controls.rejectsPartial) {
|
|
1190
|
+
throw new Error(
|
|
1191
|
+
`C14 positive control FAILED: acceptsComplete=${controls.acceptsComplete} rejectsPartial=${controls.rejectsPartial} \u2014 matcher broken, verdict untrustworthy`
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
const block = readCatalogBlock(tree);
|
|
1195
|
+
if (!block) {
|
|
1196
|
+
return {
|
|
1197
|
+
id: this.id,
|
|
1198
|
+
anchor: tree.packageJson?.path ?? tree.root,
|
|
1199
|
+
state: "absent",
|
|
1200
|
+
verdict: "no mcpCatalog block declared in package.json; this run carries no publication-catalog metadata (C-14 n/a)"
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
for (const field of REQUIRED_FIELDS) {
|
|
1204
|
+
const v = block.fields[field];
|
|
1205
|
+
if (v === void 0 || v === null) {
|
|
1206
|
+
return {
|
|
1207
|
+
id: this.id,
|
|
1208
|
+
anchor: block.anchor,
|
|
1209
|
+
state: "non-conforming",
|
|
1210
|
+
verdict: `mcpCatalog block at ${block.anchor} is missing required field "${field}" (positive-control OK) -> declare it before publication (C-14)`
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if (block.fields.bilingual !== true) {
|
|
1215
|
+
return {
|
|
1216
|
+
id: this.id,
|
|
1217
|
+
anchor: block.anchor,
|
|
1218
|
+
state: "non-conforming",
|
|
1219
|
+
verdict: `mcpCatalog.bilingual at ${block.anchor} is not literally true -> declare bilingual: true (C-14)`
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
return {
|
|
1223
|
+
id: this.id,
|
|
1224
|
+
anchor: block.anchor,
|
|
1225
|
+
// C-14 is `gate + dynamique` with NO static half: its `vert` is the entry
|
|
1226
|
+
// being trouvable via `search_components` on the LIVE registry. A complete
|
|
1227
|
+
// static declaration is NOT the rule satisfied — only the rule's precondition.
|
|
1228
|
+
// Returning `present` here would let a tally read "satisfied" from what is
|
|
1229
|
+
// only "declared", the same over-claim a gate rule (C-22) correctly abstains
|
|
1230
|
+
// on. So a complete-but-unresolved declaration is could-not-judge, naming the
|
|
1231
|
+
// gate — never `present` from a static read.
|
|
1232
|
+
state: "could-not-judge",
|
|
1233
|
+
reason: `mcpCatalog block at ${block.anchor} declares all ${REQUIRED_FIELDS.length} required fields, but C-14 is gate+dynamique with no static half: satisfaction is the entry RESOLVING via search_components on the live VantageRegistry, which a static run never performs (C-14).`,
|
|
1234
|
+
verdict: `could not judge: mcpCatalog block at ${block.anchor} declares all ${REQUIRED_FIELDS.length} required fields (positive-control OK), but whether it RESOLVES via search_components on the live registry is the gate+dynamique half a static run cannot reach \u2014 declaration complete, resolution unverified (C-14).`
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
// src/detectors/c15-annotations-untrusted.ts
|
|
1240
|
+
var AUTO_APPROVE_ACTION_RE = /\b(?:autoApprove|skipConfirm|autoRun|autoExecute)\w*\s*\(/;
|
|
1241
|
+
var HINT_REFERENCE_RE = /\b(?:readOnlyHint|destructiveHint|idempotentHint|openWorldHint)\b/;
|
|
1242
|
+
var TRUST_CHECK_RE = /\b(?:isTrustedServer|trustedSource|isPinnedSource|pinnedSource|isTrusted)\b/;
|
|
1243
|
+
function c15PositiveControls() {
|
|
1244
|
+
const unpinnedSample = "if (readOnlyHint) autoApprove(tool);";
|
|
1245
|
+
const pinnedSample = "if (readOnlyHint && isTrustedServer(server)) autoApprove(tool);";
|
|
1246
|
+
return {
|
|
1247
|
+
hintsMatcher: toolAnnotationFieldControls().behavioralHints,
|
|
1248
|
+
autoApproveDetectsUnpinned: AUTO_APPROVE_ACTION_RE.test(unpinnedSample) && HINT_REFERENCE_RE.test(unpinnedSample) && !TRUST_CHECK_RE.test(unpinnedSample),
|
|
1249
|
+
autoApproveClearsPinned: AUTO_APPROVE_ACTION_RE.test(pinnedSample) && HINT_REFERENCE_RE.test(pinnedSample) && TRUST_CHECK_RE.test(pinnedSample)
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
function allHintsDeclared(t) {
|
|
1253
|
+
return Boolean(
|
|
1254
|
+
t.readOnlyHintDeclared && t.destructiveHintDeclared && t.idempotentHintDeclared && t.openWorldHintDeclared
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
function missingHints(t) {
|
|
1258
|
+
const missing = [];
|
|
1259
|
+
if (!t.readOnlyHintDeclared) missing.push("readOnlyHint");
|
|
1260
|
+
if (!t.destructiveHintDeclared) missing.push("destructiveHint");
|
|
1261
|
+
if (!t.idempotentHintDeclared) missing.push("idempotentHint");
|
|
1262
|
+
if (!t.openWorldHintDeclared) missing.push("openWorldHint");
|
|
1263
|
+
return missing;
|
|
1264
|
+
}
|
|
1265
|
+
function findUnpinnedAutoApprovals(tree) {
|
|
1266
|
+
const hits = [];
|
|
1267
|
+
for (const file of tree.files) {
|
|
1268
|
+
for (const line of file.lines) {
|
|
1269
|
+
if (line.comment) continue;
|
|
1270
|
+
const raw = line.raw;
|
|
1271
|
+
if (AUTO_APPROVE_ACTION_RE.test(raw) && HINT_REFERENCE_RE.test(raw) && !TRUST_CHECK_RE.test(raw)) {
|
|
1272
|
+
hits.push({ file, line: line.n });
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return hits;
|
|
1277
|
+
}
|
|
1278
|
+
var c15AnnotationsUntrusted = {
|
|
1279
|
+
id: "C15 annotations-untrusted",
|
|
1280
|
+
rule: "C-15",
|
|
1281
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
1282
|
+
run(tree) {
|
|
1283
|
+
const controls = c15PositiveControls();
|
|
1284
|
+
if (!controls.hintsMatcher.matched) {
|
|
1285
|
+
throw new Error(
|
|
1286
|
+
`C15 positive control FAILED: pattern /${controls.hintsMatcher.pattern}/ did not match sample \`${controls.hintsMatcher.sample}\` \u2014 matcher broken, verdict untrustworthy`
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
if (!controls.autoApproveDetectsUnpinned || !controls.autoApproveClearsPinned) {
|
|
1290
|
+
throw new Error(
|
|
1291
|
+
"C15 auto-approval control FAILED: must flag a hint-gated approval with no trust check, and clear one that carries a trust check \u2014 verdict untrustworthy"
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
const unresolved = findUnresolvedToolSites(tree);
|
|
1295
|
+
if (unresolved.length > 0) {
|
|
1296
|
+
const reason = `tool name at ${unresolved.length} registration site(s) is computed/imported/template (${unresolved.join(", ")}) \u2014 declared annotations unattributable statically (C-15)`;
|
|
1297
|
+
return {
|
|
1298
|
+
id: this.id,
|
|
1299
|
+
anchor: unresolved[0],
|
|
1300
|
+
state: "could-not-judge",
|
|
1301
|
+
reason,
|
|
1302
|
+
verdict: `could not judge: ${reason}`
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
const unpinned = findUnpinnedAutoApprovals(tree);
|
|
1306
|
+
if (unpinned.length > 0) {
|
|
1307
|
+
const anchor = `${unpinned[0].file.rel}:${unpinned[0].line}`;
|
|
1308
|
+
return {
|
|
1309
|
+
id: this.id,
|
|
1310
|
+
anchor,
|
|
1311
|
+
state: "non-conforming",
|
|
1312
|
+
verdict: `consumer auto-approves an action off a behavioral hint with no trusted-source check at ${anchor} (positive-control OK) -> gate the auto-approval on isTrustedServer/pinned-source in addition to the hint (C-15)`
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
const tools = findToolAnnotations(tree);
|
|
1316
|
+
if (tools.length === 0) {
|
|
1317
|
+
return {
|
|
1318
|
+
id: this.id,
|
|
1319
|
+
anchor: "-",
|
|
1320
|
+
state: "absent",
|
|
1321
|
+
verdict: "no tool registrations found; annotations-untrusted check n/a"
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
for (const t of tools) {
|
|
1325
|
+
if (!allHintsDeclared(t)) {
|
|
1326
|
+
const missing = missingHints(t).join(", ");
|
|
1327
|
+
return {
|
|
1328
|
+
id: this.id,
|
|
1329
|
+
anchor: t.anchor,
|
|
1330
|
+
state: "non-conforming",
|
|
1331
|
+
verdict: `tool "${t.name}" is missing behavioral annotation(s): ${missing} (positive-control OK) -> declare readOnlyHint/destructiveHint/idempotentHint/openWorldHint (C-15)`
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
return {
|
|
1336
|
+
id: this.id,
|
|
1337
|
+
anchor: tools[0].anchor,
|
|
1338
|
+
state: "present",
|
|
1339
|
+
verdict: `${tools.length} tool(s) each declare all four behavioral annotations, no unpinned auto-approval found (positive-control OK)`
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
// src/detectors/c16-untrusted-output.ts
|
|
1345
|
+
var SYSTEM_ROLE_TEMPLATE_INTERP_RE = /role\s*:\s*["'`]system["'`][\s\S]*?(?:content|text)\s*:\s*`[^`]*\$\{/;
|
|
1346
|
+
var INSTRUCTION_FIELD_TEMPLATE_INTERP_RE = /\b(?:instruction|systemPrompt|prompt)\s*:\s*`[^`]*\$\{/;
|
|
1347
|
+
var BOUNDARY_MARKER_RE = /\b(?:sanitize\w*\s*\(|UNTRUSTED_CONTENT|trustBoundary|provenance\s*:\s*["'`]untrusted["'`])/;
|
|
1348
|
+
var WINDOW_RADIUS = 6;
|
|
1349
|
+
function c16PositiveControls() {
|
|
1350
|
+
const rawSystemSample = 'messages.push({ role: "system", content: `Context: ${toolOutput}` });';
|
|
1351
|
+
const staticSystemSample = 'messages.push({ role: "system", content: "You are a helpful assistant." });';
|
|
1352
|
+
const sanitizeSample = "content: sanitizeOutput(toolOutput)";
|
|
1353
|
+
const provenanceSample = 'meta: { provenance: "untrusted" }';
|
|
1354
|
+
return {
|
|
1355
|
+
interpolationMatchesRaw: SYSTEM_ROLE_TEMPLATE_INTERP_RE.test(rawSystemSample),
|
|
1356
|
+
interpolationClearsStatic: !SYSTEM_ROLE_TEMPLATE_INTERP_RE.test(staticSystemSample),
|
|
1357
|
+
markerMatchesSanitize: BOUNDARY_MARKER_RE.test(sanitizeSample),
|
|
1358
|
+
markerMatchesProvenance: BOUNDARY_MARKER_RE.test(provenanceSample)
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
function findInterpolationSites(tree) {
|
|
1362
|
+
const hits = [];
|
|
1363
|
+
for (const file of tree.files) {
|
|
1364
|
+
for (const line of file.lines) {
|
|
1365
|
+
if (line.comment) continue;
|
|
1366
|
+
if (SYSTEM_ROLE_TEMPLATE_INTERP_RE.test(line.raw) || INSTRUCTION_FIELD_TEMPLATE_INTERP_RE.test(line.raw)) {
|
|
1367
|
+
hits.push({ file, line: line.n });
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
return hits;
|
|
1372
|
+
}
|
|
1373
|
+
function windowHasMarker(file, siteLine) {
|
|
1374
|
+
for (const line of file.lines) {
|
|
1375
|
+
if (line.n < siteLine - WINDOW_RADIUS || line.n > siteLine + WINDOW_RADIUS) continue;
|
|
1376
|
+
if (line.comment) continue;
|
|
1377
|
+
if (BOUNDARY_MARKER_RE.test(line.raw)) return true;
|
|
1378
|
+
}
|
|
1379
|
+
return false;
|
|
1380
|
+
}
|
|
1381
|
+
var c16UntrustedOutput = {
|
|
1382
|
+
id: "C16 untrusted-output",
|
|
1383
|
+
rule: "C-16",
|
|
1384
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
1385
|
+
run(tree) {
|
|
1386
|
+
const controls = c16PositiveControls();
|
|
1387
|
+
if (!controls.interpolationMatchesRaw || !controls.interpolationClearsStatic || !controls.markerMatchesSanitize || !controls.markerMatchesProvenance) {
|
|
1388
|
+
throw new Error(
|
|
1389
|
+
`C16 positive control FAILED: raw=${controls.interpolationMatchesRaw} staticCleared=${controls.interpolationClearsStatic} sanitizeMarker=${controls.markerMatchesSanitize} provenanceMarker=${controls.markerMatchesProvenance} \u2014 matcher broken, verdict untrustworthy`
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
const sites = findInterpolationSites(tree);
|
|
1393
|
+
if (sites.length === 0) {
|
|
1394
|
+
return {
|
|
1395
|
+
id: this.id,
|
|
1396
|
+
anchor: "-",
|
|
1397
|
+
state: "absent",
|
|
1398
|
+
verdict: "no instruction-position template interpolation found; untrusted-output check n/a (C-16)"
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
for (const site of sites) {
|
|
1402
|
+
const anchor = `${site.file.rel}:${site.line}`;
|
|
1403
|
+
if (!windowHasMarker(site.file, site.line)) {
|
|
1404
|
+
return {
|
|
1405
|
+
id: this.id,
|
|
1406
|
+
anchor,
|
|
1407
|
+
state: "non-conforming",
|
|
1408
|
+
verdict: `content interpolated into an instruction position at ${anchor} with no trust-boundary marker within ${WINDOW_RADIUS} lines (positive-control OK) -> sanitize the output or mark its provenance before it reaches an instruction field (C-16)`
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
return {
|
|
1413
|
+
id: this.id,
|
|
1414
|
+
anchor: `${sites[0].file.rel}:${sites[0].line}`,
|
|
1415
|
+
state: "present",
|
|
1416
|
+
verdict: `${sites.length} instruction-position interpolation site(s), each carrying a trust-boundary marker in its window (positive-control OK)`
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1421
|
+
// src/detectors/c17-supply-chain.ts
|
|
1422
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync } from "fs";
|
|
1423
|
+
import { join as join2 } from "path";
|
|
1424
|
+
var PIN4 = "a74a042b53c71a095ab88b3073bd47728eeda814";
|
|
1425
|
+
var LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"];
|
|
1426
|
+
var WORKFLOW_DIR = join2(".github", "workflows");
|
|
1427
|
+
var PROVENANCE_RE2 = /--provenance\b|\bprovenance\s*:\s*true\b|id-token:\s*write/i;
|
|
1428
|
+
function c17PositiveControls() {
|
|
1429
|
+
const sample = "npm publish --provenance --access public";
|
|
1430
|
+
const bare = "npm publish --access public";
|
|
1431
|
+
return {
|
|
1432
|
+
provenanceMatcher: {
|
|
1433
|
+
pattern: PROVENANCE_RE2.source,
|
|
1434
|
+
sample,
|
|
1435
|
+
matched: PROVENANCE_RE2.test(sample),
|
|
1436
|
+
rejectsBare: !PROVENANCE_RE2.test(bare)
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
function findLockfile(root) {
|
|
1441
|
+
for (const name of LOCKFILES) {
|
|
1442
|
+
if (existsSync2(join2(root, name))) return name;
|
|
1443
|
+
}
|
|
1444
|
+
return null;
|
|
1445
|
+
}
|
|
1446
|
+
function findProvenanceClaim(tree, root) {
|
|
1447
|
+
const pkg = tree.packageJson;
|
|
1448
|
+
if (pkg) {
|
|
1449
|
+
const publishConfig = pkg.json.publishConfig;
|
|
1450
|
+
if (publishConfig && publishConfig.provenance === true) {
|
|
1451
|
+
return { site: `${pkg.path}:publishConfig.provenance`, text: "provenance: true" };
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
const workflowsDir = join2(root, WORKFLOW_DIR);
|
|
1455
|
+
if (!existsSync2(workflowsDir)) return null;
|
|
1456
|
+
let entries = [];
|
|
1457
|
+
try {
|
|
1458
|
+
entries = readdirSync(workflowsDir);
|
|
1459
|
+
} catch {
|
|
1460
|
+
return null;
|
|
1461
|
+
}
|
|
1462
|
+
for (const name of entries) {
|
|
1463
|
+
if (!/\.ya?ml$/.test(name)) continue;
|
|
1464
|
+
const path = join2(workflowsDir, name);
|
|
1465
|
+
let text;
|
|
1466
|
+
try {
|
|
1467
|
+
text = readFileSync2(path, "utf8");
|
|
1468
|
+
} catch {
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1471
|
+
if (PROVENANCE_RE2.test(text)) {
|
|
1472
|
+
const line = text.split("\n").findIndex((l) => PROVENANCE_RE2.test(l)) + 1;
|
|
1473
|
+
return { site: `${WORKFLOW_DIR}/${name}:${line}`, text };
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
return null;
|
|
1477
|
+
}
|
|
1478
|
+
var c17SupplyChain = {
|
|
1479
|
+
id: "C17 supply-chain",
|
|
1480
|
+
rule: "C-17",
|
|
1481
|
+
pin: PIN4,
|
|
1482
|
+
run(tree) {
|
|
1483
|
+
const controls = c17PositiveControls();
|
|
1484
|
+
if (!controls.provenanceMatcher.matched || !controls.provenanceMatcher.rejectsBare) {
|
|
1485
|
+
throw new Error(
|
|
1486
|
+
`C17 provenance-matcher control FAILED: accept(\`${controls.provenanceMatcher.sample}\`)=${controls.provenanceMatcher.matched} rejectsBare=${controls.provenanceMatcher.rejectsBare} \u2014 matcher broken, verdict untrustworthy`
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
const lockfile = findLockfile(tree.root);
|
|
1490
|
+
if (!lockfile) {
|
|
1491
|
+
return {
|
|
1492
|
+
id: this.id,
|
|
1493
|
+
anchor: tree.root,
|
|
1494
|
+
state: "non-conforming",
|
|
1495
|
+
verdict: "no committed lockfile (package-lock.json/pnpm-lock.yaml/yarn.lock/bun.lock) found -> dependencies are not pinned; commit a lockfile (C-17a)"
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
const claim = findProvenanceClaim(tree, tree.root);
|
|
1499
|
+
if (!claim) {
|
|
1500
|
+
return {
|
|
1501
|
+
id: this.id,
|
|
1502
|
+
anchor: `${lockfile}:1`,
|
|
1503
|
+
state: "non-conforming",
|
|
1504
|
+
verdict: `lockfile ${lockfile} present, but no provenance attestation claim found in package.json publishConfig or ${WORKFLOW_DIR}/*.yml -> add \`npm publish --provenance\` or \`publishConfig.provenance: true\` (C-17b)`
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
return {
|
|
1508
|
+
id: this.id,
|
|
1509
|
+
anchor: claim.site,
|
|
1510
|
+
state: "present",
|
|
1511
|
+
verdict: `lockfile ${lockfile} pinned + provenance attestation claim at ${claim.site} (C-17a/b complete). Whether the attestation record actually RESOLVES against the published artifact at the registry is a separate gate check at publish time \u2014 this static run asserts the declaration is complete, not that the gate has resolved it.`
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
// src/detectors/c18-structure-manifest.ts
|
|
1517
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
1518
|
+
import { join as join3 } from "path";
|
|
1519
|
+
var PIN5 = "a74a042b53c71a095ab88b3073bd47728eeda814";
|
|
1520
|
+
function manifestPath(root) {
|
|
1521
|
+
const extPath = join3(root, "extension.json");
|
|
1522
|
+
if (existsSync3(extPath)) return { kind: "extension", path: extPath };
|
|
1523
|
+
return { kind: "server", path: join3(root, "mcp.json") };
|
|
1524
|
+
}
|
|
1525
|
+
function readJson(path) {
|
|
1526
|
+
try {
|
|
1527
|
+
return JSON.parse(readFileSync3(path, "utf8"));
|
|
1528
|
+
} catch {
|
|
1529
|
+
return null;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
function c18PositiveControls() {
|
|
1533
|
+
const equalCheck = (a, b) => a === b;
|
|
1534
|
+
return {
|
|
1535
|
+
versionParity: {
|
|
1536
|
+
matched: equalCheck("0.1.0", "0.1.0"),
|
|
1537
|
+
rejectsMismatch: !equalCheck("0.1.0", "0.2.0")
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
var c18StructureManifest = {
|
|
1542
|
+
id: "C18 structure-manifest",
|
|
1543
|
+
rule: "C-18",
|
|
1544
|
+
pin: PIN5,
|
|
1545
|
+
run(tree) {
|
|
1546
|
+
const controls = c18PositiveControls();
|
|
1547
|
+
if (!controls.versionParity.matched || !controls.versionParity.rejectsMismatch) {
|
|
1548
|
+
throw new Error(
|
|
1549
|
+
"C18 version-parity control FAILED: equal-strings must match and unequal-strings must be rejected \u2014 verdict untrustworthy"
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
if (!tree.packageJson) {
|
|
1553
|
+
const reason = "could not locate package.json anywhere above the scanned root (C-18)";
|
|
1554
|
+
return {
|
|
1555
|
+
id: this.id,
|
|
1556
|
+
anchor: tree.root,
|
|
1557
|
+
state: "could-not-judge",
|
|
1558
|
+
reason,
|
|
1559
|
+
verdict: `could not judge: ${reason}`
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
const pkgVersion = tree.packageJson.json.version;
|
|
1563
|
+
if (typeof pkgVersion !== "string") {
|
|
1564
|
+
const reason = `no string "version" field in ${tree.packageJson.path} \u2014 manifest version-parity unverifiable (C-18)`;
|
|
1565
|
+
return {
|
|
1566
|
+
id: this.id,
|
|
1567
|
+
anchor: tree.packageJson.path,
|
|
1568
|
+
state: "could-not-judge",
|
|
1569
|
+
reason,
|
|
1570
|
+
verdict: `could not judge: ${reason}`
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
const { kind, path } = manifestPath(tree.root);
|
|
1574
|
+
const manifestName = kind === "extension" ? "extension.json" : "mcp.json";
|
|
1575
|
+
if (!existsSync3(path)) {
|
|
1576
|
+
return {
|
|
1577
|
+
id: this.id,
|
|
1578
|
+
anchor: tree.root,
|
|
1579
|
+
state: "non-conforming",
|
|
1580
|
+
verdict: `no ${manifestName} found at the tree root -> the ${kind === "extension" ? "extension" : "server/App"} manifest is required (C-18)`
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
const manifest = readJson(path);
|
|
1584
|
+
if (!manifest) {
|
|
1585
|
+
return {
|
|
1586
|
+
id: this.id,
|
|
1587
|
+
anchor: path,
|
|
1588
|
+
state: "non-conforming",
|
|
1589
|
+
verdict: `${manifestName} present but could not be parsed as JSON -> fix the manifest (C-18)`
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
if (typeof manifest.name !== "string" || manifest.name.length === 0) {
|
|
1593
|
+
return {
|
|
1594
|
+
id: this.id,
|
|
1595
|
+
anchor: path,
|
|
1596
|
+
state: "non-conforming",
|
|
1597
|
+
verdict: `${manifestName} is missing a required "name" field (C-18)`
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
if (kind === "extension") {
|
|
1601
|
+
const missing = [];
|
|
1602
|
+
if (typeof manifest.type !== "string") missing.push("type");
|
|
1603
|
+
if (typeof manifest.lifecycle !== "string") missing.push("lifecycle");
|
|
1604
|
+
if (missing.length > 0) {
|
|
1605
|
+
return {
|
|
1606
|
+
id: this.id,
|
|
1607
|
+
anchor: path,
|
|
1608
|
+
state: "non-conforming",
|
|
1609
|
+
verdict: `extension.json is missing required field(s): ${missing.join(", ")} -> declare type/lifecycle (C-18)`
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
} else {
|
|
1613
|
+
const missing = [];
|
|
1614
|
+
if (!manifest.capabilities || typeof manifest.capabilities !== "object" || Array.isArray(manifest.capabilities)) {
|
|
1615
|
+
missing.push("capabilities");
|
|
1616
|
+
}
|
|
1617
|
+
if (!manifest.transport || typeof manifest.transport !== "object" || Array.isArray(manifest.transport)) {
|
|
1618
|
+
missing.push("transport");
|
|
1619
|
+
}
|
|
1620
|
+
if (missing.length > 0) {
|
|
1621
|
+
return {
|
|
1622
|
+
id: this.id,
|
|
1623
|
+
anchor: path,
|
|
1624
|
+
state: "non-conforming",
|
|
1625
|
+
verdict: `mcp.json is missing required field(s): ${missing.join(", ")} not declared -> declare capabilities/transport (C-18)`
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
if (typeof manifest.version !== "string") {
|
|
1630
|
+
return {
|
|
1631
|
+
id: this.id,
|
|
1632
|
+
anchor: path,
|
|
1633
|
+
state: "non-conforming",
|
|
1634
|
+
verdict: `${manifestName} is missing a required "version" field (C-18)`
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
if (manifest.version !== pkgVersion) {
|
|
1638
|
+
return {
|
|
1639
|
+
id: this.id,
|
|
1640
|
+
anchor: path,
|
|
1641
|
+
state: "non-conforming",
|
|
1642
|
+
verdict: `${manifestName} version "${manifest.version}" does not equal package.json version "${pkgVersion}" -> keep the manifest version in sync (C-18)`
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
return {
|
|
1646
|
+
id: this.id,
|
|
1647
|
+
anchor: path,
|
|
1648
|
+
state: "present",
|
|
1649
|
+
verdict: `${manifestName} present, required fields declared, version "${manifest.version}" parity with package.json (C-18)`
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
|
|
1654
|
+
// src/scan.ts
|
|
1655
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync } from "fs";
|
|
1656
|
+
import { dirname, join as join4, relative } from "path";
|
|
1657
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
1658
|
+
"node_modules",
|
|
1659
|
+
"dist",
|
|
1660
|
+
"build",
|
|
1661
|
+
".git",
|
|
1662
|
+
"tests",
|
|
1663
|
+
"__tests__",
|
|
1664
|
+
"coverage",
|
|
1665
|
+
// Test material, not a target's served surface: deliberately-malformed
|
|
1666
|
+
// servers and foreign copies live here. Scanning them makes the instrument
|
|
1667
|
+
// trip its own fail-closed detectors when pointed at its own tree, and lints
|
|
1668
|
+
// inputs that are meant to be broken. A real target's fixtures are equally
|
|
1669
|
+
// test inputs, not its product surface.
|
|
1670
|
+
"fixtures"
|
|
1671
|
+
]);
|
|
1672
|
+
function isCommentLine(raw) {
|
|
1673
|
+
const t = raw.trim();
|
|
1674
|
+
if (t === "") return false;
|
|
1675
|
+
return t.startsWith("*") || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*/");
|
|
1676
|
+
}
|
|
1677
|
+
function walk(dir, out) {
|
|
1678
|
+
let entries;
|
|
1679
|
+
try {
|
|
1680
|
+
entries = readdirSync2(dir);
|
|
1681
|
+
} catch {
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
for (const name of entries) {
|
|
1685
|
+
const full = join4(dir, name);
|
|
1686
|
+
let st;
|
|
1687
|
+
try {
|
|
1688
|
+
st = statSync(full);
|
|
1689
|
+
} catch {
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
if (st.isDirectory()) {
|
|
1693
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
1694
|
+
walk(full, out);
|
|
1695
|
+
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
|
|
1696
|
+
if (name.endsWith(".d.ts") || name.endsWith(".test.ts")) continue;
|
|
1697
|
+
out.push(full);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
function loadFile(path, root) {
|
|
1702
|
+
const raw = readFileSync4(path, "utf8");
|
|
1703
|
+
const lines = raw.split("\n").map((r, i) => ({ n: i + 1, raw: r, comment: isCommentLine(r) }));
|
|
1704
|
+
return { path, rel: relative(root, path) || path, lines };
|
|
1705
|
+
}
|
|
1706
|
+
function findPackageJson(startDir) {
|
|
1707
|
+
let dir = startDir;
|
|
1708
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
1709
|
+
const candidate = join4(dir, "package.json");
|
|
1710
|
+
if (existsSync4(candidate)) {
|
|
1711
|
+
try {
|
|
1712
|
+
return { path: candidate, json: JSON.parse(readFileSync4(candidate, "utf8")) };
|
|
1713
|
+
} catch {
|
|
1714
|
+
return { path: candidate, json: {} };
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
const parent = dirname(dir);
|
|
1718
|
+
if (parent === dir) break;
|
|
1719
|
+
dir = parent;
|
|
1720
|
+
}
|
|
1721
|
+
return null;
|
|
1722
|
+
}
|
|
1723
|
+
function loadTree(root) {
|
|
1724
|
+
const st = statSync(root);
|
|
1725
|
+
const baseDir = st.isDirectory() ? root : dirname(root);
|
|
1726
|
+
const filePaths = [];
|
|
1727
|
+
if (st.isDirectory()) walk(root, filePaths);
|
|
1728
|
+
else if (root.endsWith(".ts") || root.endsWith(".tsx")) filePaths.push(root);
|
|
1729
|
+
const files = filePaths.map((p) => loadFile(p, baseDir));
|
|
1730
|
+
return { root, files, packageJson: findPackageJson(baseDir) };
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// src/detectors/c19-served-descriptions.ts
|
|
1734
|
+
var PIN6 = "a74a042b53c71a095ab88b3073bd47728eeda814";
|
|
1735
|
+
var TOOL_DESC_INLINE_RE = /\b(?:s\.tool|\.tool|registerTool)\s*\(\s*(?:["'`][^"'`]+["'`]|[A-Za-z_$][\w$]*)\s*,\s*["'`]([^"'`]+)["'`]/;
|
|
1736
|
+
var DESCRIPTOR_DESC_RE = /\bdescription\s*:\s*["'`]([^"'`]+)["'`]/;
|
|
1737
|
+
var BARE_STRING_RE = /^\s*["'`]([^"'`]+)["'`]\s*,?\s*$/;
|
|
1738
|
+
var BACKWARD_LOOKBACK = 10;
|
|
1739
|
+
var FORWARD_LOOKAHEAD = 6;
|
|
1740
|
+
function findInlineDescription(tree, siteAnchor, toolName) {
|
|
1741
|
+
const [rel, lineStr] = siteAnchor.split(":");
|
|
1742
|
+
const file = tree.files.find((f) => f.rel === rel);
|
|
1743
|
+
if (!file) return void 0;
|
|
1744
|
+
const siteLine = Number.parseInt(lineStr, 10);
|
|
1745
|
+
const start = Math.max(1, siteLine - BACKWARD_LOOKBACK);
|
|
1746
|
+
const end = Math.min(siteLine + FORWARD_LOOKAHEAD, file.lines.length + 1);
|
|
1747
|
+
for (let n = start; n < end; n++) {
|
|
1748
|
+
const line = file.lines[n - 1];
|
|
1749
|
+
if (!line || line.comment) continue;
|
|
1750
|
+
const inline = line.raw.match(TOOL_DESC_INLINE_RE);
|
|
1751
|
+
if (inline) return inline[1];
|
|
1752
|
+
const desc = line.raw.match(DESCRIPTOR_DESC_RE);
|
|
1753
|
+
if (desc) return desc[1];
|
|
1754
|
+
if (n >= siteLine) {
|
|
1755
|
+
const bare = line.raw.match(BARE_STRING_RE);
|
|
1756
|
+
if (bare && bare[1] !== toolName) return bare[1];
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
return void 0;
|
|
1760
|
+
}
|
|
1761
|
+
var TRIGGER_LIST_RE = /use this whenever/i;
|
|
1762
|
+
var TRIGGER_CLOSE_RE = /even if they don'?t say/i;
|
|
1763
|
+
var BILINGUAL_SPLIT_RE = /^(.{10,})\s\/\s(.{10,})$/;
|
|
1764
|
+
function c19PositiveControls() {
|
|
1765
|
+
const inlineSample = 's.tool("list_items", "List items", {}, handler)';
|
|
1766
|
+
const triggerSample = "List items. Use this whenever the user says list, show, or enumerate \u2014 even if they don't say 'list_items' explicitly.";
|
|
1767
|
+
const bilingualSample = "List items, use this whenever asked. / Liste les elements, a utiliser des que demande.";
|
|
1768
|
+
const monoSample = "List items only, no second language here at all.";
|
|
1769
|
+
return {
|
|
1770
|
+
inlineDescAccepts: TOOL_DESC_INLINE_RE.test(inlineSample),
|
|
1771
|
+
triggerListAccepts: TRIGGER_LIST_RE.test(triggerSample),
|
|
1772
|
+
triggerCloseAccepts: TRIGGER_CLOSE_RE.test(triggerSample),
|
|
1773
|
+
bilingualAccepts: BILINGUAL_SPLIT_RE.test(bilingualSample),
|
|
1774
|
+
bilingualRejectsMonolingual: !BILINGUAL_SPLIT_RE.test(monoSample)
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
var c19ServedDescriptions = {
|
|
1778
|
+
id: "C19 served-descriptions",
|
|
1779
|
+
rule: "C-19",
|
|
1780
|
+
pin: PIN6,
|
|
1781
|
+
run(tree) {
|
|
1782
|
+
const controls = c19PositiveControls();
|
|
1783
|
+
if (!controls.inlineDescAccepts || !controls.triggerListAccepts || !controls.triggerCloseAccepts || !controls.bilingualAccepts || !controls.bilingualRejectsMonolingual) {
|
|
1784
|
+
throw new Error(
|
|
1785
|
+
`C19 positive control FAILED: ${JSON.stringify(controls)} \u2014 matcher broken, verdict untrustworthy`
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
const sites = findRegisteredTools(tree);
|
|
1789
|
+
if (sites.length === 0) {
|
|
1790
|
+
return {
|
|
1791
|
+
id: this.id,
|
|
1792
|
+
anchor: "-",
|
|
1793
|
+
state: "absent",
|
|
1794
|
+
verdict: "no tool registrations found; served-descriptions check n/a (C-19)"
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
for (const site of sites) {
|
|
1798
|
+
const desc = findInlineDescription(tree, site.anchor, site.name);
|
|
1799
|
+
if (!desc) {
|
|
1800
|
+
return {
|
|
1801
|
+
id: this.id,
|
|
1802
|
+
anchor: site.anchor,
|
|
1803
|
+
state: "non-conforming",
|
|
1804
|
+
verdict: `tool "${site.name}" at ${site.anchor} declares no inline description in source (positive-control OK) -> declare one; the SERVED bilingual/budget/trigger-phrase measurement is the dynamic half of C-19, run with a live session`
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
return {
|
|
1809
|
+
id: this.id,
|
|
1810
|
+
anchor: sites[0].anchor,
|
|
1811
|
+
state: "present",
|
|
1812
|
+
verdict: `${sites.length} registered tool(s) each declare a source-level description (positive-control OK). Whether the SERVED text is bilingual, under the C-13 per-tool budget, and carries its trigger phrase is the dynamique half of C-19 \u2014 this static run asserts source-level presence only, run with a live session (--mode=dynamic|both) for the served measurement.`
|
|
1813
|
+
};
|
|
1814
|
+
},
|
|
1815
|
+
runDynamic(runtime) {
|
|
1816
|
+
if (runtime.launchError) {
|
|
1817
|
+
const reason = `server could not be launched/introspected: ${runtime.launchError} \u2014 served descriptions unreadable (C-19)`;
|
|
1818
|
+
return {
|
|
1819
|
+
id: this.id,
|
|
1820
|
+
anchor: runtime.root,
|
|
1821
|
+
state: "could-not-judge",
|
|
1822
|
+
reason,
|
|
1823
|
+
verdict: `could not judge: ${reason}`
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
if (runtime.tools.length === 0) {
|
|
1827
|
+
return {
|
|
1828
|
+
id: this.id,
|
|
1829
|
+
anchor: runtime.root,
|
|
1830
|
+
state: "absent",
|
|
1831
|
+
verdict: "server serves no tools over tools/list; served-descriptions check n/a (C-19)"
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
const ceiling = findCeiling(loadTree(runtime.root));
|
|
1835
|
+
for (const t of runtime.tools) {
|
|
1836
|
+
if (!t.description) {
|
|
1837
|
+
return {
|
|
1838
|
+
id: this.id,
|
|
1839
|
+
anchor: t.name,
|
|
1840
|
+
state: "non-conforming",
|
|
1841
|
+
verdict: `tool "${t.name}" serves NO description over tools/list -> C-19 requires one, in both languages, with its trigger phrase`
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
if (ceiling) {
|
|
1845
|
+
const estTokens = Math.ceil(t.description.length / 4);
|
|
1846
|
+
if (estTokens > ceiling.perTool) {
|
|
1847
|
+
return {
|
|
1848
|
+
id: this.id,
|
|
1849
|
+
anchor: t.name,
|
|
1850
|
+
state: "non-conforming",
|
|
1851
|
+
verdict: `tool "${t.name}" serves a description estimated at ${estTokens} tokens, above the declared per-tool ceiling ${ceiling.perTool} (${ceiling.anchor}) -> shorten it (C-19/C-13)`
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
if (!BILINGUAL_SPLIT_RE.test(t.description)) {
|
|
1856
|
+
return {
|
|
1857
|
+
id: this.id,
|
|
1858
|
+
anchor: t.name,
|
|
1859
|
+
state: "non-conforming",
|
|
1860
|
+
verdict: `tool "${t.name}" serves a description with no "<en> / <fr>" bilingual split -> serve both languages (C-19)`
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
if (!TRIGGER_LIST_RE.test(t.description) || !TRIGGER_CLOSE_RE.test(t.description)) {
|
|
1864
|
+
return {
|
|
1865
|
+
id: this.id,
|
|
1866
|
+
anchor: t.name,
|
|
1867
|
+
state: "non-conforming",
|
|
1868
|
+
verdict: `tool "${t.name}" serves a description with no trigger-phrase clause ("use this whenever ... even if they don't say") -> add it (C-19)`
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
return {
|
|
1873
|
+
id: this.id,
|
|
1874
|
+
anchor: runtime.tools[0].name,
|
|
1875
|
+
state: "present",
|
|
1876
|
+
verdict: `${runtime.tools.length} served tool description(s) are bilingual, under the declared per-tool budget${ceiling ? ` (${ceiling.perTool})` : " (no ceiling declared)"}, and carry their trigger phrase (C-19). Prose quality beyond this mechanical check remains a human judgment.`
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
};
|
|
1880
|
+
|
|
1881
|
+
// src/detectors/c20-locales-derived.ts
|
|
1882
|
+
var I18N_DIR_RE = /^src\/i18n\/([a-z]{2}(?:-[A-Z]{2})?)\.tsx?$/;
|
|
1883
|
+
var I18N_PLUMBING = /* @__PURE__ */ new Set(["index", "types", "locale", "locales"]);
|
|
1884
|
+
function deriveLocales(tree) {
|
|
1885
|
+
const out = [];
|
|
1886
|
+
for (const file of tree.files) {
|
|
1887
|
+
const m = file.rel.match(I18N_DIR_RE);
|
|
1888
|
+
if (m && !I18N_PLUMBING.has(m[1])) {
|
|
1889
|
+
out.push({ code: m[1], anchor: `${file.rel}:1` });
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
return out;
|
|
1893
|
+
}
|
|
1894
|
+
var HARDCODED_UNION_RE = /\btype\s+Locale\s*=\s*["'`][a-z]{2}["'`](?:\s*\|\s*["'`][a-z]{2}["'`])+/;
|
|
1895
|
+
var HARDCODED_ARRAY_RE = /\b(?:const|let)\s+(?:SUPPORTED_LOCALES|LOCALES)\s*(?::[^=]+)?=\s*\[\s*["'`][a-z]{2}["'`](?:\s*,\s*["'`][a-z]{2}["'`])+/;
|
|
1896
|
+
var HARDCODED_ENUM_RE = /\benum\s+Locale\s*\{/;
|
|
1897
|
+
function c20PositiveControls() {
|
|
1898
|
+
const unionSample = 'type Locale = "en" | "fr";';
|
|
1899
|
+
const arraySample = 'const SUPPORTED_LOCALES = ["en", "fr"];';
|
|
1900
|
+
const derivedSample = 'const LOCALES = readdirSync(join(__dirname, "i18n")).map((f) => f.replace(/\\.ts$/, ""));';
|
|
1901
|
+
return {
|
|
1902
|
+
unionMatcher: {
|
|
1903
|
+
pattern: HARDCODED_UNION_RE.source,
|
|
1904
|
+
sample: unionSample,
|
|
1905
|
+
matched: HARDCODED_UNION_RE.test(unionSample)
|
|
1906
|
+
},
|
|
1907
|
+
arrayMatcher: {
|
|
1908
|
+
pattern: HARDCODED_ARRAY_RE.source,
|
|
1909
|
+
sample: arraySample,
|
|
1910
|
+
matched: HARDCODED_ARRAY_RE.test(arraySample)
|
|
1911
|
+
},
|
|
1912
|
+
arrayClearsDerivedUsage: !HARDCODED_ARRAY_RE.test(derivedSample)
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1915
|
+
function findHardcodedEnumerations(tree) {
|
|
1916
|
+
const hits = [];
|
|
1917
|
+
for (const file of tree.files) {
|
|
1918
|
+
if (I18N_DIR_RE.test(file.rel)) continue;
|
|
1919
|
+
for (const line of file.lines) {
|
|
1920
|
+
if (line.comment) continue;
|
|
1921
|
+
if (HARDCODED_UNION_RE.test(line.raw)) {
|
|
1922
|
+
hits.push({ file: file.rel, line: line.n, kind: "union type" });
|
|
1923
|
+
} else if (HARDCODED_ARRAY_RE.test(line.raw)) {
|
|
1924
|
+
hits.push({ file: file.rel, line: line.n, kind: "literal array" });
|
|
1925
|
+
} else if (HARDCODED_ENUM_RE.test(line.raw)) {
|
|
1926
|
+
hits.push({ file: file.rel, line: line.n, kind: "enum" });
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
return hits;
|
|
1931
|
+
}
|
|
1932
|
+
var c20LocalesDerived = {
|
|
1933
|
+
id: "C20 locales-derived",
|
|
1934
|
+
rule: "C-20",
|
|
1935
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
1936
|
+
run(tree) {
|
|
1937
|
+
const controls = c20PositiveControls();
|
|
1938
|
+
if (!controls.unionMatcher.matched || !controls.arrayMatcher.matched) {
|
|
1939
|
+
throw new Error(
|
|
1940
|
+
`C20 positive control FAILED: union=${controls.unionMatcher.matched} array=${controls.arrayMatcher.matched} \u2014 matcher broken, verdict untrustworthy`
|
|
1941
|
+
);
|
|
1942
|
+
}
|
|
1943
|
+
if (!controls.arrayClearsDerivedUsage) {
|
|
1944
|
+
throw new Error(
|
|
1945
|
+
"C20 array-matcher control FAILED: must NOT flag a genuinely derived (readdirSync-built) locales binding \u2014 verdict untrustworthy"
|
|
1946
|
+
);
|
|
1947
|
+
}
|
|
1948
|
+
const locales = deriveLocales(tree);
|
|
1949
|
+
if (locales.length === 0) {
|
|
1950
|
+
return {
|
|
1951
|
+
id: this.id,
|
|
1952
|
+
anchor: "-",
|
|
1953
|
+
state: "absent",
|
|
1954
|
+
verdict: "no src/i18n/ locale files found; locales-derived check n/a"
|
|
1955
|
+
};
|
|
1956
|
+
}
|
|
1957
|
+
const hits = findHardcodedEnumerations(tree);
|
|
1958
|
+
if (hits.length > 0) {
|
|
1959
|
+
const h = hits[0];
|
|
1960
|
+
return {
|
|
1961
|
+
id: this.id,
|
|
1962
|
+
anchor: `${h.file}:${h.line}`,
|
|
1963
|
+
state: "non-conforming",
|
|
1964
|
+
verdict: `hardcoded locale ${h.kind} at ${h.file}:${h.line}, alongside ${locales.length} locale file(s) derived from src/i18n/ (positive-control OK) -> derive the locale set from src/i18n/, never hand-list it (C-20)`
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
return {
|
|
1968
|
+
id: this.id,
|
|
1969
|
+
anchor: locales[0].anchor,
|
|
1970
|
+
state: "present",
|
|
1971
|
+
verdict: `${locales.length} locale(s) derived from src/i18n/ (${locales.map((l) => l.code).join(", ")}), no hardcoded enumeration found (positive-control OK; static half only \u2014 the per-locale served-response check is dynamic and out of scope for this run)`
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
};
|
|
1975
|
+
|
|
1976
|
+
// src/detectors/c21-progressive-disclosure.ts
|
|
1977
|
+
var PIN7 = "a74a042b53c71a095ab88b3073bd47728eeda814";
|
|
1978
|
+
var ESTIMATED_BYTES_PER_TOKEN = 4;
|
|
1979
|
+
function c21PositiveControls() {
|
|
1980
|
+
return { ceilingReadable: typeof findCeiling === "function" };
|
|
1981
|
+
}
|
|
1982
|
+
function estimateHandshakeTokens(runtime) {
|
|
1983
|
+
const payload = JSON.stringify(
|
|
1984
|
+
runtime.tools.map((t) => ({ name: t.name, description: t.description ?? "" }))
|
|
1985
|
+
);
|
|
1986
|
+
return Math.ceil(payload.length / ESTIMATED_BYTES_PER_TOKEN);
|
|
1987
|
+
}
|
|
1988
|
+
var c21ProgressiveDisclosure = {
|
|
1989
|
+
id: "C21 progressive-disclosure",
|
|
1990
|
+
rule: "C-21",
|
|
1991
|
+
pin: PIN7,
|
|
1992
|
+
run(_tree) {
|
|
1993
|
+
const controls = c21PositiveControls();
|
|
1994
|
+
if (!controls.ceilingReadable) {
|
|
1995
|
+
throw new Error(
|
|
1996
|
+
"C21 control FAILED: C-13 findCeiling not importable \u2014 verdict untrustworthy"
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
return {
|
|
2000
|
+
id: this.id,
|
|
2001
|
+
anchor: "-",
|
|
2002
|
+
state: "absent",
|
|
2003
|
+
verdict: "C-21 measures what actually lands in context at connection (handshake), which a static-tree run cannot read \u2014 connect with a live session (--mode=dynamic|both) to measure it against the C-13 declared ceiling (C-21 n/a on a static-only run)"
|
|
2004
|
+
};
|
|
2005
|
+
},
|
|
2006
|
+
runDynamic(runtime) {
|
|
2007
|
+
if (runtime.launchError) {
|
|
2008
|
+
const reason = `server could not be launched/introspected: ${runtime.launchError} \u2014 connection-load unmeasurable (C-21)`;
|
|
2009
|
+
return {
|
|
2010
|
+
id: this.id,
|
|
2011
|
+
anchor: runtime.root,
|
|
2012
|
+
state: "could-not-judge",
|
|
2013
|
+
reason,
|
|
2014
|
+
verdict: `could not judge: ${reason}`
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
const ceiling = findCeiling(loadTree(runtime.root));
|
|
2018
|
+
if (!ceiling) {
|
|
2019
|
+
const reason = "no (global, perTool) context ceiling declared anywhere in the tree (C-13) \u2014 C-21 has nothing to measure against";
|
|
2020
|
+
return {
|
|
2021
|
+
id: this.id,
|
|
2022
|
+
anchor: runtime.root,
|
|
2023
|
+
state: "could-not-judge",
|
|
2024
|
+
reason,
|
|
2025
|
+
verdict: `could not judge: ${reason}`
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
const measured = estimateHandshakeTokens(runtime);
|
|
2029
|
+
if (measured > ceiling.global) {
|
|
2030
|
+
return {
|
|
2031
|
+
id: this.id,
|
|
2032
|
+
anchor: ceiling.anchor,
|
|
2033
|
+
state: "non-conforming",
|
|
2034
|
+
verdict: `measured connection load ~${measured} tokens (served tools/list payload) exceeds the declared global ceiling ${ceiling.global} (${ceiling.anchor}) -> stage publication by tiers (C-21, the measured half of C-13)`
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
return {
|
|
2038
|
+
id: this.id,
|
|
2039
|
+
anchor: ceiling.anchor,
|
|
2040
|
+
state: "present",
|
|
2041
|
+
verdict: `measured connection load ~${measured} tokens, within the declared global ceiling ${ceiling.global} (${ceiling.anchor}) -> C-13's declaration holds under measurement (C-21)`
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
};
|
|
2045
|
+
|
|
2046
|
+
// src/detectors/c22-test-ratio-gate.ts
|
|
2047
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
|
|
2048
|
+
import { join as join5 } from "path";
|
|
2049
|
+
var DOC_FILES2 = ["README.md", "README.fr.md", "CHANGELOG.md"];
|
|
2050
|
+
var RATIO_RE = /\b(?:coverage|ratio de tests?|test ratio|couverture)\b[^\n%]{0,40}?(\d{1,3}(?:\.\d+)?\s?%)/i;
|
|
2051
|
+
var IDENTIFIER_RE = /\b(?:run[/#]?\s?\d+|workflow run|gh run|actions\/runs\/\d+|CI#\d+|\b[0-9a-f]{7,40}\b)/i;
|
|
2052
|
+
function c22PositiveControls() {
|
|
2053
|
+
const ratioSample = "Coverage: 92%";
|
|
2054
|
+
const idSample = "Coverage: 92% (CI run https://github.com/x/y/actions/runs/123456)";
|
|
2055
|
+
const bareSample = "Coverage: 92%, measured locally.";
|
|
2056
|
+
return {
|
|
2057
|
+
ratioMatcher: {
|
|
2058
|
+
pattern: RATIO_RE.source,
|
|
2059
|
+
sample: ratioSample,
|
|
2060
|
+
matched: RATIO_RE.test(ratioSample)
|
|
2061
|
+
},
|
|
2062
|
+
identifierMatcher: {
|
|
2063
|
+
pattern: IDENTIFIER_RE.source,
|
|
2064
|
+
sample: idSample,
|
|
2065
|
+
matched: IDENTIFIER_RE.test(idSample)
|
|
2066
|
+
},
|
|
2067
|
+
identifierClearsBareRatio: !IDENTIFIER_RE.test(bareSample)
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
function findRatioCitations(root) {
|
|
2071
|
+
const hits = [];
|
|
2072
|
+
for (const name of DOC_FILES2) {
|
|
2073
|
+
const path = join5(root, name);
|
|
2074
|
+
if (!existsSync5(path)) continue;
|
|
2075
|
+
let text;
|
|
2076
|
+
try {
|
|
2077
|
+
text = readFileSync5(path, "utf8");
|
|
2078
|
+
} catch {
|
|
2079
|
+
continue;
|
|
2080
|
+
}
|
|
2081
|
+
const lines = text.split("\n");
|
|
2082
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2083
|
+
const raw = lines[i];
|
|
2084
|
+
if (RATIO_RE.test(raw)) {
|
|
2085
|
+
hits.push({ file: name, line: i + 1, raw, hasIdentifier: IDENTIFIER_RE.test(raw) });
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
return hits;
|
|
2090
|
+
}
|
|
2091
|
+
var c22TestRatioGate = {
|
|
2092
|
+
id: "C22 test-ratio-gate",
|
|
2093
|
+
rule: "C-22",
|
|
2094
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
2095
|
+
run(tree) {
|
|
2096
|
+
const controls = c22PositiveControls();
|
|
2097
|
+
if (!controls.ratioMatcher.matched || !controls.identifierMatcher.matched) {
|
|
2098
|
+
throw new Error(
|
|
2099
|
+
`C22 positive control FAILED: ratio=${controls.ratioMatcher.matched} identifier=${controls.identifierMatcher.matched} \u2014 matcher broken, verdict untrustworthy`
|
|
2100
|
+
);
|
|
2101
|
+
}
|
|
2102
|
+
if (!controls.identifierClearsBareRatio) {
|
|
2103
|
+
throw new Error(
|
|
2104
|
+
"C22 identifier-matcher control FAILED: must NOT flag a bare ratio with no identifier token \u2014 verdict untrustworthy"
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
const hits = findRatioCitations(tree.root);
|
|
2108
|
+
if (hits.length === 0) {
|
|
2109
|
+
return {
|
|
2110
|
+
id: this.id,
|
|
2111
|
+
anchor: "-",
|
|
2112
|
+
state: "absent",
|
|
2113
|
+
verdict: "no test/coverage ratio cited in README/CHANGELOG; test-ratio-gate check n/a"
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
const bare = hits.find((h) => !h.hasIdentifier);
|
|
2117
|
+
if (bare) {
|
|
2118
|
+
return {
|
|
2119
|
+
id: this.id,
|
|
2120
|
+
anchor: `${bare.file}:${bare.line}`,
|
|
2121
|
+
state: "non-conforming",
|
|
2122
|
+
verdict: `ratio cited at ${bare.file}:${bare.line} ("${bare.raw.trim()}") carries no passage-identifier token (positive-control OK) -> cite the automatic run/commit identifier the ratio was produced by (C-22)`
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
const withId = hits[0];
|
|
2126
|
+
const reason = `ratio cited at ${withId.file}:${withId.line} carries an identifier-shaped token, but whether it RESOLVES against a live CI run is the gate half of C-22 \u2014 this static-only run has no CI query surface and cannot confirm resolution`;
|
|
2127
|
+
return {
|
|
2128
|
+
id: this.id,
|
|
2129
|
+
anchor: `${withId.file}:${withId.line}`,
|
|
2130
|
+
state: "could-not-judge",
|
|
2131
|
+
reason,
|
|
2132
|
+
verdict: `could not judge: ${reason}`
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
};
|
|
2136
|
+
|
|
2137
|
+
// src/detectors/c25-build-identity.ts
|
|
2138
|
+
var PIN8 = "20136b50";
|
|
2139
|
+
var IDENTITY_KEY_NAMES = [
|
|
2140
|
+
"buildId",
|
|
2141
|
+
"buildID",
|
|
2142
|
+
"buildSha",
|
|
2143
|
+
"buildHash",
|
|
2144
|
+
"commitHash",
|
|
2145
|
+
"commitSha",
|
|
2146
|
+
"gitSha",
|
|
2147
|
+
"gitCommit",
|
|
2148
|
+
"gitRevision"
|
|
2149
|
+
];
|
|
2150
|
+
var CONSTRUCTOR_RE = /\bnew\s+(?:McpServer|Server)\s*\(/;
|
|
2151
|
+
var IDENTITY_KEY_LINE_RE = new RegExp(
|
|
2152
|
+
`\\b(${IDENTITY_KEY_NAMES.join("|")})\\s*:\\s*(.+?)\\s*,?\\s*$`
|
|
2153
|
+
);
|
|
2154
|
+
var IDENTITY_KEY_BARE_RE = new RegExp(`\\b(${IDENTITY_KEY_NAMES.join("|")})\\s*:\\s*$`);
|
|
2155
|
+
var LITERAL_SAMPLE = 'commitSha: "a1b2c3d",';
|
|
2156
|
+
var DERIVED_SAMPLE = "buildId: process.env.BUILD_SHA,";
|
|
2157
|
+
var FULL_COMMIT_ID = /^[0-9a-f]{40}$/;
|
|
2158
|
+
function isLiteralValue(expr) {
|
|
2159
|
+
const trimmed = expr.trim().replace(/,$/, "").trim();
|
|
2160
|
+
const m = trimmed.match(/^(["'`])([\s\S]*)\1$/);
|
|
2161
|
+
if (!m) return false;
|
|
2162
|
+
if (m[1] === "`" && /\$\{/.test(m[2])) return false;
|
|
2163
|
+
return true;
|
|
2164
|
+
}
|
|
2165
|
+
function c25PositiveControls() {
|
|
2166
|
+
const literalMatch = LITERAL_SAMPLE.match(IDENTITY_KEY_LINE_RE);
|
|
2167
|
+
const derivedMatch = DERIVED_SAMPLE.match(IDENTITY_KEY_LINE_RE);
|
|
2168
|
+
return {
|
|
2169
|
+
literalAccepts: literalMatch ? isLiteralValue(literalMatch[2]) : false,
|
|
2170
|
+
derivedRejectsAsLiteral: derivedMatch ? isLiteralValue(derivedMatch[2]) : false
|
|
2171
|
+
};
|
|
2172
|
+
}
|
|
2173
|
+
function extractConstructorArgs(file, startIdx) {
|
|
2174
|
+
const code = file.lines.filter((l) => !l.comment);
|
|
2175
|
+
const startPos = code.findIndex((l) => l.n === file.lines[startIdx].n);
|
|
2176
|
+
if (startPos < 0) return null;
|
|
2177
|
+
const text = code.slice(startPos).map((l) => l.raw).join("\n");
|
|
2178
|
+
const openIdx = text.indexOf("(");
|
|
2179
|
+
if (openIdx < 0) return null;
|
|
2180
|
+
let depth = 0;
|
|
2181
|
+
let body = "";
|
|
2182
|
+
for (let i = openIdx; i < text.length; i++) {
|
|
2183
|
+
const ch = text[i];
|
|
2184
|
+
if (ch === "(") {
|
|
2185
|
+
depth++;
|
|
2186
|
+
if (depth === 1) continue;
|
|
2187
|
+
} else if (ch === ")") {
|
|
2188
|
+
depth--;
|
|
2189
|
+
if (depth === 0) return body;
|
|
2190
|
+
}
|
|
2191
|
+
if (depth >= 1) body += ch;
|
|
2192
|
+
}
|
|
2193
|
+
return null;
|
|
2194
|
+
}
|
|
2195
|
+
function findConstructorSites(tree) {
|
|
2196
|
+
const sites = [];
|
|
2197
|
+
for (const file of tree.files) {
|
|
2198
|
+
file.lines.forEach((line, idx) => {
|
|
2199
|
+
if (line.comment) return;
|
|
2200
|
+
if (CONSTRUCTOR_RE.test(line.raw)) {
|
|
2201
|
+
sites.push({
|
|
2202
|
+
anchor: `${file.rel}:${line.n}`,
|
|
2203
|
+
args: extractConstructorArgs(file, idx)
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
return sites;
|
|
2209
|
+
}
|
|
2210
|
+
function classifyArgs(args, anchor) {
|
|
2211
|
+
for (const raw of args.split("\n")) {
|
|
2212
|
+
const bare = raw.match(IDENTITY_KEY_BARE_RE);
|
|
2213
|
+
if (bare) return { kind: "bare-continuation", key: bare[1], anchor };
|
|
2214
|
+
const m = raw.match(IDENTITY_KEY_LINE_RE);
|
|
2215
|
+
if (m) {
|
|
2216
|
+
const [, key, value] = m;
|
|
2217
|
+
if (isLiteralValue(value)) return { kind: "literal", key, value: value.trim(), anchor };
|
|
2218
|
+
return { kind: "derived", key, anchor };
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
return { kind: "none" };
|
|
2222
|
+
}
|
|
2223
|
+
var c25BuildIdentity = {
|
|
2224
|
+
id: "C25 build-identity",
|
|
2225
|
+
rule: "C-25",
|
|
2226
|
+
pin: PIN8,
|
|
2227
|
+
run(tree) {
|
|
2228
|
+
const controls = c25PositiveControls();
|
|
2229
|
+
if (!controls.literalAccepts || controls.derivedRejectsAsLiteral) {
|
|
2230
|
+
throw new Error(
|
|
2231
|
+
`C25 literal/derived control FAILED: ${JSON.stringify(controls)} \u2014 matcher broken, verdict untrustworthy`
|
|
2232
|
+
);
|
|
2233
|
+
}
|
|
2234
|
+
const sites = findConstructorSites(tree);
|
|
2235
|
+
if (sites.length === 0) {
|
|
2236
|
+
return {
|
|
2237
|
+
id: this.id,
|
|
2238
|
+
anchor: tree.files.length > 0 ? `${tree.files[0].rel}:1` : tree.root,
|
|
2239
|
+
state: "absent",
|
|
2240
|
+
verdict: "no `new McpServer(...)`/`new Server(...)` construction found; C-25 n/a"
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
for (const site of sites) {
|
|
2244
|
+
if (site.args === null) {
|
|
2245
|
+
const reason = `constructor call at ${site.anchor} could not be delimited (unbalanced/EOF) \u2014 the build-identity field could not be searched over its full argument object, scope unestablished`;
|
|
2246
|
+
return {
|
|
2247
|
+
id: this.id,
|
|
2248
|
+
anchor: site.anchor,
|
|
2249
|
+
state: "could-not-judge",
|
|
2250
|
+
reason,
|
|
2251
|
+
verdict: `could not judge: ${reason}`
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
for (const site of sites) {
|
|
2256
|
+
const finding = classifyArgs(site.args, site.anchor);
|
|
2257
|
+
if (finding.kind === "bare-continuation") {
|
|
2258
|
+
const reason = `identity key "${finding.key}" found at ${finding.anchor} but its value continues past the line (not statically joined) \u2014 verdict unreadable`;
|
|
2259
|
+
return {
|
|
2260
|
+
id: this.id,
|
|
2261
|
+
anchor: finding.anchor,
|
|
2262
|
+
state: "could-not-judge",
|
|
2263
|
+
reason,
|
|
2264
|
+
verdict: `could not judge: ${reason}`
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2267
|
+
if (finding.kind === "derived") {
|
|
2268
|
+
return {
|
|
2269
|
+
id: this.id,
|
|
2270
|
+
anchor: finding.anchor,
|
|
2271
|
+
state: "present",
|
|
2272
|
+
verdict: `build-identity field "${finding.key}" at ${finding.anchor} is fed by the build (not a literal) \u2014 served alongside version (C-25). Whether the SERVED value equals the expected commit is the dynamique half, run with a live session.`
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
if (finding.kind === "literal") {
|
|
2276
|
+
return {
|
|
2277
|
+
id: this.id,
|
|
2278
|
+
anchor: finding.anchor,
|
|
2279
|
+
state: "non-conforming",
|
|
2280
|
+
verdict: `build-identity field "${finding.key}" at ${finding.anchor} is a hand-written literal (${finding.value}) -> a literal never changes on its own and proves nothing about the tree actually served; derive it from the build (env var injected at build time, or a generated file) (C-25)`
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
return {
|
|
2285
|
+
id: this.id,
|
|
2286
|
+
anchor: sites[0].anchor,
|
|
2287
|
+
state: "non-conforming",
|
|
2288
|
+
verdict: `server construction at ${sites[0].anchor} declares no build-identity field (${IDENTITY_KEY_NAMES.join("/")}) alongside version -> a version is a DECLARED value that only changes if someone remembers to bump it; add a build identity derived from the tree actually built (C-25)`
|
|
2289
|
+
};
|
|
2290
|
+
},
|
|
2291
|
+
runDynamic(runtime) {
|
|
2292
|
+
if (runtime.launchError) {
|
|
2293
|
+
const reason = `server could not be launched/introspected: ${runtime.launchError} \u2014 served build-identity unreadable (C-25)`;
|
|
2294
|
+
return {
|
|
2295
|
+
id: this.id,
|
|
2296
|
+
anchor: runtime.root,
|
|
2297
|
+
state: "could-not-judge",
|
|
2298
|
+
reason,
|
|
2299
|
+
verdict: `could not judge: ${reason}`
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
const serverInfo = runtime.serverInfo ?? {};
|
|
2303
|
+
let found = null;
|
|
2304
|
+
for (const key of IDENTITY_KEY_NAMES) {
|
|
2305
|
+
const v = serverInfo[key];
|
|
2306
|
+
if (v !== void 0 && v !== null && String(v).trim() !== "") {
|
|
2307
|
+
found = { key, value: String(v) };
|
|
2308
|
+
break;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
if (!found) {
|
|
2312
|
+
const declaredVersion = typeof serverInfo.version === "string" ? serverInfo.version : "?";
|
|
2313
|
+
return {
|
|
2314
|
+
id: this.id,
|
|
2315
|
+
anchor: runtime.root,
|
|
2316
|
+
state: "non-conforming",
|
|
2317
|
+
verdict: `server serves no build-identity field (${IDENTITY_KEY_NAMES.join("/")}) over initialize's serverInfo \u2014 only version="${declaredVersion}" -> a declared version proves nothing about the tree served; C-25 requires one`
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
if (!runtime.expectedCommit) {
|
|
2321
|
+
const reason = `identity "${found.key}"="${found.value}" served, but no expected commit was supplied to compare against (target is not a resolvable git checkout, or HEAD did not resolve) \u2014 the static verdict stands; the served-vs-expected comparison is OWED, not a pass`;
|
|
2322
|
+
return {
|
|
2323
|
+
id: this.id,
|
|
2324
|
+
anchor: runtime.root,
|
|
2325
|
+
state: "could-not-judge",
|
|
2326
|
+
reason,
|
|
2327
|
+
verdict: `could not judge: ${reason}`
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
const served = found.value.trim().toLowerCase();
|
|
2331
|
+
const expected = runtime.expectedCommit.trim().toLowerCase();
|
|
2332
|
+
if (!FULL_COMMIT_ID.test(expected)) {
|
|
2333
|
+
const reason = `expected commit "${runtime.expectedCommit}" is not a full 40-hex commit id \u2014 the served-vs-expected comparison reasons about length and is only sound against a full id (see deriveExpectedCommit in src/runtime/launch.ts); refusing to judge rather than comparing against a value outside the contract`;
|
|
2334
|
+
return {
|
|
2335
|
+
id: this.id,
|
|
2336
|
+
anchor: runtime.root,
|
|
2337
|
+
state: "could-not-judge",
|
|
2338
|
+
reason,
|
|
2339
|
+
verdict: `could not judge: ${reason}`
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
const ABBREVIATED_IDENTITY_FLOOR = 7;
|
|
2343
|
+
if (served !== expected && served.length < ABBREVIATED_IDENTITY_FLOOR) {
|
|
2344
|
+
const reason = `served build-identity "${found.key}"="${found.value}" is ${served.length} character(s) long \u2014 below the ${ABBREVIATED_IDENTITY_FLOOR}-character floor under which an abbreviated commit cannot be told from a coincidence (one hex character matches one expected commit in sixteen); the served-vs-expected comparison against "${runtime.expectedCommit}" is UNDECIDABLE, not a pass`;
|
|
2345
|
+
return {
|
|
2346
|
+
id: this.id,
|
|
2347
|
+
anchor: runtime.root,
|
|
2348
|
+
state: "could-not-judge",
|
|
2349
|
+
reason,
|
|
2350
|
+
verdict: `could not judge: ${reason}`
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
if (served.length > expected.length && served.startsWith(expected)) {
|
|
2354
|
+
const suffix = found.value.trim().slice(expected.length);
|
|
2355
|
+
return {
|
|
2356
|
+
id: this.id,
|
|
2357
|
+
anchor: runtime.root,
|
|
2358
|
+
state: "non-conforming",
|
|
2359
|
+
verdict: `served build-identity "${found.key}"="${found.value}" is the expected commit "${runtime.expectedCommit}" DECORATED with "${suffix}" -> a full commit id carries no suffix, and the suffixes that occur in practice (-dirty, +local) state that the tree served is NOT that commit; the running server is not serving the tree it claims to (C-25)`
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
const matches = served === expected || served.length < expected.length && expected.startsWith(served);
|
|
2363
|
+
if (!matches) {
|
|
2364
|
+
return {
|
|
2365
|
+
id: this.id,
|
|
2366
|
+
anchor: runtime.root,
|
|
2367
|
+
state: "non-conforming",
|
|
2368
|
+
verdict: `served build-identity "${found.key}"="${found.value}" DIFFERS from the expected commit "${runtime.expectedCommit}" -> the running server is not serving the tree it claims to (C-25)`
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
return {
|
|
2372
|
+
id: this.id,
|
|
2373
|
+
anchor: runtime.root,
|
|
2374
|
+
state: "present",
|
|
2375
|
+
verdict: `served build-identity "${found.key}"="${found.value}" equals the expected commit "${runtime.expectedCommit}" (C-25)`
|
|
2376
|
+
};
|
|
2377
|
+
}
|
|
2378
|
+
};
|
|
2379
|
+
|
|
2380
|
+
// src/detectors/c26-subpackage-lockfile.ts
|
|
2381
|
+
import { spawnSync } from "child_process";
|
|
2382
|
+
import {
|
|
2383
|
+
existsSync as existsSync6,
|
|
2384
|
+
mkdtempSync,
|
|
2385
|
+
readFileSync as readFileSync6,
|
|
2386
|
+
readdirSync as readdirSync3,
|
|
2387
|
+
rmSync,
|
|
2388
|
+
statSync as statSync2,
|
|
2389
|
+
writeFileSync
|
|
2390
|
+
} from "fs";
|
|
2391
|
+
import { tmpdir } from "os";
|
|
2392
|
+
import { join as join6, relative as relative2 } from "path";
|
|
2393
|
+
var PIN9 = "20136b50";
|
|
2394
|
+
var DEPLOY_CONFIG_FILES = [
|
|
2395
|
+
"railway.json",
|
|
2396
|
+
"railway.toml",
|
|
2397
|
+
"Dockerfile",
|
|
2398
|
+
"fly.toml",
|
|
2399
|
+
"Procfile",
|
|
2400
|
+
"vercel.json"
|
|
2401
|
+
];
|
|
2402
|
+
var LOCKFILES2 = [
|
|
2403
|
+
{ name: "package-lock.json", command: "npm", args: ["ci"] },
|
|
2404
|
+
{ name: "bun.lock", command: "bun", args: ["install", "--frozen-lockfile"] },
|
|
2405
|
+
{ name: "bun.lockb", command: "bun", args: ["install", "--frozen-lockfile"] },
|
|
2406
|
+
{ name: "pnpm-lock.yaml", command: "pnpm", args: ["install", "--frozen-lockfile"] },
|
|
2407
|
+
{ name: "yarn.lock", command: "yarn", args: ["install", "--frozen-lockfile"] }
|
|
2408
|
+
];
|
|
2409
|
+
var SKIP_WALK_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "coverage"]);
|
|
2410
|
+
function walkForConfigs(dir, depth, seen) {
|
|
2411
|
+
if (depth > 8) return;
|
|
2412
|
+
let entries;
|
|
2413
|
+
try {
|
|
2414
|
+
entries = readdirSync3(dir);
|
|
2415
|
+
} catch {
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
for (const name of entries) {
|
|
2419
|
+
const full = join6(dir, name);
|
|
2420
|
+
let st;
|
|
2421
|
+
try {
|
|
2422
|
+
st = statSync2(full);
|
|
2423
|
+
} catch {
|
|
2424
|
+
continue;
|
|
2425
|
+
}
|
|
2426
|
+
if (st.isDirectory()) {
|
|
2427
|
+
if (SKIP_WALK_DIRS.has(name)) continue;
|
|
2428
|
+
walkForConfigs(full, depth + 1, seen);
|
|
2429
|
+
} else if (DEPLOY_CONFIG_FILES.includes(name)) {
|
|
2430
|
+
if (!seen.has(dir)) {
|
|
2431
|
+
seen.set(dir, { dir, configFile: name, configPath: full });
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
function resolveRailwayRootDirectory(configPath) {
|
|
2437
|
+
let text;
|
|
2438
|
+
try {
|
|
2439
|
+
text = readFileSync6(configPath, "utf8");
|
|
2440
|
+
} catch (err) {
|
|
2441
|
+
return {
|
|
2442
|
+
error: `could not read ${configPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
2443
|
+
};
|
|
2444
|
+
}
|
|
2445
|
+
let parsed;
|
|
2446
|
+
try {
|
|
2447
|
+
parsed = JSON.parse(text);
|
|
2448
|
+
} catch (err) {
|
|
2449
|
+
return {
|
|
2450
|
+
error: `could not parse ${configPath} as JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
const build = parsed.build;
|
|
2454
|
+
const rootDirectory = typeof parsed.rootDirectory === "string" && parsed.rootDirectory || build && typeof build.rootDirectory === "string" && build.rootDirectory || null;
|
|
2455
|
+
return rootDirectory;
|
|
2456
|
+
}
|
|
2457
|
+
function c26DeriveScope(root) {
|
|
2458
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2459
|
+
walkForConfigs(root, 0, seen);
|
|
2460
|
+
const sites = [];
|
|
2461
|
+
for (const site of seen.values()) {
|
|
2462
|
+
if (site.configFile === "railway.json") {
|
|
2463
|
+
const resolved = resolveRailwayRootDirectory(site.configPath);
|
|
2464
|
+
if (resolved && typeof resolved === "object" && "error" in resolved) {
|
|
2465
|
+
return { sites: [], unparsable: { path: site.configPath, reason: resolved.error } };
|
|
2466
|
+
}
|
|
2467
|
+
if (typeof resolved === "string" && resolved.trim()) {
|
|
2468
|
+
sites.push({ ...site, dir: join6(site.dir, resolved) });
|
|
2469
|
+
continue;
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
if (site.configFile === "vercel.json") {
|
|
2473
|
+
try {
|
|
2474
|
+
JSON.parse(readFileSync6(site.configPath, "utf8"));
|
|
2475
|
+
} catch (err) {
|
|
2476
|
+
return {
|
|
2477
|
+
sites: [],
|
|
2478
|
+
unparsable: {
|
|
2479
|
+
path: site.configPath,
|
|
2480
|
+
reason: `could not parse ${site.configPath} as JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
2481
|
+
}
|
|
2482
|
+
};
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
sites.push(site);
|
|
2486
|
+
}
|
|
2487
|
+
return { sites };
|
|
2488
|
+
}
|
|
2489
|
+
function findLockfile2(dir) {
|
|
2490
|
+
for (const l of LOCKFILES2) {
|
|
2491
|
+
if (existsSync6(join6(dir, l.name))) return l;
|
|
2492
|
+
}
|
|
2493
|
+
return null;
|
|
2494
|
+
}
|
|
2495
|
+
var NPM_INVALID_RE = /Invalid: lock file's (@?[^\s@]+(?:\/[^\s@]+)?)@(\S+) does not satisfy \1@(\S+)/;
|
|
2496
|
+
var NPM_MISSING_RE = /Missing: (@?[^\s@]+(?:\/[^\s@]+)?)@(\S+) from lock file/;
|
|
2497
|
+
function parseLenientJson(text) {
|
|
2498
|
+
return JSON.parse(text.replace(/,(\s*[\]}])/g, "$1"));
|
|
2499
|
+
}
|
|
2500
|
+
function nameBunDrift(manifestText, lockText) {
|
|
2501
|
+
let manifest;
|
|
2502
|
+
let lock;
|
|
2503
|
+
try {
|
|
2504
|
+
manifest = JSON.parse(manifestText);
|
|
2505
|
+
lock = parseLenientJson(lockText);
|
|
2506
|
+
} catch {
|
|
2507
|
+
return [];
|
|
2508
|
+
}
|
|
2509
|
+
const manifestDeps = {
|
|
2510
|
+
...manifest.dependencies,
|
|
2511
|
+
...manifest.devDependencies
|
|
2512
|
+
};
|
|
2513
|
+
const workspaces = lock.workspaces;
|
|
2514
|
+
const root = workspaces?.[""];
|
|
2515
|
+
const lockDeps = {
|
|
2516
|
+
...root?.dependencies,
|
|
2517
|
+
...root?.devDependencies
|
|
2518
|
+
};
|
|
2519
|
+
const drifted = [];
|
|
2520
|
+
for (const [name, range] of Object.entries(manifestDeps)) {
|
|
2521
|
+
const lockedRange = lockDeps[name];
|
|
2522
|
+
if (lockedRange !== void 0 && lockedRange !== range) {
|
|
2523
|
+
drifted.push(`${name} (package.json wants ${range}, bun.lock recorded ${lockedRange})`);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
return drifted;
|
|
2527
|
+
}
|
|
2528
|
+
function extractGenericToken(stderr) {
|
|
2529
|
+
const m = stderr.match(/(@?[\w.-]+(?:\/[\w.-]+)?)@[\w.^~><=|| -]+/);
|
|
2530
|
+
return m ? m[1] : null;
|
|
2531
|
+
}
|
|
2532
|
+
function nameDrift(lockfileName, siteDir, isolatedDir, stderr) {
|
|
2533
|
+
if (lockfileName === "package-lock.json") {
|
|
2534
|
+
const invalid = stderr.match(NPM_INVALID_RE);
|
|
2535
|
+
if (invalid)
|
|
2536
|
+
return `${invalid[1]} (lockfile has ${invalid[2]}, package.json wants ${invalid[3]})`;
|
|
2537
|
+
const missing = stderr.match(NPM_MISSING_RE);
|
|
2538
|
+
if (missing) return `${missing[1]}@${missing[2]} missing from lock file`;
|
|
2539
|
+
}
|
|
2540
|
+
if (lockfileName === "bun.lock" || lockfileName === "bun.lockb") {
|
|
2541
|
+
try {
|
|
2542
|
+
const manifestText = readFileSync6(join6(isolatedDir, "package.json"), "utf8");
|
|
2543
|
+
const lockText = readFileSync6(join6(isolatedDir, lockfileName), "utf8");
|
|
2544
|
+
const drifted = nameBunDrift(manifestText, lockText);
|
|
2545
|
+
if (drifted.length > 0) return drifted.join(", ");
|
|
2546
|
+
} catch {
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
const generic = extractGenericToken(stderr);
|
|
2550
|
+
if (generic)
|
|
2551
|
+
return `${generic} (name extracted from raw install output, see verdict for the full line)`;
|
|
2552
|
+
return `dependency name not statically extractable from this manager's output at ${siteDir} \u2014 raw: ${stderr.slice(0, 300).trim()}`;
|
|
2553
|
+
}
|
|
2554
|
+
function gateSite(site) {
|
|
2555
|
+
const pkgPath = join6(site.dir, "package.json");
|
|
2556
|
+
if (!existsSync6(pkgPath)) {
|
|
2557
|
+
return {
|
|
2558
|
+
site,
|
|
2559
|
+
status: "skipped",
|
|
2560
|
+
reason: `no package.json at ${site.dir} \u2014 not an npm-managed deployable subpackage, C-26 n/a for this site`
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
const lock = findLockfile2(site.dir);
|
|
2564
|
+
if (!lock) {
|
|
2565
|
+
return {
|
|
2566
|
+
site,
|
|
2567
|
+
status: "analysed",
|
|
2568
|
+
ok: false,
|
|
2569
|
+
detail: `no lockfile (${LOCKFILES2.map((l) => l.name).join("/")}) present at ${site.dir} while its deployment (${site.configFile}) requires one`
|
|
2570
|
+
};
|
|
2571
|
+
}
|
|
2572
|
+
const isolated = mkdtempSync(join6(tmpdir(), "mcp-doctor-c26-"));
|
|
2573
|
+
try {
|
|
2574
|
+
const manifestText = readFileSync6(pkgPath, "utf8");
|
|
2575
|
+
const lockText = readFileSync6(join6(site.dir, lock.name), "utf8");
|
|
2576
|
+
writeFileSync(join6(isolated, "package.json"), manifestText);
|
|
2577
|
+
writeFileSync(join6(isolated, lock.name), lockText);
|
|
2578
|
+
let proc;
|
|
2579
|
+
try {
|
|
2580
|
+
proc = spawnSync(lock.command, lock.args, {
|
|
2581
|
+
cwd: isolated,
|
|
2582
|
+
encoding: "utf8",
|
|
2583
|
+
timeout: 6e4
|
|
2584
|
+
});
|
|
2585
|
+
} catch (err) {
|
|
2586
|
+
return {
|
|
2587
|
+
site,
|
|
2588
|
+
status: "analysed",
|
|
2589
|
+
ok: false,
|
|
2590
|
+
detail: `could not run "${lock.command} ${lock.args.join(" ")}" in isolation: ${err instanceof Error ? err.message : String(err)}`
|
|
2591
|
+
};
|
|
2592
|
+
}
|
|
2593
|
+
if (proc.error) {
|
|
2594
|
+
return {
|
|
2595
|
+
site,
|
|
2596
|
+
status: "analysed",
|
|
2597
|
+
ok: false,
|
|
2598
|
+
detail: `"${lock.command}" is not available to run the frozen install in isolation: ${proc.error.message}`
|
|
2599
|
+
};
|
|
2600
|
+
}
|
|
2601
|
+
if (proc.status !== 0) {
|
|
2602
|
+
const stderr = (proc.stderr ?? "") + (proc.stdout ?? "");
|
|
2603
|
+
const dep = nameDrift(lock.name, site.dir, isolated, stderr);
|
|
2604
|
+
return {
|
|
2605
|
+
site,
|
|
2606
|
+
status: "analysed",
|
|
2607
|
+
ok: false,
|
|
2608
|
+
detail: `frozen install (${lock.command} ${lock.args.join(" ")}) FAILED in isolation at ${site.dir} -> drifted dependency: ${dep}`
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2611
|
+
return {
|
|
2612
|
+
site,
|
|
2613
|
+
status: "analysed",
|
|
2614
|
+
ok: true,
|
|
2615
|
+
detail: `frozen install (${lock.command} ${lock.args.join(" ")}) succeeded in isolation for ${site.dir}`
|
|
2616
|
+
};
|
|
2617
|
+
} finally {
|
|
2618
|
+
rmSync(isolated, { recursive: true, force: true });
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
function renderCoverage(scope, verdicts) {
|
|
2622
|
+
if (verdicts.length !== scope.length) {
|
|
2623
|
+
throw new Error(
|
|
2624
|
+
`mcp-doctor: C26 coverage inventory covers ${verdicts.length} of ${scope.length} derived sites \u2014 the run is not whole and will not report as if it were.`
|
|
2625
|
+
);
|
|
2626
|
+
}
|
|
2627
|
+
const covered = new Set(verdicts.map((v) => v.site.dir));
|
|
2628
|
+
for (const s of scope) {
|
|
2629
|
+
if (!covered.has(s.dir)) {
|
|
2630
|
+
throw new Error(`mcp-doctor: C26 coverage inventory omits derived site ${s.dir}.`);
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
return verdicts.map((v) => {
|
|
2634
|
+
const rel = relative2(process.cwd(), v.site.dir) || v.site.dir;
|
|
2635
|
+
if (v.status === "skipped") return `SKIPPED ${rel} (${v.reason})`;
|
|
2636
|
+
return `ANALYSED ${rel} -> ${v.ok ? "GREEN" : "RED"}: ${v.detail}`;
|
|
2637
|
+
}).join(" | ");
|
|
2638
|
+
}
|
|
2639
|
+
var c26SubpackageLockfile = {
|
|
2640
|
+
id: "C26 subpackage-lockfile",
|
|
2641
|
+
rule: "C-26",
|
|
2642
|
+
pin: PIN9,
|
|
2643
|
+
run(tree) {
|
|
2644
|
+
const scope = c26DeriveScope(tree.root);
|
|
2645
|
+
if (scope.unparsable) {
|
|
2646
|
+
const reason = `deployment configuration ${scope.unparsable.path} could not be parsed: ${scope.unparsable.reason} \u2014 scope unestablished, C-26 cannot derive its targets`;
|
|
2647
|
+
return {
|
|
2648
|
+
id: this.id,
|
|
2649
|
+
anchor: scope.unparsable.path,
|
|
2650
|
+
state: "could-not-judge",
|
|
2651
|
+
reason,
|
|
2652
|
+
verdict: `could not judge: ${reason}`
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
if (scope.sites.length === 0) {
|
|
2656
|
+
return {
|
|
2657
|
+
id: this.id,
|
|
2658
|
+
anchor: tree.root,
|
|
2659
|
+
state: "absent",
|
|
2660
|
+
verdict: `no supported deployment configuration (${DEPLOY_CONFIG_FILES.join("/")}) found; C-26 n/a`
|
|
2661
|
+
};
|
|
2662
|
+
}
|
|
2663
|
+
const verdicts = scope.sites.map(gateSite);
|
|
2664
|
+
const inventory = renderCoverage(scope.sites, verdicts);
|
|
2665
|
+
const analysed = verdicts.filter((v) => v.status === "analysed");
|
|
2666
|
+
if (analysed.length === 0) {
|
|
2667
|
+
return {
|
|
2668
|
+
id: this.id,
|
|
2669
|
+
anchor: tree.root,
|
|
2670
|
+
state: "absent",
|
|
2671
|
+
verdict: `deployment configuration(s) found but every targeted site is SKIPPED (none is npm-managed); C-26 n/a. Coverage: ${inventory}`
|
|
2672
|
+
};
|
|
2673
|
+
}
|
|
2674
|
+
const reds = analysed.filter((v) => v.ok === false);
|
|
2675
|
+
if (reds.length > 0) {
|
|
2676
|
+
return {
|
|
2677
|
+
id: this.id,
|
|
2678
|
+
anchor: reds[0].site.dir,
|
|
2679
|
+
state: "non-conforming",
|
|
2680
|
+
verdict: reds.some((r) => r.detail?.includes("no lockfile")) ? `${reds[0].detail} (C-26). Coverage: ${inventory}` : `${reds.map((r) => r.detail).join(" ; ")} (C-26). Coverage: ${inventory}`
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
return {
|
|
2684
|
+
id: this.id,
|
|
2685
|
+
anchor: analysed[0].site.dir,
|
|
2686
|
+
state: "present",
|
|
2687
|
+
verdict: `every analysed deployable subpackage's lockfile is consistent, verified by a frozen install run in the SAME isolation the deployment uses (C-26). Coverage: ${inventory}`
|
|
2688
|
+
};
|
|
2689
|
+
}
|
|
2690
|
+
};
|
|
2691
|
+
|
|
2692
|
+
// src/detectors/d2.ts
|
|
2693
|
+
var SESSION_RE = /mcp-session-id/i;
|
|
2694
|
+
var d2 = {
|
|
2695
|
+
id: "D2 stateless",
|
|
2696
|
+
run(tree) {
|
|
2697
|
+
const hits = [];
|
|
2698
|
+
for (const file of tree.files) {
|
|
2699
|
+
for (const line of file.lines) {
|
|
2700
|
+
if (line.comment) continue;
|
|
2701
|
+
if (SESSION_RE.test(line.raw)) hits.push({ file: file.rel, line: line.n });
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
if (hits.length > 0) {
|
|
2705
|
+
const anchor = `${hits[0].file}:${hits.map((h) => h.line).join(",")}`;
|
|
2706
|
+
return {
|
|
2707
|
+
id: this.id,
|
|
2708
|
+
anchor,
|
|
2709
|
+
state: "non-conforming",
|
|
2710
|
+
verdict: "session-keyed (mcp-session-id header) -> migrate to stateless"
|
|
2711
|
+
};
|
|
2712
|
+
}
|
|
2713
|
+
return {
|
|
2714
|
+
id: this.id,
|
|
2715
|
+
anchor: "-",
|
|
2716
|
+
state: "present",
|
|
2717
|
+
verdict: "stateless (no mcp-session-id session-keying)"
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
};
|
|
245
2721
|
|
|
246
2722
|
// src/detectors/d3.ts
|
|
247
2723
|
var RESULT_TYPE_RE = /\bresultType\b/;
|
|
@@ -487,8 +2963,8 @@ var d7 = {
|
|
|
487
2963
|
};
|
|
488
2964
|
|
|
489
2965
|
// src/detectors/d8.ts
|
|
490
|
-
import { existsSync, readFileSync, statSync } from "fs";
|
|
491
|
-
import { dirname, join, relative } from "path";
|
|
2966
|
+
import { existsSync as existsSync7, readFileSync as readFileSync7, statSync as statSync3 } from "fs";
|
|
2967
|
+
import { dirname as dirname2, join as join7, relative as relative3 } from "path";
|
|
492
2968
|
var POSITIVE_CONTROL2 = 'server.registerTool("list_items", { ... }, handler);';
|
|
493
2969
|
function d8PositiveControl() {
|
|
494
2970
|
return {
|
|
@@ -498,9 +2974,9 @@ function d8PositiveControl() {
|
|
|
498
2974
|
};
|
|
499
2975
|
}
|
|
500
2976
|
function resolveBaseDir(tree) {
|
|
501
|
-
if (tree.packageJson) return
|
|
2977
|
+
if (tree.packageJson) return dirname2(tree.packageJson.path);
|
|
502
2978
|
try {
|
|
503
|
-
return
|
|
2979
|
+
return statSync3(tree.root).isDirectory() ? tree.root : dirname2(tree.root);
|
|
504
2980
|
} catch {
|
|
505
2981
|
return tree.root;
|
|
506
2982
|
}
|
|
@@ -508,9 +2984,9 @@ function resolveBaseDir(tree) {
|
|
|
508
2984
|
function findEvalsFile(tree) {
|
|
509
2985
|
let dir = resolveBaseDir(tree);
|
|
510
2986
|
for (let depth = 0; depth < 8; depth++) {
|
|
511
|
-
const candidate =
|
|
512
|
-
if (
|
|
513
|
-
const parent =
|
|
2987
|
+
const candidate = join7(dir, "evals", "evals.json");
|
|
2988
|
+
if (existsSync7(candidate)) return candidate;
|
|
2989
|
+
const parent = dirname2(dir);
|
|
514
2990
|
if (parent === dir) break;
|
|
515
2991
|
dir = parent;
|
|
516
2992
|
}
|
|
@@ -518,6 +2994,8 @@ function findEvalsFile(tree) {
|
|
|
518
2994
|
}
|
|
519
2995
|
var d8 = {
|
|
520
2996
|
id: "D8 eval-suite-coverage",
|
|
2997
|
+
rule: "C-5",
|
|
2998
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
521
2999
|
run(tree) {
|
|
522
3000
|
const control = d8PositiveControl();
|
|
523
3001
|
if (!control.matched) {
|
|
@@ -555,10 +3033,10 @@ var d8 = {
|
|
|
555
3033
|
};
|
|
556
3034
|
}
|
|
557
3035
|
const baseDir = resolveBaseDir(tree);
|
|
558
|
-
const displayPath =
|
|
3036
|
+
const displayPath = relative3(baseDir, evalsPath) || evalsPath;
|
|
559
3037
|
let cases;
|
|
560
3038
|
try {
|
|
561
|
-
const raw =
|
|
3039
|
+
const raw = readFileSync7(evalsPath, "utf8");
|
|
562
3040
|
const parsed = JSON.parse(raw);
|
|
563
3041
|
if (!Array.isArray(parsed.cases)) {
|
|
564
3042
|
throw new Error("`cases` is not an array");
|
|
@@ -642,8 +3120,8 @@ var d8 = {
|
|
|
642
3120
|
};
|
|
643
3121
|
|
|
644
3122
|
// src/detectors/d9.ts
|
|
645
|
-
import { existsSync as
|
|
646
|
-
import { dirname as
|
|
3123
|
+
import { existsSync as existsSync8 } from "fs";
|
|
3124
|
+
import { dirname as dirname3, join as join8 } from "path";
|
|
647
3125
|
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
3126
|
var POSITIVE_CONTROL3 = "1.2.3-beta.1+build.7";
|
|
649
3127
|
function d9PositiveControl() {
|
|
@@ -655,6 +3133,8 @@ function d9PositiveControl() {
|
|
|
655
3133
|
}
|
|
656
3134
|
var d9 = {
|
|
657
3135
|
id: "D9 semver+changelog",
|
|
3136
|
+
rule: "C-3",
|
|
3137
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
658
3138
|
run(tree) {
|
|
659
3139
|
const control = d9PositiveControl();
|
|
660
3140
|
if (!control.matched) {
|
|
@@ -663,9 +3143,14 @@ var d9 = {
|
|
|
663
3143
|
);
|
|
664
3144
|
}
|
|
665
3145
|
if (!tree.packageJson) {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
3146
|
+
const reason = "no package.json found anywhere above the scanned root \u2014 cannot verify SemVer/CHANGELOG";
|
|
3147
|
+
return {
|
|
3148
|
+
id: this.id,
|
|
3149
|
+
anchor: "package.json",
|
|
3150
|
+
state: "could-not-judge",
|
|
3151
|
+
reason,
|
|
3152
|
+
verdict: `could not judge: ${reason}`
|
|
3153
|
+
};
|
|
669
3154
|
}
|
|
670
3155
|
const { path: pkgPath, json: pkgJson } = tree.packageJson;
|
|
671
3156
|
if (!pkgJson || Object.keys(pkgJson).length === 0) {
|
|
@@ -680,9 +3165,9 @@ var d9 = {
|
|
|
680
3165
|
verdict: `no "version" field in ${pkgPath.split("/").pop()} -> set a valid SemVer version`
|
|
681
3166
|
};
|
|
682
3167
|
}
|
|
683
|
-
const projectDir =
|
|
684
|
-
const changelogPath =
|
|
685
|
-
const hasChangelog =
|
|
3168
|
+
const projectDir = dirname3(pkgPath);
|
|
3169
|
+
const changelogPath = join8(projectDir, "CHANGELOG.md");
|
|
3170
|
+
const hasChangelog = existsSync8(changelogPath);
|
|
686
3171
|
const validSemver = SEMVER_RE.test(version);
|
|
687
3172
|
if (validSemver && hasChangelog) {
|
|
688
3173
|
return {
|
|
@@ -710,8 +3195,8 @@ var d9 = {
|
|
|
710
3195
|
};
|
|
711
3196
|
|
|
712
3197
|
// src/detectors/d10.ts
|
|
713
|
-
import { existsSync as
|
|
714
|
-
import { dirname as
|
|
3198
|
+
import { existsSync as existsSync9 } from "fs";
|
|
3199
|
+
import { dirname as dirname4, join as join9 } from "path";
|
|
715
3200
|
var DESCRIPTION_FR_RE = /"description_fr"\s*:\s*"[^"]+"/;
|
|
716
3201
|
var POSITIVE_CONTROL4 = '"description_fr": "Un serveur MCP conforme.",';
|
|
717
3202
|
function d10PositiveControl() {
|
|
@@ -761,6 +3246,8 @@ function scanSourceDescriptionFr(tree) {
|
|
|
761
3246
|
}
|
|
762
3247
|
var d10 = {
|
|
763
3248
|
id: "D10 bilingual",
|
|
3249
|
+
rule: "C-1",
|
|
3250
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
764
3251
|
run(tree) {
|
|
765
3252
|
const control = d10PositiveControl();
|
|
766
3253
|
if (!control.matched) {
|
|
@@ -784,7 +3271,7 @@ var d10 = {
|
|
|
784
3271
|
};
|
|
785
3272
|
}
|
|
786
3273
|
const { path: pkgPath, json: pkg } = tree.packageJson;
|
|
787
|
-
const root =
|
|
3274
|
+
const root = dirname4(pkgPath);
|
|
788
3275
|
const descriptionFr = typeof pkg.description_fr === "string" ? pkg.description_fr.trim() : "";
|
|
789
3276
|
const hasDescriptionFrJson = descriptionFr.length > 0;
|
|
790
3277
|
const sourceScan = scanSourceDescriptionFr(tree);
|
|
@@ -798,8 +3285,8 @@ var d10 = {
|
|
|
798
3285
|
};
|
|
799
3286
|
}
|
|
800
3287
|
const hasDescriptionFr = hasDescriptionFrJson || sourceScan.present;
|
|
801
|
-
const readmeFrPath =
|
|
802
|
-
const hasReadmeFr =
|
|
3288
|
+
const readmeFrPath = join9(root, "README.fr.md");
|
|
3289
|
+
const hasReadmeFr = existsSync9(readmeFrPath);
|
|
803
3290
|
const missing = [];
|
|
804
3291
|
if (!hasDescriptionFr) missing.push("description_fr (package.json or source _meta)");
|
|
805
3292
|
if (!hasReadmeFr) missing.push("README.fr.md");
|
|
@@ -807,8 +3294,8 @@ var d10 = {
|
|
|
807
3294
|
return {
|
|
808
3295
|
id: this.id,
|
|
809
3296
|
anchor: `${pkgPath}`,
|
|
810
|
-
state: "
|
|
811
|
-
verdict: `bilingual release missing: ${missing.join(", ")} -> no release without FR description + README.fr.md`
|
|
3297
|
+
state: "non-conforming",
|
|
3298
|
+
verdict: `bilingual release missing: ${missing.join(", ")} -> no release without FR description + README.fr.md (C-1)`
|
|
812
3299
|
};
|
|
813
3300
|
}
|
|
814
3301
|
const files = Array.isArray(pkg.files) ? pkg.files : null;
|
|
@@ -857,6 +3344,8 @@ function d11PositiveControls() {
|
|
|
857
3344
|
}
|
|
858
3345
|
var d11 = {
|
|
859
3346
|
id: "D11 zod-schemas",
|
|
3347
|
+
rule: "C-4",
|
|
3348
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
860
3349
|
run(tree) {
|
|
861
3350
|
const controls = d11PositiveControls();
|
|
862
3351
|
const failed = Object.entries(controls).find(([, c]) => !c.matched);
|
|
@@ -908,8 +3397,8 @@ var d11 = {
|
|
|
908
3397
|
return {
|
|
909
3398
|
id: this.id,
|
|
910
3399
|
anchor: firstToolAnchor ?? "-",
|
|
911
|
-
state: "
|
|
912
|
-
verdict: `${toolCount} tool registration(s) found, 0 outputSchema in tree (positive-control OK) -> add output Zod schemas (
|
|
3400
|
+
state: "non-conforming",
|
|
3401
|
+
verdict: `${toolCount} tool registration(s) found, 0 outputSchema in tree (positive-control OK) -> add output Zod schemas (C-4; tree-wide proxy, not per-tool \u2014 see D11 scope note)`
|
|
913
3402
|
};
|
|
914
3403
|
}
|
|
915
3404
|
return {
|
|
@@ -1178,8 +3667,14 @@ var d15 = {
|
|
|
1178
3667
|
};
|
|
1179
3668
|
|
|
1180
3669
|
// src/detectors/d16.ts
|
|
3670
|
+
import { existsSync as existsSync10, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
|
|
3671
|
+
import { dirname as dirname5, join as join10 } from "path";
|
|
1181
3672
|
var DISCOVER_RE2 = /server\/discover|DiscoverResult|ServerDiscover|handleServerDiscover/;
|
|
1182
|
-
var
|
|
3673
|
+
var HEALTH_ROUTE_FRAMEWORK_RE = /\b[A-Za-z_$][\w$]*\.(?:get|post|put|patch|delete|all|route|use)\s*\(\s*["'`][^"'`]*\/health/;
|
|
3674
|
+
var HEALTH_ROUTE_RAWPATH_RE = /\bpathname\s*===?\s*["'`][^"'`]*\/health/;
|
|
3675
|
+
var HEALTH_ROUTE_RE = new RegExp(
|
|
3676
|
+
`${HEALTH_ROUTE_FRAMEWORK_RE.source}|${HEALTH_ROUTE_RAWPATH_RE.source}`
|
|
3677
|
+
);
|
|
1183
3678
|
var VERSION_FIELD_RE = /\bversion\s*:/;
|
|
1184
3679
|
var PKG_SOURCE_RE = /(?:pkg|pkgJson|packageJson|pjson|packageInfo)\.version|process\.env\.npm_package_version|["'`][^"'`]*package\.json["'`]/;
|
|
1185
3680
|
var PKG_IMPORT_IMPORT_RE = /import\s+(?:\*\s+as\s+)?([A-Za-z_$][\w$]*)\s+from\s+["'`][^"'`]*package\.json["'`]/;
|
|
@@ -1196,11 +3691,20 @@ var CONTROLS2 = [
|
|
|
1196
3691
|
negative: 'method: "tools/list"'
|
|
1197
3692
|
},
|
|
1198
3693
|
{
|
|
1199
|
-
key: "health",
|
|
3694
|
+
key: "health-framework",
|
|
1200
3695
|
re: HEALTH_ROUTE_RE,
|
|
1201
3696
|
positive: 'app.get("/health", handler)',
|
|
1202
3697
|
negative: 'app.get("/tools", handler)'
|
|
1203
3698
|
},
|
|
3699
|
+
{
|
|
3700
|
+
// FORM 2 — the raw-pathname comparison the old matcher was blind to.
|
|
3701
|
+
// Positive is the REAL vantage-registry-mcp:297 shape; negative is the
|
|
3702
|
+
// sibling `/mcp` route on the same server (a near-miss that must NOT match).
|
|
3703
|
+
key: "health-rawpath",
|
|
3704
|
+
re: HEALTH_ROUTE_RE,
|
|
3705
|
+
positive: 'if (req.method === "GET" && url.pathname === "/health") {',
|
|
3706
|
+
negative: 'if (url.pathname === "/mcp") {'
|
|
3707
|
+
},
|
|
1204
3708
|
{
|
|
1205
3709
|
key: "pkg-source",
|
|
1206
3710
|
re: PKG_SOURCE_RE,
|
|
@@ -1308,7 +3812,7 @@ var d16 = {
|
|
|
1308
3812
|
id: this.id,
|
|
1309
3813
|
anchor: tree.files.length > 0 ? `${tree.files[0].rel}:1` : tree.root,
|
|
1310
3814
|
state: "absent",
|
|
1311
|
-
verdict: "no server/discover handler and no /health route (n/a for Server Rule 16)"
|
|
3815
|
+
verdict: "[static read] no server/discover handler and no /health route (n/a for Server Rule 16)"
|
|
1312
3816
|
};
|
|
1313
3817
|
}
|
|
1314
3818
|
const present = surfaces.find((s) => s.kind === "present");
|
|
@@ -1317,7 +3821,7 @@ var d16 = {
|
|
|
1317
3821
|
id: this.id,
|
|
1318
3822
|
anchor: present.anchor,
|
|
1319
3823
|
state: "present",
|
|
1320
|
-
verdict: `version exposed pre-auth at ${present.anchor}, sourced from package.json (Server Rule 16)`
|
|
3824
|
+
verdict: `[static read] version exposed pre-auth at ${present.anchor}, sourced from package.json (Server Rule 16)`
|
|
1321
3825
|
};
|
|
1322
3826
|
}
|
|
1323
3827
|
const decisive = surfaces.find(
|
|
@@ -1338,7 +3842,7 @@ var d16 = {
|
|
|
1338
3842
|
id: this.id,
|
|
1339
3843
|
anchor: decisive.anchor,
|
|
1340
3844
|
state: "non-conforming",
|
|
1341
|
-
verdict:
|
|
3845
|
+
verdict: `[static read] ${fix[decisive.kind]} at ${decisive.anchor} (Server Rule 16)`
|
|
1342
3846
|
};
|
|
1343
3847
|
}
|
|
1344
3848
|
const unreadable = surfaces[0];
|
|
@@ -1352,7 +3856,7 @@ var d16 = {
|
|
|
1352
3856
|
} else {
|
|
1353
3857
|
detail = "is present but its source is not a resolvable package.json binding in the read window";
|
|
1354
3858
|
}
|
|
1355
|
-
const reason = `surface at ${unreadable.anchor} ${detail} \u2014 verdict unreadable statically (Server Rule 16)`;
|
|
3859
|
+
const reason = `[static read] surface at ${unreadable.anchor} ${detail} \u2014 verdict unreadable statically (Server Rule 16)`;
|
|
1356
3860
|
return {
|
|
1357
3861
|
id: this.id,
|
|
1358
3862
|
anchor: unreadable.anchor,
|
|
@@ -1360,12 +3864,97 @@ var d16 = {
|
|
|
1360
3864
|
reason,
|
|
1361
3865
|
verdict: `could not judge: ${reason}`
|
|
1362
3866
|
};
|
|
3867
|
+
},
|
|
3868
|
+
/**
|
|
3869
|
+
* DYNAMIC — Rule 16 judged from what the RUNNING server SERVES, its true home.
|
|
3870
|
+
* The MCP `initialize` handshake returns `serverInfo.version` BEFORE any auth
|
|
3871
|
+
* token is presented, so the served version is a pre-auth observable by
|
|
3872
|
+
* construction. The served value is the truth: it is compared to the target's
|
|
3873
|
+
* own package.json version (the canonical source Rule 16 requires).
|
|
3874
|
+
*
|
|
3875
|
+
* present — the server serves a version AND it equals package.json
|
|
3876
|
+
* (observable pre-auth, sourced from the real version).
|
|
3877
|
+
* non-conforming — the server serves NO version, OR serves a version that
|
|
3878
|
+
* DRIFTS from package.json (a hardcoded literal is the
|
|
3879
|
+
* classic cause; the served value != the package's truth).
|
|
3880
|
+
* could-not-judge — the server could not be launched/introspected (naming
|
|
3881
|
+
* the failure), or its package.json could not be read to
|
|
3882
|
+
* establish the canonical version to compare against.
|
|
3883
|
+
*
|
|
3884
|
+
* Every dynamic verdict says out loud it was produced by asking the running
|
|
3885
|
+
* server ("[dynamic]"), so it is NEVER mistaken for the static fallback.
|
|
3886
|
+
*/
|
|
3887
|
+
runDynamic(runtime) {
|
|
3888
|
+
if (runtime.launchError) {
|
|
3889
|
+
const reason = `[dynamic] server could not be started/introspected \u2014 served version unobservable: ${runtime.launchError} (Server Rule 16)`;
|
|
3890
|
+
return {
|
|
3891
|
+
id: this.id,
|
|
3892
|
+
anchor: `${runtime.root} (runtime)`,
|
|
3893
|
+
state: "could-not-judge",
|
|
3894
|
+
reason,
|
|
3895
|
+
verdict: `could not judge: ${reason}`
|
|
3896
|
+
};
|
|
3897
|
+
}
|
|
3898
|
+
const servedRaw = runtime.serverInfo?.version;
|
|
3899
|
+
const served = typeof servedRaw === "string" ? servedRaw.trim() : void 0;
|
|
3900
|
+
const anchor = `${runtime.root} (runtime initialize)`;
|
|
3901
|
+
if (!served) {
|
|
3902
|
+
return {
|
|
3903
|
+
id: this.id,
|
|
3904
|
+
anchor,
|
|
3905
|
+
state: "non-conforming",
|
|
3906
|
+
verdict: "[dynamic] the running server serves NO version on the pre-auth initialize handshake -> expose a version sourced from package.json (Server Rule 16)"
|
|
3907
|
+
};
|
|
3908
|
+
}
|
|
3909
|
+
const pkgVersion = readPackageVersion(runtime.root);
|
|
3910
|
+
if (!pkgVersion) {
|
|
3911
|
+
const reason = `[dynamic] the server serves version "${served}" but its package.json version could not be read to compare against \u2014 canonical version unestablished (Server Rule 16)`;
|
|
3912
|
+
return {
|
|
3913
|
+
id: this.id,
|
|
3914
|
+
anchor,
|
|
3915
|
+
state: "could-not-judge",
|
|
3916
|
+
reason,
|
|
3917
|
+
verdict: `could not judge: ${reason}`
|
|
3918
|
+
};
|
|
3919
|
+
}
|
|
3920
|
+
if (served === pkgVersion) {
|
|
3921
|
+
return {
|
|
3922
|
+
id: this.id,
|
|
3923
|
+
anchor,
|
|
3924
|
+
state: "present",
|
|
3925
|
+
verdict: `[dynamic] running server serves version "${served}" pre-auth on initialize, matching package.json "${pkgVersion}" (Server Rule 16)`
|
|
3926
|
+
};
|
|
3927
|
+
}
|
|
3928
|
+
return {
|
|
3929
|
+
id: this.id,
|
|
3930
|
+
anchor,
|
|
3931
|
+
state: "non-conforming",
|
|
3932
|
+
verdict: `[dynamic] running server serves version "${served}" but package.json is "${pkgVersion}" -> served value drifts from the package (source it from package.json) (Server Rule 16)`
|
|
3933
|
+
};
|
|
1363
3934
|
}
|
|
1364
3935
|
};
|
|
3936
|
+
function readPackageVersion(root) {
|
|
3937
|
+
try {
|
|
3938
|
+
let dir = statSync4(root).isDirectory() ? root : dirname5(root);
|
|
3939
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
3940
|
+
const candidate = join10(dir, "package.json");
|
|
3941
|
+
if (existsSync10(candidate)) {
|
|
3942
|
+
const pkg = JSON.parse(readFileSync8(candidate, "utf8"));
|
|
3943
|
+
return typeof pkg.version === "string" ? pkg.version : null;
|
|
3944
|
+
}
|
|
3945
|
+
const parent = dirname5(dir);
|
|
3946
|
+
if (parent === dir) break;
|
|
3947
|
+
dir = parent;
|
|
3948
|
+
}
|
|
3949
|
+
} catch {
|
|
3950
|
+
return null;
|
|
3951
|
+
}
|
|
3952
|
+
return null;
|
|
3953
|
+
}
|
|
1365
3954
|
|
|
1366
3955
|
// src/detectors/dAppI18n.ts
|
|
1367
|
-
import { existsSync as
|
|
1368
|
-
import { dirname as
|
|
3956
|
+
import { existsSync as existsSync11, readFileSync as readFileSync9 } from "fs";
|
|
3957
|
+
import { dirname as dirname6, join as join11 } from "path";
|
|
1369
3958
|
function flattenKeys(obj, prefix = "") {
|
|
1370
3959
|
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
1371
3960
|
return prefix ? [prefix] : [];
|
|
@@ -1396,9 +3985,9 @@ function dAppI18nPositiveControls() {
|
|
|
1396
3985
|
}
|
|
1397
3986
|
};
|
|
1398
3987
|
}
|
|
1399
|
-
function
|
|
3988
|
+
function readJson2(path) {
|
|
1400
3989
|
try {
|
|
1401
|
-
return { ok: true, value: JSON.parse(
|
|
3990
|
+
return { ok: true, value: JSON.parse(readFileSync9(path, "utf8")) };
|
|
1402
3991
|
} catch {
|
|
1403
3992
|
return { ok: false };
|
|
1404
3993
|
}
|
|
@@ -1412,12 +4001,12 @@ var dAppI18n = {
|
|
|
1412
4001
|
`DAppI18n key-diff control FAILED: detectsGap=${control.detectsGap} detectsComplete=${control.detectsComplete} \u2014 matcher broken, verdict untrustworthy`
|
|
1413
4002
|
);
|
|
1414
4003
|
}
|
|
1415
|
-
const root = tree.packageJson ?
|
|
1416
|
-
const i18nDir =
|
|
1417
|
-
const enPath =
|
|
1418
|
-
const frPath =
|
|
1419
|
-
const hasI18n =
|
|
1420
|
-
const hasFr =
|
|
4004
|
+
const root = tree.packageJson ? dirname6(tree.packageJson.path) : tree.root;
|
|
4005
|
+
const i18nDir = join11(root, "i18n");
|
|
4006
|
+
const enPath = join11(i18nDir, "en.json");
|
|
4007
|
+
const frPath = join11(i18nDir, "fr.json");
|
|
4008
|
+
const hasI18n = existsSync11(i18nDir);
|
|
4009
|
+
const hasFr = existsSync11(frPath);
|
|
1421
4010
|
if (!hasI18n && !hasFr) {
|
|
1422
4011
|
return {
|
|
1423
4012
|
id: this.id,
|
|
@@ -1434,7 +4023,7 @@ var dAppI18n = {
|
|
|
1434
4023
|
verdict: "i18n/ layer present but i18n/fr.json is missing -> ship the French locale (App Rule 2)"
|
|
1435
4024
|
};
|
|
1436
4025
|
}
|
|
1437
|
-
const frRead =
|
|
4026
|
+
const frRead = readJson2(frPath);
|
|
1438
4027
|
if (!frRead.ok) {
|
|
1439
4028
|
const reason = "i18n/fr.json is not readable JSON \u2014 cannot compare its keys against the source locale (App Rule 2)";
|
|
1440
4029
|
return {
|
|
@@ -1445,7 +4034,7 @@ var dAppI18n = {
|
|
|
1445
4034
|
verdict: `could not judge: ${reason}`
|
|
1446
4035
|
};
|
|
1447
4036
|
}
|
|
1448
|
-
if (!
|
|
4037
|
+
if (!existsSync11(enPath)) {
|
|
1449
4038
|
const reason = "i18n/fr.json exists but no source-locale i18n/en.json to compare its keys against (App Rule 2)";
|
|
1450
4039
|
return {
|
|
1451
4040
|
id: this.id,
|
|
@@ -1455,7 +4044,7 @@ var dAppI18n = {
|
|
|
1455
4044
|
verdict: `could not judge: ${reason}`
|
|
1456
4045
|
};
|
|
1457
4046
|
}
|
|
1458
|
-
const enRead =
|
|
4047
|
+
const enRead = readJson2(enPath);
|
|
1459
4048
|
if (!enRead.ok) {
|
|
1460
4049
|
const reason = "i18n/en.json (source locale) is not readable JSON \u2014 cannot derive the reference key set (App Rule 2)";
|
|
1461
4050
|
return {
|
|
@@ -1588,46 +4177,46 @@ var dDebugOptIn = {
|
|
|
1588
4177
|
};
|
|
1589
4178
|
|
|
1590
4179
|
// src/detectors/dExt1.ts
|
|
1591
|
-
import { readFileSync as
|
|
1592
|
-
import { join as
|
|
1593
|
-
var
|
|
4180
|
+
import { readFileSync as readFileSync10, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
|
|
4181
|
+
import { join as join12 } from "path";
|
|
4182
|
+
var IDENTIFIER_RE2 = /^[a-z0-9]+(\.[a-z0-9-]+)+\/[a-z0-9-]+$/;
|
|
1594
4183
|
var IDENTIFIER_POSITIVE = "com.elpi/middleware-redact-pii";
|
|
1595
4184
|
var IDENTIFIER_NEGATIVE = "Com.Elpi/Bad_Id";
|
|
1596
4185
|
function dExt1PositiveControls() {
|
|
1597
4186
|
return {
|
|
1598
4187
|
identifier: {
|
|
1599
|
-
pattern:
|
|
1600
|
-
positive:
|
|
1601
|
-
negative:
|
|
4188
|
+
pattern: IDENTIFIER_RE2.source,
|
|
4189
|
+
positive: IDENTIFIER_RE2.test(IDENTIFIER_POSITIVE),
|
|
4190
|
+
negative: IDENTIFIER_RE2.test(IDENTIFIER_NEGATIVE)
|
|
1602
4191
|
}
|
|
1603
4192
|
};
|
|
1604
4193
|
}
|
|
1605
|
-
var
|
|
4194
|
+
var SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
1606
4195
|
function findExtensionManifests(root) {
|
|
1607
4196
|
const out = [];
|
|
1608
4197
|
let base;
|
|
1609
4198
|
try {
|
|
1610
|
-
base =
|
|
4199
|
+
base = statSync5(root).isDirectory() ? root : join12(root, "..");
|
|
1611
4200
|
} catch {
|
|
1612
4201
|
return out;
|
|
1613
4202
|
}
|
|
1614
4203
|
const walk2 = (dir) => {
|
|
1615
4204
|
let entries;
|
|
1616
4205
|
try {
|
|
1617
|
-
entries =
|
|
4206
|
+
entries = readdirSync4(dir);
|
|
1618
4207
|
} catch {
|
|
1619
4208
|
return;
|
|
1620
4209
|
}
|
|
1621
4210
|
for (const name of entries) {
|
|
1622
|
-
const full =
|
|
4211
|
+
const full = join12(dir, name);
|
|
1623
4212
|
let st;
|
|
1624
4213
|
try {
|
|
1625
|
-
st =
|
|
4214
|
+
st = statSync5(full);
|
|
1626
4215
|
} catch {
|
|
1627
4216
|
continue;
|
|
1628
4217
|
}
|
|
1629
4218
|
if (st.isDirectory()) {
|
|
1630
|
-
if (
|
|
4219
|
+
if (SKIP_DIRS2.has(name)) continue;
|
|
1631
4220
|
walk2(full);
|
|
1632
4221
|
} else if (name === "extension.json") {
|
|
1633
4222
|
out.push(full);
|
|
@@ -1667,11 +4256,11 @@ var dExt1 = {
|
|
|
1667
4256
|
);
|
|
1668
4257
|
}
|
|
1669
4258
|
const manifests = findExtensionManifests(tree.root);
|
|
1670
|
-
for (const
|
|
1671
|
-
const shortPath =
|
|
4259
|
+
for (const manifestPath2 of manifests) {
|
|
4260
|
+
const shortPath = manifestPath2.split("/").slice(-2).join("/");
|
|
1672
4261
|
let parsed;
|
|
1673
4262
|
try {
|
|
1674
|
-
parsed = JSON.parse(
|
|
4263
|
+
parsed = JSON.parse(readFileSync10(manifestPath2, "utf8"));
|
|
1675
4264
|
} catch {
|
|
1676
4265
|
throw new Error(
|
|
1677
4266
|
`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.`
|
|
@@ -1683,7 +4272,7 @@ var dExt1 = {
|
|
|
1683
4272
|
`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
4273
|
);
|
|
1685
4274
|
}
|
|
1686
|
-
if (!
|
|
4275
|
+
if (!IDENTIFIER_RE2.test(idValue)) {
|
|
1687
4276
|
return {
|
|
1688
4277
|
id: this.id,
|
|
1689
4278
|
anchor: shortPath,
|
|
@@ -1699,7 +4288,7 @@ var dExt1 = {
|
|
|
1699
4288
|
);
|
|
1700
4289
|
}
|
|
1701
4290
|
for (const site of sites) {
|
|
1702
|
-
if (!
|
|
4291
|
+
if (!IDENTIFIER_RE2.test(site.value)) {
|
|
1703
4292
|
return {
|
|
1704
4293
|
id: this.id,
|
|
1705
4294
|
anchor: site.anchor,
|
|
@@ -1727,8 +4316,8 @@ var dExt1 = {
|
|
|
1727
4316
|
};
|
|
1728
4317
|
|
|
1729
4318
|
// src/detectors/dExt2.ts
|
|
1730
|
-
import { readFileSync as
|
|
1731
|
-
import { join as
|
|
4319
|
+
import { readFileSync as readFileSync11, readdirSync as readdirSync5, statSync as statSync6 } from "fs";
|
|
4320
|
+
import { join as join13 } from "path";
|
|
1732
4321
|
var TS_GD_TRUE_RE = /\bgracefulDegradation:\s*true\b/;
|
|
1733
4322
|
var TS_GD_FALSE_RE = /\bgracefulDegradation:\s*false\b/;
|
|
1734
4323
|
var TS_GD_KEY_RE = /\bgracefulDegradation:\s*([^\s,}]+)/;
|
|
@@ -1743,32 +4332,32 @@ function dExt2PositiveControls() {
|
|
|
1743
4332
|
}
|
|
1744
4333
|
};
|
|
1745
4334
|
}
|
|
1746
|
-
var
|
|
4335
|
+
var SKIP_DIRS3 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
1747
4336
|
function findExtensionManifests2(root) {
|
|
1748
4337
|
const out = [];
|
|
1749
4338
|
let base;
|
|
1750
4339
|
try {
|
|
1751
|
-
base =
|
|
4340
|
+
base = statSync6(root).isDirectory() ? root : join13(root, "..");
|
|
1752
4341
|
} catch {
|
|
1753
4342
|
return out;
|
|
1754
4343
|
}
|
|
1755
4344
|
const walk2 = (dir) => {
|
|
1756
4345
|
let entries;
|
|
1757
4346
|
try {
|
|
1758
|
-
entries =
|
|
4347
|
+
entries = readdirSync5(dir);
|
|
1759
4348
|
} catch {
|
|
1760
4349
|
return;
|
|
1761
4350
|
}
|
|
1762
4351
|
for (const name of entries) {
|
|
1763
|
-
const full =
|
|
4352
|
+
const full = join13(dir, name);
|
|
1764
4353
|
let st;
|
|
1765
4354
|
try {
|
|
1766
|
-
st =
|
|
4355
|
+
st = statSync6(full);
|
|
1767
4356
|
} catch {
|
|
1768
4357
|
continue;
|
|
1769
4358
|
}
|
|
1770
4359
|
if (st.isDirectory()) {
|
|
1771
|
-
if (
|
|
4360
|
+
if (SKIP_DIRS3.has(name)) continue;
|
|
1772
4361
|
walk2(full);
|
|
1773
4362
|
} else if (name === "extension.json") {
|
|
1774
4363
|
out.push(full);
|
|
@@ -1828,11 +4417,11 @@ var dExt2 = {
|
|
|
1828
4417
|
);
|
|
1829
4418
|
}
|
|
1830
4419
|
const manifests = findExtensionManifests2(tree.root);
|
|
1831
|
-
for (const
|
|
1832
|
-
const shortPath =
|
|
4420
|
+
for (const manifestPath2 of manifests) {
|
|
4421
|
+
const shortPath = manifestPath2.split("/").slice(-2).join("/");
|
|
1833
4422
|
let parsed;
|
|
1834
4423
|
try {
|
|
1835
|
-
parsed = JSON.parse(
|
|
4424
|
+
parsed = JSON.parse(readFileSync11(manifestPath2, "utf8"));
|
|
1836
4425
|
} catch {
|
|
1837
4426
|
throw new Error(
|
|
1838
4427
|
`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.`
|
|
@@ -1896,8 +4485,8 @@ var dExt2 = {
|
|
|
1896
4485
|
};
|
|
1897
4486
|
|
|
1898
4487
|
// src/detectors/dExt3.ts
|
|
1899
|
-
import { readFileSync as
|
|
1900
|
-
import { join as
|
|
4488
|
+
import { readFileSync as readFileSync12, readdirSync as readdirSync6, statSync as statSync7 } from "fs";
|
|
4489
|
+
import { join as join14 } from "path";
|
|
1901
4490
|
var TYPE_MATCHERS = [
|
|
1902
4491
|
{ type: "middleware", re: /\bmiddleware\b/ },
|
|
1903
4492
|
{ type: "tool-extension", re: /\btool-ext(?:ension)?\b/ },
|
|
@@ -1920,32 +4509,32 @@ function dExt3PositiveControls() {
|
|
|
1920
4509
|
}
|
|
1921
4510
|
};
|
|
1922
4511
|
}
|
|
1923
|
-
var
|
|
4512
|
+
var SKIP_DIRS4 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
1924
4513
|
function findExtensionManifests3(root) {
|
|
1925
4514
|
const out = [];
|
|
1926
4515
|
let base;
|
|
1927
4516
|
try {
|
|
1928
|
-
base =
|
|
4517
|
+
base = statSync7(root).isDirectory() ? root : join14(root, "..");
|
|
1929
4518
|
} catch {
|
|
1930
4519
|
return out;
|
|
1931
4520
|
}
|
|
1932
4521
|
const walk2 = (dir) => {
|
|
1933
4522
|
let entries;
|
|
1934
4523
|
try {
|
|
1935
|
-
entries =
|
|
4524
|
+
entries = readdirSync6(dir);
|
|
1936
4525
|
} catch {
|
|
1937
4526
|
return;
|
|
1938
4527
|
}
|
|
1939
4528
|
for (const name of entries) {
|
|
1940
|
-
const full =
|
|
4529
|
+
const full = join14(dir, name);
|
|
1941
4530
|
let st;
|
|
1942
4531
|
try {
|
|
1943
|
-
st =
|
|
4532
|
+
st = statSync7(full);
|
|
1944
4533
|
} catch {
|
|
1945
4534
|
continue;
|
|
1946
4535
|
}
|
|
1947
4536
|
if (st.isDirectory()) {
|
|
1948
|
-
if (
|
|
4537
|
+
if (SKIP_DIRS4.has(name)) continue;
|
|
1949
4538
|
walk2(full);
|
|
1950
4539
|
} else if (name === "extension.json") {
|
|
1951
4540
|
out.push(full);
|
|
@@ -2013,11 +4602,11 @@ var dExt3 = {
|
|
|
2013
4602
|
const declared = /* @__PURE__ */ new Set();
|
|
2014
4603
|
let firstAnchor2 = null;
|
|
2015
4604
|
const manifests = findExtensionManifests3(tree.root);
|
|
2016
|
-
for (const
|
|
2017
|
-
const shortPath =
|
|
4605
|
+
for (const manifestPath2 of manifests) {
|
|
4606
|
+
const shortPath = manifestPath2.split("/").slice(-2).join("/");
|
|
2018
4607
|
let parsed;
|
|
2019
4608
|
try {
|
|
2020
|
-
parsed = JSON.parse(
|
|
4609
|
+
parsed = JSON.parse(readFileSync12(manifestPath2, "utf8"));
|
|
2021
4610
|
} catch {
|
|
2022
4611
|
throw new Error(
|
|
2023
4612
|
`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.`
|
|
@@ -2103,8 +4692,8 @@ var dExt3 = {
|
|
|
2103
4692
|
};
|
|
2104
4693
|
|
|
2105
4694
|
// src/detectors/dExt4.ts
|
|
2106
|
-
import { readFileSync as
|
|
2107
|
-
import { join as
|
|
4695
|
+
import { readFileSync as readFileSync13, readdirSync as readdirSync7, statSync as statSync8 } from "fs";
|
|
4696
|
+
import { join as join15 } from "path";
|
|
2108
4697
|
function canonical(name) {
|
|
2109
4698
|
return name.replace(/[-_]/g, "").toLowerCase();
|
|
2110
4699
|
}
|
|
@@ -2123,32 +4712,32 @@ function dExt4PositiveControls() {
|
|
|
2123
4712
|
}
|
|
2124
4713
|
};
|
|
2125
4714
|
}
|
|
2126
|
-
var
|
|
4715
|
+
var SKIP_DIRS5 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
2127
4716
|
function findExtensionManifests4(root) {
|
|
2128
4717
|
const out = [];
|
|
2129
4718
|
let base;
|
|
2130
4719
|
try {
|
|
2131
|
-
base =
|
|
4720
|
+
base = statSync8(root).isDirectory() ? root : join15(root, "..");
|
|
2132
4721
|
} catch {
|
|
2133
4722
|
return out;
|
|
2134
4723
|
}
|
|
2135
4724
|
const walk2 = (dir) => {
|
|
2136
4725
|
let entries;
|
|
2137
4726
|
try {
|
|
2138
|
-
entries =
|
|
4727
|
+
entries = readdirSync7(dir);
|
|
2139
4728
|
} catch {
|
|
2140
4729
|
return;
|
|
2141
4730
|
}
|
|
2142
4731
|
for (const name of entries) {
|
|
2143
|
-
const full =
|
|
4732
|
+
const full = join15(dir, name);
|
|
2144
4733
|
let st;
|
|
2145
4734
|
try {
|
|
2146
|
-
st =
|
|
4735
|
+
st = statSync8(full);
|
|
2147
4736
|
} catch {
|
|
2148
4737
|
continue;
|
|
2149
4738
|
}
|
|
2150
4739
|
if (st.isDirectory()) {
|
|
2151
|
-
if (
|
|
4740
|
+
if (SKIP_DIRS5.has(name)) continue;
|
|
2152
4741
|
walk2(full);
|
|
2153
4742
|
} else if (name === "extension.json") {
|
|
2154
4743
|
out.push(full);
|
|
@@ -2162,32 +4751,32 @@ function findHooksDirs(root) {
|
|
|
2162
4751
|
const out = [];
|
|
2163
4752
|
let base;
|
|
2164
4753
|
try {
|
|
2165
|
-
base =
|
|
4754
|
+
base = statSync8(root).isDirectory() ? root : join15(root, "..");
|
|
2166
4755
|
} catch {
|
|
2167
4756
|
return out;
|
|
2168
4757
|
}
|
|
2169
4758
|
const walk2 = (dir) => {
|
|
2170
4759
|
let entries;
|
|
2171
4760
|
try {
|
|
2172
|
-
entries =
|
|
4761
|
+
entries = readdirSync7(dir);
|
|
2173
4762
|
} catch {
|
|
2174
4763
|
return;
|
|
2175
4764
|
}
|
|
2176
4765
|
for (const name of entries) {
|
|
2177
|
-
const full =
|
|
4766
|
+
const full = join15(dir, name);
|
|
2178
4767
|
let st;
|
|
2179
4768
|
try {
|
|
2180
|
-
st =
|
|
4769
|
+
st = statSync8(full);
|
|
2181
4770
|
} catch {
|
|
2182
4771
|
continue;
|
|
2183
4772
|
}
|
|
2184
4773
|
if (!st.isDirectory()) continue;
|
|
2185
|
-
if (
|
|
4774
|
+
if (SKIP_DIRS5.has(name)) continue;
|
|
2186
4775
|
if (name === "hooks") {
|
|
2187
4776
|
const tokens = /* @__PURE__ */ new Set();
|
|
2188
4777
|
let files;
|
|
2189
4778
|
try {
|
|
2190
|
-
files =
|
|
4779
|
+
files = readdirSync7(full);
|
|
2191
4780
|
} catch {
|
|
2192
4781
|
files = [];
|
|
2193
4782
|
}
|
|
@@ -2276,11 +4865,11 @@ var dExt4 = {
|
|
|
2276
4865
|
const implSurfacePresent = hooksDirs.length > 0 || sites.length > 0;
|
|
2277
4866
|
const manifests = findExtensionManifests4(tree.root);
|
|
2278
4867
|
let declaredAnyLifecycle = false;
|
|
2279
|
-
for (const
|
|
2280
|
-
const shortPath =
|
|
4868
|
+
for (const manifestPath2 of manifests) {
|
|
4869
|
+
const shortPath = manifestPath2.split("/").slice(-2).join("/");
|
|
2281
4870
|
let parsed;
|
|
2282
4871
|
try {
|
|
2283
|
-
parsed = JSON.parse(
|
|
4872
|
+
parsed = JSON.parse(readFileSync13(manifestPath2, "utf8"));
|
|
2284
4873
|
} catch {
|
|
2285
4874
|
throw new Error(
|
|
2286
4875
|
`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.`
|
|
@@ -2338,8 +4927,8 @@ var dExt4 = {
|
|
|
2338
4927
|
};
|
|
2339
4928
|
|
|
2340
4929
|
// src/detectors/dExt5.ts
|
|
2341
|
-
import { existsSync as
|
|
2342
|
-
import { dirname as
|
|
4930
|
+
import { existsSync as existsSync12, readFileSync as readFileSync14, readdirSync as readdirSync8, statSync as statSync9 } from "fs";
|
|
4931
|
+
import { dirname as dirname7, join as join16 } from "path";
|
|
2343
4932
|
function nonEmptyString(v) {
|
|
2344
4933
|
return typeof v === "string" && v.trim().length > 0;
|
|
2345
4934
|
}
|
|
@@ -2353,32 +4942,32 @@ function dExt5PositiveControls() {
|
|
|
2353
4942
|
}
|
|
2354
4943
|
};
|
|
2355
4944
|
}
|
|
2356
|
-
var
|
|
4945
|
+
var SKIP_DIRS6 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
2357
4946
|
function findExtensionManifests5(root) {
|
|
2358
4947
|
const out = [];
|
|
2359
4948
|
let base;
|
|
2360
4949
|
try {
|
|
2361
|
-
base =
|
|
4950
|
+
base = statSync9(root).isDirectory() ? root : join16(root, "..");
|
|
2362
4951
|
} catch {
|
|
2363
4952
|
return out;
|
|
2364
4953
|
}
|
|
2365
4954
|
const walk2 = (dir) => {
|
|
2366
4955
|
let entries;
|
|
2367
4956
|
try {
|
|
2368
|
-
entries =
|
|
4957
|
+
entries = readdirSync8(dir);
|
|
2369
4958
|
} catch {
|
|
2370
4959
|
return;
|
|
2371
4960
|
}
|
|
2372
4961
|
for (const name of entries) {
|
|
2373
|
-
const full =
|
|
4962
|
+
const full = join16(dir, name);
|
|
2374
4963
|
let st;
|
|
2375
4964
|
try {
|
|
2376
|
-
st =
|
|
4965
|
+
st = statSync9(full);
|
|
2377
4966
|
} catch {
|
|
2378
4967
|
continue;
|
|
2379
4968
|
}
|
|
2380
4969
|
if (st.isDirectory()) {
|
|
2381
|
-
if (
|
|
4970
|
+
if (SKIP_DIRS6.has(name)) continue;
|
|
2382
4971
|
walk2(full);
|
|
2383
4972
|
} else if (name === "extension.json") {
|
|
2384
4973
|
out.push(full);
|
|
@@ -2409,9 +4998,9 @@ var dExt5 = {
|
|
|
2409
4998
|
verdict: "no extension.json manifest found -> declare a bilingual extension with description_fr + README.fr.md (Extension Rule 5)"
|
|
2410
4999
|
};
|
|
2411
5000
|
}
|
|
2412
|
-
for (const
|
|
2413
|
-
const shortPath =
|
|
2414
|
-
const root =
|
|
5001
|
+
for (const manifestPath2 of manifests) {
|
|
5002
|
+
const shortPath = manifestPath2.split("/").slice(-2).join("/");
|
|
5003
|
+
const root = dirname7(manifestPath2);
|
|
2415
5004
|
const cannotRead = (reason) => ({
|
|
2416
5005
|
id: this.id,
|
|
2417
5006
|
anchor: shortPath,
|
|
@@ -2421,7 +5010,7 @@ var dExt5 = {
|
|
|
2421
5010
|
});
|
|
2422
5011
|
let parsed;
|
|
2423
5012
|
try {
|
|
2424
|
-
parsed = JSON.parse(
|
|
5013
|
+
parsed = JSON.parse(readFileSync14(manifestPath2, "utf8"));
|
|
2425
5014
|
} catch {
|
|
2426
5015
|
return cannotRead(
|
|
2427
5016
|
`${shortPath} does not parse as JSON \u2014 the FR surface of a present manifest cannot be read (Extension Rule 5)`
|
|
@@ -2436,7 +5025,7 @@ var dExt5 = {
|
|
|
2436
5025
|
if (!nonEmptyString(parsed.description_fr)) {
|
|
2437
5026
|
missing.push("description_fr (extension.json)");
|
|
2438
5027
|
}
|
|
2439
|
-
if (!
|
|
5028
|
+
if (!existsSync12(join16(root, "README.fr.md"))) {
|
|
2440
5029
|
missing.push("README.fr.md");
|
|
2441
5030
|
}
|
|
2442
5031
|
if (isToolExtension(parsed.type) && "tools" in parsed) {
|
|
@@ -2479,8 +5068,8 @@ var dExt5 = {
|
|
|
2479
5068
|
};
|
|
2480
5069
|
|
|
2481
5070
|
// src/detectors/dExt7.ts
|
|
2482
|
-
import { existsSync as
|
|
2483
|
-
import { dirname as
|
|
5071
|
+
import { existsSync as existsSync13, readFileSync as readFileSync15, readdirSync as readdirSync9, statSync as statSync10 } from "fs";
|
|
5072
|
+
import { dirname as dirname8, join as join17 } from "path";
|
|
2484
5073
|
var ABSENCE_FLAG_FIELDS = [
|
|
2485
5074
|
"extensionAbsent",
|
|
2486
5075
|
"extension_absent",
|
|
@@ -2523,32 +5112,32 @@ function dExt7PositiveControls() {
|
|
|
2523
5112
|
negative: isAbsenceCase(ABSENCE_NEGATIVE)
|
|
2524
5113
|
};
|
|
2525
5114
|
}
|
|
2526
|
-
var
|
|
5115
|
+
var SKIP_DIRS7 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
2527
5116
|
function findExtensionManifests6(root) {
|
|
2528
5117
|
const out = [];
|
|
2529
5118
|
let base;
|
|
2530
5119
|
try {
|
|
2531
|
-
base =
|
|
5120
|
+
base = statSync10(root).isDirectory() ? root : join17(root, "..");
|
|
2532
5121
|
} catch {
|
|
2533
5122
|
return out;
|
|
2534
5123
|
}
|
|
2535
5124
|
const walk2 = (dir) => {
|
|
2536
5125
|
let entries;
|
|
2537
5126
|
try {
|
|
2538
|
-
entries =
|
|
5127
|
+
entries = readdirSync9(dir);
|
|
2539
5128
|
} catch {
|
|
2540
5129
|
return;
|
|
2541
5130
|
}
|
|
2542
5131
|
for (const name of entries) {
|
|
2543
|
-
const full =
|
|
5132
|
+
const full = join17(dir, name);
|
|
2544
5133
|
let st;
|
|
2545
5134
|
try {
|
|
2546
|
-
st =
|
|
5135
|
+
st = statSync10(full);
|
|
2547
5136
|
} catch {
|
|
2548
5137
|
continue;
|
|
2549
5138
|
}
|
|
2550
5139
|
if (st.isDirectory()) {
|
|
2551
|
-
if (
|
|
5140
|
+
if (SKIP_DIRS7.has(name)) continue;
|
|
2552
5141
|
walk2(full);
|
|
2553
5142
|
} else if (name === "extension.json") {
|
|
2554
5143
|
out.push(full);
|
|
@@ -2576,12 +5165,12 @@ var dExt7 = {
|
|
|
2576
5165
|
verdict: "no extension.json manifest found -> declare an extension with an eval suite (>=3 cases incl. 1 'extension absente') (Extension Rule 7)"
|
|
2577
5166
|
};
|
|
2578
5167
|
}
|
|
2579
|
-
for (const
|
|
2580
|
-
const extDir =
|
|
5168
|
+
for (const manifestPath2 of manifests) {
|
|
5169
|
+
const extDir = dirname8(manifestPath2);
|
|
2581
5170
|
const extLabel = extDir.split("/").pop() ?? extDir;
|
|
2582
|
-
const evalsPath =
|
|
5171
|
+
const evalsPath = join17(extDir, "evals", "evals.json");
|
|
2583
5172
|
const shortPath = `${extLabel}/evals/evals.json`;
|
|
2584
|
-
if (!
|
|
5173
|
+
if (!existsSync13(evalsPath)) {
|
|
2585
5174
|
return {
|
|
2586
5175
|
id: this.id,
|
|
2587
5176
|
anchor: `${extLabel}/extension.json`,
|
|
@@ -2591,7 +5180,7 @@ var dExt7 = {
|
|
|
2591
5180
|
}
|
|
2592
5181
|
let cases;
|
|
2593
5182
|
try {
|
|
2594
|
-
const raw =
|
|
5183
|
+
const raw = readFileSync15(evalsPath, "utf8");
|
|
2595
5184
|
const parsed = JSON.parse(raw);
|
|
2596
5185
|
if (!Array.isArray(parsed.cases)) {
|
|
2597
5186
|
throw new Error("`cases` is not an array");
|
|
@@ -2634,8 +5223,8 @@ var dExt7 = {
|
|
|
2634
5223
|
};
|
|
2635
5224
|
|
|
2636
5225
|
// src/detectors/dExt8.ts
|
|
2637
|
-
import { existsSync as
|
|
2638
|
-
import { dirname as
|
|
5226
|
+
import { existsSync as existsSync14, readFileSync as readFileSync16, readdirSync as readdirSync10, statSync as statSync11 } from "fs";
|
|
5227
|
+
import { dirname as dirname9, join as join18 } from "path";
|
|
2639
5228
|
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
5229
|
var POSITIVE_CONTROL5 = "1.2.3-beta.1+build.7";
|
|
2641
5230
|
function dExt8PositiveControl() {
|
|
@@ -2645,32 +5234,32 @@ function dExt8PositiveControl() {
|
|
|
2645
5234
|
matched: SEMVER_RE2.test(POSITIVE_CONTROL5)
|
|
2646
5235
|
};
|
|
2647
5236
|
}
|
|
2648
|
-
var
|
|
5237
|
+
var SKIP_DIRS8 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
2649
5238
|
function findExtensionManifests7(root) {
|
|
2650
5239
|
const out = [];
|
|
2651
5240
|
let base;
|
|
2652
5241
|
try {
|
|
2653
|
-
base =
|
|
5242
|
+
base = statSync11(root).isDirectory() ? root : join18(root, "..");
|
|
2654
5243
|
} catch {
|
|
2655
5244
|
return out;
|
|
2656
5245
|
}
|
|
2657
5246
|
const walk2 = (dir) => {
|
|
2658
5247
|
let entries;
|
|
2659
5248
|
try {
|
|
2660
|
-
entries =
|
|
5249
|
+
entries = readdirSync10(dir);
|
|
2661
5250
|
} catch {
|
|
2662
5251
|
return;
|
|
2663
5252
|
}
|
|
2664
5253
|
for (const name of entries) {
|
|
2665
|
-
const full =
|
|
5254
|
+
const full = join18(dir, name);
|
|
2666
5255
|
let st;
|
|
2667
5256
|
try {
|
|
2668
|
-
st =
|
|
5257
|
+
st = statSync11(full);
|
|
2669
5258
|
} catch {
|
|
2670
5259
|
continue;
|
|
2671
5260
|
}
|
|
2672
5261
|
if (st.isDirectory()) {
|
|
2673
|
-
if (
|
|
5262
|
+
if (SKIP_DIRS8.has(name)) continue;
|
|
2674
5263
|
walk2(full);
|
|
2675
5264
|
} else if (name === "extension.json") {
|
|
2676
5265
|
out.push(full);
|
|
@@ -2698,19 +5287,19 @@ var dExt8 = {
|
|
|
2698
5287
|
verdict: "no extension.json manifest found -> declare an extension with a valid SemVer version + CHANGELOG.md (Extension Rule 8)"
|
|
2699
5288
|
};
|
|
2700
5289
|
}
|
|
2701
|
-
for (const
|
|
2702
|
-
const extDir =
|
|
5290
|
+
for (const manifestPath2 of manifests) {
|
|
5291
|
+
const extDir = dirname9(manifestPath2);
|
|
2703
5292
|
const extLabel = extDir.split("/").pop() ?? extDir;
|
|
2704
|
-
const pkgPath =
|
|
5293
|
+
const pkgPath = join18(extDir, "package.json");
|
|
2705
5294
|
const shortPkgPath = `${extLabel}/package.json`;
|
|
2706
|
-
if (!
|
|
5295
|
+
if (!existsSync14(pkgPath)) {
|
|
2707
5296
|
throw new Error(
|
|
2708
5297
|
`DExt8 could not locate ${shortPkgPath} for extension "${extLabel}" \u2014 cannot verify SemVer/CHANGELOG (Extension Rule 8). Fail-closed, not a silent present.`
|
|
2709
5298
|
);
|
|
2710
5299
|
}
|
|
2711
5300
|
let pkgJson;
|
|
2712
5301
|
try {
|
|
2713
|
-
pkgJson = JSON.parse(
|
|
5302
|
+
pkgJson = JSON.parse(readFileSync16(pkgPath, "utf8"));
|
|
2714
5303
|
} catch {
|
|
2715
5304
|
throw new Error(
|
|
2716
5305
|
`DExt8 could not parse ${shortPkgPath} as JSON for extension "${extLabel}" \u2014 cannot verify SemVer/CHANGELOG (Extension Rule 8). Fail-closed, not a silent present.`
|
|
@@ -2731,8 +5320,8 @@ var dExt8 = {
|
|
|
2731
5320
|
};
|
|
2732
5321
|
}
|
|
2733
5322
|
const validSemver = SEMVER_RE2.test(version);
|
|
2734
|
-
const changelogPath =
|
|
2735
|
-
const hasChangelog =
|
|
5323
|
+
const changelogPath = join18(extDir, "CHANGELOG.md");
|
|
5324
|
+
const hasChangelog = existsSync14(changelogPath);
|
|
2736
5325
|
if (!validSemver) {
|
|
2737
5326
|
return {
|
|
2738
5327
|
id: this.id,
|
|
@@ -2761,38 +5350,38 @@ var dExt8 = {
|
|
|
2761
5350
|
};
|
|
2762
5351
|
|
|
2763
5352
|
// src/detectors/dExt9.ts
|
|
2764
|
-
import { readFileSync as
|
|
2765
|
-
import { join as
|
|
2766
|
-
var
|
|
5353
|
+
import { readFileSync as readFileSync17, readdirSync as readdirSync11, statSync as statSync12 } from "fs";
|
|
5354
|
+
import { join as join19 } from "path";
|
|
5355
|
+
var SKIP_DIRS9 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
2767
5356
|
function collectHooksDirSources(root) {
|
|
2768
5357
|
const out = [];
|
|
2769
5358
|
let base;
|
|
2770
5359
|
try {
|
|
2771
|
-
base =
|
|
5360
|
+
base = statSync12(root).isDirectory() ? root : join19(root, "..");
|
|
2772
5361
|
} catch {
|
|
2773
5362
|
return out;
|
|
2774
5363
|
}
|
|
2775
5364
|
const walk2 = (dir) => {
|
|
2776
5365
|
let entries;
|
|
2777
5366
|
try {
|
|
2778
|
-
entries =
|
|
5367
|
+
entries = readdirSync11(dir);
|
|
2779
5368
|
} catch {
|
|
2780
5369
|
return;
|
|
2781
5370
|
}
|
|
2782
5371
|
for (const name of entries) {
|
|
2783
|
-
const full =
|
|
5372
|
+
const full = join19(dir, name);
|
|
2784
5373
|
let st;
|
|
2785
5374
|
try {
|
|
2786
|
-
st =
|
|
5375
|
+
st = statSync12(full);
|
|
2787
5376
|
} catch {
|
|
2788
5377
|
continue;
|
|
2789
5378
|
}
|
|
2790
5379
|
if (!st.isDirectory()) continue;
|
|
2791
|
-
if (
|
|
5380
|
+
if (SKIP_DIRS9.has(name)) continue;
|
|
2792
5381
|
if (name === "hooks") {
|
|
2793
5382
|
let files;
|
|
2794
5383
|
try {
|
|
2795
|
-
files =
|
|
5384
|
+
files = readdirSync11(full);
|
|
2796
5385
|
} catch {
|
|
2797
5386
|
files = [];
|
|
2798
5387
|
}
|
|
@@ -2800,10 +5389,10 @@ function collectHooksDirSources(root) {
|
|
|
2800
5389
|
if (!f.endsWith(".ts") || f.endsWith(".d.ts")) continue;
|
|
2801
5390
|
const stem = f.replace(/\.ts$/, "");
|
|
2802
5391
|
if (stem === "index") continue;
|
|
2803
|
-
const filePath =
|
|
5392
|
+
const filePath = join19(full, f);
|
|
2804
5393
|
let body;
|
|
2805
5394
|
try {
|
|
2806
|
-
body =
|
|
5395
|
+
body = readFileSync17(filePath, "utf8");
|
|
2807
5396
|
} catch {
|
|
2808
5397
|
throw new Error(
|
|
2809
5398
|
`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.`
|
|
@@ -2910,11 +5499,11 @@ var dExt9 = {
|
|
|
2910
5499
|
}
|
|
2911
5500
|
const manifests = findExtensionManifests4(tree.root);
|
|
2912
5501
|
let declaredAnyLifecycle = false;
|
|
2913
|
-
for (const
|
|
2914
|
-
const shortPath =
|
|
5502
|
+
for (const manifestPath2 of manifests) {
|
|
5503
|
+
const shortPath = manifestPath2.split("/").slice(-2).join("/");
|
|
2915
5504
|
let parsed;
|
|
2916
5505
|
try {
|
|
2917
|
-
parsed = JSON.parse(
|
|
5506
|
+
parsed = JSON.parse(readFileSync17(manifestPath2, "utf8"));
|
|
2918
5507
|
} catch {
|
|
2919
5508
|
throw new Error(
|
|
2920
5509
|
`DExt9 could not parse ${shortPath} as JSON \u2014 timeout guards undeterminable (Extension Rule 9). Fail-closed, not a silent absent.`
|
|
@@ -2992,6 +5581,8 @@ function unscopePackageName(name) {
|
|
|
2992
5581
|
}
|
|
2993
5582
|
var dServer2 = {
|
|
2994
5583
|
id: "DServer2 naming",
|
|
5584
|
+
rule: "C-2",
|
|
5585
|
+
pin: "a74a042b53c71a095ab88b3073bd47728eeda814",
|
|
2995
5586
|
run(tree) {
|
|
2996
5587
|
const controls = dServer2PositiveControls();
|
|
2997
5588
|
if (!controls.toolSnake.positive || controls.toolSnake.negative) {
|
|
@@ -3123,11 +5714,17 @@ var dServer2 = {
|
|
|
3123
5714
|
// src/detectors/dServer6.ts
|
|
3124
5715
|
var LOG_OPENER_RE = /\b(?:console|logger|log)\.(?:log|info|warn|error|debug|trace)\s*\(/;
|
|
3125
5716
|
var SECRET_RE = /\b(?:api[_-]?key|token|secret|passwd|password|authorization|auth[_-]?token|bearer|access[_-]?key|client[_-]?secret|private[_-]?key)\b/i;
|
|
5717
|
+
var PII_RE = /\b(?:email|e[_-]?mail|ip[_-]?address|full[_-]?name|phone[_-]?number|ssn|social[_-]?security)\b/i;
|
|
5718
|
+
var INTERNAL_RE = /\b(?:hostname|host[_-]?name|absolute[_-]?path|internal[_-]?path|infra[_-]?version|server[_-]?version|__dirname|__filename)\b/;
|
|
3126
5719
|
var STACK_RE = /\b(?:error|err|e|ex|exception)\.stack\b/;
|
|
3127
5720
|
var CLIENT_SINK_RE = /\b(?:content|text|message|body)\s*:|(?:\breturn\b|\bthrow\b|JSON\.stringify)/;
|
|
3128
5721
|
var SPREAD_RE = /\.\.\.[A-Za-z_$]/;
|
|
3129
5722
|
var SECRET_POSITIVE = "apiKey";
|
|
3130
5723
|
var SECRET_NEGATIVE = "customerId";
|
|
5724
|
+
var PII_POSITIVE = "email";
|
|
5725
|
+
var PII_NEGATIVE = "customerId";
|
|
5726
|
+
var INTERNAL_POSITIVE = "hostname";
|
|
5727
|
+
var INTERNAL_NEGATIVE = "toolName";
|
|
3131
5728
|
var STACK_POSITIVE = "error.stack";
|
|
3132
5729
|
var STACK_NEGATIVE = "error.message";
|
|
3133
5730
|
function dServer6PositiveControls() {
|
|
@@ -3137,6 +5734,16 @@ function dServer6PositiveControls() {
|
|
|
3137
5734
|
positive: SECRET_RE.test(SECRET_POSITIVE),
|
|
3138
5735
|
negative: SECRET_RE.test(SECRET_NEGATIVE)
|
|
3139
5736
|
},
|
|
5737
|
+
pii: {
|
|
5738
|
+
pattern: PII_RE.source,
|
|
5739
|
+
positive: PII_RE.test(PII_POSITIVE),
|
|
5740
|
+
negative: PII_RE.test(PII_NEGATIVE)
|
|
5741
|
+
},
|
|
5742
|
+
internal: {
|
|
5743
|
+
pattern: INTERNAL_RE.source,
|
|
5744
|
+
positive: INTERNAL_RE.test(INTERNAL_POSITIVE),
|
|
5745
|
+
negative: INTERNAL_RE.test(INTERNAL_NEGATIVE)
|
|
5746
|
+
},
|
|
3140
5747
|
stack: {
|
|
3141
5748
|
pattern: STACK_RE.source,
|
|
3142
5749
|
positive: STACK_RE.test(STACK_POSITIVE),
|
|
@@ -3186,8 +5793,11 @@ function collectLogCalls(file) {
|
|
|
3186
5793
|
}
|
|
3187
5794
|
return { calls, unbalanced };
|
|
3188
5795
|
}
|
|
5796
|
+
var PIN10 = "a74a042b53c71a095ab88b3073bd47728eeda814";
|
|
3189
5797
|
var dServer6 = {
|
|
3190
5798
|
id: "DServer6 secret-hygiene",
|
|
5799
|
+
rule: "C-6",
|
|
5800
|
+
pin: PIN10,
|
|
3191
5801
|
run(tree) {
|
|
3192
5802
|
const controls = dServer6PositiveControls();
|
|
3193
5803
|
if (!controls.secret.positive || controls.secret.negative) {
|
|
@@ -3195,6 +5805,16 @@ var dServer6 = {
|
|
|
3195
5805
|
`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
5806
|
);
|
|
3197
5807
|
}
|
|
5808
|
+
if (!controls.pii.positive || controls.pii.negative) {
|
|
5809
|
+
throw new Error(
|
|
5810
|
+
`DServer6 PII control FAILED: /${controls.pii.pattern}/ accept(\`${PII_POSITIVE}\`)=${controls.pii.positive} reject(\`${PII_NEGATIVE}\`)=${!controls.pii.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
5811
|
+
);
|
|
5812
|
+
}
|
|
5813
|
+
if (!controls.internal.positive || controls.internal.negative) {
|
|
5814
|
+
throw new Error(
|
|
5815
|
+
`DServer6 internal-detail control FAILED: /${controls.internal.pattern}/ accept(\`${INTERNAL_POSITIVE}\`)=${controls.internal.positive} reject(\`${INTERNAL_NEGATIVE}\`)=${!controls.internal.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
5816
|
+
);
|
|
5817
|
+
}
|
|
3198
5818
|
if (!controls.stack.positive || controls.stack.negative) {
|
|
3199
5819
|
throw new Error(
|
|
3200
5820
|
`DServer6 stack control FAILED: /${controls.stack.pattern}/ accept(\`${STACK_POSITIVE}\`)=${controls.stack.positive} reject(\`${STACK_NEGATIVE}\`)=${!controls.stack.negative} \u2014 matcher broken, verdict untrustworthy`
|
|
@@ -3204,21 +5824,38 @@ var dServer6 = {
|
|
|
3204
5824
|
const { calls, unbalanced } = collectLogCalls(file);
|
|
3205
5825
|
if (unbalanced.length > 0) {
|
|
3206
5826
|
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 (
|
|
5827
|
+
`DServer6 could not read the argument list of ${unbalanced.length} log call(s): ${unbalanced.join(", ")} \u2014 parentheses never balance, secret/PII content unverifiable (C-6). Fail-closed, not a silent present.`
|
|
3208
5828
|
);
|
|
3209
5829
|
}
|
|
3210
5830
|
for (const call of calls) {
|
|
3211
5831
|
if (SPREAD_RE.test(call.argText)) {
|
|
3212
5832
|
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 (
|
|
5833
|
+
`DServer6 cannot inspect the spread argument of the log call at ${call.anchor} \u2014 a spread hides arbitrary content, secret/PII presence unverifiable (C-6). Fail-closed, not a silent present.`
|
|
3214
5834
|
);
|
|
3215
5835
|
}
|
|
3216
|
-
|
|
5836
|
+
const refs = referencedIdentifiers(call.argText);
|
|
5837
|
+
if (SECRET_RE.test(refs)) {
|
|
5838
|
+
return {
|
|
5839
|
+
id: this.id,
|
|
5840
|
+
anchor: call.anchor,
|
|
5841
|
+
state: "non-conforming",
|
|
5842
|
+
verdict: "Form A \u2014 log call references a secret-like identifier (positive-control OK) -> never log secrets; redact before logging (C-6)"
|
|
5843
|
+
};
|
|
5844
|
+
}
|
|
5845
|
+
if (PII_RE.test(refs)) {
|
|
5846
|
+
return {
|
|
5847
|
+
id: this.id,
|
|
5848
|
+
anchor: call.anchor,
|
|
5849
|
+
state: "non-conforming",
|
|
5850
|
+
verdict: "Form C \u2014 log call references a PII-shaped identifier (positive-control OK) -> never log PII; redact before logging (C-6)"
|
|
5851
|
+
};
|
|
5852
|
+
}
|
|
5853
|
+
if (INTERNAL_RE.test(refs)) {
|
|
3217
5854
|
return {
|
|
3218
5855
|
id: this.id,
|
|
3219
5856
|
anchor: call.anchor,
|
|
3220
5857
|
state: "non-conforming",
|
|
3221
|
-
verdict: "Form
|
|
5858
|
+
verdict: "Form D \u2014 log call references an internal-infrastructure identifier (positive-control OK) -> never log internal system detail; redact before logging (C-6)"
|
|
3222
5859
|
};
|
|
3223
5860
|
}
|
|
3224
5861
|
}
|
|
@@ -3231,7 +5868,7 @@ var dServer6 = {
|
|
|
3231
5868
|
id: this.id,
|
|
3232
5869
|
anchor: `${file.rel}:${line.n}`,
|
|
3233
5870
|
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 (
|
|
5871
|
+
verdict: "Form B \u2014 raw error.stack serialized into a client-facing response (positive-control OK) -> return an McpError message, never the raw stack (C-6)"
|
|
3235
5872
|
};
|
|
3236
5873
|
}
|
|
3237
5874
|
}
|
|
@@ -3240,7 +5877,7 @@ var dServer6 = {
|
|
|
3240
5877
|
id: this.id,
|
|
3241
5878
|
anchor: tree.files.length > 0 ? `${tree.files[0].rel}:1` : tree.root,
|
|
3242
5879
|
state: "present",
|
|
3243
|
-
verdict: "no secret referenced in any log call; no raw error.stack exposed to the client (
|
|
5880
|
+
verdict: "no secret/PII/internal-detail referenced in any log call; no raw error.stack exposed to the client (all positive-controls OK). Logging-sink configuration and explicit-activation-only diagnostic mode are the gate/runtime half, out of static scope (declared)."
|
|
3244
5881
|
};
|
|
3245
5882
|
}
|
|
3246
5883
|
};
|
|
@@ -3568,6 +6205,24 @@ var detectors = [
|
|
|
3568
6205
|
d12,
|
|
3569
6206
|
d15,
|
|
3570
6207
|
d16,
|
|
6208
|
+
c7ArtifactVerifiable,
|
|
6209
|
+
c8AuthorityDeclared,
|
|
6210
|
+
c9VerbDeclared,
|
|
6211
|
+
c10MechanismDeclared,
|
|
6212
|
+
c11Pagination,
|
|
6213
|
+
c12NoDuplicates,
|
|
6214
|
+
c13ContextCeiling,
|
|
6215
|
+
c14CatalogRegistration,
|
|
6216
|
+
c15AnnotationsUntrusted,
|
|
6217
|
+
c16UntrustedOutput,
|
|
6218
|
+
c17SupplyChain,
|
|
6219
|
+
c18StructureManifest,
|
|
6220
|
+
c19ServedDescriptions,
|
|
6221
|
+
c20LocalesDerived,
|
|
6222
|
+
c21ProgressiveDisclosure,
|
|
6223
|
+
c22TestRatioGate,
|
|
6224
|
+
c25BuildIdentity,
|
|
6225
|
+
c26SubpackageLockfile,
|
|
3571
6226
|
dServer2,
|
|
3572
6227
|
dServer6,
|
|
3573
6228
|
dDebugOptIn,
|
|
@@ -3585,19 +6240,19 @@ var detectors = [
|
|
|
3585
6240
|
];
|
|
3586
6241
|
|
|
3587
6242
|
// src/runtime/launch.ts
|
|
3588
|
-
import { spawnSync } from "child_process";
|
|
3589
|
-
import { existsSync as
|
|
3590
|
-
import { dirname as
|
|
6243
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
6244
|
+
import { existsSync as existsSync15, readFileSync as readFileSync18, statSync as statSync13 } from "fs";
|
|
6245
|
+
import { dirname as dirname10, isAbsolute, join as join20, resolve } from "path";
|
|
3591
6246
|
function findPackageDir(target) {
|
|
3592
6247
|
let dir;
|
|
3593
6248
|
try {
|
|
3594
|
-
dir =
|
|
6249
|
+
dir = statSync13(target).isDirectory() ? target : dirname10(target);
|
|
3595
6250
|
} catch {
|
|
3596
6251
|
return null;
|
|
3597
6252
|
}
|
|
3598
6253
|
for (let depth = 0; depth < 8; depth++) {
|
|
3599
|
-
if (
|
|
3600
|
-
const parent =
|
|
6254
|
+
if (existsSync15(join20(dir, "package.json"))) return dir;
|
|
6255
|
+
const parent = dirname10(dir);
|
|
3601
6256
|
if (parent === dir) break;
|
|
3602
6257
|
dir = parent;
|
|
3603
6258
|
}
|
|
@@ -3621,10 +6276,10 @@ function deriveLaunchPlan(target) {
|
|
|
3621
6276
|
}
|
|
3622
6277
|
let pkg;
|
|
3623
6278
|
try {
|
|
3624
|
-
pkg = JSON.parse(
|
|
6279
|
+
pkg = JSON.parse(readFileSync18(join20(pkgDir, "package.json"), "utf8"));
|
|
3625
6280
|
} catch (err) {
|
|
3626
6281
|
return {
|
|
3627
|
-
error: `could not parse ${
|
|
6282
|
+
error: `could not parse ${join20(pkgDir, "package.json")}: ${err instanceof Error ? err.message : String(err)}`
|
|
3628
6283
|
};
|
|
3629
6284
|
}
|
|
3630
6285
|
const bin = firstBinEntry(pkg.bin);
|
|
@@ -3663,6 +6318,7 @@ try {
|
|
|
3663
6318
|
done({ launchError: "spawn failed: " + (e && e.message ? e.message : String(e)) });
|
|
3664
6319
|
}
|
|
3665
6320
|
let settled = false;
|
|
6321
|
+
let serverInfo;
|
|
3666
6322
|
const fail = (msg) => { if (settled) return; settled = true; try { child.kill(); } catch {} done({ launchError: msg }); };
|
|
3667
6323
|
const to = setTimeout(() => fail("timeout: server did not answer initialize+tools/list within the deadline"), 12000);
|
|
3668
6324
|
child.on("error", (e) => fail("spawn error: " + (e && e.message ? e.message : String(e))));
|
|
@@ -3677,13 +6333,19 @@ child.stdout.on("data", (d) => {
|
|
|
3677
6333
|
if (!line.trim()) continue;
|
|
3678
6334
|
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
3679
6335
|
if (msg.id === 1) {
|
|
6336
|
+
// The initialize response's serverInfo is the SAME non-authenticated
|
|
6337
|
+
// discovery surface a client reads version from (S-7); captured here
|
|
6338
|
+
// so C-25's dynamic half can read a served build-identity field
|
|
6339
|
+
// alongside it, additively -- every existing consumer of the
|
|
6340
|
+
// tools-only introspector output is unaffected.
|
|
6341
|
+
serverInfo = msg.result && msg.result.serverInfo;
|
|
3680
6342
|
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
3681
6343
|
send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
|
|
3682
6344
|
} else if (msg.id === 2) {
|
|
3683
6345
|
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) }));
|
|
6346
|
+
const tools = (msg.result && Array.isArray(msg.result.tools) ? msg.result.tools : []).map((t) => ({ name: String(t && t.name), description: t && typeof t.description === "string" ? t.description : undefined }));
|
|
3685
6347
|
try { child.kill(); } catch {}
|
|
3686
|
-
done({ tools });
|
|
6348
|
+
done({ tools, serverInfo });
|
|
3687
6349
|
}
|
|
3688
6350
|
}
|
|
3689
6351
|
});
|
|
@@ -3695,10 +6357,10 @@ function readEvalCounts(target) {
|
|
|
3695
6357
|
if (!pkgDir) return counts;
|
|
3696
6358
|
let dir = pkgDir;
|
|
3697
6359
|
for (let depth = 0; depth < 8; depth++) {
|
|
3698
|
-
const candidate =
|
|
3699
|
-
if (
|
|
6360
|
+
const candidate = join20(dir, "evals", "evals.json");
|
|
6361
|
+
if (existsSync15(candidate)) {
|
|
3700
6362
|
try {
|
|
3701
|
-
const parsed = JSON.parse(
|
|
6363
|
+
const parsed = JSON.parse(readFileSync18(candidate, "utf8"));
|
|
3702
6364
|
for (const c of parsed.cases ?? []) {
|
|
3703
6365
|
if (c && typeof c.tool === "string") counts.set(c.tool, (counts.get(c.tool) ?? 0) + 1);
|
|
3704
6366
|
}
|
|
@@ -3706,7 +6368,7 @@ function readEvalCounts(target) {
|
|
|
3706
6368
|
}
|
|
3707
6369
|
return counts;
|
|
3708
6370
|
}
|
|
3709
|
-
const parent =
|
|
6371
|
+
const parent = dirname10(dir);
|
|
3710
6372
|
if (parent === dir) break;
|
|
3711
6373
|
dir = parent;
|
|
3712
6374
|
}
|
|
@@ -3714,17 +6376,32 @@ function readEvalCounts(target) {
|
|
|
3714
6376
|
}
|
|
3715
6377
|
function statSyncSafe(p) {
|
|
3716
6378
|
try {
|
|
3717
|
-
return
|
|
6379
|
+
return statSync13(p).isDirectory();
|
|
3718
6380
|
} catch {
|
|
3719
6381
|
return false;
|
|
3720
6382
|
}
|
|
3721
6383
|
}
|
|
6384
|
+
function deriveExpectedCommit(cwd) {
|
|
6385
|
+
try {
|
|
6386
|
+
const out = spawnSync2("git", ["rev-parse", "HEAD"], {
|
|
6387
|
+
cwd,
|
|
6388
|
+
encoding: "utf8",
|
|
6389
|
+
timeout: 5e3
|
|
6390
|
+
});
|
|
6391
|
+
if (out.status === 0 && typeof out.stdout === "string") {
|
|
6392
|
+
const sha = out.stdout.trim();
|
|
6393
|
+
if (/^[0-9a-f]{40}$/i.test(sha)) return sha;
|
|
6394
|
+
}
|
|
6395
|
+
} catch {
|
|
6396
|
+
}
|
|
6397
|
+
return void 0;
|
|
6398
|
+
}
|
|
3722
6399
|
function launchAndIntrospect(target) {
|
|
3723
6400
|
const plan = deriveLaunchPlan(target);
|
|
3724
6401
|
if ("error" in plan) {
|
|
3725
6402
|
return { root: target, tools: [], launchError: plan.error };
|
|
3726
6403
|
}
|
|
3727
|
-
const proc =
|
|
6404
|
+
const proc = spawnSync2(process.execPath, ["--input-type=module", "-e", INTROSPECTOR], {
|
|
3728
6405
|
env: { ...process.env, MCP_DOCTOR_PLAN: JSON.stringify(plan) },
|
|
3729
6406
|
encoding: "utf8",
|
|
3730
6407
|
timeout: 2e4,
|
|
@@ -3763,82 +6440,16 @@ function launchAndIntrospect(target) {
|
|
|
3763
6440
|
const tools = (parsed.tools ?? []).map((t) => {
|
|
3764
6441
|
const name = t.name;
|
|
3765
6442
|
const count = evalCounts.get(name);
|
|
3766
|
-
|
|
6443
|
+
const base = t.description ? { name, description: t.description } : { name };
|
|
6444
|
+
return count === void 0 ? base : { ...base, evalCaseCount: count };
|
|
3767
6445
|
});
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
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) };
|
|
6446
|
+
const expectedCommit = deriveExpectedCommit(plan.cwd);
|
|
6447
|
+
return {
|
|
6448
|
+
root: target,
|
|
6449
|
+
tools,
|
|
6450
|
+
...parsed.serverInfo ? { serverInfo: parsed.serverInfo } : {},
|
|
6451
|
+
...expectedCommit ? { expectedCommit } : {}
|
|
6452
|
+
};
|
|
3842
6453
|
}
|
|
3843
6454
|
|
|
3844
6455
|
// src/types.ts
|
|
@@ -3995,12 +6606,45 @@ function computeExitCode(results) {
|
|
|
3995
6606
|
if (results.some((r) => r.state === "could-not-judge")) return 2;
|
|
3996
6607
|
return 0;
|
|
3997
6608
|
}
|
|
6609
|
+
var USAGE = [
|
|
6610
|
+
"usage: mcp-doctor <source-tree-path> [--mode=static|dynamic|both]",
|
|
6611
|
+
"",
|
|
6612
|
+
" --mode=static read the tree only (default)",
|
|
6613
|
+
" --mode=dynamic launch the server and judge what it serves",
|
|
6614
|
+
" --mode=both both, dynamic findings taking precedence",
|
|
6615
|
+
" --version print the version and exit",
|
|
6616
|
+
" --help print this and exit",
|
|
6617
|
+
"",
|
|
6618
|
+
"exit: 0 clean, 1 a violation, 2 an abstention with no violation"
|
|
6619
|
+
].join("\n");
|
|
6620
|
+
function readVersion() {
|
|
6621
|
+
try {
|
|
6622
|
+
const here = dirname11(fileURLToPath(import.meta.url));
|
|
6623
|
+
const manifest = JSON.parse(readFileSync19(join21(here, "..", "package.json"), "utf8"));
|
|
6624
|
+
return manifest.version ?? null;
|
|
6625
|
+
} catch {
|
|
6626
|
+
return null;
|
|
6627
|
+
}
|
|
6628
|
+
}
|
|
3998
6629
|
function main() {
|
|
3999
6630
|
const args = process.argv.slice(2);
|
|
6631
|
+
if (args.includes("--version") || args.includes("-V")) {
|
|
6632
|
+
const version = readVersion();
|
|
6633
|
+
if (!version) {
|
|
6634
|
+
console.error("mcp-doctor: could not read its own version from package.json");
|
|
6635
|
+
process.exit(1);
|
|
6636
|
+
}
|
|
6637
|
+
console.log(version);
|
|
6638
|
+
return;
|
|
6639
|
+
}
|
|
6640
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
6641
|
+
console.log(USAGE);
|
|
6642
|
+
return;
|
|
6643
|
+
}
|
|
4000
6644
|
const modeArg = args.find((a) => a.startsWith("--mode="));
|
|
4001
6645
|
const target = args.find((a) => !a.startsWith("--"));
|
|
4002
6646
|
if (!target) {
|
|
4003
|
-
console.error(
|
|
6647
|
+
console.error(USAGE);
|
|
4004
6648
|
process.exit(1);
|
|
4005
6649
|
}
|
|
4006
6650
|
const mode = modeArg ? modeArg.slice("--mode=".length) : "static";
|
|
@@ -4012,7 +6656,22 @@ function main() {
|
|
|
4012
6656
|
console.log(renderTable(target, results));
|
|
4013
6657
|
process.exitCode = computeExitCode(results);
|
|
4014
6658
|
}
|
|
4015
|
-
|
|
6659
|
+
function isDirectInvocation() {
|
|
6660
|
+
const entry = process.argv[1];
|
|
6661
|
+
if (!entry) return false;
|
|
6662
|
+
let selfReal;
|
|
6663
|
+
try {
|
|
6664
|
+
selfReal = realpathSync(fileURLToPath(import.meta.url));
|
|
6665
|
+
} catch {
|
|
6666
|
+
return false;
|
|
6667
|
+
}
|
|
6668
|
+
try {
|
|
6669
|
+
return realpathSync(resolve2(entry)) === selfReal;
|
|
6670
|
+
} catch {
|
|
6671
|
+
return false;
|
|
6672
|
+
}
|
|
6673
|
+
}
|
|
6674
|
+
if (isDirectInvocation()) main();
|
|
4016
6675
|
export {
|
|
4017
6676
|
computeExitCode,
|
|
4018
6677
|
isLiveDetector,
|