@vantageos/mcp-doctor 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/doctor.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/doctor.ts
4
- import { readFileSync as readFileSync17, realpathSync } from "fs";
4
+ import { readFileSync as readFileSync16, realpathSync } from "fs";
5
5
  import { dirname as dirname17, join as join27, resolve as resolve3 } from "path";
6
6
  import { fileURLToPath as fileURLToPath2 } from "url";
7
7
 
8
8
  // src/scan.ts
9
- import { existsSync, readFileSync, readdirSync, statSync } from "fs";
9
+ import { constants, accessSync, existsSync, readFileSync, readdirSync, statSync } from "fs";
10
10
  import { basename, dirname, join, relative, sep } from "path";
11
11
  var SKIP_DIR_INFO = {
12
12
  node_modules: { reason: "vendored dependencies", authored: false },
@@ -46,11 +46,12 @@ function isCommentLine(raw) {
46
46
  }
47
47
  var UNSCANNED_RE = /\.(?:m?js|cjs|jsx|mts|cts)$/;
48
48
  var NOT_PRODUCT_NAME_RE = /\.d\.[mc]?ts$|\.(?:test|spec)\.[mc]?[jt]sx?$|\.config\.[mc]?[jt]s$|\.min\./;
49
- function walk(dir, out, onSkip, unscanned = []) {
49
+ function walk(dir, out, onSkip, unscanned = [], seen = { files: 0 }) {
50
50
  let entries;
51
51
  try {
52
52
  entries = readdirSync(dir);
53
- } catch {
53
+ } catch (err) {
54
+ onSkip(dir, `could not be listed (${fsErrorCode(err)}) \u2014 not read`);
54
55
  return;
55
56
  }
56
57
  for (const name of entries) {
@@ -58,7 +59,8 @@ function walk(dir, out, onSkip, unscanned = []) {
58
59
  let st;
59
60
  try {
60
61
  st = statSync(full);
61
- } catch {
62
+ } catch (err) {
63
+ onSkip(full, `could not be read (${fsErrorCode(err)}) \u2014 not read`);
62
64
  continue;
63
65
  }
64
66
  if (st.isDirectory()) {
@@ -66,8 +68,21 @@ function walk(dir, out, onSkip, unscanned = []) {
66
68
  onSkip(full);
67
69
  continue;
68
70
  }
69
- walk(full, out, onSkip, unscanned);
70
- } else if (UNSCANNED_RE.test(name)) {
71
+ walk(full, out, onSkip, unscanned, seen);
72
+ continue;
73
+ }
74
+ if (!st.isFile()) {
75
+ onSkip(full, "not a regular file (FIFO, socket or device) \u2014 not read");
76
+ continue;
77
+ }
78
+ try {
79
+ accessSync(full, constants.R_OK);
80
+ } catch (err) {
81
+ onSkip(full, `could not be read (${fsErrorCode(err)}) \u2014 not read`);
82
+ continue;
83
+ }
84
+ seen.files++;
85
+ if (UNSCANNED_RE.test(name)) {
71
86
  if (!NOT_PRODUCT_NAME_RE.test(name)) unscanned.push(full);
72
87
  } else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
73
88
  if (name.endsWith(".d.ts") || name.endsWith(".test.ts")) continue;
@@ -75,6 +90,22 @@ function walk(dir, out, onSkip, unscanned = []) {
75
90
  }
76
91
  }
77
92
  }
93
+ function fsErrorCode(err) {
94
+ const code = err?.code;
95
+ return typeof code === "string" ? code : String(err);
96
+ }
97
+ var SubjectUnreadableError = class extends Error {
98
+ target;
99
+ form;
100
+ constructor(target, form) {
101
+ super(
102
+ `mcp-doctor: could not judge ${target} \u2014 ${form}. Nothing was read, so no verdict is given (exit 2).`
103
+ );
104
+ this.name = "SubjectUnreadableError";
105
+ this.target = target;
106
+ this.form = form;
107
+ }
108
+ };
78
109
  function loadFile(path, root) {
79
110
  const raw = readFileSync(path, "utf8");
80
111
  const lines = raw.split("\n").map((r, i) => ({ n: i + 1, raw: r, comment: isCommentLine(r) }));
@@ -103,6 +134,9 @@ function findPackageJson(startDir) {
103
134
  const candidate = join(dir, "package.json");
104
135
  if (existsSync(candidate)) {
105
136
  try {
137
+ if (!statSync(candidate).isFile()) {
138
+ return { path: candidate, json: {}, unreadable: "not a regular file" };
139
+ }
106
140
  const parsed = JSON.parse(readFileSync(candidate, "utf8"));
107
141
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
108
142
  return { path: candidate, json: {}, unreadable: "not a JSON object" };
@@ -119,16 +153,67 @@ function findPackageJson(startDir) {
119
153
  return null;
120
154
  }
121
155
  function loadTree(root, backendRoot) {
122
- const st = statSync(root);
156
+ let st;
157
+ try {
158
+ st = statSync(root);
159
+ } catch (err) {
160
+ const code = fsErrorCode(err);
161
+ throw new SubjectUnreadableError(
162
+ root,
163
+ code === "ENOENT" ? "the path does not exist" : `the path cannot be read (${code})`
164
+ );
165
+ }
123
166
  const baseDir = st.isDirectory() ? root : dirname(root);
124
167
  const filePaths = [];
125
168
  const tree = { root, files: [], packageJson: null, skippedDirs: [] };
126
169
  if (backendRoot !== void 0) tree.backendRoot = backendRoot;
127
170
  const unscanned = [];
128
- if (st.isDirectory()) walk(root, filePaths, (d) => recordSkip(tree, d, root), unscanned);
129
- else if (root.endsWith(".ts") || root.endsWith(".tsx")) filePaths.push(root);
171
+ if (st.isDirectory()) {
172
+ let entries;
173
+ try {
174
+ entries = readdirSync(root);
175
+ } catch (err) {
176
+ throw new SubjectUnreadableError(
177
+ root,
178
+ `the directory cannot be listed (${fsErrorCode(err)})`
179
+ );
180
+ }
181
+ if (entries.length === 0) {
182
+ throw new SubjectUnreadableError(root, "the directory is empty (0 entries)");
183
+ }
184
+ const seen = { files: 0 };
185
+ walk(root, filePaths, (d, reason) => recordSkip(tree, d, root, reason), unscanned, seen);
186
+ for (const p of filePaths) {
187
+ try {
188
+ tree.files.push(loadFile(p, baseDir));
189
+ } catch (err) {
190
+ recordSkip(tree, p, root, `could not be read (${fsErrorCode(err)}) \u2014 not read`);
191
+ seen.files--;
192
+ }
193
+ }
194
+ if (seen.files === 0) {
195
+ const passed = tree.skippedDirs.map((d) => `${d.path} (${d.reason})`).join(", ");
196
+ throw new SubjectUnreadableError(
197
+ root,
198
+ `the walk reached no readable file (${entries.length} entr${entries.length === 1 ? "y" : "ies"}, none a readable file${passed ? `; passed over: ${passed}` : ""})`
199
+ );
200
+ }
201
+ } else if (st.isFile()) {
202
+ if (!root.endsWith(".ts") && !root.endsWith(".tsx")) {
203
+ throw new SubjectUnreadableError(
204
+ root,
205
+ "a file the scan does not read: a target is a source directory or a single .ts/.tsx file"
206
+ );
207
+ }
208
+ try {
209
+ tree.files.push(loadFile(root, baseDir));
210
+ } catch (err) {
211
+ throw new SubjectUnreadableError(root, `the file cannot be read (${fsErrorCode(err)})`);
212
+ }
213
+ } else {
214
+ throw new SubjectUnreadableError(root, "neither a directory nor a regular file");
215
+ }
130
216
  tree.unscannedSource = unscanned.map((p) => relative(baseDir, p).split(sep).join("/"));
131
- tree.files = filePaths.map((p) => loadFile(p, baseDir));
132
217
  tree.packageJson = findPackageJson(baseDir);
133
218
  return tree;
134
219
  }
@@ -423,7 +508,7 @@ function countInCode(file, line, ch) {
423
508
  }
424
509
 
425
510
  // src/detectors/couldNotJudge.ts
426
- import { readFileSync as readFileSync2 } from "fs";
511
+ import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
427
512
  function couldNotJudge(id, anchor, reason, owedVerdict) {
428
513
  return {
429
514
  id,
@@ -451,6 +536,7 @@ function settle(id, decided, unreadable) {
451
536
  function readJsonObject(path) {
452
537
  let value;
453
538
  try {
539
+ if (!statSync2(path).isFile()) return null;
454
540
  value = JSON.parse(readFileSync2(path, "utf8"));
455
541
  } catch {
456
542
  return null;
@@ -477,8 +563,194 @@ function guardUnreadablePackageJson(inner, opts) {
477
563
  };
478
564
  }
479
565
 
566
+ // src/detectors/siteScan.ts
567
+ import { constants as constants2, accessSync as accessSync2, existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
568
+ import { join as join2, relative as relative2, sep as sep2 } from "path";
569
+ function codeSites(tree, re) {
570
+ const out = [];
571
+ const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`);
572
+ for (const file of tree.files) {
573
+ for (const line of file.lines) {
574
+ if (line.comment) continue;
575
+ for (const m of line.raw.matchAll(g)) {
576
+ const col = m.index ?? 0;
577
+ if (isCodeAt(file, line.n, col)) {
578
+ out.push({ file, line, col, match: m, anchor: `${file.rel}:${line.n}` });
579
+ }
580
+ }
581
+ }
582
+ }
583
+ return out;
584
+ }
585
+ var uncommentedCache = /* @__PURE__ */ new WeakMap();
586
+ function uncommentedLines(file) {
587
+ const hit = uncommentedCache.get(file);
588
+ if (hit) return hit;
589
+ const rows = file.lines.map((l) => {
590
+ let s = "";
591
+ for (let c = 0; c < l.raw.length; c++) {
592
+ s += l.comment || isCommentAt(file, l.n, c) ? " " : l.raw[c];
593
+ }
594
+ return s;
595
+ });
596
+ uncommentedCache.set(file, rows);
597
+ return rows;
598
+ }
599
+ function balancedBlock(file, line, col, open, close) {
600
+ const rows = uncommentedLines(file);
601
+ let depth = 0;
602
+ let text = "";
603
+ for (let n = line; n <= file.lines.length; n++) {
604
+ const raw = rows[n - 1];
605
+ const from = n === line ? col : 0;
606
+ for (let c = from; c < raw.length; c++) {
607
+ const ch = raw[c];
608
+ if (ch === open && isCodeAt(file, n, c)) depth++;
609
+ else if (ch === close && isCodeAt(file, n, c)) depth--;
610
+ text += ch;
611
+ if (depth === 0 && ch === close) {
612
+ return { startLine: line, endLine: n, text };
613
+ }
614
+ }
615
+ text += "\n";
616
+ }
617
+ return null;
618
+ }
619
+ function nextOpener(file, line, col, openers = "{[(", maxLines = 3) {
620
+ const rows = uncommentedLines(file);
621
+ for (let n = line; n <= Math.min(file.lines.length, line + maxLines); n++) {
622
+ const raw = rows[n - 1];
623
+ for (let c = n === line ? col : 0; c < raw.length; c++) {
624
+ if (openers.includes(raw[c]) && isCodeAt(file, n, c)) return { line: n, col: c, ch: raw[c] };
625
+ }
626
+ }
627
+ return null;
628
+ }
629
+ function isRecord(v) {
630
+ return typeof v === "object" && v !== null && !Array.isArray(v);
631
+ }
632
+ function baseOf(tree) {
633
+ try {
634
+ return statSync3(tree.root).isDirectory() ? tree.root : join2(tree.root, "..");
635
+ } catch {
636
+ return tree.root;
637
+ }
638
+ }
639
+ function findDiskFiles(tree, pred) {
640
+ const out = [];
641
+ const base = baseOf(tree);
642
+ const walk2 = (dir) => {
643
+ let entries;
644
+ try {
645
+ entries = readdirSync2(dir);
646
+ } catch (err) {
647
+ recordSkip(tree, dir, base, `could not be listed (${fsErrorCode(err)}) \u2014 not read`);
648
+ return;
649
+ }
650
+ for (const name of entries.sort()) {
651
+ const full = join2(dir, name);
652
+ let st;
653
+ try {
654
+ st = statSync3(full);
655
+ } catch (err) {
656
+ recordSkip(tree, full, base, `could not be read (${fsErrorCode(err)}) \u2014 not read`);
657
+ continue;
658
+ }
659
+ if (st.isDirectory()) {
660
+ if (SKIP_DIRS.has(name)) {
661
+ recordSkip(tree, full, base);
662
+ continue;
663
+ }
664
+ walk2(full);
665
+ } else {
666
+ const rel = relative2(base, full).split(sep2).join("/");
667
+ if (!pred(name, rel)) continue;
668
+ if (!st.isFile()) {
669
+ recordSkip(tree, full, base, "not a regular file (FIFO, socket or device) \u2014 not read");
670
+ continue;
671
+ }
672
+ try {
673
+ accessSync2(full, constants2.R_OK);
674
+ } catch (err) {
675
+ recordSkip(tree, full, base, `could not be read (${fsErrorCode(err)}) \u2014 not read`);
676
+ continue;
677
+ }
678
+ out.push({ path: full, rel, name });
679
+ }
680
+ }
681
+ };
682
+ walk2(base);
683
+ return out;
684
+ }
685
+ function readText(path) {
686
+ try {
687
+ if (!statSync3(path).isFile()) return null;
688
+ return readFileSync3(path, "utf8");
689
+ } catch {
690
+ return null;
691
+ }
692
+ }
693
+ function readJsonRecord(path) {
694
+ const text = readText(path);
695
+ if (text === null) return null;
696
+ try {
697
+ const v = JSON.parse(text);
698
+ return isRecord(v) ? v : null;
699
+ } catch {
700
+ return null;
701
+ }
702
+ }
703
+ function proseWordCount(markdown) {
704
+ const noFences = markdown.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, " ");
705
+ const noInline = noFences.replace(/`[^`\n]*`/g, " ");
706
+ return (noInline.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length;
707
+ }
708
+ function lineHits(tree, re) {
709
+ const out = [];
710
+ for (const file of tree.files) {
711
+ const rows = uncommentedLines(file);
712
+ rows.forEach((text, i) => {
713
+ if (re.test(text)) {
714
+ out.push({ file, line: i + 1, anchor: `${file.rel}:${i + 1}`, text });
715
+ }
716
+ });
717
+ }
718
+ return out;
719
+ }
720
+ function enclosingBlock(file, line, col, levels = 0) {
721
+ const rows = uncommentedLines(file);
722
+ let curLine = line;
723
+ let curCol = col;
724
+ let result = null;
725
+ for (let level = 0; level <= levels; level++) {
726
+ let depth = 0;
727
+ let found = null;
728
+ outer: for (let n = curLine; n >= 1; n--) {
729
+ const raw = rows[n - 1];
730
+ for (let c = (n === curLine ? curCol : raw.length) - 1; c >= 0; c--) {
731
+ if (!isCodeAt(file, n, c)) continue;
732
+ if (raw[c] === "}") depth++;
733
+ else if (raw[c] === "{") {
734
+ if (depth === 0) {
735
+ found = { l: n, c };
736
+ break outer;
737
+ }
738
+ depth--;
739
+ }
740
+ }
741
+ }
742
+ if (!found) return result;
743
+ const b = balancedBlock(file, found.l, found.c, "{", "}");
744
+ if (!b) return null;
745
+ result = { ...b, openLine: found.l, openCol: found.c };
746
+ curLine = found.l;
747
+ curCol = found.c;
748
+ }
749
+ return result;
750
+ }
751
+
480
752
  // src/detectors/standardPin.ts
481
- var STANDARD_PIN = "b714129a110237c5521d5269ee61ab919408a17f";
753
+ var STANDARD_PIN = "6d463a1ecc5256cd59dd1afe5aa5e335accacc90";
482
754
 
483
755
  // src/detectors/dLocalIdentity.ts
484
756
  var CLOUD_IDENTITY_PACKAGE = "@vantageos/cloud-identity";
@@ -601,21 +873,155 @@ function literalHoldsIdentity(m) {
601
873
  const arg = unfold(m[2]);
602
874
  return m[1].split(",").map((e) => unfold(e.trim())).some((e) => e !== "" && !isLiteral(e) && !isLiteral(arg) && comparesIdentity(e, arg));
603
875
  }
604
- function digestsStaticInput(m, ctx) {
876
+ function scopesOf(ctx) {
877
+ const out = [];
878
+ for (let level = 0; level < 6; level++) {
879
+ const b = enclosingBlock(ctx.file, ctx.line.n, ctx.idx, level);
880
+ if (!b || out.length > 0 && b.text === out[out.length - 1]) break;
881
+ out.push(b.text);
882
+ }
883
+ return out;
884
+ }
885
+ var escRe = (x) => x.replace(/[$]/g, "\\$&");
886
+ function exprUntilSemicolon(text, from) {
887
+ let depth = 0;
888
+ for (let i = from; i < text.length; i++) {
889
+ const c = text[i];
890
+ if (c === '"' || c === "'" || c === "`") {
891
+ for (i++; i < text.length && text[i] !== c; i++) if (text[i] === "\\") i++;
892
+ continue;
893
+ }
894
+ if (c === "(" || c === "[" || c === "{") depth++;
895
+ else if (c === ")" || c === "]" || c === "}") {
896
+ if (depth === 0) return text.slice(from, i).trim();
897
+ depth--;
898
+ } else if (c === ";" && depth === 0) return text.slice(from, i).trim();
899
+ }
900
+ return text.slice(from).trim();
901
+ }
902
+ function bindingOf(name, scopes) {
903
+ const n = escRe(name);
904
+ for (const text of scopes) {
905
+ const decl = new RegExp(String.raw`\b(?:const|let|var)\s+${n}\s*(?::[^=;]+)?=\s*`).exec(text);
906
+ if (decl) return { kind: "value", expr: exprUntilSemicolon(text, decl.index + decl[0].length) };
907
+ const loop = new RegExp(String.raw`\bfor\s*\(\s*(?:const|let|var)\s+${n}\s+of\s+`).exec(text);
908
+ if (loop) {
909
+ const inner = exprUntilSemicolon(text, loop.index + loop[0].length);
910
+ return { kind: "element", expr: inner };
911
+ }
912
+ }
913
+ return null;
914
+ }
915
+ function functionBody(file, name) {
916
+ const text = uncommentedLines(file).join("\n");
917
+ const decl = new RegExp(String.raw`\bfunction\s+${escRe(name)}\s*\(`).exec(text);
918
+ if (!decl) return null;
919
+ const params = callArgs(text, decl.index + decl[0].length);
920
+ if (!params) return null;
921
+ return blockFrom(text, params.end);
922
+ }
923
+ function isLocalListing(e, ctx, scopes, depth) {
924
+ if (depth > 4) return false;
925
+ const x = e.trim().replace(/^await\s+/, "");
926
+ if (/^(?:[\w$]+\s*\.\s*)*(?:readdirSync|readdir)\s*\(/.test(x)) return true;
927
+ const call = x.match(/^([A-Za-z_$][\w$]*)\s*\(/);
928
+ if (call) {
929
+ const body = functionBody(ctx.file, call[1]);
930
+ return body !== null && /\b(?:readdirSync|readdir)\s*\(/.test(body);
931
+ }
932
+ if (/^[A-Za-z_$][\w$]*$/.test(x)) {
933
+ const b = bindingOf(x, scopes);
934
+ return b !== null && b.kind === "value" && isLocalListing(b.expr, ctx, scopes, depth + 1);
935
+ }
936
+ return false;
937
+ }
938
+ function templateParts(e) {
939
+ if (!/^`[\s\S]*`$/.test(e)) return null;
940
+ const parts = [];
941
+ for (let i = 1; i < e.length - 1; i++) {
942
+ if (e[i] === "\\") {
943
+ i++;
944
+ continue;
945
+ }
946
+ if (e[i] === "$" && e[i + 1] === "{") {
947
+ let depth = 0;
948
+ let j = i + 1;
949
+ for (; j < e.length; j++) {
950
+ if (e[j] === "{") depth++;
951
+ else if (e[j] === "}" && --depth === 0) break;
952
+ }
953
+ parts.push(e.slice(i + 2, j));
954
+ i = j;
955
+ }
956
+ }
957
+ return parts;
958
+ }
959
+ function isLocalContent(e, ctx, scopes, depth) {
960
+ if (depth > 4) return false;
961
+ const x = e.trim().replace(/^await\s+/, "");
962
+ if (isStaticInput(x, ctx.statics)) return true;
963
+ const fsRead = x.match(/^(?:[\w$]+\s*\.\s*)*(?:readFileSync|readFile)\s*\(/);
964
+ if (fsRead) {
965
+ const a = callArgs(x, fsRead[0].length);
966
+ return a !== null && a.end === x.length;
967
+ }
968
+ const wrapped = x.match(/^(?:new\s+TextEncoder\s*\(\s*\)\s*\.\s*encode|Buffer\s*\.\s*from)\s*\(/);
969
+ if (wrapped) {
970
+ const a = callArgs(x, wrapped[0].length);
971
+ return a !== null && a.end === x.length && isLocalContent(a.args[0], ctx, scopes, depth + 1);
972
+ }
973
+ const tpl = templateParts(x);
974
+ if (tpl) return tpl.every((p) => isLocalContent(p, ctx, scopes, depth + 1));
975
+ const ch = x.match(/^(?:[\w$]+\s*\.\s*)?createHash\s*\(/);
976
+ if (ch) {
977
+ const c = callArgs(x, ch[0].length);
978
+ const up = c ? x.slice(c.end).match(/^\s*\.\s*update\s*\(/) : null;
979
+ const d = c && up ? callArgs(x, c.end + up[0].length) : null;
980
+ return d !== null && isLocalContent(d.args[0], ctx, scopes, depth + 1);
981
+ }
982
+ const sd = x.match(/^(?:[\w$]+\s*\.\s*)*subtle\s*\.\s*digest\s*\(/);
983
+ if (sd) {
984
+ const c = callArgs(x, sd[0].length);
985
+ return c !== null && c.args.length >= 2 && isLocalContent(c.args[1], ctx, scopes, depth + 1);
986
+ }
987
+ if (/^[A-Za-z_$][\w$]*$/.test(x)) {
988
+ const b = bindingOf(x, scopes);
989
+ if (!b) return false;
990
+ return b.kind === "element" ? isLocalListing(b.expr, ctx, scopes, depth + 1) : isLocalContent(b.expr, ctx, scopes, depth + 1);
991
+ }
992
+ return false;
993
+ }
994
+ function digestsNonCredentialInput(m, ctx) {
605
995
  if (!ctx) return false;
606
996
  const text = textFrom(ctx);
607
997
  const open = text.indexOf("(");
608
998
  if (open < 0) return false;
609
999
  const call = callArgs(text, open + 1);
610
1000
  if (!call) return false;
1001
+ const scopes = scopesOf(ctx);
1002
+ const local = (e) => isLocalContent(e, ctx, scopes, 0);
611
1003
  if (/^subtle/.test(m[0])) {
612
- return m[1] === "digest" && call.args.length >= 2 && isStaticInput(call.args[1], ctx.statics);
1004
+ return m[1] === "digest" && call.args.length >= 2 && local(call.args[1]);
613
1005
  }
614
1006
  if (!/^createHash/.test(m[0])) return false;
615
1007
  const update = text.slice(call.end).match(/^\s*\.\s*update\s*\(/);
616
- if (!update) return false;
617
- const data = callArgs(text, call.end + update[0].length);
618
- return data !== null && isStaticInput(data.args[0], ctx.statics);
1008
+ if (update) {
1009
+ const data = callArgs(text, call.end + update[0].length);
1010
+ return data !== null && local(data.args[0]);
1011
+ }
1012
+ const held = ctx.line.raw.slice(0, ctx.idx).match(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*$/);
1013
+ if (!held || scopes.length === 0) return false;
1014
+ const v = escRe(held[1]);
1015
+ const scope = scopes[0];
1016
+ const updates = [...scope.matchAll(new RegExp(String.raw`\b${v}\s*\.\s*update\s*\(`, "g"))];
1017
+ if (updates.length === 0) return false;
1018
+ const digests = [...scope.matchAll(new RegExp(String.raw`\b${v}\s*\.\s*digest\s*\(`, "g"))];
1019
+ const uses = [...scope.matchAll(new RegExp(String.raw`(?<![\w$.])${v}(?![\w$])`, "g"))];
1020
+ if (uses.length !== 1 + updates.length + digests.length) return false;
1021
+ return updates.every((u) => {
1022
+ const a = callArgs(scope, (u.index ?? 0) + u[0].length);
1023
+ return a !== null && local(a.args[0]);
1024
+ });
619
1025
  }
620
1026
  var RENAMEABLE = [
621
1027
  "createHash",
@@ -856,10 +1262,11 @@ var MATCHERS = [
856
1262
  // Any digest of a presented credential, whatever the algorithm.
857
1263
  id: "local-digest",
858
1264
  category: "validates-credential",
859
- // A digest over static content (a literal, or a constant made of literals) is
860
- // not a digest of a credential. HMAC and sign/verify always take a key: a site.
1265
+ // A digest over static content (a literal, or a constant made of literals) or over local
1266
+ // content (file bytes, a listing, a digest of them: a content fingerprint of the tree) is not a
1267
+ // digest of a credential. HMAC and sign/verify always take a key: a site.
861
1268
  re: /\bcreateHash\s*\(|\bcreateHmac\s*\(|\bsubtle\s*\.\s*(digest|sign|verify)\s*\(/,
862
- accept: (m, ctx) => !digestsStaticInput(m, ctx),
1269
+ accept: (m, ctx) => !digestsNonCredentialInput(m, ctx),
863
1270
  sample: 'const h = await crypto.subtle.digest("SHA-256", data);'
864
1271
  },
865
1272
  // ── Minting / signing: shown, not counted (category "mints-token") ──────────
@@ -1188,178 +1595,6 @@ function makeLocalIdentity(opts = {}) {
1188
1595
  }
1189
1596
  var localIdentity = makeLocalIdentity();
1190
1597
 
1191
- // src/detectors/siteScan.ts
1192
- import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
1193
- import { join as join2, relative as relative2, sep as sep2 } from "path";
1194
- function codeSites(tree, re) {
1195
- const out = [];
1196
- const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`);
1197
- for (const file of tree.files) {
1198
- for (const line of file.lines) {
1199
- if (line.comment) continue;
1200
- for (const m of line.raw.matchAll(g)) {
1201
- const col = m.index ?? 0;
1202
- if (isCodeAt(file, line.n, col)) {
1203
- out.push({ file, line, col, match: m, anchor: `${file.rel}:${line.n}` });
1204
- }
1205
- }
1206
- }
1207
- }
1208
- return out;
1209
- }
1210
- var uncommentedCache = /* @__PURE__ */ new WeakMap();
1211
- function uncommentedLines(file) {
1212
- const hit = uncommentedCache.get(file);
1213
- if (hit) return hit;
1214
- const rows = file.lines.map((l) => {
1215
- let s = "";
1216
- for (let c = 0; c < l.raw.length; c++) {
1217
- s += l.comment || isCommentAt(file, l.n, c) ? " " : l.raw[c];
1218
- }
1219
- return s;
1220
- });
1221
- uncommentedCache.set(file, rows);
1222
- return rows;
1223
- }
1224
- function balancedBlock(file, line, col, open, close) {
1225
- const rows = uncommentedLines(file);
1226
- let depth = 0;
1227
- let text = "";
1228
- for (let n = line; n <= file.lines.length; n++) {
1229
- const raw = rows[n - 1];
1230
- const from = n === line ? col : 0;
1231
- for (let c = from; c < raw.length; c++) {
1232
- const ch = raw[c];
1233
- if (ch === open && isCodeAt(file, n, c)) depth++;
1234
- else if (ch === close && isCodeAt(file, n, c)) depth--;
1235
- text += ch;
1236
- if (depth === 0 && ch === close) {
1237
- return { startLine: line, endLine: n, text };
1238
- }
1239
- }
1240
- text += "\n";
1241
- }
1242
- return null;
1243
- }
1244
- function nextOpener(file, line, col, openers = "{[(", maxLines = 3) {
1245
- const rows = uncommentedLines(file);
1246
- for (let n = line; n <= Math.min(file.lines.length, line + maxLines); n++) {
1247
- const raw = rows[n - 1];
1248
- for (let c = n === line ? col : 0; c < raw.length; c++) {
1249
- if (openers.includes(raw[c]) && isCodeAt(file, n, c)) return { line: n, col: c, ch: raw[c] };
1250
- }
1251
- }
1252
- return null;
1253
- }
1254
- function isRecord(v) {
1255
- return typeof v === "object" && v !== null && !Array.isArray(v);
1256
- }
1257
- function baseOf(tree) {
1258
- try {
1259
- return statSync2(tree.root).isDirectory() ? tree.root : join2(tree.root, "..");
1260
- } catch {
1261
- return tree.root;
1262
- }
1263
- }
1264
- function findDiskFiles(tree, pred) {
1265
- const out = [];
1266
- const base = baseOf(tree);
1267
- const walk2 = (dir) => {
1268
- let entries;
1269
- try {
1270
- entries = readdirSync2(dir);
1271
- } catch {
1272
- return;
1273
- }
1274
- for (const name of entries.sort()) {
1275
- const full = join2(dir, name);
1276
- let st;
1277
- try {
1278
- st = statSync2(full);
1279
- } catch {
1280
- continue;
1281
- }
1282
- if (st.isDirectory()) {
1283
- if (SKIP_DIRS.has(name)) {
1284
- recordSkip(tree, full, base);
1285
- continue;
1286
- }
1287
- walk2(full);
1288
- } else {
1289
- const rel = relative2(base, full).split(sep2).join("/");
1290
- if (pred(name, rel)) out.push({ path: full, rel, name });
1291
- }
1292
- }
1293
- };
1294
- walk2(base);
1295
- return out;
1296
- }
1297
- function readText(path) {
1298
- try {
1299
- return readFileSync3(path, "utf8");
1300
- } catch {
1301
- return null;
1302
- }
1303
- }
1304
- function readJsonRecord(path) {
1305
- const text = readText(path);
1306
- if (text === null) return null;
1307
- try {
1308
- const v = JSON.parse(text);
1309
- return isRecord(v) ? v : null;
1310
- } catch {
1311
- return null;
1312
- }
1313
- }
1314
- function proseWordCount(markdown) {
1315
- const noFences = markdown.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, " ");
1316
- const noInline = noFences.replace(/`[^`\n]*`/g, " ");
1317
- return (noInline.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length;
1318
- }
1319
- function lineHits(tree, re) {
1320
- const out = [];
1321
- for (const file of tree.files) {
1322
- const rows = uncommentedLines(file);
1323
- rows.forEach((text, i) => {
1324
- if (re.test(text)) {
1325
- out.push({ file, line: i + 1, anchor: `${file.rel}:${i + 1}`, text });
1326
- }
1327
- });
1328
- }
1329
- return out;
1330
- }
1331
- function enclosingBlock(file, line, col, levels = 0) {
1332
- const rows = uncommentedLines(file);
1333
- let curLine = line;
1334
- let curCol = col;
1335
- let result = null;
1336
- for (let level = 0; level <= levels; level++) {
1337
- let depth = 0;
1338
- let found = null;
1339
- outer: for (let n = curLine; n >= 1; n--) {
1340
- const raw = rows[n - 1];
1341
- for (let c = (n === curLine ? curCol : raw.length) - 1; c >= 0; c--) {
1342
- if (!isCodeAt(file, n, c)) continue;
1343
- if (raw[c] === "}") depth++;
1344
- else if (raw[c] === "{") {
1345
- if (depth === 0) {
1346
- found = { l: n, c };
1347
- break outer;
1348
- }
1349
- depth--;
1350
- }
1351
- }
1352
- }
1353
- if (!found) return result;
1354
- const b = balancedBlock(file, found.l, found.c, "{", "}");
1355
- if (!b) return null;
1356
- result = { ...b, openLine: found.l, openCol: found.c };
1357
- curLine = found.l;
1358
- curCol = found.c;
1359
- }
1360
- return result;
1361
- }
1362
-
1363
1598
  // src/detectors/a2-declared-domains.ts
1364
1599
  function stripComments(text) {
1365
1600
  return text.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
@@ -1518,7 +1753,7 @@ var a2DeclaredDomains = {
1518
1753
  };
1519
1754
 
1520
1755
  // src/detectors/v080Kit.ts
1521
- import { existsSync as existsSync3, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1756
+ import { existsSync as existsSync3, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
1522
1757
  import { dirname as dirname2, join as join3 } from "path";
1523
1758
 
1524
1759
  // src/detectors/toolContext.ts
@@ -1936,47 +2171,75 @@ function findUnresolvedToolSites(tree) {
1936
2171
  const kept = out.filter((a) => !wrappers.internal.has(a));
1937
2172
  return [...kept, ...wrappers.unresolved];
1938
2173
  }
1939
- function findListReadBuilders(tree) {
1940
- const out = [];
1941
- for (const file of tree.files) {
1942
- let currentTool = null;
1943
- let currentToolLine = 0;
1944
- let awaitingName = false;
1945
- for (const line of file.lines) {
1946
- if (line.comment) continue;
1947
- const raw = line.raw;
1948
- const inline = raw.match(TOOL_INLINE_LITERAL_RE);
1949
- if (inline) {
1950
- currentTool = inline[1];
1951
- currentToolLine = line.n;
1952
- awaitingName = false;
1953
- } else if (OPENER_RE.test(raw)) {
1954
- awaitingName = true;
1955
- } else if (awaitingName) {
1956
- const bare = raw.match(BARE_NAME_RE);
1957
- if (bare) {
1958
- currentTool = bare[1];
1959
- currentToolLine = line.n;
1960
- }
1961
- awaitingName = false;
1962
- }
1963
- const desc = raw.match(DESCRIPTOR_NAME_RE);
1964
- if (desc) {
1965
- currentTool = desc[1];
1966
- currentToolLine = line.n;
1967
- }
1968
- if (CONTENT_RE.test(raw) && currentTool && LIST_READ_RE.test(currentTool)) {
1969
- out.push({
1970
- file,
1971
- toolName: currentTool,
1972
- toolNameLine: currentToolLine,
1973
- contentLine: line.n
1974
- });
1975
- }
2174
+ function findListReadBuilders(tree) {
2175
+ const bindings = resolveNameBindings(tree);
2176
+ const extents = [];
2177
+ const endOf = (startLine, startCol, text) => {
2178
+ const lines = text.split("\n");
2179
+ const last = lines.length - 1;
2180
+ return [startLine + last, last === 0 ? startCol + text.length - 1 : lines[last].length - 1];
2181
+ };
2182
+ for (const site of codeSites(tree, /(?:\.tool|\bregisterTool)\s*\(/g)) {
2183
+ const open = site.col + site.match[0].lastIndexOf("(");
2184
+ const b = balancedBlock(site.file, site.line.n, open, "(", ")");
2185
+ if (!b) continue;
2186
+ const first = firstArgument(b.text.slice(1, -1));
2187
+ const lit = first.match(/^["'`]([^"'`]+)["'`]$/);
2188
+ const ident = first.match(/^([A-Za-z_$][\w$]*)(?:\.name)?$/);
2189
+ const name = lit ? lit[1] : ident ? bindings.get(ident[1]) ?? null : null;
2190
+ extents.push({
2191
+ file: site.file,
2192
+ name,
2193
+ nameLine: site.line.n,
2194
+ from: [site.line.n, open],
2195
+ to: endOf(site.line.n, open, b.text)
2196
+ });
2197
+ }
2198
+ for (const site of codeSites(tree, new RegExp(DESCRIPTOR_NAME_RE.source, "g"))) {
2199
+ const b = enclosingBlock(site.file, site.line.n, site.col);
2200
+ if (!b) continue;
2201
+ extents.push({
2202
+ file: site.file,
2203
+ name: site.match[1],
2204
+ nameLine: site.line.n,
2205
+ from: [b.openLine, b.openCol],
2206
+ to: endOf(b.openLine, b.openCol, b.text)
2207
+ });
2208
+ }
2209
+ const before = (a, b) => a[0] < b[0] || a[0] === b[0] && a[1] <= b[1];
2210
+ const out = [];
2211
+ for (const site of codeSites(tree, new RegExp(CONTENT_RE.source, "g"))) {
2212
+ const at = [site.line.n, site.col];
2213
+ let owner = null;
2214
+ for (const e of extents) {
2215
+ if (e.file !== site.file || !before(e.from, at) || !before(at, e.to)) continue;
2216
+ if (!owner || before(owner.from, e.from)) owner = e;
2217
+ }
2218
+ if (owner?.name && LIST_READ_RE.test(owner.name)) {
2219
+ out.push({
2220
+ file: site.file,
2221
+ toolName: owner.name,
2222
+ toolNameLine: owner.nameLine,
2223
+ contentLine: site.line.n
2224
+ });
1976
2225
  }
1977
2226
  }
1978
2227
  return out;
1979
2228
  }
2229
+ function firstArgument(inner) {
2230
+ let depth = 0;
2231
+ for (let i = 0; i < inner.length; i++) {
2232
+ const c = inner[i];
2233
+ if (c === '"' || c === "'" || c === "`") {
2234
+ for (i++; i < inner.length && inner[i] !== c; i++) if (inner[i] === "\\") i++;
2235
+ continue;
2236
+ }
2237
+ if (c === "(" || c === "[" || c === "{") depth++;
2238
+ else if (c === ")" || c === "]" || c === "}") depth--;
2239
+ else if (c === "," && depth === 0) return inner.slice(0, i).trim();
2240
+ }
2241
+ return inner.trim();
2242
+ }
1980
2243
  var SDK_FIRST_RE = /\bregisterTool\s*\(|\.tool\s*\(|new\s+McpServer\s*\(/;
1981
2244
  function sdkFirstAnchor(tree) {
1982
2245
  for (const file of tree.files) {
@@ -2381,16 +2644,29 @@ function governingWorkflows(tree) {
2381
2644
  const wf = join3(dir, ".github", "workflows");
2382
2645
  if (existsSync3(wf)) {
2383
2646
  const out = [];
2384
- for (const name of readdirSync3(wf).sort()) {
2647
+ const base = baseOf(tree);
2648
+ let names;
2649
+ try {
2650
+ names = readdirSync3(wf).sort();
2651
+ } catch (err) {
2652
+ recordSkip(tree, wf, base, `could not be listed (${fsErrorCode(err)}) \u2014 not read`);
2653
+ return out;
2654
+ }
2655
+ for (const name of names) {
2385
2656
  if (!/\.ya?ml$/.test(name)) continue;
2386
2657
  const full = join3(wf, name);
2387
2658
  try {
2388
- if (!statSync3(full).isFile()) continue;
2389
- } catch {
2659
+ if (!statSync4(full).isFile()) {
2660
+ recordSkip(tree, full, base, "not a regular file (FIFO, socket or device) \u2014 not read");
2661
+ continue;
2662
+ }
2663
+ } catch (err) {
2664
+ recordSkip(tree, full, base, `could not be read (${fsErrorCode(err)}) \u2014 not read`);
2390
2665
  continue;
2391
2666
  }
2392
2667
  const text = readText(full);
2393
2668
  if (text !== null) out.push({ rel: `.github/workflows/${name}`, text });
2669
+ else recordSkip(tree, full, base, "could not be read \u2014 not read");
2394
2670
  }
2395
2671
  return out;
2396
2672
  }
@@ -2699,7 +2975,7 @@ var a4WcagGate = {
2699
2975
  };
2700
2976
 
2701
2977
  // src/detectors/a5-performance-budgets.ts
2702
- import { readFileSync as readFileSync4, readdirSync as readdirSync4, statSync as statSync4 } from "fs";
2978
+ import { readFileSync as readFileSync4, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
2703
2979
  import { join as join4, relative as relative3, sep as sep3 } from "path";
2704
2980
  import { gzipSync } from "zlib";
2705
2981
  var BUDGET_BYTES = 250 * 1024;
@@ -2735,7 +3011,7 @@ function collect(base) {
2735
3011
  const candidate = BUNDLE_EXT_RE.test(name) && UI_PATH_RE.test(rel);
2736
3012
  let st;
2737
3013
  try {
2738
- st = statSync4(full);
3014
+ st = statSync5(full);
2739
3015
  } catch {
2740
3016
  if (candidate) unmeasured.push(`${rel} (cannot be read: dangling link or permission)`);
2741
3017
  continue;
@@ -2743,6 +3019,10 @@ function collect(base) {
2743
3019
  if (st.isDirectory()) {
2744
3020
  if (!WALK_SKIP.has(name)) walk2(full, depth + 1);
2745
3021
  } else if (candidate) {
3022
+ if (!st.isFile()) {
3023
+ unmeasured.push(`${rel} (not a regular file, not read)`);
3024
+ continue;
3025
+ }
2746
3026
  if (st.size > MAX_MEASURE_BYTES) {
2747
3027
  unmeasured.push(`${rel} (${(st.size / 1048576).toFixed(0)} MB, too large to measure)`);
2748
3028
  continue;
@@ -3996,7 +4276,7 @@ var a12FileWrite = {
3996
4276
  };
3997
4277
 
3998
4278
  // src/detectors/c7-artifact-verifiable.ts
3999
- import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
4279
+ import { existsSync as existsSync4, readFileSync as readFileSync5, statSync as statSync6 } from "fs";
4000
4280
  import { join as join5 } from "path";
4001
4281
  var PIN = STANDARD_PIN;
4002
4282
  var DOC_FILES = ["README.md", "README.fr.md"];
@@ -4034,13 +4314,18 @@ function c7PositiveControls() {
4034
4314
  }
4035
4315
  };
4036
4316
  }
4037
- function readDocText(root) {
4317
+ function readDocText(root, unreadable) {
4038
4318
  for (const name of DOC_FILES) {
4039
4319
  const path = join5(root, name);
4040
4320
  if (!existsSync4(path)) continue;
4041
4321
  try {
4322
+ if (!statSync6(path).isFile()) {
4323
+ unreadable.push({ anchor: name, reason: `${name} is not a regular file \u2014 not read` });
4324
+ continue;
4325
+ }
4042
4326
  return { text: readFileSync5(path, "utf8"), file: name };
4043
- } catch {
4327
+ } catch (err) {
4328
+ unreadable.push({ anchor: name, reason: `${name} could not be read (${fsErrorCode(err)})` });
4044
4329
  }
4045
4330
  }
4046
4331
  return null;
@@ -4071,7 +4356,15 @@ var c7ArtifactVerifiable = {
4071
4356
  `C7 channel-matcher control FAILED on \`${controls.channelMatcher.sample}\` \u2014 matcher broken, verdict untrustworthy`
4072
4357
  );
4073
4358
  }
4074
- const doc = readDocText(tree.root);
4359
+ const unreadable = [];
4360
+ const doc = readDocText(tree.root, unreadable);
4361
+ if (unreadable.length > 0) {
4362
+ return couldNotJudge(
4363
+ this.id,
4364
+ unreadable[0].anchor,
4365
+ `${unreadable.map((u) => u.reason).join("; ")} \u2014 the install, pin, channel and provenance propositions are unjudged (C-7)`
4366
+ );
4367
+ }
4075
4368
  if (!doc) {
4076
4369
  return {
4077
4370
  id: this.id,
@@ -4224,6 +4517,7 @@ var VERB_SET = /* @__PURE__ */ new Set([
4224
4517
  "get",
4225
4518
  "search",
4226
4519
  "aggregate",
4520
+ "prepare",
4227
4521
  "create",
4228
4522
  "update",
4229
4523
  "delete",
@@ -4239,6 +4533,7 @@ function c9PositiveControls() {
4239
4533
  return {
4240
4534
  fieldMatcher: field,
4241
4535
  setAcceptsCreate: VERB_SET.has("create"),
4536
+ setAcceptsPrepare: VERB_SET.has("prepare"),
4242
4537
  setRejectsRpc: !VERB_SET.has("rpc")
4243
4538
  };
4244
4539
  }
@@ -4253,9 +4548,9 @@ var c9VerbDeclared = {
4253
4548
  `C9 positive control FAILED: pattern /${controls.fieldMatcher.pattern}/ did not match sample \`${controls.fieldMatcher.sample}\` \u2014 matcher broken, verdict untrustworthy`
4254
4549
  );
4255
4550
  }
4256
- if (!controls.setAcceptsCreate || !controls.setRejectsRpc) {
4551
+ if (!controls.setAcceptsCreate || !controls.setAcceptsPrepare || !controls.setRejectsRpc) {
4257
4552
  throw new Error(
4258
- "C9 closed-set control FAILED: VERB_SET must accept a legitimate verb (create) and reject the banned rpc sentinel \u2014 verdict untrustworthy"
4553
+ "C9 closed-set control FAILED: VERB_SET must accept the legitimate verbs create and prepare and reject the banned rpc sentinel \u2014 verdict untrustworthy"
4259
4554
  );
4260
4555
  }
4261
4556
  const unresolved = findUnresolvedToolSites(tree);
@@ -4302,7 +4597,7 @@ var c9VerbDeclared = {
4302
4597
  id: this.id,
4303
4598
  anchor: t.anchor,
4304
4599
  state: "non-conforming",
4305
- 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)`
4600
+ verdict: `tool "${t.name}" declares verb "${t.verb}", outside the closed set (positive-control OK) -> use one of ${[...VERB_SET].join("/")} (C-9)`
4306
4601
  };
4307
4602
  }
4308
4603
  }
@@ -4475,17 +4770,14 @@ var c11Pagination = {
4475
4770
  };
4476
4771
  },
4477
4772
  /**
4478
- * DYNAMIC — confirms the tool(s) the static shape considers paginable are
4479
- * genuinely SERVED by the running server (a real runtime fact from the
4480
- * tools/list snapshot). Does NOT perform the invalid-cursor -> -32602
4481
- * round trip: the one-shot RuntimeContext snapshot carries no per-tool
4482
- * schema and no `callTool` verb — that half needs `Session.callTool` with a
4483
- * cursor argument (documented gap, types.ts `Session`). The verdict names
4484
- * this explicitly rather than folding it into a silent full "present".
4773
+ * DYNAMIC — the invalid-cursor round trip, on each surface where a cursor arrives (C-11 (a) and
4774
+ * (b)). Red on any probe answered in the wrong form; could-not-judge on a probe that got no
4775
+ * answer, was not sent, or recorded an outcome this code cannot read; present only when every
4776
+ * probe answered in its surface's form.
4485
4777
  */
4486
4778
  runDynamic(runtime) {
4487
4779
  if (runtime.launchError) {
4488
- const reason = `server could not be started/introspected \u2014 runtime pagination applicability unverifiable: ${runtime.launchError} (C-11)`;
4780
+ const reason = `server could not be started/introspected \u2014 the invalid-cursor round trip could not run: ${runtime.launchError} (C-11)`;
4489
4781
  return {
4490
4782
  id: this.id,
4491
4783
  anchor: `${runtime.root} (runtime)`,
@@ -4494,24 +4786,117 @@ var c11Pagination = {
4494
4786
  verdict: `could not judge: ${reason}`
4495
4787
  };
4496
4788
  }
4497
- const paginable = runtime.tools.filter((t) => PAGINABLE_RE.test(t.name));
4789
+ const paginable = runtime.tools.filter((t) => PAGINABLE_RE.test(t.name) || t.cursorArgument);
4498
4790
  if (paginable.length === 0) {
4499
4791
  return {
4500
4792
  id: this.id,
4501
4793
  anchor: `${runtime.root} (runtime tools/list)`,
4502
4794
  state: "absent",
4503
- verdict: "server advertises no list/read/get/search tool at runtime; pagination check n/a"
4795
+ verdict: "server advertises no list/read/get/search tool and no tool taking a cursor at runtime; pagination check n/a"
4796
+ };
4797
+ }
4798
+ const anchor = `${runtime.root} (runtime invalid-cursor probes)`;
4799
+ const probes = runtime.cursorProbes;
4800
+ if (!probes || probes.length === 0) {
4801
+ const reason = "no invalid-cursor round trip was recorded for this run: the -32602 / invalid_cursor answers are unread (C-11)";
4802
+ return {
4803
+ id: this.id,
4804
+ anchor,
4805
+ state: "could-not-judge",
4806
+ reason,
4807
+ verdict: `could not judge: ${reason}`
4808
+ };
4809
+ }
4810
+ const red = [];
4811
+ const unread = [];
4812
+ const ok = [];
4813
+ for (const p of probes) {
4814
+ const j = judgeCursorProbe(p);
4815
+ if (j.kind === "red") red.push(j.text);
4816
+ else if (j.kind === "unread") unread.push(j.text);
4817
+ else ok.push(j.text);
4818
+ }
4819
+ if (red.length > 0) {
4820
+ return {
4821
+ id: this.id,
4822
+ anchor,
4823
+ state: "non-conforming",
4824
+ verdict: `${red.join("; ")} -> an invalid cursor on a protocol list method is -32602; passed as a tool argument it is a tool result with isError: true and structuredContent.error.code "invalid_cursor" (SEP-1303) (C-11)`
4825
+ };
4826
+ }
4827
+ if (unread.length > 0) {
4828
+ const reason = `${unread.join("; ")} (C-11)`;
4829
+ return {
4830
+ id: this.id,
4831
+ anchor,
4832
+ state: "could-not-judge",
4833
+ reason,
4834
+ verdict: `could not judge: ${reason}`
4504
4835
  };
4505
4836
  }
4506
- const names = paginable.map((t) => t.name).join(", ");
4837
+ const toolProbes = probes.filter((p) => p.surface === "tool-argument").length;
4507
4838
  return {
4508
4839
  id: this.id,
4509
- anchor: `${runtime.root} (runtime tools/list)`,
4840
+ anchor,
4510
4841
  state: "present",
4511
- 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.`
4842
+ verdict: `${ok.join("; ")}${toolProbes === 0 ? "; no served tool takes a cursor argument, so C-11 (b) has no object on this server" : ""} (C-11)`
4512
4843
  };
4513
4844
  }
4514
4845
  };
4846
+ function judgeCursorProbe(p) {
4847
+ const where = p.surface === "list-method" ? `${p.target} (protocol list method)` : `tool "${p.target}" (cursor argument)`;
4848
+ const o = p.outcome;
4849
+ switch (o.kind) {
4850
+ case "no-answer":
4851
+ return { kind: "unread", text: `${where}: no answer to the invalid cursor (${o.reason})` };
4852
+ case "not-sent":
4853
+ return { kind: "unread", text: `${where}: probe not sent (${o.reason})` };
4854
+ case "error":
4855
+ if (p.surface === "list-method") {
4856
+ return o.code === -32602 ? { kind: "ok", text: `${where}: -32602 on an invalid cursor` } : {
4857
+ kind: "red",
4858
+ text: `${where}: answered an invalid cursor with error ${String(o.code)}, not -32602`
4859
+ };
4860
+ }
4861
+ return {
4862
+ kind: "red",
4863
+ text: `${where}: answered an invalid cursor argument with a protocol error ${String(o.code)} \u2014 a tool input error is a tool result, never a protocol error`
4864
+ };
4865
+ case "result":
4866
+ if (p.surface === "list-method") {
4867
+ return {
4868
+ kind: "red",
4869
+ text: `${where}: answered an invalid cursor with a result (a silent page), not -32602`
4870
+ };
4871
+ }
4872
+ if (!o.isError) {
4873
+ return {
4874
+ kind: "red",
4875
+ text: `${where}: answered an invalid cursor argument with a silent page (no isError)`
4876
+ };
4877
+ }
4878
+ if (o.errorCode === void 0) {
4879
+ return {
4880
+ kind: "red",
4881
+ text: `${where}: isError: true with no structuredContent.error.code (no stable machine code)`
4882
+ };
4883
+ }
4884
+ return o.errorCode === "invalid_cursor" ? {
4885
+ kind: "ok",
4886
+ text: `${where}: isError: true, structuredContent.error.code "invalid_cursor"`
4887
+ } : {
4888
+ kind: "red",
4889
+ text: `${where}: isError: true with structuredContent.error.code ${JSON.stringify(o.errorCode)}, not "invalid_cursor"`
4890
+ };
4891
+ default: {
4892
+ const unknown = o;
4893
+ return {
4894
+ kind: "unread",
4895
+ text: `${where}: an outcome this code cannot read (${JSON.stringify(unknown.kind)})`
4896
+ };
4897
+ }
4898
+ }
4899
+ }
4515
4900
 
4516
4901
  // src/detectors/c12-no-duplicates.ts
4517
4902
  function tripletKey(t) {
@@ -4607,37 +4992,162 @@ var c12NoDuplicates = {
4607
4992
  };
4608
4993
 
4609
4994
  // src/detectors/c13-context-ceiling.ts
4610
- var CEILING_GLOBAL_RE = /\b(?:CONTEXT_CEILING|contextCeiling)\b[\s\S]{0,80}?\bglobal\s*:\s*(\d+)\b/;
4611
- var CEILING_PER_TOOL_RE = /\b(?:CONTEXT_CEILING|contextCeiling)\b[\s\S]{0,80}?\bperTool\s*:\s*(\d+)\b/;
4612
- function findCeiling(tree) {
4995
+ var SERVER_MANIFEST_NAMES = /* @__PURE__ */ new Set(["mcp.json", "mcp-server.json"]);
4996
+ var NAME_RE = /\b(?:CONTEXT_CEILING|contextCeiling)\b/g;
4997
+ var NUM = String.raw`(\d[\d_]*)`;
4998
+ var halfRe = (key) => new RegExp(String.raw`(?:^|[{,\s])["'\x60]?${key}["'\x60]?\s*:\s*([^,}\s][^,}]*)`);
4999
+ function readObjectText(text) {
5000
+ const halves = {};
5001
+ for (const key of ["global", "perTool"]) {
5002
+ const m = text.match(halfRe(key));
5003
+ if (!m) halves[key] = "missing";
5004
+ else {
5005
+ const v = m[1].trim().replace(/\s+as\s+const$/, "");
5006
+ const n = v.match(new RegExp(`^${NUM}$`));
5007
+ halves[key] = n ? Number.parseInt(n[1].replace(/_/g, ""), 10) : v;
5008
+ }
5009
+ }
5010
+ const odd = Object.entries(halves).filter(([, v]) => typeof v === "string" && v !== "missing");
5011
+ if (odd.length > 0) {
5012
+ return { kind: "unreadable", why: odd.map(([k, v]) => `${k}: ${v}`).join(", ") };
5013
+ }
5014
+ const missing = Object.entries(halves).filter(([, v]) => v === "missing").map(([k]) => k);
5015
+ if (missing.length === 2 && /\.\.\./.test(text)) {
5016
+ return { kind: "unreadable", why: `a spread with no literal halves (${text.slice(0, 60)})` };
5017
+ }
5018
+ if (missing.length > 0) return { kind: "partial", missing };
5019
+ return { kind: "pair", global: halves.global, perTool: halves.perTool };
5020
+ }
5021
+ function braceBlock(text) {
5022
+ let depth = 0;
5023
+ for (let i = 0; i < text.length; i++) {
5024
+ if (text[i] === "{") depth++;
5025
+ else if (text[i] === "}" && --depth === 0) return text.slice(0, i + 1);
5026
+ }
5027
+ return null;
5028
+ }
5029
+ function sameFileConstObject(file, name) {
5030
+ const text = uncommentedLines(file).map(codeOnly).join("\n");
5031
+ const m = new RegExp(
5032
+ String.raw`\bconst\s+${name.replace(/\$/g, "\\$")}\s*(?::[^=]+)?=\s*\{`
5033
+ ).exec(text);
5034
+ if (!m) return null;
5035
+ return braceBlock(text.slice(m.index + m[0].length - 1));
5036
+ }
5037
+ function ceilingCensus(tree) {
5038
+ const census = { readable: [], partial: [], unreadable: [] };
5039
+ for (const f of findDiskFiles(tree, (n) => SERVER_MANIFEST_NAMES.has(n))) {
5040
+ const text = readText(f.path);
5041
+ if (text === null) {
5042
+ census.unreadable.push({ anchor: f.rel, reason: `${f.rel} is not readable as text` });
5043
+ continue;
5044
+ }
5045
+ let json;
5046
+ try {
5047
+ json = JSON.parse(text);
5048
+ } catch (e) {
5049
+ census.unreadable.push({
5050
+ anchor: f.rel,
5051
+ reason: `${f.rel} does not parse as JSON (${e instanceof Error ? e.message : String(e)}): a contextCeiling it may declare is unread`
5052
+ });
5053
+ continue;
5054
+ }
5055
+ if (!isRecord(json) || !("contextCeiling" in json)) continue;
5056
+ const anchor = `${f.rel}:${lineOf(text, /"contextCeiling"/)}`;
5057
+ const c = json.contextCeiling;
5058
+ if (!isRecord(c)) {
5059
+ census.partial.push({
5060
+ anchor,
5061
+ form: "manifest",
5062
+ missing: ["global", "perTool (contextCeiling is not an object)"]
5063
+ });
5064
+ continue;
5065
+ }
5066
+ const ok = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
5067
+ const missing = [ok(c.global) ? "" : "global", ok(c.perTool) ? "" : "perTool"].filter(Boolean);
5068
+ if (missing.length > 0) census.partial.push({ anchor, form: "manifest", missing });
5069
+ else
5070
+ census.readable.push({
5071
+ global: c.global,
5072
+ perTool: c.perTool,
5073
+ anchor,
5074
+ form: "manifest"
5075
+ });
5076
+ }
4613
5077
  for (const file of tree.files) {
4614
- for (const line of file.lines) {
4615
- if (line.comment) continue;
4616
- if (/\b(?:CONTEXT_CEILING|contextCeiling)\b/.test(line.raw)) {
4617
- const start = line.n - 1;
4618
- const windowLines = file.lines.slice(start, start + 6).map((l) => l.raw);
4619
- const window = windowLines.join("\n");
4620
- const g = window.match(CEILING_GLOBAL_RE);
4621
- const p = window.match(CEILING_PER_TOOL_RE);
4622
- if (g && p) {
4623
- return {
4624
- global: Number.parseInt(g[1], 10),
4625
- perTool: Number.parseInt(p[1], 10),
4626
- anchor: `${file.rel}:${line.n}`
4627
- };
5078
+ const rows = uncommentedLines(file).map(codeOnly);
5079
+ rows.forEach((row, i) => {
5080
+ for (const hit of row.matchAll(NAME_RE)) {
5081
+ const at = hit.index ?? 0;
5082
+ const anchor = `${file.rel}:${i + 1}`;
5083
+ const before = row.slice(0, at);
5084
+ const window = [row.slice(at + hit[0].length), ...rows.slice(i + 1, i + 9)].join("\n");
5085
+ const rest = window.replace(/^["'\x60]/, "");
5086
+ let value = null;
5087
+ if (/\b(?:const|let|var)\s+$/.test(before)) {
5088
+ const m = rest.match(/^\s*(?::[^=]+?)?=(?![=>])\s*/);
5089
+ if (m) value = rest.slice(m[0].length);
5090
+ } else {
5091
+ const m = rest.match(/^\s*\??:(?!:)\s*/) ?? rest.match(/^\s*=(?![=>])\s*/);
5092
+ if (m) value = rest.slice(m[0].length);
5093
+ }
5094
+ if (value === null) continue;
5095
+ if (/^[A-Z][a-z]\w*\s*(?:[;,)|>=]|$)/.test(value)) continue;
5096
+ let obj = null;
5097
+ if (value.startsWith("{")) obj = braceBlock(value);
5098
+ else {
5099
+ const id = value.match(/^([A-Za-z_$][\w$]*)\s*(?:[;,)}\n]|as\b|$)/);
5100
+ if (id) {
5101
+ obj = sameFileConstObject(file, id[1]);
5102
+ if (obj === null) {
5103
+ census.unreadable.push({
5104
+ anchor,
5105
+ reason: `${anchor} declares ${hit[0]} as \`${id[1]}\`, which is not a same-file const object literal`
5106
+ });
5107
+ continue;
5108
+ }
5109
+ }
5110
+ }
5111
+ if (obj === null) {
5112
+ census.unreadable.push({
5113
+ anchor,
5114
+ reason: `${anchor} declares ${hit[0]} as a computed value (\`${value.split("\n")[0].trim().slice(0, 50)}\`): its numbers are not readable statically`
5115
+ });
5116
+ continue;
4628
5117
  }
5118
+ const r = readObjectText(obj);
5119
+ if (r.kind === "partial" && r.missing.length === 2 && /;/.test(obj)) continue;
5120
+ if (r.kind === "pair")
5121
+ census.readable.push({ global: r.global, perTool: r.perTool, anchor, form: "source" });
5122
+ else if (r.kind === "partial")
5123
+ census.partial.push({ anchor, form: "source", missing: r.missing });
5124
+ else
5125
+ census.unreadable.push({
5126
+ anchor,
5127
+ reason: `${anchor} declares ${hit[0]} with ${r.why}: not numeric literals`
5128
+ });
4629
5129
  }
4630
- }
5130
+ });
4631
5131
  }
4632
- return null;
5132
+ return census;
5133
+ }
5134
+ function agreed(census) {
5135
+ const [first, ...rest] = census.readable;
5136
+ if (!first) return null;
5137
+ return rest.every((c) => c.global === first.global && c.perTool === first.perTool) ? first : null;
5138
+ }
5139
+ function findCeiling(tree) {
5140
+ const c = agreed(ceilingCensus(tree));
5141
+ return c ? { global: c.global, perTool: c.perTool, anchor: c.anchor } : null;
4633
5142
  }
4634
5143
  function c13PositiveControls() {
4635
- const complete = "const CONTEXT_CEILING = { global: 8000, perTool: 800 };";
4636
- const partial = "const CONTEXT_CEILING = { global: 8000 };";
5144
+ const complete = readObjectText("{ global: 8000, perTool: 800 }");
5145
+ const quoted = readObjectText('{ "global": 1200, "perTool": 700 }');
5146
+ const partial = readObjectText("{ global: 8000 }");
4637
5147
  return {
4638
5148
  tokensMatcher: toolAnnotationFieldControls().tokensCtx,
4639
- ceilingAcceptsComplete: CEILING_GLOBAL_RE.test(complete) && CEILING_PER_TOOL_RE.test(complete),
4640
- ceilingRejectsPartial: !(CEILING_GLOBAL_RE.test(partial) && CEILING_PER_TOOL_RE.test(partial))
5149
+ ceilingAcceptsComplete: complete.kind === "pair" && quoted.kind === "pair",
5150
+ ceilingRejectsPartial: partial.kind === "partial"
4641
5151
  };
4642
5152
  }
4643
5153
  var c13ContextCeiling = {
@@ -4653,7 +5163,7 @@ var c13ContextCeiling = {
4653
5163
  }
4654
5164
  if (!controls.ceilingAcceptsComplete || !controls.ceilingRejectsPartial) {
4655
5165
  throw new Error(
4656
- "C13 ceiling control FAILED: must accept a complete (global, perTool) declaration and reject a partial one \u2014 verdict untrustworthy"
5166
+ "C13 ceiling control FAILED: must accept a complete (global, perTool) declaration, bare or quoted keys, and reject a partial one \u2014 verdict untrustworthy"
4657
5167
  );
4658
5168
  }
4659
5169
  const unresolved = findUnresolvedToolSites(tree);
@@ -4678,13 +5188,44 @@ var c13ContextCeiling = {
4678
5188
  }
4679
5189
  const opaque = opaqueAnnotationsAbstain(this.id, "C-13", tools);
4680
5190
  if (opaque) return opaque;
4681
- const ceiling = findCeiling(tree);
4682
- if (!ceiling) {
5191
+ const census = ceilingCensus(tree);
5192
+ const unreadNote = census.unreadable.length > 0 ? `; also seen and not read: ${census.unreadable.map((u) => u.reason).join("; ")}` : "";
5193
+ if (census.readable.length === 0) {
5194
+ if (census.partial.length > 0) {
5195
+ const p = census.partial[0];
5196
+ return {
5197
+ id: this.id,
5198
+ anchor: p.anchor,
5199
+ state: "non-conforming",
5200
+ verdict: `${census.partial.map((x) => `${x.anchor} declares a context ceiling without ${x.missing.join(" and ")}`).join("; ")} (positive-control OK) -> declare both a global and a per-tool ceiling, as positive numbers (C-13)${unreadNote}`
5201
+ };
5202
+ }
5203
+ if (census.unreadable.length > 0) {
5204
+ const reason = `the context ceiling is declared in a form this run cannot read: ${census.unreadable.map((u) => u.reason).join("; ")} (C-13)`;
5205
+ return {
5206
+ id: this.id,
5207
+ anchor: census.unreadable[0].anchor,
5208
+ state: "could-not-judge",
5209
+ reason,
5210
+ verdict: `could not judge: ${reason}`
5211
+ };
5212
+ }
4683
5213
  return {
4684
5214
  id: this.id,
4685
5215
  anchor: tools[0].anchor,
4686
5216
  state: "non-conforming",
4687
- verdict: `${tools.length} tool(s) registered but no (global, perTool) context ceiling declared anywhere (positive-control OK) -> declare CONTEXT_CEILING = { global, perTool } (C-13)`
5217
+ verdict: `${tools.length} tool(s) registered but no (global, perTool) context ceiling declared anywhere \u2014 read: the server manifest's contextCeiling (mcp.json / mcp-server.json) and a CONTEXT_CEILING / contextCeiling const or field in source (positive-control OK) -> declare contextCeiling: { global, perTool } in the server manifest (C-13)`
5218
+ };
5219
+ }
5220
+ const ceiling = agreed(census);
5221
+ if (!ceiling) {
5222
+ const reason = `${census.readable.length} declared context ceilings disagree (${census.readable.map((c) => `${c.anchor} global ${c.global} perTool ${c.perTool}`).join("; ")}): which one governs is unknown (C-13)`;
5223
+ return {
5224
+ id: this.id,
5225
+ anchor: census.readable[0].anchor,
5226
+ state: "could-not-judge",
5227
+ reason,
5228
+ verdict: `could not judge: ${reason}`
4688
5229
  };
4689
5230
  }
4690
5231
  let sum = 0;
@@ -4719,7 +5260,7 @@ var c13ContextCeiling = {
4719
5260
  id: this.id,
4720
5261
  anchor: ceiling.anchor,
4721
5262
  state: "present",
4722
- 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)`
5263
+ verdict: `${tools.length} tool(s) each declare tokensEstimeContexte within the declared per-tool ceiling (${ceiling.perTool}); sum ${sum} within global ceiling ${ceiling.global}, declared at ${ceiling.anchor} (positive-control OK)${unreadNote}`
4723
5264
  };
4724
5265
  }
4725
5266
  };
@@ -4826,7 +5367,7 @@ var c14CatalogRegistration = guardUnreadablePackageJson(
4826
5367
  );
4827
5368
 
4828
5369
  // src/detectors/c15-annotations-untrusted.ts
4829
- var READ_VERBS = /* @__PURE__ */ new Set(["list", "get", "search", "aggregate"]);
5370
+ var READ_VERBS = /* @__PURE__ */ new Set(["list", "get", "search", "aggregate", "prepare"]);
4830
5371
  var WRITE_VERBS = /* @__PURE__ */ new Set([
4831
5372
  "create",
4832
5373
  "update",
@@ -5086,7 +5627,7 @@ var c16UntrustedOutput = {
5086
5627
  };
5087
5628
 
5088
5629
  // src/detectors/c17-supply-chain.ts
5089
- import { existsSync as existsSync5, readFileSync as readFileSync6, readdirSync as readdirSync5 } from "fs";
5630
+ import { existsSync as existsSync5, readFileSync as readFileSync6, readdirSync as readdirSync5, statSync as statSync7 } from "fs";
5090
5631
  import { dirname as dirname3, join as join6, relative as relative4, resolve } from "path";
5091
5632
  var PIN4 = STANDARD_PIN;
5092
5633
  var LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"];
@@ -5110,8 +5651,8 @@ var RECORD_LINE_RE = /^[\s>*_\-+]*Provenance\s*:\s*(.*)$/i;
5110
5651
  var SHASUM_HEX_RE = /\bshasum\b[^\n]*?\b([0-9a-f]{40})\b/i;
5111
5652
  function judgeProvenanceRecord(line, name, version) {
5112
5653
  const body = line.match(RECORD_LINE_RE)?.[1] ?? "";
5113
- const esc2 = (x) => x.replace(/[.+*?^$()[\]{}|\\/]/g, "\\$&");
5114
- const named = new RegExp(`(?:^|[\\s(])${esc2(name)}@v?${esc2(version)}(?![\\w.-]*\\d)`);
5654
+ const esc3 = (x) => x.replace(/[.+*?^$()[\]{}|\\/]/g, "\\$&");
5655
+ const named = new RegExp(`(?:^|[\\s(])${esc3(name)}@v?${esc3(version)}(?![\\w.-]*\\d)`);
5115
5656
  if (!named.test(body)) {
5116
5657
  return { ok: false, why: `it does not name ${name}@${version}, the artifact being published` };
5117
5658
  }
@@ -5120,15 +5661,26 @@ function judgeProvenanceRecord(line, name, version) {
5120
5661
  }
5121
5662
  return { ok: true, why: "" };
5122
5663
  }
5123
- function findProvenanceRecord(root, name, version) {
5664
+ function findProvenanceRecord(root, name, version, unreadable) {
5124
5665
  let firstBad = null;
5125
5666
  for (const recordFile of PROVENANCE_RECORD_FILES) {
5126
5667
  const path = join6(root, recordFile);
5127
5668
  if (!existsSync5(path)) continue;
5128
5669
  let text;
5129
5670
  try {
5671
+ if (!statSync7(path).isFile()) {
5672
+ unreadable.push({
5673
+ anchor: recordFile,
5674
+ reason: `${recordFile} is not a regular file \u2014 not read`
5675
+ });
5676
+ continue;
5677
+ }
5130
5678
  text = readFileSync6(path, "utf8");
5131
- } catch {
5679
+ } catch (err) {
5680
+ unreadable.push({
5681
+ anchor: recordFile,
5682
+ reason: `${recordFile} could not be read (${fsErrorCode(err)})`
5683
+ });
5132
5684
  continue;
5133
5685
  }
5134
5686
  const lines = text.split("\n");
@@ -5181,7 +5733,7 @@ function findLockfile(root) {
5181
5733
  }
5182
5734
  return null;
5183
5735
  }
5184
- function findProvenanceClaim(tree, root) {
5736
+ function findProvenanceClaim(tree, root, unreadable) {
5185
5737
  const pkg = tree.packageJson;
5186
5738
  if (pkg) {
5187
5739
  const publishConfig = pkg.json.publishConfig;
@@ -5195,16 +5747,29 @@ function findProvenanceClaim(tree, root) {
5195
5747
  let entries = [];
5196
5748
  try {
5197
5749
  entries = readdirSync5(workflowsDir).sort();
5198
- } catch {
5750
+ } catch (err) {
5751
+ unreadable.push({
5752
+ anchor: relative4(root, workflowsDir) || WORKFLOW_DIR,
5753
+ reason: `${relative4(root, workflowsDir) || WORKFLOW_DIR} cannot be listed (${fsErrorCode(err)})`
5754
+ });
5199
5755
  continue;
5200
5756
  }
5201
5757
  for (const name of entries) {
5202
5758
  if (!/\.ya?ml$/.test(name)) continue;
5203
5759
  const path = join6(workflowsDir, name);
5204
5760
  let text;
5761
+ const shown = `${relative4(root, workflowsDir) || WORKFLOW_DIR}/${name}`;
5205
5762
  try {
5763
+ if (!statSync7(path).isFile()) {
5764
+ unreadable.push({ anchor: shown, reason: `${shown} is not a regular file \u2014 not read` });
5765
+ continue;
5766
+ }
5206
5767
  text = readFileSync6(path, "utf8");
5207
- } catch {
5768
+ } catch (err) {
5769
+ unreadable.push({
5770
+ anchor: shown,
5771
+ reason: `${shown} could not be read (${fsErrorCode(err)})`
5772
+ });
5208
5773
  continue;
5209
5774
  }
5210
5775
  const hit = workflowStatements(text).find(([, l]) => PROVENANCE_RE2.test(l));
@@ -5253,10 +5818,11 @@ var c17SupplyChainInner = {
5253
5818
  verdict: "no committed lockfile (package-lock.json/pnpm-lock.yaml/yarn.lock/bun.lock) found -> dependencies are not pinned; commit a lockfile (C-17a)"
5254
5819
  };
5255
5820
  }
5256
- const claim = findProvenanceClaim(tree, tree.root);
5821
+ const unreadable = [];
5822
+ const claim = findProvenanceClaim(tree, tree.root, unreadable);
5257
5823
  const pkgVersion = typeof tree.packageJson?.json.version === "string" ? tree.packageJson.json.version : void 0;
5258
5824
  const pkgName = typeof tree.packageJson?.json.name === "string" ? tree.packageJson.json.name : void 0;
5259
- const record = claim ? null : findProvenanceRecord(tree.root, pkgName, pkgVersion);
5825
+ const record = claim ? null : findProvenanceRecord(tree.root, pkgName, pkgVersion, unreadable);
5260
5826
  if (!claim && record?.ok) {
5261
5827
  return {
5262
5828
  id: this.id,
@@ -5273,6 +5839,13 @@ var c17SupplyChainInner = {
5273
5839
  verdict: `lockfile ${lockfile} present and a \`Provenance:\` line at ${record.site}, but ${record.why} -> a record covers only the version it names and must carry the reproduced 40-hex shasum (C-17, non-CI path; proposed evidence form)`
5274
5840
  };
5275
5841
  }
5842
+ if (!claim && !record && unreadable.length > 0) {
5843
+ return couldNotJudge(
5844
+ this.id,
5845
+ unreadable[0].anchor,
5846
+ `no provenance claim in the readable places, but ${unreadable.length} candidate(s) were present and unread \u2014 ${unreadable.map((u) => u.reason).join("; ")} (C-17b)`
5847
+ );
5848
+ }
5276
5849
  if (!claim) {
5277
5850
  return {
5278
5851
  id: this.id,
@@ -5294,7 +5867,7 @@ var c17SupplyChain = guardUnreadablePackageJson(c17SupplyChainInner, {
5294
5867
  });
5295
5868
 
5296
5869
  // src/detectors/c18-structure-manifest.ts
5297
- import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
5870
+ import { existsSync as existsSync6, readFileSync as readFileSync7, statSync as statSync8 } from "fs";
5298
5871
  import { join as join7 } from "path";
5299
5872
  var PIN5 = STANDARD_PIN;
5300
5873
  function manifestPath(root) {
@@ -5302,7 +5875,7 @@ function manifestPath(root) {
5302
5875
  if (existsSync6(extPath)) return { kind: "extension", path: extPath };
5303
5876
  return { kind: "server", path: join7(root, "mcp.json") };
5304
5877
  }
5305
- var SERVER_MANIFEST_NAMES = ["mcp.json", "mcp-server.json"];
5878
+ var SERVER_MANIFEST_NAMES2 = ["mcp.json", "mcp-server.json"];
5306
5879
  function isObject(v) {
5307
5880
  return typeof v === "object" && v !== null && !Array.isArray(v);
5308
5881
  }
@@ -5316,19 +5889,34 @@ function classifyRootJson(json) {
5316
5889
  }
5317
5890
  function rootCandidates(root) {
5318
5891
  const out = [];
5319
- for (const name of SERVER_MANIFEST_NAMES) {
5892
+ for (const name of SERVER_MANIFEST_NAMES2) {
5320
5893
  const path = join7(root, name);
5321
5894
  if (!existsSync6(path)) continue;
5322
- const json = readJson(path);
5323
- out.push({ name, path, role: classifyRootJson(json), json });
5895
+ const { json, readError } = readJson(path);
5896
+ out.push({
5897
+ name,
5898
+ path,
5899
+ role: classifyRootJson(json),
5900
+ json,
5901
+ ...readError ? { readError } : {}
5902
+ });
5324
5903
  }
5325
5904
  return out;
5326
5905
  }
5327
5906
  function readJson(path) {
5907
+ let text;
5908
+ try {
5909
+ if (!statSync8(path).isFile()) {
5910
+ return { json: null, readError: "not a regular file \u2014 not read" };
5911
+ }
5912
+ text = readFileSync7(path, "utf8");
5913
+ } catch (err) {
5914
+ return { json: null, readError: `could not be read (${fsErrorCode(err)})` };
5915
+ }
5328
5916
  try {
5329
- return JSON.parse(readFileSync7(path, "utf8"));
5917
+ return { json: JSON.parse(text) };
5330
5918
  } catch {
5331
- return null;
5919
+ return { json: null };
5332
5920
  }
5333
5921
  }
5334
5922
  function c18PositiveControls() {
@@ -5387,6 +5975,14 @@ var c18StructureManifestInner = {
5387
5975
  let manifestName = kind === "extension" ? "extension.json" : "mcp.json";
5388
5976
  if (kind === "server") {
5389
5977
  const cands = rootCandidates(tree.root);
5978
+ const unread = cands.find((c) => c.readError !== void 0);
5979
+ if (unread) {
5980
+ return couldNotJudge(
5981
+ this.id,
5982
+ unread.path,
5983
+ `${unread.name} is present but ${unread.readError} \u2014 the server manifest cannot be judged (C-18)`
5984
+ );
5985
+ }
5390
5986
  const bad = cands.find((c) => c.role === "unparseable");
5391
5987
  if (bad) {
5392
5988
  return {
@@ -5427,7 +6023,15 @@ var c18StructureManifestInner = {
5427
6023
  verdict: "no extension.json found at the tree root -> the extension manifest is required (C-18)"
5428
6024
  };
5429
6025
  }
5430
- manifest = readJson(path);
6026
+ const read = readJson(path);
6027
+ if (read.readError !== void 0) {
6028
+ return couldNotJudge(
6029
+ this.id,
6030
+ path,
6031
+ `${manifestName} is present but ${read.readError} \u2014 the manifest cannot be judged (C-18)`
6032
+ );
6033
+ }
6034
+ manifest = read.json;
5431
6035
  if (!manifest) {
5432
6036
  return {
5433
6037
  id: this.id,
@@ -5872,7 +6476,7 @@ var c21ProgressiveDisclosure = {
5872
6476
  };
5873
6477
 
5874
6478
  // src/detectors/c22-test-ratio-gate.ts
5875
- import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
6479
+ import { existsSync as existsSync7, readFileSync as readFileSync8, statSync as statSync9 } from "fs";
5876
6480
  import { join as join8 } from "path";
5877
6481
  var DOC_FILES2 = ["README.md", "README.fr.md", "CHANGELOG.md"];
5878
6482
  var RATIO_RE = /\b(?:coverage|ratio de tests?|test ratio|couverture)\b[^\n%]{0,40}?(\d{1,3}(?:\.\d+)?\s?%)/i;
@@ -5895,15 +6499,20 @@ function c22PositiveControls() {
5895
6499
  identifierClearsBareRatio: !IDENTIFIER_RE.test(bareSample)
5896
6500
  };
5897
6501
  }
5898
- function findRatioCitations(root) {
6502
+ function findRatioCitations(root, unreadable) {
5899
6503
  const hits = [];
5900
6504
  for (const name of DOC_FILES2) {
5901
6505
  const path = join8(root, name);
5902
6506
  if (!existsSync7(path)) continue;
5903
6507
  let text;
5904
6508
  try {
6509
+ if (!statSync9(path).isFile()) {
6510
+ unreadable.push({ anchor: name, reason: `${name} is not a regular file \u2014 not read` });
6511
+ continue;
6512
+ }
5905
6513
  text = readFileSync8(path, "utf8");
5906
- } catch {
6514
+ } catch (err) {
6515
+ unreadable.push({ anchor: name, reason: `${name} could not be read (${fsErrorCode(err)})` });
5907
6516
  continue;
5908
6517
  }
5909
6518
  const lines = text.split("\n");
@@ -5921,44 +6530,48 @@ var c22TestRatioGate = {
5921
6530
  rule: "C-22",
5922
6531
  pin: STANDARD_PIN,
5923
6532
  run(tree) {
5924
- const controls = c22PositiveControls();
5925
- if (!controls.ratioMatcher.matched || !controls.identifierMatcher.matched) {
5926
- throw new Error(
5927
- `C22 positive control FAILED: ratio=${controls.ratioMatcher.matched} identifier=${controls.identifierMatcher.matched} \u2014 matcher broken, verdict untrustworthy`
5928
- );
5929
- }
5930
- if (!controls.identifierClearsBareRatio) {
5931
- throw new Error(
5932
- "C22 identifier-matcher control FAILED: must NOT flag a bare ratio with no identifier token \u2014 verdict untrustworthy"
5933
- );
5934
- }
5935
- const hits = findRatioCitations(tree.root);
5936
- if (hits.length === 0) {
5937
- return {
5938
- id: this.id,
5939
- anchor: "-",
5940
- state: "absent",
5941
- verdict: "no test/coverage ratio cited in README/CHANGELOG; test-ratio-gate check n/a"
5942
- };
5943
- }
5944
- const bare = hits.find((h) => !h.hasIdentifier);
5945
- if (bare) {
6533
+ const unreadable = [];
6534
+ const decided = (() => {
6535
+ const controls = c22PositiveControls();
6536
+ if (!controls.ratioMatcher.matched || !controls.identifierMatcher.matched) {
6537
+ throw new Error(
6538
+ `C22 positive control FAILED: ratio=${controls.ratioMatcher.matched} identifier=${controls.identifierMatcher.matched} \u2014 matcher broken, verdict untrustworthy`
6539
+ );
6540
+ }
6541
+ if (!controls.identifierClearsBareRatio) {
6542
+ throw new Error(
6543
+ "C22 identifier-matcher control FAILED: must NOT flag a bare ratio with no identifier token \u2014 verdict untrustworthy"
6544
+ );
6545
+ }
6546
+ const hits = findRatioCitations(tree.root, unreadable);
6547
+ if (hits.length === 0) {
6548
+ return {
6549
+ id: this.id,
6550
+ anchor: "-",
6551
+ state: "absent",
6552
+ verdict: "no test/coverage ratio cited in README/CHANGELOG; test-ratio-gate check n/a"
6553
+ };
6554
+ }
6555
+ const bare = hits.find((h) => !h.hasIdentifier);
6556
+ if (bare) {
6557
+ return {
6558
+ id: this.id,
6559
+ anchor: `${bare.file}:${bare.line}`,
6560
+ state: "non-conforming",
6561
+ 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)`
6562
+ };
6563
+ }
6564
+ const withId = hits[0];
6565
+ 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`;
5946
6566
  return {
5947
6567
  id: this.id,
5948
- anchor: `${bare.file}:${bare.line}`,
5949
- state: "non-conforming",
5950
- 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)`
6568
+ anchor: `${withId.file}:${withId.line}`,
6569
+ state: "could-not-judge",
6570
+ reason,
6571
+ verdict: `could not judge: ${reason}`
5951
6572
  };
5952
- }
5953
- const withId = hits[0];
5954
- 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`;
5955
- return {
5956
- id: this.id,
5957
- anchor: `${withId.file}:${withId.line}`,
5958
- state: "could-not-judge",
5959
- reason,
5960
- verdict: `could not judge: ${reason}`
5961
- };
6573
+ })();
6574
+ return settle(this.id, decided, unreadable);
5962
6575
  }
5963
6576
  };
5964
6577
 
@@ -6453,7 +7066,7 @@ import {
6453
7066
  readFileSync as readFileSync9,
6454
7067
  readdirSync as readdirSync6,
6455
7068
  rmSync,
6456
- statSync as statSync5,
7069
+ statSync as statSync10,
6457
7070
  writeFileSync
6458
7071
  } from "fs";
6459
7072
  import { tmpdir } from "os";
@@ -6479,15 +7092,17 @@ function walkForConfigs(dir, depth, seen, onSkip) {
6479
7092
  let entries;
6480
7093
  try {
6481
7094
  entries = readdirSync6(dir);
6482
- } catch {
7095
+ } catch (err) {
7096
+ onSkip(dir, depth, `could not be listed (${fsErrorCode(err)}) \u2014 not read`);
6483
7097
  return;
6484
7098
  }
6485
7099
  for (const name of entries) {
6486
7100
  const full = join9(dir, name);
6487
7101
  let st;
6488
7102
  try {
6489
- st = statSync5(full);
6490
- } catch {
7103
+ st = statSync10(full);
7104
+ } catch (err) {
7105
+ onSkip(full, depth + 1, `could not be read (${fsErrorCode(err)}) \u2014 not read`);
6491
7106
  continue;
6492
7107
  }
6493
7108
  if (st.isDirectory()) {
@@ -6497,6 +7112,10 @@ function walkForConfigs(dir, depth, seen, onSkip) {
6497
7112
  }
6498
7113
  walkForConfigs(full, depth + 1, seen, onSkip);
6499
7114
  } else if (DEPLOY_CONFIG_FILES.includes(name)) {
7115
+ if (!st.isFile()) {
7116
+ onSkip(full, depth + 1, "not a regular file (FIFO, socket or device) \u2014 not read");
7117
+ continue;
7118
+ }
6500
7119
  if (!seen.has(dir)) {
6501
7120
  seen.set(dir, { dir, configFile: name, configPath: full });
6502
7121
  }
@@ -6516,7 +7135,7 @@ function probeForCandidate(dir, depth) {
6516
7135
  const full = join9(dir, name);
6517
7136
  let st;
6518
7137
  try {
6519
- st = statSync5(full);
7138
+ st = statSync10(full);
6520
7139
  } catch {
6521
7140
  continue;
6522
7141
  }
@@ -6558,12 +7177,12 @@ function resolveRailwayRootDirectory(configPath) {
6558
7177
  function c26DeriveScope(root, tree) {
6559
7178
  const seen = /* @__PURE__ */ new Map();
6560
7179
  const skipped = [];
6561
- const onSkip = (skippedDir, depth) => {
6562
- const entry = tree ? recordSkip(tree, skippedDir, root) : {
7180
+ const onSkip = (skippedDir, depth, reason) => {
7181
+ const entry = tree ? recordSkip(tree, skippedDir, root, reason) : {
6563
7182
  path: relative5(root, skippedDir).split(sep4).join("/"),
6564
- reason: SKIP_DIR_INFO[basename2(skippedDir)]?.reason ?? "skipped"
7183
+ reason: reason ?? SKIP_DIR_INFO[basename2(skippedDir)]?.reason ?? "skipped"
6565
7184
  };
6566
- if (SKIP_DIR_INFO[basename2(skippedDir)]?.authored) {
7185
+ if (reason === void 0 && SKIP_DIR_INFO[basename2(skippedDir)]?.authored) {
6567
7186
  const hit = probeForCandidate(skippedDir, depth);
6568
7187
  if (hit) entry.candidate = relative5(root, hit).split(sep4).join("/");
6569
7188
  }
@@ -6689,8 +7308,22 @@ function gateSite(site) {
6689
7308
  }
6690
7309
  const isolated = mkdtempSync(join9(tmpdir(), "mcp-doctor-c26-"));
6691
7310
  try {
6692
- const manifestText = readFileSync9(pkgPath, "utf8");
6693
- const lockText = readFileSync9(join9(site.dir, lock.name), "utf8");
7311
+ let manifestText;
7312
+ let lockText;
7313
+ const lockPath = join9(site.dir, lock.name);
7314
+ try {
7315
+ for (const p of [pkgPath, lockPath]) {
7316
+ if (!statSync10(p).isFile()) throw new Error(`${p} is not a regular file`);
7317
+ }
7318
+ manifestText = readFileSync9(pkgPath, "utf8");
7319
+ lockText = readFileSync9(lockPath, "utf8");
7320
+ } catch (err) {
7321
+ return {
7322
+ site,
7323
+ status: "unreadable",
7324
+ reason: `${site.dir}: package.json or ${lock.name} is present but cannot be read (${err instanceof Error ? err.message : String(err)}) \u2014 the frozen install was not run`
7325
+ };
7326
+ }
6694
7327
  writeFileSync(join9(isolated, "package.json"), manifestText);
6695
7328
  writeFileSync(join9(isolated, lock.name), lockText);
6696
7329
  let proc;
@@ -6751,6 +7384,7 @@ function renderCoverage(scope, verdicts) {
6751
7384
  return verdicts.map((v) => {
6752
7385
  const rel = relative5(process.cwd(), v.site.dir) || v.site.dir;
6753
7386
  if (v.status === "skipped") return `SKIPPED ${rel} (${v.reason})`;
7387
+ if (v.status === "unreadable") return `UNREAD ${rel} (${v.reason})`;
6754
7388
  return `ANALYSED ${rel} -> ${v.ok ? "GREEN" : "RED"}: ${v.detail}`;
6755
7389
  }).join(" | ");
6756
7390
  }
@@ -6793,6 +7427,17 @@ var c26SubpackageLockfile = {
6793
7427
  const verdicts = scope.sites.map(gateSite);
6794
7428
  const inventory = renderCoverage(scope.sites, verdicts);
6795
7429
  const analysed = verdicts.filter((v) => v.status === "analysed");
7430
+ const unread = verdicts.filter((v) => v.status === "unreadable");
7431
+ if (unread.length > 0 && !analysed.some((v) => v.ok === false)) {
7432
+ const reason = `${unread.length} deployable site(s) could not be read \u2014 ${unread.map((v) => v.reason).join("; ")}. Coverage: ${inventory}`;
7433
+ return {
7434
+ id: this.id,
7435
+ anchor: unread[0].site.dir,
7436
+ state: "could-not-judge",
7437
+ reason,
7438
+ verdict: `could not judge: ${reason}`
7439
+ };
7440
+ }
6796
7441
  if (analysed.length === 0) {
6797
7442
  if (hidden.length > 0) return unjudgedHidden("no analysable site; ");
6798
7443
  return {
@@ -6932,22 +7577,21 @@ var c27SubpackageTypes = {
6932
7577
  const workflows = governingWorkflows(tree);
6933
7578
  const reds = [];
6934
7579
  const oks = [];
7580
+ const unreadDocs = [];
6935
7581
  for (const site of tsSites) {
6936
7582
  const siteRel = relative6(base, site.dir) || ".";
6937
7583
  const docs = [];
6938
7584
  const dockerfile = join10(site.dir, "Dockerfile");
6939
7585
  if (existsSync9(dockerfile)) {
6940
- docs.push({
6941
- where: `${siteRel}/Dockerfile`,
6942
- segments: dockerSegments(readText(dockerfile) ?? "")
6943
- });
7586
+ const text = readText(dockerfile);
7587
+ if (text === null) unreadDocs.push(`${siteRel}/Dockerfile`);
7588
+ else docs.push({ where: `${siteRel}/Dockerfile`, segments: dockerSegments(text) });
6944
7589
  }
6945
7590
  const railway = join10(site.dir, "railway.json");
6946
7591
  if (existsSync9(railway)) {
6947
- docs.push({
6948
- where: `${siteRel}/railway.json`,
6949
- segments: railwayBuildCommand(readText(railway) ?? "")
6950
- });
7592
+ const text = readText(railway);
7593
+ if (text === null) unreadDocs.push(`${siteRel}/railway.json`);
7594
+ else docs.push({ where: `${siteRel}/railway.json`, segments: railwayBuildCommand(text) });
6951
7595
  }
6952
7596
  for (const w of workflows) {
6953
7597
  if (siteRel === "." || w.text.includes(siteRel)) {
@@ -6960,6 +7604,7 @@ var c27SubpackageTypes = {
6960
7604
  oks.push(`${ok.d.where} (${siteRel})`);
6961
7605
  continue;
6962
7606
  }
7607
+ if (unreadDocs.some((u) => u.startsWith(`${siteRel}/`))) continue;
6963
7608
  const unfrozen = verdicts.find((v) => v.phase === "unfrozen");
6964
7609
  const early = verdicts.find((v) => v.phase === "before-install");
6965
7610
  const why = unfrozen ? `a type check follows a non-frozen install at ${unfrozen.d.where} (the tree is not the lockfile's: use a frozen install)` : early ? `a type check at ${early.d.where} runs before any install (it checks the workspace's tree, not the isolation's)` : "no type check runs in the deployment isolation (no tsc / typecheck step after a frozen install)";
@@ -6972,6 +7617,13 @@ var c27SubpackageTypes = {
6972
7617
  `${reds.length} TypeScript deployable(s): ${reds.join("; ")} -> run the sub-package type check inside the isolation the deployment installs, after the frozen install (C-27)`
6973
7618
  );
6974
7619
  }
7620
+ if (unreadDocs.length > 0) {
7621
+ return couldNotJudge(
7622
+ this.id,
7623
+ `${unreadDocs[0]}:1`,
7624
+ `${unreadDocs.map((u) => `\`${u}\``).join(", ")} is present but cannot be read (permission, or not a regular file): whether the deployment type-checks in its isolation is unread \u2014 never "no type check" (C-27)`
7625
+ );
7626
+ }
6975
7627
  return couldNotJudge(
6976
7628
  this.id,
6977
7629
  oks[0],
@@ -7201,7 +7853,7 @@ var c30SkillsOrgBound = {
7201
7853
  };
7202
7854
 
7203
7855
  // src/detectors/c31-plugin-installable.ts
7204
- import { existsSync as existsSync10, readdirSync as readdirSync8, statSync as statSync6 } from "fs";
7856
+ import { existsSync as existsSync10, readdirSync as readdirSync8, statSync as statSync11 } from "fs";
7205
7857
  import { basename as basename3, dirname as dirname4, join as join11, relative as relative8, sep as sep5 } from "path";
7206
7858
  var MIN_README_WORDS = 40;
7207
7859
  var KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -7402,12 +8054,17 @@ function judgeFolder(tree, folder, manifestPath2, out) {
7402
8054
  }
7403
8055
  const hasLicenseField = typeof manifest.license === "string" && manifest.license.trim() !== "";
7404
8056
  let hasLicenseFile = false;
8057
+ let folderListed = true;
7405
8058
  try {
7406
8059
  hasLicenseFile = readdirSync8(folder).some((n) => /^LICEN[CS]E/i.test(n));
7407
- } catch {
7408
- hasLicenseFile = false;
8060
+ } catch (err) {
8061
+ folderListed = false;
8062
+ out.unreadable.push({
8063
+ anchor: rel(folder),
8064
+ reason: `${rel(folder)} cannot be listed (${fsErrorCode(err)}) \u2014 whether it carries a LICENSE file is unread`
8065
+ });
7409
8066
  }
7410
- if (!hasLicenseField && !hasLicenseFile) {
8067
+ if (!hasLicenseField && !hasLicenseFile && folderListed) {
7411
8068
  out.reds.push({
7412
8069
  anchor: a,
7413
8070
  text: "no licence: plugin.json has no `license` and the folder has no LICENSE file"
@@ -7415,7 +8072,12 @@ function judgeFolder(tree, folder, manifestPath2, out) {
7415
8072
  }
7416
8073
  const readmePath = join11(folder, "README.md");
7417
8074
  const readme = readText(readmePath);
7418
- if (readme === null) {
8075
+ if (readme === null && existsSync10(readmePath)) {
8076
+ out.unreadable.push({
8077
+ anchor: rel(readmePath),
8078
+ reason: `${rel(readmePath)} is present but cannot be read as text (permission, or not a regular file) \u2014 its length and credential content are unread`
8079
+ });
8080
+ } else if (readme === null) {
7419
8081
  out.reds.push({ anchor: rel(folder), text: "no README.md at the plugin folder root" });
7420
8082
  } else {
7421
8083
  const n = proseWordCount(readme);
@@ -7475,13 +8137,20 @@ function judgeFolder(tree, folder, manifestPath2, out) {
7475
8137
  judgeServers(servers, mcpAnchor, out);
7476
8138
  }
7477
8139
  try {
7478
- if (statSync6(join11(folder, "bin")).isDirectory()) {
8140
+ if (statSync11(join11(folder, "bin")).isDirectory()) {
7479
8141
  out.reds.push({
7480
8142
  anchor: rel(join11(folder, "bin")),
7481
8143
  text: "a top-level bin/ directory (a plugin ships no bin/ at its top level)"
7482
8144
  });
7483
8145
  }
7484
- } catch {
8146
+ } catch (err) {
8147
+ const code = fsErrorCode(err);
8148
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
8149
+ out.unreadable.push({
8150
+ anchor: rel(join11(folder, "bin")),
8151
+ reason: `${rel(join11(folder, "bin"))} could not be probed (${code}) \u2014 whether the plugin ships a top-level bin/ is unread`
8152
+ });
8153
+ }
7485
8154
  }
7486
8155
  const jsons = [[a, manifest]];
7487
8156
  if (servers) jsons.push([mcpAnchor, servers]);
@@ -8261,7 +8930,7 @@ var d7Inner = {
8261
8930
  var d7 = guardUnreadablePackageJson(d7Inner, { authoritative: true });
8262
8931
 
8263
8932
  // src/detectors/d8.ts
8264
- import { existsSync as existsSync11, statSync as statSync7 } from "fs";
8933
+ import { existsSync as existsSync11, statSync as statSync12 } from "fs";
8265
8934
  import { dirname as dirname5, join as join12, relative as relative9 } from "path";
8266
8935
  var POSITIVE_CONTROL2 = 'server.registerTool("list_items", { ... }, handler);';
8267
8936
  function d8PositiveControl() {
@@ -8274,7 +8943,7 @@ function d8PositiveControl() {
8274
8943
  function resolveBaseDir(tree) {
8275
8944
  if (tree.packageJson) return dirname5(tree.packageJson.path);
8276
8945
  try {
8277
- return statSync7(tree.root).isDirectory() ? tree.root : dirname5(tree.root);
8946
+ return statSync12(tree.root).isDirectory() ? tree.root : dirname5(tree.root);
8278
8947
  } catch {
8279
8948
  return tree.root;
8280
8949
  }
@@ -8408,6 +9077,13 @@ var d8 = {
8408
9077
  verdict: "server advertises no tools at runtime -> n/a for eval coverage"
8409
9078
  };
8410
9079
  }
9080
+ if (runtime.evalsUnreadable) {
9081
+ return couldNotJudge(
9082
+ this.id,
9083
+ `${runtime.root} (runtime tools/list)`,
9084
+ `the eval file is present but unread \u2014 ${runtime.evalsUnreadable}; per-tool coverage is unknown, never zero (Server Rule 5)`
9085
+ );
9086
+ }
8411
9087
  const summary = runtime.tools.map((t) => `${t.name}=${t.evalCaseCount ?? 0}`).join(" ");
8412
9088
  const under = runtime.tools.filter((t) => (t.evalCaseCount ?? 0) < 3);
8413
9089
  if (under.length > 0) {
@@ -9033,7 +9709,7 @@ var d15 = {
9033
9709
  };
9034
9710
 
9035
9711
  // src/detectors/d16.ts
9036
- import { existsSync as existsSync14, readFileSync as readFileSync10, statSync as statSync8 } from "fs";
9712
+ import { existsSync as existsSync14, readFileSync as readFileSync10, statSync as statSync13 } from "fs";
9037
9713
  import { dirname as dirname8, join as join15 } from "path";
9038
9714
  var DISCOVER_RE2 = /server\/discover|DiscoverResult|ServerDiscover|handleServerDiscover/;
9039
9715
  var HEALTH_ROUTE_FRAMEWORK_RE = /\b[A-Za-z_$][\w$]*\.(?:get|post|put|patch|delete|all|route|use)\s*\(\s*["'`][^"'`]*\/health/;
@@ -9355,10 +10031,11 @@ var d16 = {
9355
10031
  };
9356
10032
  function readPackageVersion(root) {
9357
10033
  try {
9358
- let dir = statSync8(root).isDirectory() ? root : dirname8(root);
10034
+ let dir = statSync13(root).isDirectory() ? root : dirname8(root);
9359
10035
  for (let depth = 0; depth < 8; depth++) {
9360
10036
  const candidate = join15(dir, "package.json");
9361
10037
  if (existsSync14(candidate)) {
10038
+ if (!statSync13(candidate).isFile()) return null;
9362
10039
  const pkg = JSON.parse(readFileSync10(candidate, "utf8"));
9363
10040
  return typeof pkg.version === "string" ? pkg.version : null;
9364
10041
  }
@@ -9373,7 +10050,7 @@ function readPackageVersion(root) {
9373
10050
  }
9374
10051
 
9375
10052
  // src/detectors/dAppI18n.ts
9376
- import { existsSync as existsSync15, readFileSync as readFileSync11 } from "fs";
10053
+ import { existsSync as existsSync15, readFileSync as readFileSync11, statSync as statSync14 } from "fs";
9377
10054
  import { dirname as dirname9, join as join16 } from "path";
9378
10055
  function flattenKeys(obj, prefix = "") {
9379
10056
  if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
@@ -9407,6 +10084,7 @@ function dAppI18nPositiveControls() {
9407
10084
  }
9408
10085
  function readJson2(path) {
9409
10086
  try {
10087
+ if (!statSync14(path).isFile()) return { ok: false };
9410
10088
  return { ok: true, value: JSON.parse(readFileSync11(path, "utf8")) };
9411
10089
  } catch {
9412
10090
  return { ok: false };
@@ -9605,14 +10283,14 @@ var dDebugOptIn = {
9605
10283
  };
9606
10284
 
9607
10285
  // src/detectors/extManifests.ts
9608
- import { readdirSync as readdirSync9, statSync as statSync9 } from "fs";
10286
+ import { readdirSync as readdirSync9, statSync as statSync15 } from "fs";
9609
10287
  import { join as join17 } from "path";
9610
10288
  function findExtensionManifests(tree) {
9611
10289
  const out = [];
9612
10290
  const root = tree.root;
9613
10291
  let base;
9614
10292
  try {
9615
- base = statSync9(root).isDirectory() ? root : join17(root, "..");
10293
+ base = statSync15(root).isDirectory() ? root : join17(root, "..");
9616
10294
  } catch {
9617
10295
  return out;
9618
10296
  }
@@ -9620,15 +10298,17 @@ function findExtensionManifests(tree) {
9620
10298
  let entries;
9621
10299
  try {
9622
10300
  entries = readdirSync9(dir);
9623
- } catch {
10301
+ } catch (err) {
10302
+ recordSkip(tree, dir, base, `could not be listed (${fsErrorCode(err)}) \u2014 not read`);
9624
10303
  return;
9625
10304
  }
9626
10305
  for (const name of entries) {
9627
10306
  const full = join17(dir, name);
9628
10307
  let st;
9629
10308
  try {
9630
- st = statSync9(full);
9631
- } catch {
10309
+ st = statSync15(full);
10310
+ } catch (err) {
10311
+ recordSkip(tree, full, base, `could not be read (${fsErrorCode(err)}) \u2014 not read`);
9632
10312
  continue;
9633
10313
  }
9634
10314
  if (st.isDirectory()) {
@@ -9638,6 +10318,10 @@ function findExtensionManifests(tree) {
9638
10318
  }
9639
10319
  walk2(full);
9640
10320
  } else if (name === "extension.json") {
10321
+ if (!st.isFile()) {
10322
+ recordSkip(tree, full, base, "not a regular file (FIFO, socket or device) \u2014 not read");
10323
+ continue;
10324
+ }
9641
10325
  out.push(full);
9642
10326
  }
9643
10327
  }
@@ -10254,8 +10938,8 @@ var dExt3 = {
10254
10938
  };
10255
10939
 
10256
10940
  // src/detectors/dExt4.ts
10257
- import { readdirSync as readdirSync10, statSync as statSync10 } from "fs";
10258
- import { join as join19 } from "path";
10941
+ import { readdirSync as readdirSync10, statSync as statSync16 } from "fs";
10942
+ import { join as join19, relative as relative10 } from "path";
10259
10943
  function canonical(name) {
10260
10944
  return name.replace(/[-_]/g, "").toLowerCase();
10261
10945
  }
@@ -10274,28 +10958,39 @@ function dExt4PositiveControls() {
10274
10958
  }
10275
10959
  };
10276
10960
  }
10277
- function findHooksDirs(tree) {
10961
+ function findHooksDirs(tree, unreadable, gaps) {
10278
10962
  const root = tree.root;
10279
10963
  const out = [];
10280
10964
  let base;
10281
10965
  try {
10282
- base = statSync10(root).isDirectory() ? root : join19(root, "..");
10966
+ base = statSync16(root).isDirectory() ? root : join19(root, "..");
10283
10967
  } catch {
10284
10968
  return out;
10285
10969
  }
10970
+ const noteUnread = (path, what, err) => {
10971
+ gaps.count++;
10972
+ const anchor = relative10(base, path) || ".";
10973
+ const reason = `${anchor} ${what} (${fsErrorCode(err)}) \u2014 a hooks/ directory under it may exist unseen, so the implemented hooks are unverifiable (Extension Rule 4)`;
10974
+ if (!unreadable.some((u) => u.anchor === anchor && u.reason === reason)) {
10975
+ unreadable.push({ anchor, reason });
10976
+ }
10977
+ };
10286
10978
  const walk2 = (dir) => {
10287
10979
  let entries;
10288
10980
  try {
10289
10981
  entries = readdirSync10(dir);
10290
- } catch {
10982
+ } catch (err) {
10983
+ noteUnread(dir, "could not be listed", err);
10291
10984
  return;
10292
10985
  }
10293
10986
  for (const name of entries) {
10294
10987
  const full = join19(dir, name);
10295
10988
  let st;
10296
10989
  try {
10297
- st = statSync10(full);
10298
- } catch {
10990
+ st = statSync16(full);
10991
+ } catch (err) {
10992
+ if (fsErrorCode(err) !== "ENOENT" && fsErrorCode(err) !== "ELOOP")
10993
+ noteUnread(full, "could not be read", err);
10299
10994
  continue;
10300
10995
  }
10301
10996
  if (!st.isDirectory()) continue;
@@ -10308,7 +11003,8 @@ function findHooksDirs(tree) {
10308
11003
  let files;
10309
11004
  try {
10310
11005
  files = readdirSync10(full);
10311
- } catch {
11006
+ } catch (err) {
11007
+ noteUnread(full, "could not be listed", err);
10312
11008
  files = [];
10313
11009
  }
10314
11010
  for (const f of files) {
@@ -10386,7 +11082,8 @@ var dExt4 = {
10386
11082
  }
10387
11083
  const unreadable = [];
10388
11084
  const decided = (() => {
10389
- const hooksDirs = findHooksDirs(tree);
11085
+ const hooksDirGaps = { count: 0 };
11086
+ const hooksDirs = findHooksDirs(tree, unreadable, hooksDirGaps);
10390
11087
  const { sites, unresolved } = findFactorySites(tree);
10391
11088
  for (const anchor of unresolved) {
10392
11089
  unreadable.push({
@@ -10430,6 +11127,7 @@ var dExt4 = {
10430
11127
  continue;
10431
11128
  }
10432
11129
  const phantom = [...declared].filter((h) => !implemented.has(h));
11130
+ if (phantom.length > 0 && hooksDirGaps.count > 0) continue;
10433
11131
  if (phantom.length > 0) {
10434
11132
  return {
10435
11133
  id: this.id,
@@ -10817,30 +11515,41 @@ var dExt8 = {
10817
11515
  };
10818
11516
 
10819
11517
  // src/detectors/dExt9.ts
10820
- import { readFileSync as readFileSync13, readdirSync as readdirSync11, statSync as statSync11 } from "fs";
10821
- import { join as join23 } from "path";
10822
- function collectHooksDirSources(tree, unreadable) {
11518
+ import { readFileSync as readFileSync13, readdirSync as readdirSync11, statSync as statSync17 } from "fs";
11519
+ import { join as join23, relative as relative11 } from "path";
11520
+ function collectHooksDirSources(tree, unreadable, gaps) {
10823
11521
  const root = tree.root;
10824
11522
  const out = [];
10825
11523
  let base;
10826
11524
  try {
10827
- base = statSync11(root).isDirectory() ? root : join23(root, "..");
11525
+ base = statSync17(root).isDirectory() ? root : join23(root, "..");
10828
11526
  } catch {
10829
11527
  return out;
10830
11528
  }
11529
+ const noteUnread = (path, what, err) => {
11530
+ gaps.count++;
11531
+ const anchor = relative11(base, path) || ".";
11532
+ const reason = `${anchor} ${what} (${fsErrorCode(err)}) \u2014 a hooks/ directory under it may exist unseen, so the timeout guards are undeterminable (Extension Rule 9)`;
11533
+ if (!unreadable.some((u) => u.anchor === anchor && u.reason === reason)) {
11534
+ unreadable.push({ anchor, reason });
11535
+ }
11536
+ };
10831
11537
  const walk2 = (dir) => {
10832
11538
  let entries;
10833
11539
  try {
10834
11540
  entries = readdirSync11(dir);
10835
- } catch {
11541
+ } catch (err) {
11542
+ noteUnread(dir, "could not be listed", err);
10836
11543
  return;
10837
11544
  }
10838
11545
  for (const name of entries) {
10839
11546
  const full = join23(dir, name);
10840
11547
  let st;
10841
11548
  try {
10842
- st = statSync11(full);
10843
- } catch {
11549
+ st = statSync17(full);
11550
+ } catch (err) {
11551
+ if (fsErrorCode(err) !== "ENOENT" && fsErrorCode(err) !== "ELOOP")
11552
+ noteUnread(full, "could not be read", err);
10844
11553
  continue;
10845
11554
  }
10846
11555
  if (!st.isDirectory()) continue;
@@ -10852,7 +11561,8 @@ function collectHooksDirSources(tree, unreadable) {
10852
11561
  let files;
10853
11562
  try {
10854
11563
  files = readdirSync11(full);
10855
- } catch {
11564
+ } catch (err) {
11565
+ noteUnread(full, "could not be listed", err);
10856
11566
  files = [];
10857
11567
  }
10858
11568
  for (const f of files) {
@@ -10862,6 +11572,7 @@ function collectHooksDirSources(tree, unreadable) {
10862
11572
  const filePath = join23(full, f);
10863
11573
  let body;
10864
11574
  try {
11575
+ if (!statSync17(filePath).isFile()) throw new Error("not a regular file");
10865
11576
  body = readFileSync13(filePath, "utf8");
10866
11577
  } catch {
10867
11578
  const shortPath = filePath.split("/").slice(-2).join("/");
@@ -10965,8 +11676,9 @@ var dExt9 = {
10965
11676
  }
10966
11677
  const unreadable = [];
10967
11678
  const decided = (() => {
11679
+ const hooksDirGaps = { count: 0 };
10968
11680
  const hookSources = [
10969
- ...collectHooksDirSources(tree, unreadable),
11681
+ ...collectHooksDirSources(tree, unreadable, hooksDirGaps),
10970
11682
  ...collectFactorySources(tree)
10971
11683
  ];
10972
11684
  const implByToken = /* @__PURE__ */ new Map();
@@ -11015,6 +11727,7 @@ var dExt9 = {
11015
11727
  const hasImplTimeout = implSources.some((s) => hasImplTimeoutGuard(s.body));
11016
11728
  if (!hasManifestTimeout && !hasImplTimeout) noTimeout.push(hook);
11017
11729
  }
11730
+ if (noTimeout.length > 0 && hooksDirGaps.count > 0) continue;
11018
11731
  if (noTimeout.length > 0) {
11019
11732
  return {
11020
11733
  id: this.id,
@@ -12028,7 +12741,6 @@ var dServer14 = {
12028
12741
  };
12029
12742
 
12030
12743
  // src/detectors/e5-extension-isolation.ts
12031
- import { readFileSync as readFileSync14 } from "fs";
12032
12744
  var GLOBAL_RES = [
12033
12745
  /\b(?:globalThis|global|window|self)\s*(?:\.\s*[\w$]+|\[[^\]]+\])\s*=(?!=)/,
12034
12746
  /Object\.(?:assign|defineProperty|defineProperties|setPrototypeOf)\(\s*(?:globalThis|global|window|self)\b/,
@@ -12059,11 +12771,8 @@ var e5ExtensionIsolation = {
12059
12771
  const manifests = findExtensionManifests(tree);
12060
12772
  const own = /* @__PURE__ */ new Set();
12061
12773
  for (const m of manifests) {
12062
- try {
12063
- const j = JSON.parse(readFileSync14(m, "utf8"));
12064
- if (typeof j.identifier === "string") own.add(j.identifier);
12065
- } catch {
12066
- }
12774
+ const j = readJsonObject(m);
12775
+ if (j && typeof j.identifier === "string") own.add(j.identifier);
12067
12776
  }
12068
12777
  const defined = tree.files.some(
12069
12778
  (f) => isProductSource(f.rel) && uncommentedLines(f).some((r) => /\bdefineExtension\s*\(/.test(r))
@@ -12493,8 +13202,8 @@ var e10MrtrOnly = {
12493
13202
  };
12494
13203
 
12495
13204
  // src/detectors/e11-surfaces-derived.ts
12496
- import { existsSync as existsSync20, statSync as statSync12 } from "fs";
12497
- import { dirname as dirname14, join as join24, relative as relative10, sep as sep6 } from "path";
13205
+ import { existsSync as existsSync20, statSync as statSync18 } from "fs";
13206
+ import { dirname as dirname14, join as join24, relative as relative12, sep as sep6 } from "path";
12498
13207
  var ALL = ["chat", "cowork", "claude-code"];
12499
13208
  function excludedSurfaces(c) {
12500
13209
  const cause = /* @__PURE__ */ new Map();
@@ -12510,10 +13219,17 @@ function excludedSurfaces(c) {
12510
13219
  if (c.localMcp) exclude("chat", "a local MCP command excludes chat");
12511
13220
  return cause;
12512
13221
  }
12513
- function isDir(p) {
13222
+ function isDir(p, anchor, unreadable) {
12514
13223
  try {
12515
- return statSync12(p).isDirectory();
12516
- } catch {
13224
+ return statSync18(p).isDirectory();
13225
+ } catch (err) {
13226
+ const code = fsErrorCode(err);
13227
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
13228
+ unreadable.push({
13229
+ anchor,
13230
+ reason: `${anchor} could not be probed (${code}) \u2014 whether this component exists is unread`
13231
+ });
13232
+ }
12517
13233
  return false;
12518
13234
  }
12519
13235
  }
@@ -12552,7 +13268,7 @@ var e11SurfacesDerived = {
12552
13268
  continue;
12553
13269
  }
12554
13270
  const folder = dirname14(m.path);
12555
- const rel = (p) => relative10(tree.root, p).split(sep6).join("/") || ".";
13271
+ const rel = (p) => relative12(tree.root, p).split(sep6).join("/") || ".";
12556
13272
  if (!Array.isArray(j.surfaces)) {
12557
13273
  reds.push({
12558
13274
  anchor: m.rel,
@@ -12588,9 +13304,9 @@ var e11SurfacesDerived = {
12588
13304
  }
12589
13305
  }
12590
13306
  const cause = excludedSurfaces({
12591
- bin: isDir(join24(folder, "bin")),
12592
- hooks: isDir(join24(folder, "hooks")),
12593
- agents: isDir(join24(folder, "agents")),
13307
+ bin: isDir(join24(folder, "bin"), rel(join24(folder, "bin")), unreadable),
13308
+ hooks: isDir(join24(folder, "hooks"), rel(join24(folder, "hooks")), unreadable),
13309
+ agents: isDir(join24(folder, "agents"), rel(join24(folder, "agents")), unreadable),
12594
13310
  localMcp
12595
13311
  });
12596
13312
  for (const s of declared) {
@@ -14355,6 +15071,7 @@ var TRANSPORT_RE2 = /\bStreamableHTTPServerTransport\b|\bWebStandardStreamableHT
14355
15071
  var ORIGIN_READ_RE = /headers?\.get\(\s*["'`]origin["'`]|headers?\[\s*["'`]origin["'`]\s*\]|headers?\.origin\b|\.header\(\s*["'`]origin["'`]\s*\)/i;
14356
15072
  var PROTECT_FLAG_RE = /enableDnsRebindingProtection\s*:\s*true/;
14357
15073
  var ALLOWED_ORIGINS_RE = /\ballowedOrigins\s*:\s*/g;
15074
+ var TYPE_ANNOTATION_RE = /^(?:readonly\s+)?(?:string\s*\[\s*\]|(?:Readonly)?Array\s*<\s*string\s*>)\s*(?:\|\s*undefined\s*)?[;,]?\s*$/;
14358
15075
  var DISABLED_RE = /enableDnsRebindingProtection\s*:\s*false/;
14359
15076
  var REFUSE_RE = /\b403\b|Forbidden/;
14360
15077
  var WIDE_BIND_RE = /\.listen\s*\([^)]*["'`]0\.0\.0\.0["'`]|\bhost(?:name)?\s*:\s*["'`]0\.0\.0\.0["'`]/;
@@ -14462,12 +15179,14 @@ var s22OriginValidation = {
14462
15179
  );
14463
15180
  }
14464
15181
  };
14465
- function resolveConstLiteral(tree, name) {
15182
+ function resolveConstLiteral(tree, name, file) {
14466
15183
  const re = new RegExp(
14467
15184
  `\\b(?:const|let|var)\\s+${name.replace(/[$]/g, "\\$&")}\\b[^=\\n]*=\\s*`,
14468
15185
  "g"
14469
15186
  );
14470
- for (const site of codeSites(tree, re)) {
15187
+ const sites = codeSites(tree, re);
15188
+ const ordered = file ? [...sites.filter((x) => x.file === file), ...sites.filter((x) => x.file !== file)] : sites;
15189
+ for (const site of ordered) {
14471
15190
  const after = site.col + site.match[0].length;
14472
15191
  const ch = site.line.raw[after];
14473
15192
  if (ch === "[") {
@@ -14483,10 +15202,11 @@ function classifyAllowedOrigins(tree) {
14483
15202
  const out = { sites: 0, first: "", ok: [], reds: [], unresolved: [] };
14484
15203
  for (const site of codeSites(tree, ALLOWED_ORIGINS_RE)) {
14485
15204
  if (!isProductSource(site.file.rel)) continue;
14486
- out.sites++;
14487
- out.first ||= site.anchor;
14488
15205
  const after = site.col + site.match[0].length;
14489
15206
  const rest = site.line.raw.slice(after);
15207
+ if (TYPE_ANNOTATION_RE.test(rest)) continue;
15208
+ out.sites++;
15209
+ out.first ||= site.anchor;
14490
15210
  let value;
14491
15211
  if (rest.startsWith("[")) {
14492
15212
  const b = balancedBlock(site.file, site.line.n, after, "[", "]");
@@ -14494,7 +15214,7 @@ function classifyAllowedOrigins(tree) {
14494
15214
  } else {
14495
15215
  value = (rest.match(/^(?:"[^"]*"|'[^']*'|`[^`]*`|[A-Za-z_$][\w$.]*)/) ?? [rest.trim()])[0];
14496
15216
  }
14497
- const verdict = classifyOriginValue(tree, value);
15217
+ const verdict = classifyOriginValue(tree, value, site.file);
14498
15218
  if (verdict.kind === "wildcard") {
14499
15219
  out.reds.push({
14500
15220
  anchor: site.anchor,
@@ -14513,31 +15233,42 @@ function classifyAllowedOrigins(tree) {
14513
15233
  }
14514
15234
  return out;
14515
15235
  }
14516
- function classifyOriginValue(tree, value, depth = 0) {
14517
- const v = value.trim();
14518
- const str = v.match(/^(["'`])(.*)\1$/);
14519
- if (str)
14520
- return str[2].includes("*") ? { kind: "wildcard", detail: str[0] } : { kind: "ok", detail: str[0] };
14521
- if (v.startsWith("[")) {
14522
- const elements = splitTopLevel(v.slice(1, -1));
14523
- if (elements.length === 0) return { kind: "empty", detail: "[]" };
14524
- let unresolved = "";
14525
- for (const el of elements) {
14526
- const lit = el.match(/^(["'`])(.*)\1$/);
14527
- if (!lit) {
14528
- unresolved ||= el;
14529
- continue;
14530
- }
14531
- if (lit[2].includes("*")) return { kind: "wildcard", detail: lit[0] };
15236
+ function leavesOf(tree, v, file, depth) {
15237
+ const out = { strings: [], unresolved: "" };
15238
+ const t = v.trim();
15239
+ const str = t.match(/^(["'`])(.*)\1$/s);
15240
+ if (str) {
15241
+ out.strings.push(str[0]);
15242
+ return out;
15243
+ }
15244
+ if (t.startsWith("[")) {
15245
+ for (const el of splitTopLevel(t.slice(1, -1))) {
15246
+ const inner = leavesOf(tree, el.startsWith("...") ? el.slice(3) : el, file, depth);
15247
+ out.strings.push(...inner.strings);
15248
+ out.unresolved ||= inner.unresolved;
14532
15249
  }
14533
- return unresolved ? { kind: "unresolved", detail: unresolved } : { kind: "ok", detail: v };
15250
+ return out;
14534
15251
  }
14535
- if (/^[A-Za-z_$][\w$]*$/.test(v) && depth < 3) {
14536
- const lit = resolveConstLiteral(tree, v);
14537
- if (lit === null) return { kind: "unresolved", detail: v };
14538
- return classifyOriginValue(tree, lit, depth + 1);
15252
+ if (/^[A-Za-z_$][\w$]*$/.test(t) && depth < 3) {
15253
+ const lit = resolveConstLiteral(tree, t, file);
15254
+ if (lit === null) {
15255
+ out.unresolved = t;
15256
+ return out;
15257
+ }
15258
+ return leavesOf(tree, lit, file, depth + 1);
14539
15259
  }
14540
- return { kind: "unresolved", detail: v };
15260
+ out.unresolved = t;
15261
+ return out;
15262
+ }
15263
+ function classifyOriginValue(tree, value, file) {
15264
+ const v = value.trim();
15265
+ const leaves = leavesOf(tree, v, file, 0);
15266
+ const wild = leaves.strings.find((x) => x.slice(1, -1).includes("*"));
15267
+ if (wild) return { kind: "wildcard", detail: wild };
15268
+ if (leaves.unresolved) return { kind: "unresolved", detail: leaves.unresolved };
15269
+ if (leaves.strings.length === 0)
15270
+ return { kind: "empty", detail: v.startsWith("[") ? v : `${v} (an empty list)` };
15271
+ return { kind: "ok", detail: v };
14541
15272
  }
14542
15273
 
14543
15274
  // src/detectors/s23-protocol-version-header.ts
@@ -14921,8 +15652,8 @@ var s27ElicitationFloor = {
14921
15652
  };
14922
15653
 
14923
15654
  // src/detectors/s28-claim-forwarded.ts
14924
- import { existsSync as existsSync21, readFileSync as readFileSync15, readdirSync as readdirSync12, statSync as statSync13 } from "fs";
14925
- import { dirname as dirname15, join as join25, relative as relative11 } from "path";
15655
+ import { existsSync as existsSync21, readFileSync as readFileSync14, readdirSync as readdirSync12, statSync as statSync19 } from "fs";
15656
+ import { dirname as dirname15, join as join25, relative as relative13 } from "path";
14926
15657
  var PIN11 = STANDARD_PIN;
14927
15658
  var CLAIM_TOKEN = /\bverified(?:Org|Person)\b/;
14928
15659
  var ANY_CLAIM = /\bverified(?:Actor|Org|Person)\b/g;
@@ -14980,11 +15711,12 @@ function splitTop(m, from, to) {
14980
15711
  if (start < to && m.slice(start, to).trim() !== "") parts.push([start, to]);
14981
15712
  return parts;
14982
15713
  }
14983
- function listTs(dir, out = []) {
15714
+ function listTs(dir, unread, out = []) {
14984
15715
  let names;
14985
15716
  try {
14986
15717
  names = readdirSync12(dir);
14987
- } catch {
15718
+ } catch (err) {
15719
+ unread.push(`${dir} could not be listed (${fsErrorCode(err)})`);
14988
15720
  return out;
14989
15721
  }
14990
15722
  for (const n of names) {
@@ -14992,19 +15724,23 @@ function listTs(dir, out = []) {
14992
15724
  const p = join25(dir, n);
14993
15725
  let st;
14994
15726
  try {
14995
- st = statSync13(p);
14996
- } catch {
15727
+ st = statSync19(p);
15728
+ } catch (err) {
15729
+ unread.push(`${p} could not be read (${fsErrorCode(err)})`);
14997
15730
  continue;
14998
15731
  }
14999
- if (st.isDirectory()) listTs(p, out);
15000
- else if (n.endsWith(".ts") && !n.endsWith(".test.ts") && !n.endsWith(".d.ts")) out.push(p);
15732
+ if (st.isDirectory()) listTs(p, unread, out);
15733
+ else if (n.endsWith(".ts") && !n.endsWith(".test.ts") && !n.endsWith(".d.ts")) {
15734
+ if (!st.isFile()) unread.push(`${p} is not a regular file \u2014 not read`);
15735
+ else out.push(p);
15736
+ }
15001
15737
  }
15002
15738
  return out;
15003
15739
  }
15004
15740
  function discoverBackend(root) {
15005
15741
  let dir = root;
15006
15742
  try {
15007
- if (!statSync13(root).isDirectory()) dir = dirname15(root);
15743
+ if (!statSync19(root).isDirectory()) dir = dirname15(root);
15008
15744
  } catch {
15009
15745
  return void 0;
15010
15746
  }
@@ -15017,22 +15753,23 @@ function discoverBackend(root) {
15017
15753
  }
15018
15754
  return void 0;
15019
15755
  }
15020
- function readDoors(backendRoot) {
15756
+ function readDoors(backendRoot, unread) {
15021
15757
  const doors = /* @__PURE__ */ new Map();
15022
- for (const path of listTs(backendRoot)) {
15758
+ for (const path of listTs(backendRoot, unread)) {
15023
15759
  let text;
15024
15760
  try {
15025
- text = readFileSync15(path, "utf8");
15026
- } catch {
15761
+ text = readFileSync14(path, "utf8");
15762
+ } catch (err) {
15763
+ unread.push(`${path} could not be read (${fsErrorCode(err)})`);
15027
15764
  continue;
15028
15765
  }
15029
15766
  const file = {
15030
15767
  path,
15031
- rel: relative11(backendRoot, path),
15768
+ rel: relative13(backendRoot, path),
15032
15769
  lines: text.split("\n").map((raw, i) => ({ n: i + 1, raw, comment: false }))
15033
15770
  };
15034
15771
  const m = masked(file);
15035
- const module = relative11(backendRoot, path).replace(/\.ts$/, "").split("\\").join("/");
15772
+ const module = relative13(backendRoot, path).replace(/\.ts$/, "").split("\\").join("/");
15036
15773
  const re = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:query|mutation|action)\s*\(/g;
15037
15774
  for (let hit = re.exec(m); hit; hit = re.exec(m)) {
15038
15775
  const open = hit.index + hit[0].length - 1;
@@ -15086,8 +15823,8 @@ function readDoors(backendRoot) {
15086
15823
  return doors;
15087
15824
  }
15088
15825
  function definitionOf(name, files) {
15089
- const esc2 = name.replace(/\$/g, "\\$");
15090
- const re = new RegExp(`(?:\\b(?:const|let|var)\\s+${esc2}\\b|\\bfunction\\s+${esc2}\\b)`);
15826
+ const esc3 = name.replace(/\$/g, "\\$");
15827
+ const re = new RegExp(`(?:\\b(?:const|let|var)\\s+${esc3}\\b|\\bfunction\\s+${esc3}\\b)`);
15091
15828
  for (const f of files) {
15092
15829
  const hit = re.exec(f.m);
15093
15830
  if (!hit) continue;
@@ -15145,7 +15882,9 @@ function scanS28(tree) {
15145
15882
  const scan = { violate: [], conform: [], cnj: [], calls: 0, backend: void 0 };
15146
15883
  const backendRoot = tree.backendRoot ?? discoverBackend(tree.root);
15147
15884
  scan.backend = backendRoot;
15148
- const doors = backendRoot ? readDoors(backendRoot) : void 0;
15885
+ const unread = [];
15886
+ const doors = backendRoot ? readDoors(backendRoot, unread) : void 0;
15887
+ const unreadNote = unread.length > 0 ? `; backend source NOT read: ${unread.join("; ")}` : "";
15149
15888
  const files = tree.files.map((f) => ({ f, text: rawText(f), m: masked(f) }));
15150
15889
  const actorDoors = /* @__PURE__ */ new Set();
15151
15890
  for (const { text } of files) {
@@ -15265,7 +16004,7 @@ function scanS28(tree) {
15265
16004
  if (!info) {
15266
16005
  scan.cnj.push(
15267
16006
  site(
15268
- `forwards no verified claim and the door ${door} is not defined in the supplied backend (${backendRoot})`
16007
+ `forwards no verified claim and the door ${door} is not defined in the supplied backend (${backendRoot})${unreadNote}`
15269
16008
  )
15270
16009
  );
15271
16010
  continue;
@@ -15654,6 +16393,329 @@ var s34AccessRecheck = {
15654
16393
  }
15655
16394
  };
15656
16395
 
16396
+ // src/detectors/s35-scope-in-read.ts
16397
+ var CALL_RE2 = /\b([A-Za-z_$][\w$]*)\s*\.\s*(query|action)\s*\(/g;
16398
+ var NARROW_WORD = /\b(?:org|orgId|orgSlug|organizationId|organisationId|organization|organisation|tenantId|namespace|recipient|recipientId|owner|ownerId|createdBy|assignedTo|oauthCtx|fromAllowList)\b/;
16399
+ var CARRY_WORD = /\b(?:orgId|organizationId|organisationId|tenantId|verifiedOrg|verifiedPerson|verifiedActor|oauthCtx)\b/;
16400
+ var PAGINATION_WORD = /\b(?:nextCursor|truncated|hasMore|total)\b/;
16401
+ function masked2(file) {
16402
+ return file.lines.map((line) => {
16403
+ let row = "";
16404
+ for (let c = 0; c < line.raw.length; c++) {
16405
+ const ch = line.raw[c];
16406
+ row += line.comment ? " " : ch === " " || isCodeAt(file, line.n, c) ? ch : " ";
16407
+ }
16408
+ return row;
16409
+ }).join("\n");
16410
+ }
16411
+ var CLOSE = { "(": ")", "[": "]", "{": "}" };
16412
+ function matchClose2(m, open) {
16413
+ const stack = [];
16414
+ for (let i = open; i < m.length; i++) {
16415
+ const ch = m[i];
16416
+ if (CLOSE[ch] !== void 0) stack.push(CLOSE[ch]);
16417
+ else if (ch === ")" || ch === "]" || ch === "}") {
16418
+ if (stack.pop() !== ch) return -1;
16419
+ if (stack.length === 0) return i;
16420
+ }
16421
+ }
16422
+ return -1;
16423
+ }
16424
+ function splitTop2(m, from, to) {
16425
+ const out = [];
16426
+ let depth = 0;
16427
+ let start = from;
16428
+ for (let i = from; i < to; i++) {
16429
+ const ch = m[i];
16430
+ if (ch === "(" || ch === "[" || ch === "{") depth++;
16431
+ else if (ch === ")" || ch === "]" || ch === "}") depth--;
16432
+ else if (ch === "," && depth === 0) {
16433
+ out.push([start, i]);
16434
+ start = i + 1;
16435
+ }
16436
+ }
16437
+ if (m.slice(start, to).trim() !== "") out.push([start, to]);
16438
+ return out;
16439
+ }
16440
+ function blockEnd(m, at) {
16441
+ let depth = 0;
16442
+ for (let i = at - 1; i >= 0; i--) {
16443
+ const ch = m[i];
16444
+ if (ch === "}") depth++;
16445
+ else if (ch === "{") {
16446
+ if (depth === 0) {
16447
+ const close = matchClose2(m, i);
16448
+ return close < 0 ? m.length : close;
16449
+ }
16450
+ depth--;
16451
+ }
16452
+ }
16453
+ return m.length;
16454
+ }
16455
+ var lineAt2 = (m, offset) => m.slice(0, offset).split("\n").length;
16456
+ var esc2 = (s) => s.replace(/\$/g, "\\$");
16457
+ function boundNames(m, callStart) {
16458
+ const lineStart = m.lastIndexOf("\n", callStart - 1) + 1;
16459
+ const before = m.slice(lineStart, callStart);
16460
+ const simple = before.match(
16461
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:await\s+)?$/
16462
+ );
16463
+ if (simple) return [simple[1]];
16464
+ const destr = before.match(/\b(?:const|let|var)\s+\{([^}]*)\}\s*=\s*(?:await\s+)?$/);
16465
+ if (destr) {
16466
+ return destr[1].split(",").map((p) => p.trim()).filter((p) => p !== "" && !p.startsWith("...")).map((p) => (p.includes(":") ? p.split(":")[1] : p).replace(/=.*$/, "").trim()).filter((p) => /^[A-Za-z_$][\w$]*$/.test(p));
16467
+ }
16468
+ return [];
16469
+ }
16470
+ function inlineNarrowing(m, start, close, end) {
16471
+ let pos = m.slice(0, start).replace(/\bawait\s+$/, "").length;
16472
+ let grouped = 0;
16473
+ for (; ; ) {
16474
+ let depth = 0;
16475
+ let open2 = -1;
16476
+ for (let i = pos - 1; i >= 0; i--) {
16477
+ const ch = m[i];
16478
+ if (ch === ")" || ch === "]" || ch === "}") depth++;
16479
+ else if (ch === "(" || ch === "[" || ch === "{") {
16480
+ if (depth === 0) {
16481
+ open2 = ch === "(" ? i : -2;
16482
+ break;
16483
+ }
16484
+ depth--;
16485
+ } else if (ch === ";" && depth === 0) break;
16486
+ }
16487
+ if (open2 < 0) break;
16488
+ const ident = /([A-Za-z_$][\w$]*)\s*$/.exec(m.slice(0, open2));
16489
+ if (ident) {
16490
+ if (ident[1] === "scopeFilterList" || ident[1] === "scopeFilterGet") {
16491
+ return {
16492
+ narrowing: { offset: open2 - ident[0].trimEnd().length, form: ident[1] }
16493
+ };
16494
+ }
16495
+ break;
16496
+ }
16497
+ if (!/^\s*(?:await\s+)?$/.test(m.slice(open2 + 1, start))) break;
16498
+ grouped++;
16499
+ pos = m.slice(0, open2).replace(/\bawait\s+$/, "").length;
16500
+ }
16501
+ let p = close + 1;
16502
+ for (let g = 0; g < grouped; g++) {
16503
+ const rest = m.slice(p);
16504
+ const skip = /^\s*\)/.exec(rest);
16505
+ if (!skip) return null;
16506
+ p += skip[0].length;
16507
+ }
16508
+ const chain = /^\s*(?:\??\.\s*(?!filter\b|then\b)[A-Za-z_$][\w$]*\s*)*\??\.\s*(filter|then)\s*\(/.exec(
16509
+ m.slice(p, end)
16510
+ );
16511
+ if (!chain) return null;
16512
+ const open = p + chain[0].length - 1;
16513
+ const cbClose = matchClose2(m, open);
16514
+ if (cbClose < 0) return { unreadable: `a .${chain[1]}( whose closing paren is not found` };
16515
+ const argSpans = splitTop2(m, open + 1, cbClose);
16516
+ const cbSpan = argSpans[0];
16517
+ const cb = cbSpan ? m.slice(cbSpan[0], cbSpan[1]) : "";
16518
+ if (chain[1] === "filter") {
16519
+ return NARROW_WORD.test(cb) ? { narrowing: { offset: p + chain[0].indexOf("."), form: ".filter" } } : null;
16520
+ }
16521
+ const head = /^\s*(?:async\s+)?(?:\(\s*([A-Za-z_$][\w$]*)\s*(?::[^)]*)?\)|([A-Za-z_$][\w$]*))\s*=>\s*/.exec(
16522
+ cb
16523
+ ) ?? /^\s*(?:async\s+)?function\s*[\w$]*\s*\(\s*([A-Za-z_$][\w$]*)[^)]*\)\s*(?=\{)/.exec(cb);
16524
+ const param = head ? head[1] ?? head[2] : void 0;
16525
+ if (!head || param === void 0) {
16526
+ return {
16527
+ unreadable: `a .then callback (\`${cb.trim().slice(0, 40)}\`) whose body the scan does not read`
16528
+ };
16529
+ }
16530
+ const bodyFrom = (cbSpan?.[0] ?? 0) + head[0].length;
16531
+ const bodyTo = cbSpan?.[1] ?? bodyFrom;
16532
+ const found = narrowingsOver(m, [param], bodyFrom, bodyTo);
16533
+ return found.length > 0 ? { narrowing: found[0] } : null;
16534
+ }
16535
+ function rebound(m, names, from, to) {
16536
+ const out = [];
16537
+ const region = m.slice(from, to);
16538
+ for (const name of names) {
16539
+ for (const hit of region.matchAll(
16540
+ new RegExp(
16541
+ `\\b(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*(?::[^=]+)?=\\s*${esc2(name)}\\s*(?:;|$)`,
16542
+ "gm"
16543
+ )
16544
+ )) {
16545
+ out.push(hit[1]);
16546
+ }
16547
+ }
16548
+ return out;
16549
+ }
16550
+ function narrowingsOver(m, names, from, to) {
16551
+ const out = [];
16552
+ const region = m.slice(from, to);
16553
+ for (const name of names) {
16554
+ const n = esc2(name);
16555
+ for (const hit of region.matchAll(/\bscopeFilter(List|Get)\s*\(/g)) {
16556
+ const open = from + (hit.index ?? 0) + hit[0].length - 1;
16557
+ const close = matchClose2(m, open);
16558
+ const args = m.slice(open + 1, close < 0 ? to : close);
16559
+ if (new RegExp(`\\b${n}\\b`).test(args)) {
16560
+ out.push({ offset: from + (hit.index ?? 0), form: `scopeFilter${hit[1]}` });
16561
+ }
16562
+ }
16563
+ for (const hit of region.matchAll(
16564
+ new RegExp(`\\b${n}(?:\\s*\\??\\.\\s*[A-Za-z_$][\\w$]*)*\\s*\\??\\.\\s*filter\\s*\\(`, "g")
16565
+ )) {
16566
+ const open = from + (hit.index ?? 0) + hit[0].length - 1;
16567
+ const close = matchClose2(m, open);
16568
+ const cb = m.slice(open + 1, close < 0 ? to : close);
16569
+ if (NARROW_WORD.test(cb)) out.push({ offset: from + (hit.index ?? 0), form: ".filter" });
16570
+ }
16571
+ for (const hit of region.matchAll(
16572
+ new RegExp(
16573
+ `\\bfor\\s*\\(\\s*(?:const|let|var)\\s+[A-Za-z_$][\\w$]*\\s+of\\s+${n}(?:\\s*\\??\\.\\s*[A-Za-z_$][\\w$]*)*\\s*\\)\\s*\\{`,
16574
+ "g"
16575
+ )
16576
+ )) {
16577
+ const open = from + (hit.index ?? 0) + hit[0].length - 1;
16578
+ const close = matchClose2(m, open);
16579
+ const body = m.slice(open + 1, close < 0 ? to : close);
16580
+ for (const cond of body.matchAll(/\bif\s*\(/g)) {
16581
+ const co = open + 1 + (cond.index ?? 0) + cond[0].length - 1;
16582
+ const cc = matchClose2(m, co);
16583
+ if (cc < 0) continue;
16584
+ const after = m.slice(cc + 1, cc + 40);
16585
+ if (NARROW_WORD.test(m.slice(co, cc)) && /^\s*(?:\{\s*)?continue\b/.test(after)) {
16586
+ out.push({ offset: from + (hit.index ?? 0), form: "a skipping branch in a loop" });
16587
+ break;
16588
+ }
16589
+ }
16590
+ }
16591
+ }
16592
+ return out.sort((a, b) => a.offset - b.offset);
16593
+ }
16594
+ function scanS35(tree) {
16595
+ const res = { calls: 0, red: [], cnj: [], ok: [] };
16596
+ for (const file of tree.files) {
16597
+ if (!isProductSource(file.rel)) continue;
16598
+ const m = masked2(file);
16599
+ const raw = file.lines.map((l) => l.raw).join("\n");
16600
+ for (const hit of m.matchAll(CALL_RE2)) {
16601
+ if (!/convex|client/i.test(hit[1])) continue;
16602
+ const start = hit.index ?? 0;
16603
+ const open = start + hit[0].length - 1;
16604
+ const close = matchClose2(m, open);
16605
+ if (close < 0) continue;
16606
+ res.calls++;
16607
+ const anchor = `${file.rel}:${lineAt2(m, start)}`;
16608
+ const parts = splitTop2(m, open + 1, close);
16609
+ const first = parts[0] ? raw.slice(parts[0][0], parts[0][1]) : "";
16610
+ const cast = String.raw`(?:\s+as\s+[\w$.<>[\] ]+)?\s*$`;
16611
+ const lit = new RegExp(String.raw`^\s*["'\x60]([\w/]+:[\w]+)["'\x60]${cast}`).exec(first);
16612
+ const ref = new RegExp(String.raw`^\s*(?:api|internal)\.([\w]+)\.([\w]+)${cast}`).exec(first);
16613
+ const door = lit ? lit[1] : ref ? `${ref[1]}:${ref[2]}` : void 0;
16614
+ if (door === void 0) {
16615
+ res.cnj.push({
16616
+ anchor,
16617
+ note: `${anchor} names its door by an expression (\`${first.trim().slice(0, 40)}\`): which door, and so which arguments it accepts, is unknown`
16618
+ });
16619
+ continue;
16620
+ }
16621
+ const argText = parts.slice(1).map(([a, b]) => m.slice(a, b)).join(",");
16622
+ const carried = CARRY_WORD.test(argText);
16623
+ const names = boundNames(m, start);
16624
+ const end = blockEnd(m, start);
16625
+ const inline = inlineNarrowing(m, start, close, end);
16626
+ const narrowings = [
16627
+ ...inline && "narrowing" in inline ? [inline.narrowing] : [],
16628
+ ...narrowingsOver(m, [...names, ...rebound(m, names, close + 1, end)], close + 1, end)
16629
+ ].sort((a, b) => a.offset - b.offset);
16630
+ if (narrowings.length === 0 && inline && "unreadable" in inline && !carried) {
16631
+ res.cnj.push({
16632
+ anchor,
16633
+ note: `${anchor} ${door} is followed by ${inline.unreadable}: whether it narrows the result is unread`
16634
+ });
16635
+ continue;
16636
+ }
16637
+ if (narrowings.length === 0 || carried) {
16638
+ res.ok.push({
16639
+ anchor,
16640
+ note: names.length === 0 && !inline ? `${anchor} ${door}: result not bound, nothing narrows it` : `${anchor} ${door}`
16641
+ });
16642
+ continue;
16643
+ }
16644
+ const spread = /\.\.\.\s*[A-Za-z_$]/.test(argText);
16645
+ const n = narrowings[0];
16646
+ const at = `${file.rel}:${lineAt2(m, n.offset)}`;
16647
+ if (spread) {
16648
+ res.cnj.push({
16649
+ anchor,
16650
+ note: `${anchor} ${door} spreads its arguments from a value the scan does not read, and ${at} narrows the result (${n.form}): whether the read carried the predicate is unread`
16651
+ });
16652
+ continue;
16653
+ }
16654
+ const paged = PAGINATION_WORD.test(m.slice(n.offset, end));
16655
+ res.red.push({
16656
+ anchor: at,
16657
+ note: `${at} narrows (${n.form}) the rows of ${anchor} ${door}, whose arguments carry no scope predicate${paged ? "; a pagination value (nextCursor/truncated/hasMore/total) is served after the narrowing" : ""}`
16658
+ });
16659
+ }
16660
+ }
16661
+ return res;
16662
+ }
16663
+ function s35Controls() {
16664
+ const red = scanS35Text(
16665
+ 'const rows = await client.query("t:list", { limit });\nconst mine = scopeFilterList(rows, oauthCtx);\n'
16666
+ );
16667
+ const green = scanS35Text(
16668
+ 'const rows = await client.query("t:list", { limit, orgId: oauthCtx.orgId });\nconst mine = scopeFilterList(rows, oauthCtx);\n'
16669
+ );
16670
+ return { sees: red, spares: !green };
16671
+ }
16672
+ function scanS35Text(text) {
16673
+ const call = /\bclient\.query\(([^)]*)\)/.exec(text);
16674
+ if (!call) return false;
16675
+ const carried = CARRY_WORD.test(call[1].split(",").slice(1).join(","));
16676
+ return !carried && narrowingsOver(text, ["rows"], call.index + call[0].length, text.length).length > 0;
16677
+ }
16678
+ var s35ScopeInRead = {
16679
+ id: "S35 scope-in-read",
16680
+ rule: "S-35",
16681
+ pin: STANDARD_PIN,
16682
+ run(tree) {
16683
+ const ctl = s35Controls();
16684
+ if (!ctl.sees || !ctl.spares) {
16685
+ throw new Error(
16686
+ `S35 positive control FAILED: ${JSON.stringify(ctl)} \u2014 the narrowing matcher cannot tell a filter after an unscoped read from a repeated one, verdict untrustworthy`
16687
+ );
16688
+ }
16689
+ const scan = scanS35(tree);
16690
+ if (scan.calls === 0) {
16691
+ return absentResult(
16692
+ this.id,
16693
+ "-",
16694
+ "no backend call (<client>.query|action) in product source (positive control OK) \u2014 S-35 n/a"
16695
+ );
16696
+ }
16697
+ if (scan.red.length > 0) {
16698
+ return redResult(
16699
+ this.id,
16700
+ scan.red[0].anchor,
16701
+ `${scan.red.map((s) => s.note).join("; ")} -> pass the scope predicate to the door as an argument and forward its pagination unchanged (S-35)`
16702
+ );
16703
+ }
16704
+ if (scan.cnj.length > 0) {
16705
+ return couldNotJudge(
16706
+ this.id,
16707
+ scan.cnj[0].anchor,
16708
+ `${scan.cnj.map((s) => s.note).join("; ")} (S-35)`
16709
+ );
16710
+ }
16711
+ return presentResult(
16712
+ this.id,
16713
+ scan.ok[0].anchor,
16714
+ `${scan.calls} backend call(s) read; no scope narrowing over an unscoped result (positive control OK). Not decided here: a narrowing in a helper the scan cannot find, and the door applying the predicate (R-11, R-55) (S-35)`
16715
+ );
16716
+ }
16717
+ };
16718
+
15657
16719
  // src/detectors/index.ts
15658
16720
  var registered = [
15659
16721
  d2,
@@ -15752,6 +16814,7 @@ var registered = [
15752
16814
  s25ResourceDiscipline,
15753
16815
  s26ListChangedParity,
15754
16816
  s27ElicitationFloor,
16817
+ s35ScopeInRead,
15755
16818
  e5ExtensionIsolation,
15756
16819
  a3SandboxRespected,
15757
16820
  a4WcagGate,
@@ -15778,6 +16841,7 @@ var READS_CODE = /* @__PURE__ */ new Set([
15778
16841
  "S-25",
15779
16842
  "S-26",
15780
16843
  "S-27",
16844
+ "S-35",
15781
16845
  "E-5",
15782
16846
  "A-3"
15783
16847
  ]);
@@ -15787,12 +16851,12 @@ var detectors = registered.map(
15787
16851
 
15788
16852
  // src/runtime/launch.ts
15789
16853
  import { spawnSync as spawnSync2 } from "child_process";
15790
- import { existsSync as existsSync22, readFileSync as readFileSync16, statSync as statSync14 } from "fs";
16854
+ import { existsSync as existsSync22, readFileSync as readFileSync15, statSync as statSync20 } from "fs";
15791
16855
  import { dirname as dirname16, isAbsolute, join as join26, resolve as resolve2 } from "path";
15792
16856
  function findPackageDir(target) {
15793
16857
  let dir;
15794
16858
  try {
15795
- dir = statSync14(target).isDirectory() ? target : dirname16(target);
16859
+ dir = statSync20(target).isDirectory() ? target : dirname16(target);
15796
16860
  } catch {
15797
16861
  return null;
15798
16862
  }
@@ -15822,7 +16886,10 @@ function deriveLaunchPlan(target) {
15822
16886
  }
15823
16887
  let pkg;
15824
16888
  try {
15825
- pkg = JSON.parse(readFileSync16(join26(pkgDir, "package.json"), "utf8"));
16889
+ if (!statSync20(join26(pkgDir, "package.json")).isFile()) {
16890
+ return { error: `${join26(pkgDir, "package.json")} is not a regular file \u2014 not read` };
16891
+ }
16892
+ pkg = JSON.parse(readFileSync15(join26(pkgDir, "package.json"), "utf8"));
15826
16893
  } catch (err) {
15827
16894
  return {
15828
16895
  error: `could not parse ${join26(pkgDir, "package.json")}: ${err instanceof Error ? err.message : String(err)}`
@@ -15856,6 +16923,7 @@ function deriveLaunchPlan(target) {
15856
16923
  var INTROSPECTOR = String.raw`
15857
16924
  import { spawn } from "node:child_process";
15858
16925
  const plan = JSON.parse(process.env.MCP_DOCTOR_PLAN);
16926
+ const INVALID_CURSOR = "mcp-doctor:invalid-cursor:%%%";
15859
16927
  const done = (o) => { process.stdout.write(JSON.stringify(o)); process.exit(0); };
15860
16928
  let child;
15861
16929
  try {
@@ -15865,11 +16933,35 @@ try {
15865
16933
  }
15866
16934
  let settled = false;
15867
16935
  let serverInfo;
16936
+ let capabilities = {};
16937
+ let tools;
16938
+ const probes = [];
16939
+ const pending = new Map();
16940
+ let nextId = 10;
16941
+ let probeTimer;
15868
16942
  const fail = (msg) => { if (settled) return; settled = true; try { child.kill(); } catch {} done({ launchError: msg }); };
15869
- const to = setTimeout(() => fail("timeout: server did not answer initialize+tools/list within the deadline"), 12000);
16943
+ const finish = (why) => {
16944
+ if (settled) return; settled = true; clearTimeout(to); clearTimeout(probeTimer);
16945
+ for (const idx of pending.values()) probes[idx].outcome = { kind: "no-answer", reason: why };
16946
+ try { child.kill(); } catch {}
16947
+ done({ tools, serverInfo, cursorProbes: probes });
16948
+ };
16949
+ const to = setTimeout(() => {
16950
+ if (tools) finish("no answer before the introspection deadline");
16951
+ else fail("timeout: server did not answer initialize+tools/list within the deadline");
16952
+ }, 12000);
15870
16953
  child.on("error", (e) => fail("spawn error: " + (e && e.message ? e.message : String(e))));
15871
- child.on("exit", (code) => fail("server exited (code " + code + ") before completing the MCP handshake"));
16954
+ child.on("exit", (code) => {
16955
+ if (tools) finish("the server exited (code " + code + ") before answering");
16956
+ else fail("server exited (code " + code + ") before completing the MCP handshake");
16957
+ });
15872
16958
  const send = (o) => { try { child.stdin.write(JSON.stringify(o) + "\n"); } catch (e) { fail("write failed: " + e.message); } };
16959
+ const probe = (target, surface, method, params) => {
16960
+ const id = nextId++;
16961
+ pending.set(id, probes.length);
16962
+ probes.push({ target, surface });
16963
+ send({ jsonrpc: "2.0", id, method, params });
16964
+ };
15873
16965
  let buf = "";
15874
16966
  child.stdout.on("data", (d) => {
15875
16967
  buf += d.toString();
@@ -15878,6 +16970,8 @@ child.stdout.on("data", (d) => {
15878
16970
  const line = buf.slice(0, i); buf = buf.slice(i + 1);
15879
16971
  if (!line.trim()) continue;
15880
16972
  let msg; try { msg = JSON.parse(line); } catch { continue; }
16973
+ // A request or notification FROM the server carries a method: it is not an answer.
16974
+ if (msg.method !== undefined) continue;
15881
16975
  if (msg.id === 1) {
15882
16976
  // The initialize response's serverInfo is the SAME non-authenticated
15883
16977
  // discovery surface a client reads version from (S-7); captured here
@@ -15885,13 +16979,46 @@ child.stdout.on("data", (d) => {
15885
16979
  // alongside it, additively -- every existing consumer of the
15886
16980
  // tools-only introspector output is unaffected.
15887
16981
  serverInfo = msg.result && msg.result.serverInfo;
16982
+ capabilities = (msg.result && msg.result.capabilities) || {};
15888
16983
  send({ jsonrpc: "2.0", method: "notifications/initialized" });
15889
16984
  send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
15890
16985
  } else if (msg.id === 2) {
15891
- if (settled) return; settled = true; clearTimeout(to);
15892
- 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 }));
15893
- try { child.kill(); } catch {}
15894
- done({ tools, serverInfo });
16986
+ if (settled) return;
16987
+ const served = msg.result && Array.isArray(msg.result.tools) ? msg.result.tools : [];
16988
+ const schemaOf = (t) => (t && t.inputSchema && typeof t.inputSchema === "object" ? t.inputSchema : {});
16989
+ const takesCursor = (t) => {
16990
+ const props = schemaOf(t).properties;
16991
+ return Boolean(props && typeof props === "object" && Object.prototype.hasOwnProperty.call(props, "cursor"));
16992
+ };
16993
+ tools = served.map((t) => ({ name: String(t && t.name), description: t && typeof t.description === "string" ? t.description : undefined, cursorArgument: takesCursor(t) }));
16994
+ const lists = ["tools/list"];
16995
+ if (capabilities.resources) lists.push("resources/list", "resources/templates/list");
16996
+ if (capabilities.prompts) lists.push("prompts/list");
16997
+ for (const m of lists) probe(m, "list-method", m, { cursor: INVALID_CURSOR });
16998
+ for (const t of served) {
16999
+ if (!takesCursor(t)) continue;
17000
+ const name = String(t && t.name);
17001
+ const required = Array.isArray(schemaOf(t).required) ? schemaOf(t).required.filter((r) => r !== "cursor") : [];
17002
+ if (required.length > 0) {
17003
+ probes.push({ target: name, surface: "tool-argument", outcome: { kind: "not-sent", reason: "the tool requires " + required.join(", ") + " beside the cursor; the probe never invents a value for them" } });
17004
+ continue;
17005
+ }
17006
+ probe(name, "tool-argument", "tools/call", { name, arguments: { cursor: INVALID_CURSOR } });
17007
+ }
17008
+ if (pending.size === 0) { finish("no probe pending"); return; }
17009
+ probeTimer = setTimeout(() => finish("no answer within the 5 s probe deadline"), 5000);
17010
+ } else if (pending.has(msg.id)) {
17011
+ const idx = pending.get(msg.id); pending.delete(msg.id);
17012
+ if (msg.error && typeof msg.error === "object") {
17013
+ probes[idx].outcome = { kind: "error", code: msg.error.code, message: typeof msg.error.message === "string" ? msg.error.message : undefined };
17014
+ } else if (msg.result && typeof msg.result === "object") {
17015
+ const sc = msg.result.structuredContent;
17016
+ const err = sc && typeof sc === "object" ? sc.error : undefined;
17017
+ probes[idx].outcome = { kind: "result", isError: msg.result.isError === true, errorCode: err && typeof err === "object" ? err.code : undefined };
17018
+ } else {
17019
+ probes[idx].outcome = { kind: "no-answer", reason: "an answer with neither result nor error" };
17020
+ }
17021
+ if (pending.size === 0) finish("no probe pending");
15895
17022
  }
15896
17023
  }
15897
17024
  });
@@ -15900,29 +17027,41 @@ send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "
15900
17027
  function readEvalCounts(target) {
15901
17028
  const counts = /* @__PURE__ */ new Map();
15902
17029
  const pkgDir = findPackageDir(target) ?? (statSyncSafe(target) ? target : null);
15903
- if (!pkgDir) return counts;
17030
+ if (!pkgDir) return { counts };
15904
17031
  let dir = pkgDir;
15905
17032
  for (let depth = 0; depth < 8; depth++) {
15906
17033
  const candidate = join26(dir, "evals", "evals.json");
15907
17034
  if (existsSync22(candidate)) {
17035
+ let text;
17036
+ try {
17037
+ if (!statSync20(candidate).isFile()) {
17038
+ return { counts, unreadable: `${candidate} is not a regular file \u2014 not read` };
17039
+ }
17040
+ text = readFileSync15(candidate, "utf8");
17041
+ } catch (err) {
17042
+ return {
17043
+ counts,
17044
+ unreadable: `${candidate} could not be read (${fsErrorCode(err)})`
17045
+ };
17046
+ }
15908
17047
  try {
15909
- const parsed = JSON.parse(readFileSync16(candidate, "utf8"));
17048
+ const parsed = JSON.parse(text);
15910
17049
  for (const c of parsed.cases ?? []) {
15911
17050
  if (c && typeof c.tool === "string") counts.set(c.tool, (counts.get(c.tool) ?? 0) + 1);
15912
17051
  }
15913
17052
  } catch {
15914
17053
  }
15915
- return counts;
17054
+ return { counts };
15916
17055
  }
15917
17056
  const parent = dirname16(dir);
15918
17057
  if (parent === dir) break;
15919
17058
  dir = parent;
15920
17059
  }
15921
- return counts;
17060
+ return { counts };
15922
17061
  }
15923
17062
  function statSyncSafe(p) {
15924
17063
  try {
15925
- return statSync14(p).isDirectory();
17064
+ return statSync20(p).isDirectory();
15926
17065
  } catch {
15927
17066
  return false;
15928
17067
  }
@@ -15942,6 +17081,54 @@ function deriveExpectedCommit(cwd) {
15942
17081
  }
15943
17082
  return void 0;
15944
17083
  }
17084
+ function readCursorProbe(raw) {
17085
+ const r = raw ?? {};
17086
+ const target = typeof r.target === "string" ? r.target : "<unnamed>";
17087
+ const surface = r.surface === "tool-argument" ? "tool-argument" : "list-method";
17088
+ const o = r.outcome ?? {};
17089
+ const unreadable = (what) => ({
17090
+ target,
17091
+ surface,
17092
+ outcome: { kind: "no-answer", reason: `unreadable probe record (${what})` }
17093
+ });
17094
+ if (r.surface !== "tool-argument" && r.surface !== "list-method") {
17095
+ return unreadable(`surface ${JSON.stringify(r.surface)}`);
17096
+ }
17097
+ switch (o.kind) {
17098
+ case "error":
17099
+ return {
17100
+ target,
17101
+ surface,
17102
+ outcome: {
17103
+ kind: "error",
17104
+ code: o.code,
17105
+ ...typeof o.message === "string" ? { message: o.message } : {}
17106
+ }
17107
+ };
17108
+ case "result":
17109
+ return {
17110
+ target,
17111
+ surface,
17112
+ outcome: {
17113
+ kind: "result",
17114
+ isError: o.isError === true,
17115
+ ...o.errorCode !== void 0 ? { errorCode: o.errorCode } : {}
17116
+ }
17117
+ };
17118
+ case "no-answer":
17119
+ case "not-sent":
17120
+ return {
17121
+ target,
17122
+ surface,
17123
+ outcome: {
17124
+ kind: o.kind,
17125
+ reason: typeof o.reason === "string" ? o.reason : "no reason given"
17126
+ }
17127
+ };
17128
+ default:
17129
+ return unreadable(`outcome kind ${JSON.stringify(o.kind)}`);
17130
+ }
17131
+ }
15945
17132
  function launchAndIntrospect(target) {
15946
17133
  const plan = deriveLaunchPlan(target);
15947
17134
  if ("error" in plan) {
@@ -15982,19 +17169,22 @@ function launchAndIntrospect(target) {
15982
17169
  if (parsed.launchError) {
15983
17170
  return { root: target, tools: [], launchError: parsed.launchError };
15984
17171
  }
15985
- const evalCounts = readEvalCounts(target);
17172
+ const { counts: evalCounts, unreadable: evalsUnreadable } = readEvalCounts(target);
15986
17173
  const tools = (parsed.tools ?? []).map((t) => {
15987
17174
  const name = t.name;
15988
17175
  const count = evalCounts.get(name);
15989
17176
  const base = t.description ? { name, description: t.description } : { name };
15990
- return count === void 0 ? base : { ...base, evalCaseCount: count };
17177
+ const withCursor = t.cursorArgument === true ? { ...base, cursorArgument: true } : base;
17178
+ return count === void 0 ? withCursor : { ...withCursor, evalCaseCount: count };
15991
17179
  });
15992
17180
  const expectedCommit = deriveExpectedCommit(plan.cwd);
15993
17181
  return {
15994
17182
  root: target,
15995
17183
  tools,
15996
17184
  ...parsed.serverInfo ? { serverInfo: parsed.serverInfo } : {},
15997
- ...expectedCommit ? { expectedCommit } : {}
17185
+ ...expectedCommit ? { expectedCommit } : {},
17186
+ ...evalsUnreadable ? { evalsUnreadable } : {},
17187
+ ...Array.isArray(parsed.cursorProbes) ? { cursorProbes: parsed.cursorProbes.map(readCursorProbe) } : {}
15998
17188
  };
15999
17189
  }
16000
17190
 
@@ -16246,12 +17436,13 @@ var USAGE = [
16246
17436
  " --version print the version and exit",
16247
17437
  " --help print this and exit",
16248
17438
  "",
16249
- "exit: 0 clean, 1 a violation, 2 an abstention with no violation"
17439
+ "exit: 0 clean, 1 a violation, 2 an abstention with no violation (including a target",
17440
+ " that names nothing readable: missing, empty, or not a source tree)"
16250
17441
  ].join("\n");
16251
17442
  function readVersion() {
16252
17443
  try {
16253
17444
  const here = dirname17(fileURLToPath2(import.meta.url));
16254
- const manifest = JSON.parse(readFileSync17(join27(here, "..", "package.json"), "utf8"));
17445
+ const manifest = JSON.parse(readFileSync16(join27(here, "..", "package.json"), "utf8"));
16255
17446
  return manifest.version ?? null;
16256
17447
  } catch {
16257
17448
  return null;
@@ -16297,10 +17488,28 @@ function main() {
16297
17488
  detectorList = detectors.map((d) => d === localIdentity ? ratchet : d);
16298
17489
  }
16299
17490
  const backend = backendArg ? backendArg.slice("--backend=".length) : void 0;
16300
- const { results, skipped } = runDoctorReport(target, detectorList, {
16301
- mode,
16302
- ...backend !== void 0 ? { backend } : {}
16303
- });
17491
+ let report;
17492
+ try {
17493
+ report = runDoctorReport(target, detectorList, {
17494
+ mode,
17495
+ ...backend !== void 0 ? { backend } : {}
17496
+ });
17497
+ } catch (err) {
17498
+ if (!(err instanceof SubjectUnreadableError)) throw err;
17499
+ if (args.includes("--json")) {
17500
+ console.log(
17501
+ JSON.stringify(
17502
+ { surface: target, exitCode: 2, subjectUnreadable: err.form, results: [], skipped: [] },
17503
+ null,
17504
+ 2
17505
+ )
17506
+ );
17507
+ }
17508
+ console.error(err.message);
17509
+ process.exitCode = 2;
17510
+ return;
17511
+ }
17512
+ const { results, skipped } = report;
16304
17513
  console.log(
16305
17514
  args.includes("--json") ? renderJson(target, results, skipped) : renderTable(target, results, skipped)
16306
17515
  );
@@ -16323,6 +17532,7 @@ function isDirectInvocation() {
16323
17532
  }
16324
17533
  if (isDirectInvocation()) main();
16325
17534
  export {
17535
+ SubjectUnreadableError,
16326
17536
  computeExitCode,
16327
17537
  countOwedHumanVerdicts,
16328
17538
  isLiveDetector,