@vantageos/mcp-doctor 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/doctor.js CHANGED
@@ -6,7 +6,7 @@ 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
  }
@@ -477,8 +562,180 @@ function guardUnreadablePackageJson(inner, opts) {
477
562
  };
478
563
  }
479
564
 
565
+ // src/detectors/siteScan.ts
566
+ import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
567
+ import { join as join2, relative as relative2, sep as sep2 } from "path";
568
+ function codeSites(tree, re) {
569
+ const out = [];
570
+ const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`);
571
+ for (const file of tree.files) {
572
+ for (const line of file.lines) {
573
+ if (line.comment) continue;
574
+ for (const m of line.raw.matchAll(g)) {
575
+ const col = m.index ?? 0;
576
+ if (isCodeAt(file, line.n, col)) {
577
+ out.push({ file, line, col, match: m, anchor: `${file.rel}:${line.n}` });
578
+ }
579
+ }
580
+ }
581
+ }
582
+ return out;
583
+ }
584
+ var uncommentedCache = /* @__PURE__ */ new WeakMap();
585
+ function uncommentedLines(file) {
586
+ const hit = uncommentedCache.get(file);
587
+ if (hit) return hit;
588
+ const rows = file.lines.map((l) => {
589
+ let s = "";
590
+ for (let c = 0; c < l.raw.length; c++) {
591
+ s += l.comment || isCommentAt(file, l.n, c) ? " " : l.raw[c];
592
+ }
593
+ return s;
594
+ });
595
+ uncommentedCache.set(file, rows);
596
+ return rows;
597
+ }
598
+ function balancedBlock(file, line, col, open, close) {
599
+ const rows = uncommentedLines(file);
600
+ let depth = 0;
601
+ let text = "";
602
+ for (let n = line; n <= file.lines.length; n++) {
603
+ const raw = rows[n - 1];
604
+ const from = n === line ? col : 0;
605
+ for (let c = from; c < raw.length; c++) {
606
+ const ch = raw[c];
607
+ if (ch === open && isCodeAt(file, n, c)) depth++;
608
+ else if (ch === close && isCodeAt(file, n, c)) depth--;
609
+ text += ch;
610
+ if (depth === 0 && ch === close) {
611
+ return { startLine: line, endLine: n, text };
612
+ }
613
+ }
614
+ text += "\n";
615
+ }
616
+ return null;
617
+ }
618
+ function nextOpener(file, line, col, openers = "{[(", maxLines = 3) {
619
+ const rows = uncommentedLines(file);
620
+ for (let n = line; n <= Math.min(file.lines.length, line + maxLines); n++) {
621
+ const raw = rows[n - 1];
622
+ for (let c = n === line ? col : 0; c < raw.length; c++) {
623
+ if (openers.includes(raw[c]) && isCodeAt(file, n, c)) return { line: n, col: c, ch: raw[c] };
624
+ }
625
+ }
626
+ return null;
627
+ }
628
+ function isRecord(v) {
629
+ return typeof v === "object" && v !== null && !Array.isArray(v);
630
+ }
631
+ function baseOf(tree) {
632
+ try {
633
+ return statSync2(tree.root).isDirectory() ? tree.root : join2(tree.root, "..");
634
+ } catch {
635
+ return tree.root;
636
+ }
637
+ }
638
+ function findDiskFiles(tree, pred) {
639
+ const out = [];
640
+ const base = baseOf(tree);
641
+ const walk2 = (dir) => {
642
+ let entries;
643
+ try {
644
+ entries = readdirSync2(dir);
645
+ } catch {
646
+ return;
647
+ }
648
+ for (const name of entries.sort()) {
649
+ const full = join2(dir, name);
650
+ let st;
651
+ try {
652
+ st = statSync2(full);
653
+ } catch {
654
+ continue;
655
+ }
656
+ if (st.isDirectory()) {
657
+ if (SKIP_DIRS.has(name)) {
658
+ recordSkip(tree, full, base);
659
+ continue;
660
+ }
661
+ walk2(full);
662
+ } else {
663
+ const rel = relative2(base, full).split(sep2).join("/");
664
+ if (pred(name, rel)) out.push({ path: full, rel, name });
665
+ }
666
+ }
667
+ };
668
+ walk2(base);
669
+ return out;
670
+ }
671
+ function readText(path) {
672
+ try {
673
+ return readFileSync3(path, "utf8");
674
+ } catch {
675
+ return null;
676
+ }
677
+ }
678
+ function readJsonRecord(path) {
679
+ const text = readText(path);
680
+ if (text === null) return null;
681
+ try {
682
+ const v = JSON.parse(text);
683
+ return isRecord(v) ? v : null;
684
+ } catch {
685
+ return null;
686
+ }
687
+ }
688
+ function proseWordCount(markdown) {
689
+ const noFences = markdown.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, " ");
690
+ const noInline = noFences.replace(/`[^`\n]*`/g, " ");
691
+ return (noInline.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length;
692
+ }
693
+ function lineHits(tree, re) {
694
+ const out = [];
695
+ for (const file of tree.files) {
696
+ const rows = uncommentedLines(file);
697
+ rows.forEach((text, i) => {
698
+ if (re.test(text)) {
699
+ out.push({ file, line: i + 1, anchor: `${file.rel}:${i + 1}`, text });
700
+ }
701
+ });
702
+ }
703
+ return out;
704
+ }
705
+ function enclosingBlock(file, line, col, levels = 0) {
706
+ const rows = uncommentedLines(file);
707
+ let curLine = line;
708
+ let curCol = col;
709
+ let result = null;
710
+ for (let level = 0; level <= levels; level++) {
711
+ let depth = 0;
712
+ let found = null;
713
+ outer: for (let n = curLine; n >= 1; n--) {
714
+ const raw = rows[n - 1];
715
+ for (let c = (n === curLine ? curCol : raw.length) - 1; c >= 0; c--) {
716
+ if (!isCodeAt(file, n, c)) continue;
717
+ if (raw[c] === "}") depth++;
718
+ else if (raw[c] === "{") {
719
+ if (depth === 0) {
720
+ found = { l: n, c };
721
+ break outer;
722
+ }
723
+ depth--;
724
+ }
725
+ }
726
+ }
727
+ if (!found) return result;
728
+ const b = balancedBlock(file, found.l, found.c, "{", "}");
729
+ if (!b) return null;
730
+ result = { ...b, openLine: found.l, openCol: found.c };
731
+ curLine = found.l;
732
+ curCol = found.c;
733
+ }
734
+ return result;
735
+ }
736
+
480
737
  // src/detectors/standardPin.ts
481
- var STANDARD_PIN = "b714129a110237c5521d5269ee61ab919408a17f";
738
+ var STANDARD_PIN = "6d463a1ecc5256cd59dd1afe5aa5e335accacc90";
482
739
 
483
740
  // src/detectors/dLocalIdentity.ts
484
741
  var CLOUD_IDENTITY_PACKAGE = "@vantageos/cloud-identity";
@@ -601,21 +858,155 @@ function literalHoldsIdentity(m) {
601
858
  const arg = unfold(m[2]);
602
859
  return m[1].split(",").map((e) => unfold(e.trim())).some((e) => e !== "" && !isLiteral(e) && !isLiteral(arg) && comparesIdentity(e, arg));
603
860
  }
604
- function digestsStaticInput(m, ctx) {
861
+ function scopesOf(ctx) {
862
+ const out = [];
863
+ for (let level = 0; level < 6; level++) {
864
+ const b = enclosingBlock(ctx.file, ctx.line.n, ctx.idx, level);
865
+ if (!b || out.length > 0 && b.text === out[out.length - 1]) break;
866
+ out.push(b.text);
867
+ }
868
+ return out;
869
+ }
870
+ var escRe = (x) => x.replace(/[$]/g, "\\$&");
871
+ function exprUntilSemicolon(text, from) {
872
+ let depth = 0;
873
+ for (let i = from; i < text.length; i++) {
874
+ const c = text[i];
875
+ if (c === '"' || c === "'" || c === "`") {
876
+ for (i++; i < text.length && text[i] !== c; i++) if (text[i] === "\\") i++;
877
+ continue;
878
+ }
879
+ if (c === "(" || c === "[" || c === "{") depth++;
880
+ else if (c === ")" || c === "]" || c === "}") {
881
+ if (depth === 0) return text.slice(from, i).trim();
882
+ depth--;
883
+ } else if (c === ";" && depth === 0) return text.slice(from, i).trim();
884
+ }
885
+ return text.slice(from).trim();
886
+ }
887
+ function bindingOf(name, scopes) {
888
+ const n = escRe(name);
889
+ for (const text of scopes) {
890
+ const decl = new RegExp(String.raw`\b(?:const|let|var)\s+${n}\s*(?::[^=;]+)?=\s*`).exec(text);
891
+ if (decl) return { kind: "value", expr: exprUntilSemicolon(text, decl.index + decl[0].length) };
892
+ const loop = new RegExp(String.raw`\bfor\s*\(\s*(?:const|let|var)\s+${n}\s+of\s+`).exec(text);
893
+ if (loop) {
894
+ const inner = exprUntilSemicolon(text, loop.index + loop[0].length);
895
+ return { kind: "element", expr: inner };
896
+ }
897
+ }
898
+ return null;
899
+ }
900
+ function functionBody(file, name) {
901
+ const text = uncommentedLines(file).join("\n");
902
+ const decl = new RegExp(String.raw`\bfunction\s+${escRe(name)}\s*\(`).exec(text);
903
+ if (!decl) return null;
904
+ const params = callArgs(text, decl.index + decl[0].length);
905
+ if (!params) return null;
906
+ return blockFrom(text, params.end);
907
+ }
908
+ function isLocalListing(e, ctx, scopes, depth) {
909
+ if (depth > 4) return false;
910
+ const x = e.trim().replace(/^await\s+/, "");
911
+ if (/^(?:[\w$]+\s*\.\s*)*(?:readdirSync|readdir)\s*\(/.test(x)) return true;
912
+ const call = x.match(/^([A-Za-z_$][\w$]*)\s*\(/);
913
+ if (call) {
914
+ const body = functionBody(ctx.file, call[1]);
915
+ return body !== null && /\b(?:readdirSync|readdir)\s*\(/.test(body);
916
+ }
917
+ if (/^[A-Za-z_$][\w$]*$/.test(x)) {
918
+ const b = bindingOf(x, scopes);
919
+ return b !== null && b.kind === "value" && isLocalListing(b.expr, ctx, scopes, depth + 1);
920
+ }
921
+ return false;
922
+ }
923
+ function templateParts(e) {
924
+ if (!/^`[\s\S]*`$/.test(e)) return null;
925
+ const parts = [];
926
+ for (let i = 1; i < e.length - 1; i++) {
927
+ if (e[i] === "\\") {
928
+ i++;
929
+ continue;
930
+ }
931
+ if (e[i] === "$" && e[i + 1] === "{") {
932
+ let depth = 0;
933
+ let j = i + 1;
934
+ for (; j < e.length; j++) {
935
+ if (e[j] === "{") depth++;
936
+ else if (e[j] === "}" && --depth === 0) break;
937
+ }
938
+ parts.push(e.slice(i + 2, j));
939
+ i = j;
940
+ }
941
+ }
942
+ return parts;
943
+ }
944
+ function isLocalContent(e, ctx, scopes, depth) {
945
+ if (depth > 4) return false;
946
+ const x = e.trim().replace(/^await\s+/, "");
947
+ if (isStaticInput(x, ctx.statics)) return true;
948
+ const fsRead = x.match(/^(?:[\w$]+\s*\.\s*)*(?:readFileSync|readFile)\s*\(/);
949
+ if (fsRead) {
950
+ const a = callArgs(x, fsRead[0].length);
951
+ return a !== null && a.end === x.length;
952
+ }
953
+ const wrapped = x.match(/^(?:new\s+TextEncoder\s*\(\s*\)\s*\.\s*encode|Buffer\s*\.\s*from)\s*\(/);
954
+ if (wrapped) {
955
+ const a = callArgs(x, wrapped[0].length);
956
+ return a !== null && a.end === x.length && isLocalContent(a.args[0], ctx, scopes, depth + 1);
957
+ }
958
+ const tpl = templateParts(x);
959
+ if (tpl) return tpl.every((p) => isLocalContent(p, ctx, scopes, depth + 1));
960
+ const ch = x.match(/^(?:[\w$]+\s*\.\s*)?createHash\s*\(/);
961
+ if (ch) {
962
+ const c = callArgs(x, ch[0].length);
963
+ const up = c ? x.slice(c.end).match(/^\s*\.\s*update\s*\(/) : null;
964
+ const d = c && up ? callArgs(x, c.end + up[0].length) : null;
965
+ return d !== null && isLocalContent(d.args[0], ctx, scopes, depth + 1);
966
+ }
967
+ const sd = x.match(/^(?:[\w$]+\s*\.\s*)*subtle\s*\.\s*digest\s*\(/);
968
+ if (sd) {
969
+ const c = callArgs(x, sd[0].length);
970
+ return c !== null && c.args.length >= 2 && isLocalContent(c.args[1], ctx, scopes, depth + 1);
971
+ }
972
+ if (/^[A-Za-z_$][\w$]*$/.test(x)) {
973
+ const b = bindingOf(x, scopes);
974
+ if (!b) return false;
975
+ return b.kind === "element" ? isLocalListing(b.expr, ctx, scopes, depth + 1) : isLocalContent(b.expr, ctx, scopes, depth + 1);
976
+ }
977
+ return false;
978
+ }
979
+ function digestsNonCredentialInput(m, ctx) {
605
980
  if (!ctx) return false;
606
981
  const text = textFrom(ctx);
607
982
  const open = text.indexOf("(");
608
983
  if (open < 0) return false;
609
984
  const call = callArgs(text, open + 1);
610
985
  if (!call) return false;
986
+ const scopes = scopesOf(ctx);
987
+ const local = (e) => isLocalContent(e, ctx, scopes, 0);
611
988
  if (/^subtle/.test(m[0])) {
612
- return m[1] === "digest" && call.args.length >= 2 && isStaticInput(call.args[1], ctx.statics);
989
+ return m[1] === "digest" && call.args.length >= 2 && local(call.args[1]);
613
990
  }
614
991
  if (!/^createHash/.test(m[0])) return false;
615
992
  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);
993
+ if (update) {
994
+ const data = callArgs(text, call.end + update[0].length);
995
+ return data !== null && local(data.args[0]);
996
+ }
997
+ const held = ctx.line.raw.slice(0, ctx.idx).match(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*$/);
998
+ if (!held || scopes.length === 0) return false;
999
+ const v = escRe(held[1]);
1000
+ const scope = scopes[0];
1001
+ const updates = [...scope.matchAll(new RegExp(String.raw`\b${v}\s*\.\s*update\s*\(`, "g"))];
1002
+ if (updates.length === 0) return false;
1003
+ const digests = [...scope.matchAll(new RegExp(String.raw`\b${v}\s*\.\s*digest\s*\(`, "g"))];
1004
+ const uses = [...scope.matchAll(new RegExp(String.raw`(?<![\w$.])${v}(?![\w$])`, "g"))];
1005
+ if (uses.length !== 1 + updates.length + digests.length) return false;
1006
+ return updates.every((u) => {
1007
+ const a = callArgs(scope, (u.index ?? 0) + u[0].length);
1008
+ return a !== null && local(a.args[0]);
1009
+ });
619
1010
  }
620
1011
  var RENAMEABLE = [
621
1012
  "createHash",
@@ -856,10 +1247,11 @@ var MATCHERS = [
856
1247
  // Any digest of a presented credential, whatever the algorithm.
857
1248
  id: "local-digest",
858
1249
  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.
1250
+ // A digest over static content (a literal, or a constant made of literals) or over local
1251
+ // content (file bytes, a listing, a digest of them: a content fingerprint of the tree) is not a
1252
+ // digest of a credential. HMAC and sign/verify always take a key: a site.
861
1253
  re: /\bcreateHash\s*\(|\bcreateHmac\s*\(|\bsubtle\s*\.\s*(digest|sign|verify)\s*\(/,
862
- accept: (m, ctx) => !digestsStaticInput(m, ctx),
1254
+ accept: (m, ctx) => !digestsNonCredentialInput(m, ctx),
863
1255
  sample: 'const h = await crypto.subtle.digest("SHA-256", data);'
864
1256
  },
865
1257
  // ── Minting / signing: shown, not counted (category "mints-token") ──────────
@@ -1188,178 +1580,6 @@ function makeLocalIdentity(opts = {}) {
1188
1580
  }
1189
1581
  var localIdentity = makeLocalIdentity();
1190
1582
 
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
1583
  // src/detectors/a2-declared-domains.ts
1364
1584
  function stripComments(text) {
1365
1585
  return text.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
@@ -1937,46 +2157,74 @@ function findUnresolvedToolSites(tree) {
1937
2157
  return [...kept, ...wrappers.unresolved];
1938
2158
  }
1939
2159
  function findListReadBuilders(tree) {
2160
+ const bindings = resolveNameBindings(tree);
2161
+ const extents = [];
2162
+ const endOf = (startLine, startCol, text) => {
2163
+ const lines = text.split("\n");
2164
+ const last = lines.length - 1;
2165
+ return [startLine + last, last === 0 ? startCol + text.length - 1 : lines[last].length - 1];
2166
+ };
2167
+ for (const site of codeSites(tree, /(?:\.tool|\bregisterTool)\s*\(/g)) {
2168
+ const open = site.col + site.match[0].lastIndexOf("(");
2169
+ const b = balancedBlock(site.file, site.line.n, open, "(", ")");
2170
+ if (!b) continue;
2171
+ const first = firstArgument(b.text.slice(1, -1));
2172
+ const lit = first.match(/^["'`]([^"'`]+)["'`]$/);
2173
+ const ident = first.match(/^([A-Za-z_$][\w$]*)(?:\.name)?$/);
2174
+ const name = lit ? lit[1] : ident ? bindings.get(ident[1]) ?? null : null;
2175
+ extents.push({
2176
+ file: site.file,
2177
+ name,
2178
+ nameLine: site.line.n,
2179
+ from: [site.line.n, open],
2180
+ to: endOf(site.line.n, open, b.text)
2181
+ });
2182
+ }
2183
+ for (const site of codeSites(tree, new RegExp(DESCRIPTOR_NAME_RE.source, "g"))) {
2184
+ const b = enclosingBlock(site.file, site.line.n, site.col);
2185
+ if (!b) continue;
2186
+ extents.push({
2187
+ file: site.file,
2188
+ name: site.match[1],
2189
+ nameLine: site.line.n,
2190
+ from: [b.openLine, b.openCol],
2191
+ to: endOf(b.openLine, b.openCol, b.text)
2192
+ });
2193
+ }
2194
+ const before = (a, b) => a[0] < b[0] || a[0] === b[0] && a[1] <= b[1];
1940
2195
  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
- }
2196
+ for (const site of codeSites(tree, new RegExp(CONTENT_RE.source, "g"))) {
2197
+ const at = [site.line.n, site.col];
2198
+ let owner = null;
2199
+ for (const e of extents) {
2200
+ if (e.file !== site.file || !before(e.from, at) || !before(at, e.to)) continue;
2201
+ if (!owner || before(owner.from, e.from)) owner = e;
2202
+ }
2203
+ if (owner?.name && LIST_READ_RE.test(owner.name)) {
2204
+ out.push({
2205
+ file: site.file,
2206
+ toolName: owner.name,
2207
+ toolNameLine: owner.nameLine,
2208
+ contentLine: site.line.n
2209
+ });
1976
2210
  }
1977
2211
  }
1978
2212
  return out;
1979
2213
  }
2214
+ function firstArgument(inner) {
2215
+ let depth = 0;
2216
+ for (let i = 0; i < inner.length; i++) {
2217
+ const c = inner[i];
2218
+ if (c === '"' || c === "'" || c === "`") {
2219
+ for (i++; i < inner.length && inner[i] !== c; i++) if (inner[i] === "\\") i++;
2220
+ continue;
2221
+ }
2222
+ if (c === "(" || c === "[" || c === "{") depth++;
2223
+ else if (c === ")" || c === "]" || c === "}") depth--;
2224
+ else if (c === "," && depth === 0) return inner.slice(0, i).trim();
2225
+ }
2226
+ return inner.trim();
2227
+ }
1980
2228
  var SDK_FIRST_RE = /\bregisterTool\s*\(|\.tool\s*\(|new\s+McpServer\s*\(/;
1981
2229
  function sdkFirstAnchor(tree) {
1982
2230
  for (const file of tree.files) {
@@ -4224,6 +4472,7 @@ var VERB_SET = /* @__PURE__ */ new Set([
4224
4472
  "get",
4225
4473
  "search",
4226
4474
  "aggregate",
4475
+ "prepare",
4227
4476
  "create",
4228
4477
  "update",
4229
4478
  "delete",
@@ -4239,6 +4488,7 @@ function c9PositiveControls() {
4239
4488
  return {
4240
4489
  fieldMatcher: field,
4241
4490
  setAcceptsCreate: VERB_SET.has("create"),
4491
+ setAcceptsPrepare: VERB_SET.has("prepare"),
4242
4492
  setRejectsRpc: !VERB_SET.has("rpc")
4243
4493
  };
4244
4494
  }
@@ -4253,9 +4503,9 @@ var c9VerbDeclared = {
4253
4503
  `C9 positive control FAILED: pattern /${controls.fieldMatcher.pattern}/ did not match sample \`${controls.fieldMatcher.sample}\` \u2014 matcher broken, verdict untrustworthy`
4254
4504
  );
4255
4505
  }
4256
- if (!controls.setAcceptsCreate || !controls.setRejectsRpc) {
4506
+ if (!controls.setAcceptsCreate || !controls.setAcceptsPrepare || !controls.setRejectsRpc) {
4257
4507
  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"
4508
+ "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
4509
  );
4260
4510
  }
4261
4511
  const unresolved = findUnresolvedToolSites(tree);
@@ -4302,7 +4552,7 @@ var c9VerbDeclared = {
4302
4552
  id: this.id,
4303
4553
  anchor: t.anchor,
4304
4554
  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)`
4555
+ verdict: `tool "${t.name}" declares verb "${t.verb}", outside the closed set (positive-control OK) -> use one of ${[...VERB_SET].join("/")} (C-9)`
4306
4556
  };
4307
4557
  }
4308
4558
  }
@@ -4475,17 +4725,14 @@ var c11Pagination = {
4475
4725
  };
4476
4726
  },
4477
4727
  /**
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".
4728
+ * DYNAMIC — the invalid-cursor round trip, on each surface where a cursor arrives (C-11 (a) and
4729
+ * (b)). Red on any probe answered in the wrong form; could-not-judge on a probe that got no
4730
+ * answer, was not sent, or recorded an outcome this code cannot read; present only when every
4731
+ * probe answered in its surface's form.
4485
4732
  */
4486
4733
  runDynamic(runtime) {
4487
4734
  if (runtime.launchError) {
4488
- const reason = `server could not be started/introspected \u2014 runtime pagination applicability unverifiable: ${runtime.launchError} (C-11)`;
4735
+ const reason = `server could not be started/introspected \u2014 the invalid-cursor round trip could not run: ${runtime.launchError} (C-11)`;
4489
4736
  return {
4490
4737
  id: this.id,
4491
4738
  anchor: `${runtime.root} (runtime)`,
@@ -4494,24 +4741,117 @@ var c11Pagination = {
4494
4741
  verdict: `could not judge: ${reason}`
4495
4742
  };
4496
4743
  }
4497
- const paginable = runtime.tools.filter((t) => PAGINABLE_RE.test(t.name));
4744
+ const paginable = runtime.tools.filter((t) => PAGINABLE_RE.test(t.name) || t.cursorArgument);
4498
4745
  if (paginable.length === 0) {
4499
4746
  return {
4500
4747
  id: this.id,
4501
4748
  anchor: `${runtime.root} (runtime tools/list)`,
4502
4749
  state: "absent",
4503
- verdict: "server advertises no list/read/get/search tool at runtime; pagination check n/a"
4750
+ verdict: "server advertises no list/read/get/search tool and no tool taking a cursor at runtime; pagination check n/a"
4751
+ };
4752
+ }
4753
+ const anchor = `${runtime.root} (runtime invalid-cursor probes)`;
4754
+ const probes = runtime.cursorProbes;
4755
+ if (!probes || probes.length === 0) {
4756
+ const reason = "no invalid-cursor round trip was recorded for this run: the -32602 / invalid_cursor answers are unread (C-11)";
4757
+ return {
4758
+ id: this.id,
4759
+ anchor,
4760
+ state: "could-not-judge",
4761
+ reason,
4762
+ verdict: `could not judge: ${reason}`
4763
+ };
4764
+ }
4765
+ const red = [];
4766
+ const unread = [];
4767
+ const ok = [];
4768
+ for (const p of probes) {
4769
+ const j = judgeCursorProbe(p);
4770
+ if (j.kind === "red") red.push(j.text);
4771
+ else if (j.kind === "unread") unread.push(j.text);
4772
+ else ok.push(j.text);
4773
+ }
4774
+ if (red.length > 0) {
4775
+ return {
4776
+ id: this.id,
4777
+ anchor,
4778
+ state: "non-conforming",
4779
+ 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)`
4780
+ };
4781
+ }
4782
+ if (unread.length > 0) {
4783
+ const reason = `${unread.join("; ")} (C-11)`;
4784
+ return {
4785
+ id: this.id,
4786
+ anchor,
4787
+ state: "could-not-judge",
4788
+ reason,
4789
+ verdict: `could not judge: ${reason}`
4790
+ };
4791
+ }
4792
+ const toolProbes = probes.filter((p) => p.surface === "tool-argument").length;
4793
+ return {
4794
+ id: this.id,
4795
+ anchor,
4796
+ state: "present",
4797
+ verdict: `${ok.join("; ")}${toolProbes === 0 ? "; no served tool takes a cursor argument, so C-11 (b) has no object on this server" : ""} (C-11)`
4798
+ };
4799
+ }
4800
+ };
4801
+ function judgeCursorProbe(p) {
4802
+ const where = p.surface === "list-method" ? `${p.target} (protocol list method)` : `tool "${p.target}" (cursor argument)`;
4803
+ const o = p.outcome;
4804
+ switch (o.kind) {
4805
+ case "no-answer":
4806
+ return { kind: "unread", text: `${where}: no answer to the invalid cursor (${o.reason})` };
4807
+ case "not-sent":
4808
+ return { kind: "unread", text: `${where}: probe not sent (${o.reason})` };
4809
+ case "error":
4810
+ if (p.surface === "list-method") {
4811
+ return o.code === -32602 ? { kind: "ok", text: `${where}: -32602 on an invalid cursor` } : {
4812
+ kind: "red",
4813
+ text: `${where}: answered an invalid cursor with error ${String(o.code)}, not -32602`
4814
+ };
4815
+ }
4816
+ return {
4817
+ kind: "red",
4818
+ 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`
4819
+ };
4820
+ case "result":
4821
+ if (p.surface === "list-method") {
4822
+ return {
4823
+ kind: "red",
4824
+ text: `${where}: answered an invalid cursor with a result (a silent page), not -32602`
4825
+ };
4826
+ }
4827
+ if (!o.isError) {
4828
+ return {
4829
+ kind: "red",
4830
+ text: `${where}: answered an invalid cursor argument with a silent page (no isError)`
4831
+ };
4832
+ }
4833
+ if (o.errorCode === void 0) {
4834
+ return {
4835
+ kind: "red",
4836
+ text: `${where}: isError: true with no structuredContent.error.code (no stable machine code)`
4837
+ };
4838
+ }
4839
+ return o.errorCode === "invalid_cursor" ? {
4840
+ kind: "ok",
4841
+ text: `${where}: isError: true, structuredContent.error.code "invalid_cursor"`
4842
+ } : {
4843
+ kind: "red",
4844
+ text: `${where}: isError: true with structuredContent.error.code ${JSON.stringify(o.errorCode)}, not "invalid_cursor"`
4845
+ };
4846
+ default: {
4847
+ const unknown = o;
4848
+ return {
4849
+ kind: "unread",
4850
+ text: `${where}: an outcome this code cannot read (${JSON.stringify(unknown.kind)})`
4504
4851
  };
4505
4852
  }
4506
- const names = paginable.map((t) => t.name).join(", ");
4507
- return {
4508
- id: this.id,
4509
- anchor: `${runtime.root} (runtime tools/list)`,
4510
- 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.`
4512
- };
4513
4853
  }
4514
- };
4854
+ }
4515
4855
 
4516
4856
  // src/detectors/c12-no-duplicates.ts
4517
4857
  function tripletKey(t) {
@@ -4607,37 +4947,162 @@ var c12NoDuplicates = {
4607
4947
  };
4608
4948
 
4609
4949
  // 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) {
4950
+ var SERVER_MANIFEST_NAMES = /* @__PURE__ */ new Set(["mcp.json", "mcp-server.json"]);
4951
+ var NAME_RE = /\b(?:CONTEXT_CEILING|contextCeiling)\b/g;
4952
+ var NUM = String.raw`(\d[\d_]*)`;
4953
+ var halfRe = (key) => new RegExp(String.raw`(?:^|[{,\s])["'\x60]?${key}["'\x60]?\s*:\s*([^,}\s][^,}]*)`);
4954
+ function readObjectText(text) {
4955
+ const halves = {};
4956
+ for (const key of ["global", "perTool"]) {
4957
+ const m = text.match(halfRe(key));
4958
+ if (!m) halves[key] = "missing";
4959
+ else {
4960
+ const v = m[1].trim().replace(/\s+as\s+const$/, "");
4961
+ const n = v.match(new RegExp(`^${NUM}$`));
4962
+ halves[key] = n ? Number.parseInt(n[1].replace(/_/g, ""), 10) : v;
4963
+ }
4964
+ }
4965
+ const odd = Object.entries(halves).filter(([, v]) => typeof v === "string" && v !== "missing");
4966
+ if (odd.length > 0) {
4967
+ return { kind: "unreadable", why: odd.map(([k, v]) => `${k}: ${v}`).join(", ") };
4968
+ }
4969
+ const missing = Object.entries(halves).filter(([, v]) => v === "missing").map(([k]) => k);
4970
+ if (missing.length === 2 && /\.\.\./.test(text)) {
4971
+ return { kind: "unreadable", why: `a spread with no literal halves (${text.slice(0, 60)})` };
4972
+ }
4973
+ if (missing.length > 0) return { kind: "partial", missing };
4974
+ return { kind: "pair", global: halves.global, perTool: halves.perTool };
4975
+ }
4976
+ function braceBlock(text) {
4977
+ let depth = 0;
4978
+ for (let i = 0; i < text.length; i++) {
4979
+ if (text[i] === "{") depth++;
4980
+ else if (text[i] === "}" && --depth === 0) return text.slice(0, i + 1);
4981
+ }
4982
+ return null;
4983
+ }
4984
+ function sameFileConstObject(file, name) {
4985
+ const text = uncommentedLines(file).map(codeOnly).join("\n");
4986
+ const m = new RegExp(
4987
+ String.raw`\bconst\s+${name.replace(/\$/g, "\\$")}\s*(?::[^=]+)?=\s*\{`
4988
+ ).exec(text);
4989
+ if (!m) return null;
4990
+ return braceBlock(text.slice(m.index + m[0].length - 1));
4991
+ }
4992
+ function ceilingCensus(tree) {
4993
+ const census = { readable: [], partial: [], unreadable: [] };
4994
+ for (const f of findDiskFiles(tree, (n) => SERVER_MANIFEST_NAMES.has(n))) {
4995
+ const text = readText(f.path);
4996
+ if (text === null) {
4997
+ census.unreadable.push({ anchor: f.rel, reason: `${f.rel} is not readable as text` });
4998
+ continue;
4999
+ }
5000
+ let json;
5001
+ try {
5002
+ json = JSON.parse(text);
5003
+ } catch (e) {
5004
+ census.unreadable.push({
5005
+ anchor: f.rel,
5006
+ reason: `${f.rel} does not parse as JSON (${e instanceof Error ? e.message : String(e)}): a contextCeiling it may declare is unread`
5007
+ });
5008
+ continue;
5009
+ }
5010
+ if (!isRecord(json) || !("contextCeiling" in json)) continue;
5011
+ const anchor = `${f.rel}:${lineOf(text, /"contextCeiling"/)}`;
5012
+ const c = json.contextCeiling;
5013
+ if (!isRecord(c)) {
5014
+ census.partial.push({
5015
+ anchor,
5016
+ form: "manifest",
5017
+ missing: ["global", "perTool (contextCeiling is not an object)"]
5018
+ });
5019
+ continue;
5020
+ }
5021
+ const ok = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
5022
+ const missing = [ok(c.global) ? "" : "global", ok(c.perTool) ? "" : "perTool"].filter(Boolean);
5023
+ if (missing.length > 0) census.partial.push({ anchor, form: "manifest", missing });
5024
+ else
5025
+ census.readable.push({
5026
+ global: c.global,
5027
+ perTool: c.perTool,
5028
+ anchor,
5029
+ form: "manifest"
5030
+ });
5031
+ }
4613
5032
  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
- };
5033
+ const rows = uncommentedLines(file).map(codeOnly);
5034
+ rows.forEach((row, i) => {
5035
+ for (const hit of row.matchAll(NAME_RE)) {
5036
+ const at = hit.index ?? 0;
5037
+ const anchor = `${file.rel}:${i + 1}`;
5038
+ const before = row.slice(0, at);
5039
+ const window = [row.slice(at + hit[0].length), ...rows.slice(i + 1, i + 9)].join("\n");
5040
+ const rest = window.replace(/^["'\x60]/, "");
5041
+ let value = null;
5042
+ if (/\b(?:const|let|var)\s+$/.test(before)) {
5043
+ const m = rest.match(/^\s*(?::[^=]+?)?=(?![=>])\s*/);
5044
+ if (m) value = rest.slice(m[0].length);
5045
+ } else {
5046
+ const m = rest.match(/^\s*\??:(?!:)\s*/) ?? rest.match(/^\s*=(?![=>])\s*/);
5047
+ if (m) value = rest.slice(m[0].length);
5048
+ }
5049
+ if (value === null) continue;
5050
+ if (/^[A-Z][a-z]\w*\s*(?:[;,)|>=]|$)/.test(value)) continue;
5051
+ let obj = null;
5052
+ if (value.startsWith("{")) obj = braceBlock(value);
5053
+ else {
5054
+ const id = value.match(/^([A-Za-z_$][\w$]*)\s*(?:[;,)}\n]|as\b|$)/);
5055
+ if (id) {
5056
+ obj = sameFileConstObject(file, id[1]);
5057
+ if (obj === null) {
5058
+ census.unreadable.push({
5059
+ anchor,
5060
+ reason: `${anchor} declares ${hit[0]} as \`${id[1]}\`, which is not a same-file const object literal`
5061
+ });
5062
+ continue;
5063
+ }
5064
+ }
5065
+ }
5066
+ if (obj === null) {
5067
+ census.unreadable.push({
5068
+ anchor,
5069
+ reason: `${anchor} declares ${hit[0]} as a computed value (\`${value.split("\n")[0].trim().slice(0, 50)}\`): its numbers are not readable statically`
5070
+ });
5071
+ continue;
4628
5072
  }
5073
+ const r = readObjectText(obj);
5074
+ if (r.kind === "partial" && r.missing.length === 2 && /;/.test(obj)) continue;
5075
+ if (r.kind === "pair")
5076
+ census.readable.push({ global: r.global, perTool: r.perTool, anchor, form: "source" });
5077
+ else if (r.kind === "partial")
5078
+ census.partial.push({ anchor, form: "source", missing: r.missing });
5079
+ else
5080
+ census.unreadable.push({
5081
+ anchor,
5082
+ reason: `${anchor} declares ${hit[0]} with ${r.why}: not numeric literals`
5083
+ });
4629
5084
  }
4630
- }
5085
+ });
4631
5086
  }
4632
- return null;
5087
+ return census;
5088
+ }
5089
+ function agreed(census) {
5090
+ const [first, ...rest] = census.readable;
5091
+ if (!first) return null;
5092
+ return rest.every((c) => c.global === first.global && c.perTool === first.perTool) ? first : null;
5093
+ }
5094
+ function findCeiling(tree) {
5095
+ const c = agreed(ceilingCensus(tree));
5096
+ return c ? { global: c.global, perTool: c.perTool, anchor: c.anchor } : null;
4633
5097
  }
4634
5098
  function c13PositiveControls() {
4635
- const complete = "const CONTEXT_CEILING = { global: 8000, perTool: 800 };";
4636
- const partial = "const CONTEXT_CEILING = { global: 8000 };";
5099
+ const complete = readObjectText("{ global: 8000, perTool: 800 }");
5100
+ const quoted = readObjectText('{ "global": 1200, "perTool": 700 }');
5101
+ const partial = readObjectText("{ global: 8000 }");
4637
5102
  return {
4638
5103
  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))
5104
+ ceilingAcceptsComplete: complete.kind === "pair" && quoted.kind === "pair",
5105
+ ceilingRejectsPartial: partial.kind === "partial"
4641
5106
  };
4642
5107
  }
4643
5108
  var c13ContextCeiling = {
@@ -4653,7 +5118,7 @@ var c13ContextCeiling = {
4653
5118
  }
4654
5119
  if (!controls.ceilingAcceptsComplete || !controls.ceilingRejectsPartial) {
4655
5120
  throw new Error(
4656
- "C13 ceiling control FAILED: must accept a complete (global, perTool) declaration and reject a partial one \u2014 verdict untrustworthy"
5121
+ "C13 ceiling control FAILED: must accept a complete (global, perTool) declaration, bare or quoted keys, and reject a partial one \u2014 verdict untrustworthy"
4657
5122
  );
4658
5123
  }
4659
5124
  const unresolved = findUnresolvedToolSites(tree);
@@ -4678,13 +5143,44 @@ var c13ContextCeiling = {
4678
5143
  }
4679
5144
  const opaque = opaqueAnnotationsAbstain(this.id, "C-13", tools);
4680
5145
  if (opaque) return opaque;
4681
- const ceiling = findCeiling(tree);
4682
- if (!ceiling) {
5146
+ const census = ceilingCensus(tree);
5147
+ const unreadNote = census.unreadable.length > 0 ? `; also seen and not read: ${census.unreadable.map((u) => u.reason).join("; ")}` : "";
5148
+ if (census.readable.length === 0) {
5149
+ if (census.partial.length > 0) {
5150
+ const p = census.partial[0];
5151
+ return {
5152
+ id: this.id,
5153
+ anchor: p.anchor,
5154
+ state: "non-conforming",
5155
+ 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}`
5156
+ };
5157
+ }
5158
+ if (census.unreadable.length > 0) {
5159
+ const reason = `the context ceiling is declared in a form this run cannot read: ${census.unreadable.map((u) => u.reason).join("; ")} (C-13)`;
5160
+ return {
5161
+ id: this.id,
5162
+ anchor: census.unreadable[0].anchor,
5163
+ state: "could-not-judge",
5164
+ reason,
5165
+ verdict: `could not judge: ${reason}`
5166
+ };
5167
+ }
4683
5168
  return {
4684
5169
  id: this.id,
4685
5170
  anchor: tools[0].anchor,
4686
5171
  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)`
5172
+ 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)`
5173
+ };
5174
+ }
5175
+ const ceiling = agreed(census);
5176
+ if (!ceiling) {
5177
+ 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)`;
5178
+ return {
5179
+ id: this.id,
5180
+ anchor: census.readable[0].anchor,
5181
+ state: "could-not-judge",
5182
+ reason,
5183
+ verdict: `could not judge: ${reason}`
4688
5184
  };
4689
5185
  }
4690
5186
  let sum = 0;
@@ -4719,7 +5215,7 @@ var c13ContextCeiling = {
4719
5215
  id: this.id,
4720
5216
  anchor: ceiling.anchor,
4721
5217
  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)`
5218
+ 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
5219
  };
4724
5220
  }
4725
5221
  };
@@ -4826,7 +5322,7 @@ var c14CatalogRegistration = guardUnreadablePackageJson(
4826
5322
  );
4827
5323
 
4828
5324
  // src/detectors/c15-annotations-untrusted.ts
4829
- var READ_VERBS = /* @__PURE__ */ new Set(["list", "get", "search", "aggregate"]);
5325
+ var READ_VERBS = /* @__PURE__ */ new Set(["list", "get", "search", "aggregate", "prepare"]);
4830
5326
  var WRITE_VERBS = /* @__PURE__ */ new Set([
4831
5327
  "create",
4832
5328
  "update",
@@ -5110,8 +5606,8 @@ var RECORD_LINE_RE = /^[\s>*_\-+]*Provenance\s*:\s*(.*)$/i;
5110
5606
  var SHASUM_HEX_RE = /\bshasum\b[^\n]*?\b([0-9a-f]{40})\b/i;
5111
5607
  function judgeProvenanceRecord(line, name, version) {
5112
5608
  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)`);
5609
+ const esc3 = (x) => x.replace(/[.+*?^$()[\]{}|\\/]/g, "\\$&");
5610
+ const named = new RegExp(`(?:^|[\\s(])${esc3(name)}@v?${esc3(version)}(?![\\w.-]*\\d)`);
5115
5611
  if (!named.test(body)) {
5116
5612
  return { ok: false, why: `it does not name ${name}@${version}, the artifact being published` };
5117
5613
  }
@@ -5302,7 +5798,7 @@ function manifestPath(root) {
5302
5798
  if (existsSync6(extPath)) return { kind: "extension", path: extPath };
5303
5799
  return { kind: "server", path: join7(root, "mcp.json") };
5304
5800
  }
5305
- var SERVER_MANIFEST_NAMES = ["mcp.json", "mcp-server.json"];
5801
+ var SERVER_MANIFEST_NAMES2 = ["mcp.json", "mcp-server.json"];
5306
5802
  function isObject(v) {
5307
5803
  return typeof v === "object" && v !== null && !Array.isArray(v);
5308
5804
  }
@@ -5316,7 +5812,7 @@ function classifyRootJson(json) {
5316
5812
  }
5317
5813
  function rootCandidates(root) {
5318
5814
  const out = [];
5319
- for (const name of SERVER_MANIFEST_NAMES) {
5815
+ for (const name of SERVER_MANIFEST_NAMES2) {
5320
5816
  const path = join7(root, name);
5321
5817
  if (!existsSync6(path)) continue;
5322
5818
  const json = readJson(path);
@@ -14355,6 +14851,7 @@ var TRANSPORT_RE2 = /\bStreamableHTTPServerTransport\b|\bWebStandardStreamableHT
14355
14851
  var ORIGIN_READ_RE = /headers?\.get\(\s*["'`]origin["'`]|headers?\[\s*["'`]origin["'`]\s*\]|headers?\.origin\b|\.header\(\s*["'`]origin["'`]\s*\)/i;
14356
14852
  var PROTECT_FLAG_RE = /enableDnsRebindingProtection\s*:\s*true/;
14357
14853
  var ALLOWED_ORIGINS_RE = /\ballowedOrigins\s*:\s*/g;
14854
+ var TYPE_ANNOTATION_RE = /^(?:readonly\s+)?(?:string\s*\[\s*\]|(?:Readonly)?Array\s*<\s*string\s*>)\s*(?:\|\s*undefined\s*)?[;,]?\s*$/;
14358
14855
  var DISABLED_RE = /enableDnsRebindingProtection\s*:\s*false/;
14359
14856
  var REFUSE_RE = /\b403\b|Forbidden/;
14360
14857
  var WIDE_BIND_RE = /\.listen\s*\([^)]*["'`]0\.0\.0\.0["'`]|\bhost(?:name)?\s*:\s*["'`]0\.0\.0\.0["'`]/;
@@ -14462,12 +14959,14 @@ var s22OriginValidation = {
14462
14959
  );
14463
14960
  }
14464
14961
  };
14465
- function resolveConstLiteral(tree, name) {
14962
+ function resolveConstLiteral(tree, name, file) {
14466
14963
  const re = new RegExp(
14467
14964
  `\\b(?:const|let|var)\\s+${name.replace(/[$]/g, "\\$&")}\\b[^=\\n]*=\\s*`,
14468
14965
  "g"
14469
14966
  );
14470
- for (const site of codeSites(tree, re)) {
14967
+ const sites = codeSites(tree, re);
14968
+ const ordered = file ? [...sites.filter((x) => x.file === file), ...sites.filter((x) => x.file !== file)] : sites;
14969
+ for (const site of ordered) {
14471
14970
  const after = site.col + site.match[0].length;
14472
14971
  const ch = site.line.raw[after];
14473
14972
  if (ch === "[") {
@@ -14483,10 +14982,11 @@ function classifyAllowedOrigins(tree) {
14483
14982
  const out = { sites: 0, first: "", ok: [], reds: [], unresolved: [] };
14484
14983
  for (const site of codeSites(tree, ALLOWED_ORIGINS_RE)) {
14485
14984
  if (!isProductSource(site.file.rel)) continue;
14486
- out.sites++;
14487
- out.first ||= site.anchor;
14488
14985
  const after = site.col + site.match[0].length;
14489
14986
  const rest = site.line.raw.slice(after);
14987
+ if (TYPE_ANNOTATION_RE.test(rest)) continue;
14988
+ out.sites++;
14989
+ out.first ||= site.anchor;
14490
14990
  let value;
14491
14991
  if (rest.startsWith("[")) {
14492
14992
  const b = balancedBlock(site.file, site.line.n, after, "[", "]");
@@ -14494,7 +14994,7 @@ function classifyAllowedOrigins(tree) {
14494
14994
  } else {
14495
14995
  value = (rest.match(/^(?:"[^"]*"|'[^']*'|`[^`]*`|[A-Za-z_$][\w$.]*)/) ?? [rest.trim()])[0];
14496
14996
  }
14497
- const verdict = classifyOriginValue(tree, value);
14997
+ const verdict = classifyOriginValue(tree, value, site.file);
14498
14998
  if (verdict.kind === "wildcard") {
14499
14999
  out.reds.push({
14500
15000
  anchor: site.anchor,
@@ -14513,31 +15013,42 @@ function classifyAllowedOrigins(tree) {
14513
15013
  }
14514
15014
  return out;
14515
15015
  }
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] };
15016
+ function leavesOf(tree, v, file, depth) {
15017
+ const out = { strings: [], unresolved: "" };
15018
+ const t = v.trim();
15019
+ const str = t.match(/^(["'`])(.*)\1$/s);
15020
+ if (str) {
15021
+ out.strings.push(str[0]);
15022
+ return out;
15023
+ }
15024
+ if (t.startsWith("[")) {
15025
+ for (const el of splitTopLevel(t.slice(1, -1))) {
15026
+ const inner = leavesOf(tree, el.startsWith("...") ? el.slice(3) : el, file, depth);
15027
+ out.strings.push(...inner.strings);
15028
+ out.unresolved ||= inner.unresolved;
14532
15029
  }
14533
- return unresolved ? { kind: "unresolved", detail: unresolved } : { kind: "ok", detail: v };
15030
+ return out;
14534
15031
  }
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);
15032
+ if (/^[A-Za-z_$][\w$]*$/.test(t) && depth < 3) {
15033
+ const lit = resolveConstLiteral(tree, t, file);
15034
+ if (lit === null) {
15035
+ out.unresolved = t;
15036
+ return out;
15037
+ }
15038
+ return leavesOf(tree, lit, file, depth + 1);
14539
15039
  }
14540
- return { kind: "unresolved", detail: v };
15040
+ out.unresolved = t;
15041
+ return out;
15042
+ }
15043
+ function classifyOriginValue(tree, value, file) {
15044
+ const v = value.trim();
15045
+ const leaves = leavesOf(tree, v, file, 0);
15046
+ const wild = leaves.strings.find((x) => x.slice(1, -1).includes("*"));
15047
+ if (wild) return { kind: "wildcard", detail: wild };
15048
+ if (leaves.unresolved) return { kind: "unresolved", detail: leaves.unresolved };
15049
+ if (leaves.strings.length === 0)
15050
+ return { kind: "empty", detail: v.startsWith("[") ? v : `${v} (an empty list)` };
15051
+ return { kind: "ok", detail: v };
14541
15052
  }
14542
15053
 
14543
15054
  // src/detectors/s23-protocol-version-header.ts
@@ -15086,8 +15597,8 @@ function readDoors(backendRoot) {
15086
15597
  return doors;
15087
15598
  }
15088
15599
  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)`);
15600
+ const esc3 = name.replace(/\$/g, "\\$");
15601
+ const re = new RegExp(`(?:\\b(?:const|let|var)\\s+${esc3}\\b|\\bfunction\\s+${esc3}\\b)`);
15091
15602
  for (const f of files) {
15092
15603
  const hit = re.exec(f.m);
15093
15604
  if (!hit) continue;
@@ -15654,6 +16165,329 @@ var s34AccessRecheck = {
15654
16165
  }
15655
16166
  };
15656
16167
 
16168
+ // src/detectors/s35-scope-in-read.ts
16169
+ var CALL_RE2 = /\b([A-Za-z_$][\w$]*)\s*\.\s*(query|action)\s*\(/g;
16170
+ var NARROW_WORD = /\b(?:org|orgId|orgSlug|organizationId|organisationId|organization|organisation|tenantId|namespace|recipient|recipientId|owner|ownerId|createdBy|assignedTo|oauthCtx|fromAllowList)\b/;
16171
+ var CARRY_WORD = /\b(?:orgId|organizationId|organisationId|tenantId|verifiedOrg|verifiedPerson|verifiedActor|oauthCtx)\b/;
16172
+ var PAGINATION_WORD = /\b(?:nextCursor|truncated|hasMore|total)\b/;
16173
+ function masked2(file) {
16174
+ return file.lines.map((line) => {
16175
+ let row = "";
16176
+ for (let c = 0; c < line.raw.length; c++) {
16177
+ const ch = line.raw[c];
16178
+ row += line.comment ? " " : ch === " " || isCodeAt(file, line.n, c) ? ch : " ";
16179
+ }
16180
+ return row;
16181
+ }).join("\n");
16182
+ }
16183
+ var CLOSE = { "(": ")", "[": "]", "{": "}" };
16184
+ function matchClose2(m, open) {
16185
+ const stack = [];
16186
+ for (let i = open; i < m.length; i++) {
16187
+ const ch = m[i];
16188
+ if (CLOSE[ch] !== void 0) stack.push(CLOSE[ch]);
16189
+ else if (ch === ")" || ch === "]" || ch === "}") {
16190
+ if (stack.pop() !== ch) return -1;
16191
+ if (stack.length === 0) return i;
16192
+ }
16193
+ }
16194
+ return -1;
16195
+ }
16196
+ function splitTop2(m, from, to) {
16197
+ const out = [];
16198
+ let depth = 0;
16199
+ let start = from;
16200
+ for (let i = from; i < to; i++) {
16201
+ const ch = m[i];
16202
+ if (ch === "(" || ch === "[" || ch === "{") depth++;
16203
+ else if (ch === ")" || ch === "]" || ch === "}") depth--;
16204
+ else if (ch === "," && depth === 0) {
16205
+ out.push([start, i]);
16206
+ start = i + 1;
16207
+ }
16208
+ }
16209
+ if (m.slice(start, to).trim() !== "") out.push([start, to]);
16210
+ return out;
16211
+ }
16212
+ function blockEnd(m, at) {
16213
+ let depth = 0;
16214
+ for (let i = at - 1; i >= 0; i--) {
16215
+ const ch = m[i];
16216
+ if (ch === "}") depth++;
16217
+ else if (ch === "{") {
16218
+ if (depth === 0) {
16219
+ const close = matchClose2(m, i);
16220
+ return close < 0 ? m.length : close;
16221
+ }
16222
+ depth--;
16223
+ }
16224
+ }
16225
+ return m.length;
16226
+ }
16227
+ var lineAt2 = (m, offset) => m.slice(0, offset).split("\n").length;
16228
+ var esc2 = (s) => s.replace(/\$/g, "\\$");
16229
+ function boundNames(m, callStart) {
16230
+ const lineStart = m.lastIndexOf("\n", callStart - 1) + 1;
16231
+ const before = m.slice(lineStart, callStart);
16232
+ const simple = before.match(
16233
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:await\s+)?$/
16234
+ );
16235
+ if (simple) return [simple[1]];
16236
+ const destr = before.match(/\b(?:const|let|var)\s+\{([^}]*)\}\s*=\s*(?:await\s+)?$/);
16237
+ if (destr) {
16238
+ 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));
16239
+ }
16240
+ return [];
16241
+ }
16242
+ function inlineNarrowing(m, start, close, end) {
16243
+ let pos = m.slice(0, start).replace(/\bawait\s+$/, "").length;
16244
+ let grouped = 0;
16245
+ for (; ; ) {
16246
+ let depth = 0;
16247
+ let open2 = -1;
16248
+ for (let i = pos - 1; i >= 0; i--) {
16249
+ const ch = m[i];
16250
+ if (ch === ")" || ch === "]" || ch === "}") depth++;
16251
+ else if (ch === "(" || ch === "[" || ch === "{") {
16252
+ if (depth === 0) {
16253
+ open2 = ch === "(" ? i : -2;
16254
+ break;
16255
+ }
16256
+ depth--;
16257
+ } else if (ch === ";" && depth === 0) break;
16258
+ }
16259
+ if (open2 < 0) break;
16260
+ const ident = /([A-Za-z_$][\w$]*)\s*$/.exec(m.slice(0, open2));
16261
+ if (ident) {
16262
+ if (ident[1] === "scopeFilterList" || ident[1] === "scopeFilterGet") {
16263
+ return {
16264
+ narrowing: { offset: open2 - ident[0].trimEnd().length, form: ident[1] }
16265
+ };
16266
+ }
16267
+ break;
16268
+ }
16269
+ if (!/^\s*(?:await\s+)?$/.test(m.slice(open2 + 1, start))) break;
16270
+ grouped++;
16271
+ pos = m.slice(0, open2).replace(/\bawait\s+$/, "").length;
16272
+ }
16273
+ let p = close + 1;
16274
+ for (let g = 0; g < grouped; g++) {
16275
+ const rest = m.slice(p);
16276
+ const skip = /^\s*\)/.exec(rest);
16277
+ if (!skip) return null;
16278
+ p += skip[0].length;
16279
+ }
16280
+ const chain = /^\s*(?:\??\.\s*(?!filter\b|then\b)[A-Za-z_$][\w$]*\s*)*\??\.\s*(filter|then)\s*\(/.exec(
16281
+ m.slice(p, end)
16282
+ );
16283
+ if (!chain) return null;
16284
+ const open = p + chain[0].length - 1;
16285
+ const cbClose = matchClose2(m, open);
16286
+ if (cbClose < 0) return { unreadable: `a .${chain[1]}( whose closing paren is not found` };
16287
+ const argSpans = splitTop2(m, open + 1, cbClose);
16288
+ const cbSpan = argSpans[0];
16289
+ const cb = cbSpan ? m.slice(cbSpan[0], cbSpan[1]) : "";
16290
+ if (chain[1] === "filter") {
16291
+ return NARROW_WORD.test(cb) ? { narrowing: { offset: p + chain[0].indexOf("."), form: ".filter" } } : null;
16292
+ }
16293
+ const head = /^\s*(?:async\s+)?(?:\(\s*([A-Za-z_$][\w$]*)\s*(?::[^)]*)?\)|([A-Za-z_$][\w$]*))\s*=>\s*/.exec(
16294
+ cb
16295
+ ) ?? /^\s*(?:async\s+)?function\s*[\w$]*\s*\(\s*([A-Za-z_$][\w$]*)[^)]*\)\s*(?=\{)/.exec(cb);
16296
+ const param = head ? head[1] ?? head[2] : void 0;
16297
+ if (!head || param === void 0) {
16298
+ return {
16299
+ unreadable: `a .then callback (\`${cb.trim().slice(0, 40)}\`) whose body the scan does not read`
16300
+ };
16301
+ }
16302
+ const bodyFrom = (cbSpan?.[0] ?? 0) + head[0].length;
16303
+ const bodyTo = cbSpan?.[1] ?? bodyFrom;
16304
+ const found = narrowingsOver(m, [param], bodyFrom, bodyTo);
16305
+ return found.length > 0 ? { narrowing: found[0] } : null;
16306
+ }
16307
+ function rebound(m, names, from, to) {
16308
+ const out = [];
16309
+ const region = m.slice(from, to);
16310
+ for (const name of names) {
16311
+ for (const hit of region.matchAll(
16312
+ new RegExp(
16313
+ `\\b(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*(?::[^=]+)?=\\s*${esc2(name)}\\s*(?:;|$)`,
16314
+ "gm"
16315
+ )
16316
+ )) {
16317
+ out.push(hit[1]);
16318
+ }
16319
+ }
16320
+ return out;
16321
+ }
16322
+ function narrowingsOver(m, names, from, to) {
16323
+ const out = [];
16324
+ const region = m.slice(from, to);
16325
+ for (const name of names) {
16326
+ const n = esc2(name);
16327
+ for (const hit of region.matchAll(/\bscopeFilter(List|Get)\s*\(/g)) {
16328
+ const open = from + (hit.index ?? 0) + hit[0].length - 1;
16329
+ const close = matchClose2(m, open);
16330
+ const args = m.slice(open + 1, close < 0 ? to : close);
16331
+ if (new RegExp(`\\b${n}\\b`).test(args)) {
16332
+ out.push({ offset: from + (hit.index ?? 0), form: `scopeFilter${hit[1]}` });
16333
+ }
16334
+ }
16335
+ for (const hit of region.matchAll(
16336
+ new RegExp(`\\b${n}(?:\\s*\\??\\.\\s*[A-Za-z_$][\\w$]*)*\\s*\\??\\.\\s*filter\\s*\\(`, "g")
16337
+ )) {
16338
+ const open = from + (hit.index ?? 0) + hit[0].length - 1;
16339
+ const close = matchClose2(m, open);
16340
+ const cb = m.slice(open + 1, close < 0 ? to : close);
16341
+ if (NARROW_WORD.test(cb)) out.push({ offset: from + (hit.index ?? 0), form: ".filter" });
16342
+ }
16343
+ for (const hit of region.matchAll(
16344
+ new RegExp(
16345
+ `\\bfor\\s*\\(\\s*(?:const|let|var)\\s+[A-Za-z_$][\\w$]*\\s+of\\s+${n}(?:\\s*\\??\\.\\s*[A-Za-z_$][\\w$]*)*\\s*\\)\\s*\\{`,
16346
+ "g"
16347
+ )
16348
+ )) {
16349
+ const open = from + (hit.index ?? 0) + hit[0].length - 1;
16350
+ const close = matchClose2(m, open);
16351
+ const body = m.slice(open + 1, close < 0 ? to : close);
16352
+ for (const cond of body.matchAll(/\bif\s*\(/g)) {
16353
+ const co = open + 1 + (cond.index ?? 0) + cond[0].length - 1;
16354
+ const cc = matchClose2(m, co);
16355
+ if (cc < 0) continue;
16356
+ const after = m.slice(cc + 1, cc + 40);
16357
+ if (NARROW_WORD.test(m.slice(co, cc)) && /^\s*(?:\{\s*)?continue\b/.test(after)) {
16358
+ out.push({ offset: from + (hit.index ?? 0), form: "a skipping branch in a loop" });
16359
+ break;
16360
+ }
16361
+ }
16362
+ }
16363
+ }
16364
+ return out.sort((a, b) => a.offset - b.offset);
16365
+ }
16366
+ function scanS35(tree) {
16367
+ const res = { calls: 0, red: [], cnj: [], ok: [] };
16368
+ for (const file of tree.files) {
16369
+ if (!isProductSource(file.rel)) continue;
16370
+ const m = masked2(file);
16371
+ const raw = file.lines.map((l) => l.raw).join("\n");
16372
+ for (const hit of m.matchAll(CALL_RE2)) {
16373
+ if (!/convex|client/i.test(hit[1])) continue;
16374
+ const start = hit.index ?? 0;
16375
+ const open = start + hit[0].length - 1;
16376
+ const close = matchClose2(m, open);
16377
+ if (close < 0) continue;
16378
+ res.calls++;
16379
+ const anchor = `${file.rel}:${lineAt2(m, start)}`;
16380
+ const parts = splitTop2(m, open + 1, close);
16381
+ const first = parts[0] ? raw.slice(parts[0][0], parts[0][1]) : "";
16382
+ const cast = String.raw`(?:\s+as\s+[\w$.<>[\] ]+)?\s*$`;
16383
+ const lit = new RegExp(String.raw`^\s*["'\x60]([\w/]+:[\w]+)["'\x60]${cast}`).exec(first);
16384
+ const ref = new RegExp(String.raw`^\s*(?:api|internal)\.([\w]+)\.([\w]+)${cast}`).exec(first);
16385
+ const door = lit ? lit[1] : ref ? `${ref[1]}:${ref[2]}` : void 0;
16386
+ if (door === void 0) {
16387
+ res.cnj.push({
16388
+ anchor,
16389
+ note: `${anchor} names its door by an expression (\`${first.trim().slice(0, 40)}\`): which door, and so which arguments it accepts, is unknown`
16390
+ });
16391
+ continue;
16392
+ }
16393
+ const argText = parts.slice(1).map(([a, b]) => m.slice(a, b)).join(",");
16394
+ const carried = CARRY_WORD.test(argText);
16395
+ const names = boundNames(m, start);
16396
+ const end = blockEnd(m, start);
16397
+ const inline = inlineNarrowing(m, start, close, end);
16398
+ const narrowings = [
16399
+ ...inline && "narrowing" in inline ? [inline.narrowing] : [],
16400
+ ...narrowingsOver(m, [...names, ...rebound(m, names, close + 1, end)], close + 1, end)
16401
+ ].sort((a, b) => a.offset - b.offset);
16402
+ if (narrowings.length === 0 && inline && "unreadable" in inline && !carried) {
16403
+ res.cnj.push({
16404
+ anchor,
16405
+ note: `${anchor} ${door} is followed by ${inline.unreadable}: whether it narrows the result is unread`
16406
+ });
16407
+ continue;
16408
+ }
16409
+ if (narrowings.length === 0 || carried) {
16410
+ res.ok.push({
16411
+ anchor,
16412
+ note: names.length === 0 && !inline ? `${anchor} ${door}: result not bound, nothing narrows it` : `${anchor} ${door}`
16413
+ });
16414
+ continue;
16415
+ }
16416
+ const spread = /\.\.\.\s*[A-Za-z_$]/.test(argText);
16417
+ const n = narrowings[0];
16418
+ const at = `${file.rel}:${lineAt2(m, n.offset)}`;
16419
+ if (spread) {
16420
+ res.cnj.push({
16421
+ anchor,
16422
+ 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`
16423
+ });
16424
+ continue;
16425
+ }
16426
+ const paged = PAGINATION_WORD.test(m.slice(n.offset, end));
16427
+ res.red.push({
16428
+ anchor: at,
16429
+ 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" : ""}`
16430
+ });
16431
+ }
16432
+ }
16433
+ return res;
16434
+ }
16435
+ function s35Controls() {
16436
+ const red = scanS35Text(
16437
+ 'const rows = await client.query("t:list", { limit });\nconst mine = scopeFilterList(rows, oauthCtx);\n'
16438
+ );
16439
+ const green = scanS35Text(
16440
+ 'const rows = await client.query("t:list", { limit, orgId: oauthCtx.orgId });\nconst mine = scopeFilterList(rows, oauthCtx);\n'
16441
+ );
16442
+ return { sees: red, spares: !green };
16443
+ }
16444
+ function scanS35Text(text) {
16445
+ const call = /\bclient\.query\(([^)]*)\)/.exec(text);
16446
+ if (!call) return false;
16447
+ const carried = CARRY_WORD.test(call[1].split(",").slice(1).join(","));
16448
+ return !carried && narrowingsOver(text, ["rows"], call.index + call[0].length, text.length).length > 0;
16449
+ }
16450
+ var s35ScopeInRead = {
16451
+ id: "S35 scope-in-read",
16452
+ rule: "S-35",
16453
+ pin: STANDARD_PIN,
16454
+ run(tree) {
16455
+ const ctl = s35Controls();
16456
+ if (!ctl.sees || !ctl.spares) {
16457
+ throw new Error(
16458
+ `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`
16459
+ );
16460
+ }
16461
+ const scan = scanS35(tree);
16462
+ if (scan.calls === 0) {
16463
+ return absentResult(
16464
+ this.id,
16465
+ "-",
16466
+ "no backend call (<client>.query|action) in product source (positive control OK) \u2014 S-35 n/a"
16467
+ );
16468
+ }
16469
+ if (scan.red.length > 0) {
16470
+ return redResult(
16471
+ this.id,
16472
+ scan.red[0].anchor,
16473
+ `${scan.red.map((s) => s.note).join("; ")} -> pass the scope predicate to the door as an argument and forward its pagination unchanged (S-35)`
16474
+ );
16475
+ }
16476
+ if (scan.cnj.length > 0) {
16477
+ return couldNotJudge(
16478
+ this.id,
16479
+ scan.cnj[0].anchor,
16480
+ `${scan.cnj.map((s) => s.note).join("; ")} (S-35)`
16481
+ );
16482
+ }
16483
+ return presentResult(
16484
+ this.id,
16485
+ scan.ok[0].anchor,
16486
+ `${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)`
16487
+ );
16488
+ }
16489
+ };
16490
+
15657
16491
  // src/detectors/index.ts
15658
16492
  var registered = [
15659
16493
  d2,
@@ -15752,6 +16586,7 @@ var registered = [
15752
16586
  s25ResourceDiscipline,
15753
16587
  s26ListChangedParity,
15754
16588
  s27ElicitationFloor,
16589
+ s35ScopeInRead,
15755
16590
  e5ExtensionIsolation,
15756
16591
  a3SandboxRespected,
15757
16592
  a4WcagGate,
@@ -15778,6 +16613,7 @@ var READS_CODE = /* @__PURE__ */ new Set([
15778
16613
  "S-25",
15779
16614
  "S-26",
15780
16615
  "S-27",
16616
+ "S-35",
15781
16617
  "E-5",
15782
16618
  "A-3"
15783
16619
  ]);
@@ -15856,6 +16692,7 @@ function deriveLaunchPlan(target) {
15856
16692
  var INTROSPECTOR = String.raw`
15857
16693
  import { spawn } from "node:child_process";
15858
16694
  const plan = JSON.parse(process.env.MCP_DOCTOR_PLAN);
16695
+ const INVALID_CURSOR = "mcp-doctor:invalid-cursor:%%%";
15859
16696
  const done = (o) => { process.stdout.write(JSON.stringify(o)); process.exit(0); };
15860
16697
  let child;
15861
16698
  try {
@@ -15865,11 +16702,35 @@ try {
15865
16702
  }
15866
16703
  let settled = false;
15867
16704
  let serverInfo;
16705
+ let capabilities = {};
16706
+ let tools;
16707
+ const probes = [];
16708
+ const pending = new Map();
16709
+ let nextId = 10;
16710
+ let probeTimer;
15868
16711
  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);
16712
+ const finish = (why) => {
16713
+ if (settled) return; settled = true; clearTimeout(to); clearTimeout(probeTimer);
16714
+ for (const idx of pending.values()) probes[idx].outcome = { kind: "no-answer", reason: why };
16715
+ try { child.kill(); } catch {}
16716
+ done({ tools, serverInfo, cursorProbes: probes });
16717
+ };
16718
+ const to = setTimeout(() => {
16719
+ if (tools) finish("no answer before the introspection deadline");
16720
+ else fail("timeout: server did not answer initialize+tools/list within the deadline");
16721
+ }, 12000);
15870
16722
  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"));
16723
+ child.on("exit", (code) => {
16724
+ if (tools) finish("the server exited (code " + code + ") before answering");
16725
+ else fail("server exited (code " + code + ") before completing the MCP handshake");
16726
+ });
15872
16727
  const send = (o) => { try { child.stdin.write(JSON.stringify(o) + "\n"); } catch (e) { fail("write failed: " + e.message); } };
16728
+ const probe = (target, surface, method, params) => {
16729
+ const id = nextId++;
16730
+ pending.set(id, probes.length);
16731
+ probes.push({ target, surface });
16732
+ send({ jsonrpc: "2.0", id, method, params });
16733
+ };
15873
16734
  let buf = "";
15874
16735
  child.stdout.on("data", (d) => {
15875
16736
  buf += d.toString();
@@ -15878,6 +16739,8 @@ child.stdout.on("data", (d) => {
15878
16739
  const line = buf.slice(0, i); buf = buf.slice(i + 1);
15879
16740
  if (!line.trim()) continue;
15880
16741
  let msg; try { msg = JSON.parse(line); } catch { continue; }
16742
+ // A request or notification FROM the server carries a method: it is not an answer.
16743
+ if (msg.method !== undefined) continue;
15881
16744
  if (msg.id === 1) {
15882
16745
  // The initialize response's serverInfo is the SAME non-authenticated
15883
16746
  // discovery surface a client reads version from (S-7); captured here
@@ -15885,13 +16748,46 @@ child.stdout.on("data", (d) => {
15885
16748
  // alongside it, additively -- every existing consumer of the
15886
16749
  // tools-only introspector output is unaffected.
15887
16750
  serverInfo = msg.result && msg.result.serverInfo;
16751
+ capabilities = (msg.result && msg.result.capabilities) || {};
15888
16752
  send({ jsonrpc: "2.0", method: "notifications/initialized" });
15889
16753
  send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
15890
16754
  } 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 });
16755
+ if (settled) return;
16756
+ const served = msg.result && Array.isArray(msg.result.tools) ? msg.result.tools : [];
16757
+ const schemaOf = (t) => (t && t.inputSchema && typeof t.inputSchema === "object" ? t.inputSchema : {});
16758
+ const takesCursor = (t) => {
16759
+ const props = schemaOf(t).properties;
16760
+ return Boolean(props && typeof props === "object" && Object.prototype.hasOwnProperty.call(props, "cursor"));
16761
+ };
16762
+ tools = served.map((t) => ({ name: String(t && t.name), description: t && typeof t.description === "string" ? t.description : undefined, cursorArgument: takesCursor(t) }));
16763
+ const lists = ["tools/list"];
16764
+ if (capabilities.resources) lists.push("resources/list", "resources/templates/list");
16765
+ if (capabilities.prompts) lists.push("prompts/list");
16766
+ for (const m of lists) probe(m, "list-method", m, { cursor: INVALID_CURSOR });
16767
+ for (const t of served) {
16768
+ if (!takesCursor(t)) continue;
16769
+ const name = String(t && t.name);
16770
+ const required = Array.isArray(schemaOf(t).required) ? schemaOf(t).required.filter((r) => r !== "cursor") : [];
16771
+ if (required.length > 0) {
16772
+ 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" } });
16773
+ continue;
16774
+ }
16775
+ probe(name, "tool-argument", "tools/call", { name, arguments: { cursor: INVALID_CURSOR } });
16776
+ }
16777
+ if (pending.size === 0) { finish("no probe pending"); return; }
16778
+ probeTimer = setTimeout(() => finish("no answer within the 5 s probe deadline"), 5000);
16779
+ } else if (pending.has(msg.id)) {
16780
+ const idx = pending.get(msg.id); pending.delete(msg.id);
16781
+ if (msg.error && typeof msg.error === "object") {
16782
+ probes[idx].outcome = { kind: "error", code: msg.error.code, message: typeof msg.error.message === "string" ? msg.error.message : undefined };
16783
+ } else if (msg.result && typeof msg.result === "object") {
16784
+ const sc = msg.result.structuredContent;
16785
+ const err = sc && typeof sc === "object" ? sc.error : undefined;
16786
+ probes[idx].outcome = { kind: "result", isError: msg.result.isError === true, errorCode: err && typeof err === "object" ? err.code : undefined };
16787
+ } else {
16788
+ probes[idx].outcome = { kind: "no-answer", reason: "an answer with neither result nor error" };
16789
+ }
16790
+ if (pending.size === 0) finish("no probe pending");
15895
16791
  }
15896
16792
  }
15897
16793
  });
@@ -15942,6 +16838,54 @@ function deriveExpectedCommit(cwd) {
15942
16838
  }
15943
16839
  return void 0;
15944
16840
  }
16841
+ function readCursorProbe(raw) {
16842
+ const r = raw ?? {};
16843
+ const target = typeof r.target === "string" ? r.target : "<unnamed>";
16844
+ const surface = r.surface === "tool-argument" ? "tool-argument" : "list-method";
16845
+ const o = r.outcome ?? {};
16846
+ const unreadable = (what) => ({
16847
+ target,
16848
+ surface,
16849
+ outcome: { kind: "no-answer", reason: `unreadable probe record (${what})` }
16850
+ });
16851
+ if (r.surface !== "tool-argument" && r.surface !== "list-method") {
16852
+ return unreadable(`surface ${JSON.stringify(r.surface)}`);
16853
+ }
16854
+ switch (o.kind) {
16855
+ case "error":
16856
+ return {
16857
+ target,
16858
+ surface,
16859
+ outcome: {
16860
+ kind: "error",
16861
+ code: o.code,
16862
+ ...typeof o.message === "string" ? { message: o.message } : {}
16863
+ }
16864
+ };
16865
+ case "result":
16866
+ return {
16867
+ target,
16868
+ surface,
16869
+ outcome: {
16870
+ kind: "result",
16871
+ isError: o.isError === true,
16872
+ ...o.errorCode !== void 0 ? { errorCode: o.errorCode } : {}
16873
+ }
16874
+ };
16875
+ case "no-answer":
16876
+ case "not-sent":
16877
+ return {
16878
+ target,
16879
+ surface,
16880
+ outcome: {
16881
+ kind: o.kind,
16882
+ reason: typeof o.reason === "string" ? o.reason : "no reason given"
16883
+ }
16884
+ };
16885
+ default:
16886
+ return unreadable(`outcome kind ${JSON.stringify(o.kind)}`);
16887
+ }
16888
+ }
15945
16889
  function launchAndIntrospect(target) {
15946
16890
  const plan = deriveLaunchPlan(target);
15947
16891
  if ("error" in plan) {
@@ -15987,14 +16931,16 @@ function launchAndIntrospect(target) {
15987
16931
  const name = t.name;
15988
16932
  const count = evalCounts.get(name);
15989
16933
  const base = t.description ? { name, description: t.description } : { name };
15990
- return count === void 0 ? base : { ...base, evalCaseCount: count };
16934
+ const withCursor = t.cursorArgument === true ? { ...base, cursorArgument: true } : base;
16935
+ return count === void 0 ? withCursor : { ...withCursor, evalCaseCount: count };
15991
16936
  });
15992
16937
  const expectedCommit = deriveExpectedCommit(plan.cwd);
15993
16938
  return {
15994
16939
  root: target,
15995
16940
  tools,
15996
16941
  ...parsed.serverInfo ? { serverInfo: parsed.serverInfo } : {},
15997
- ...expectedCommit ? { expectedCommit } : {}
16942
+ ...expectedCommit ? { expectedCommit } : {},
16943
+ ...Array.isArray(parsed.cursorProbes) ? { cursorProbes: parsed.cursorProbes.map(readCursorProbe) } : {}
15998
16944
  };
15999
16945
  }
16000
16946
 
@@ -16246,7 +17192,8 @@ var USAGE = [
16246
17192
  " --version print the version and exit",
16247
17193
  " --help print this and exit",
16248
17194
  "",
16249
- "exit: 0 clean, 1 a violation, 2 an abstention with no violation"
17195
+ "exit: 0 clean, 1 a violation, 2 an abstention with no violation (including a target",
17196
+ " that names nothing readable: missing, empty, or not a source tree)"
16250
17197
  ].join("\n");
16251
17198
  function readVersion() {
16252
17199
  try {
@@ -16297,10 +17244,28 @@ function main() {
16297
17244
  detectorList = detectors.map((d) => d === localIdentity ? ratchet : d);
16298
17245
  }
16299
17246
  const backend = backendArg ? backendArg.slice("--backend=".length) : void 0;
16300
- const { results, skipped } = runDoctorReport(target, detectorList, {
16301
- mode,
16302
- ...backend !== void 0 ? { backend } : {}
16303
- });
17247
+ let report;
17248
+ try {
17249
+ report = runDoctorReport(target, detectorList, {
17250
+ mode,
17251
+ ...backend !== void 0 ? { backend } : {}
17252
+ });
17253
+ } catch (err) {
17254
+ if (!(err instanceof SubjectUnreadableError)) throw err;
17255
+ if (args.includes("--json")) {
17256
+ console.log(
17257
+ JSON.stringify(
17258
+ { surface: target, exitCode: 2, subjectUnreadable: err.form, results: [], skipped: [] },
17259
+ null,
17260
+ 2
17261
+ )
17262
+ );
17263
+ }
17264
+ console.error(err.message);
17265
+ process.exitCode = 2;
17266
+ return;
17267
+ }
17268
+ const { results, skipped } = report;
16304
17269
  console.log(
16305
17270
  args.includes("--json") ? renderJson(target, results, skipped) : renderTable(target, results, skipped)
16306
17271
  );
@@ -16323,6 +17288,7 @@ function isDirectInvocation() {
16323
17288
  }
16324
17289
  if (isDirectInvocation()) main();
16325
17290
  export {
17291
+ SubjectUnreadableError,
16326
17292
  computeExitCode,
16327
17293
  countOwedHumanVerdicts,
16328
17294
  isLiveDetector,