@vantageos/mcp-doctor 0.8.1 → 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/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to `@vantageos/mcp-doctor` are documented here.
4
4
  This project adheres to [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 0.8.2 — 2026-10-25
7
+
8
+ ### Fixed
9
+ - **A detector's own read that fails, or could block, is named instead of being judged empty** (task
10
+ k17dddv66dbzd1fsmz9bwg4ysx8gy6zv). 0.8.1 closed this for the target walk. Each detector's own reads
11
+ still had the same holes:
12
+ - a read failure answered by a bare `return` or `continue`;
13
+ - a `readFileSync` that would block forever on a FIFO.
14
+
15
+ Every site is now classified in `analysis/detector-read-class.md`: 87 rows, of which 43 are declared
16
+ legitimate (with a comment at the site) and 44 are converted. A converted site reports
17
+ `could-not-judge` naming what it did not read, or records a skip. A non-regular file is never read.
18
+ C-26 no longer aborts the run on an unreadable `package.json` or lockfile. D8 (dynamic) no longer
19
+ counts an unreadable `evals.json` as zero coverage.
20
+
21
+ On readable input every verdict is unchanged: four real trees give the same 101 results, the same
22
+ states and the same exit code on 0.8.1 and 0.8.2.
23
+
6
24
  ## 0.8.1 — 2026-10-13
7
25
 
8
26
  Standard re-pinned to ElPi-Corp `6d463a1ecc5256cd59dd1afe5aa5e335accacc90`: the direct child of
package/dist/doctor.d.ts CHANGED
@@ -201,6 +201,12 @@ interface RuntimeContext {
201
201
  root: string;
202
202
  tools: RuntimeTool[];
203
203
  launchError?: string;
204
+ /**
205
+ * Set when the target's `evals/evals.json` is present but was NOT READ (permission, or not a
206
+ * regular file). Every `evalCaseCount` is then unset because the file was unread, not because it
207
+ * covers nothing: D8 reads it as `could-not-judge`, never as zero coverage.
208
+ */
209
+ evalsUnreadable?: string;
204
210
  /**
205
211
  * The `serverInfo` object the running server returned in its `initialize`
206
212
  * response — the same non-authenticated discovery handshake a client reads
package/dist/doctor.js CHANGED
@@ -1,7 +1,7 @@
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
 
@@ -508,7 +508,7 @@ function countInCode(file, line, ch) {
508
508
  }
509
509
 
510
510
  // src/detectors/couldNotJudge.ts
511
- import { readFileSync as readFileSync2 } from "fs";
511
+ import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
512
512
  function couldNotJudge(id, anchor, reason, owedVerdict) {
513
513
  return {
514
514
  id,
@@ -536,6 +536,7 @@ function settle(id, decided, unreadable) {
536
536
  function readJsonObject(path) {
537
537
  let value;
538
538
  try {
539
+ if (!statSync2(path).isFile()) return null;
539
540
  value = JSON.parse(readFileSync2(path, "utf8"));
540
541
  } catch {
541
542
  return null;
@@ -563,7 +564,7 @@ function guardUnreadablePackageJson(inner, opts) {
563
564
  }
564
565
 
565
566
  // src/detectors/siteScan.ts
566
- import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
567
+ import { constants as constants2, accessSync as accessSync2, existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
567
568
  import { join as join2, relative as relative2, sep as sep2 } from "path";
568
569
  function codeSites(tree, re) {
569
570
  const out = [];
@@ -630,7 +631,7 @@ function isRecord(v) {
630
631
  }
631
632
  function baseOf(tree) {
632
633
  try {
633
- return statSync2(tree.root).isDirectory() ? tree.root : join2(tree.root, "..");
634
+ return statSync3(tree.root).isDirectory() ? tree.root : join2(tree.root, "..");
634
635
  } catch {
635
636
  return tree.root;
636
637
  }
@@ -642,15 +643,17 @@ function findDiskFiles(tree, pred) {
642
643
  let entries;
643
644
  try {
644
645
  entries = readdirSync2(dir);
645
- } catch {
646
+ } catch (err) {
647
+ recordSkip(tree, dir, base, `could not be listed (${fsErrorCode(err)}) \u2014 not read`);
646
648
  return;
647
649
  }
648
650
  for (const name of entries.sort()) {
649
651
  const full = join2(dir, name);
650
652
  let st;
651
653
  try {
652
- st = statSync2(full);
653
- } catch {
654
+ st = statSync3(full);
655
+ } catch (err) {
656
+ recordSkip(tree, full, base, `could not be read (${fsErrorCode(err)}) \u2014 not read`);
654
657
  continue;
655
658
  }
656
659
  if (st.isDirectory()) {
@@ -661,7 +664,18 @@ function findDiskFiles(tree, pred) {
661
664
  walk2(full);
662
665
  } else {
663
666
  const rel = relative2(base, full).split(sep2).join("/");
664
- if (pred(name, rel)) out.push({ path: full, rel, name });
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 });
665
679
  }
666
680
  }
667
681
  };
@@ -670,6 +684,7 @@ function findDiskFiles(tree, pred) {
670
684
  }
671
685
  function readText(path) {
672
686
  try {
687
+ if (!statSync3(path).isFile()) return null;
673
688
  return readFileSync3(path, "utf8");
674
689
  } catch {
675
690
  return null;
@@ -1738,7 +1753,7 @@ var a2DeclaredDomains = {
1738
1753
  };
1739
1754
 
1740
1755
  // src/detectors/v080Kit.ts
1741
- 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";
1742
1757
  import { dirname as dirname2, join as join3 } from "path";
1743
1758
 
1744
1759
  // src/detectors/toolContext.ts
@@ -2629,16 +2644,29 @@ function governingWorkflows(tree) {
2629
2644
  const wf = join3(dir, ".github", "workflows");
2630
2645
  if (existsSync3(wf)) {
2631
2646
  const out = [];
2632
- 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) {
2633
2656
  if (!/\.ya?ml$/.test(name)) continue;
2634
2657
  const full = join3(wf, name);
2635
2658
  try {
2636
- if (!statSync3(full).isFile()) continue;
2637
- } 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`);
2638
2665
  continue;
2639
2666
  }
2640
2667
  const text = readText(full);
2641
2668
  if (text !== null) out.push({ rel: `.github/workflows/${name}`, text });
2669
+ else recordSkip(tree, full, base, "could not be read \u2014 not read");
2642
2670
  }
2643
2671
  return out;
2644
2672
  }
@@ -2947,7 +2975,7 @@ var a4WcagGate = {
2947
2975
  };
2948
2976
 
2949
2977
  // src/detectors/a5-performance-budgets.ts
2950
- 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";
2951
2979
  import { join as join4, relative as relative3, sep as sep3 } from "path";
2952
2980
  import { gzipSync } from "zlib";
2953
2981
  var BUDGET_BYTES = 250 * 1024;
@@ -2983,7 +3011,7 @@ function collect(base) {
2983
3011
  const candidate = BUNDLE_EXT_RE.test(name) && UI_PATH_RE.test(rel);
2984
3012
  let st;
2985
3013
  try {
2986
- st = statSync4(full);
3014
+ st = statSync5(full);
2987
3015
  } catch {
2988
3016
  if (candidate) unmeasured.push(`${rel} (cannot be read: dangling link or permission)`);
2989
3017
  continue;
@@ -2991,6 +3019,10 @@ function collect(base) {
2991
3019
  if (st.isDirectory()) {
2992
3020
  if (!WALK_SKIP.has(name)) walk2(full, depth + 1);
2993
3021
  } else if (candidate) {
3022
+ if (!st.isFile()) {
3023
+ unmeasured.push(`${rel} (not a regular file, not read)`);
3024
+ continue;
3025
+ }
2994
3026
  if (st.size > MAX_MEASURE_BYTES) {
2995
3027
  unmeasured.push(`${rel} (${(st.size / 1048576).toFixed(0)} MB, too large to measure)`);
2996
3028
  continue;
@@ -4244,7 +4276,7 @@ var a12FileWrite = {
4244
4276
  };
4245
4277
 
4246
4278
  // src/detectors/c7-artifact-verifiable.ts
4247
- import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
4279
+ import { existsSync as existsSync4, readFileSync as readFileSync5, statSync as statSync6 } from "fs";
4248
4280
  import { join as join5 } from "path";
4249
4281
  var PIN = STANDARD_PIN;
4250
4282
  var DOC_FILES = ["README.md", "README.fr.md"];
@@ -4282,13 +4314,18 @@ function c7PositiveControls() {
4282
4314
  }
4283
4315
  };
4284
4316
  }
4285
- function readDocText(root) {
4317
+ function readDocText(root, unreadable) {
4286
4318
  for (const name of DOC_FILES) {
4287
4319
  const path = join5(root, name);
4288
4320
  if (!existsSync4(path)) continue;
4289
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
+ }
4290
4326
  return { text: readFileSync5(path, "utf8"), file: name };
4291
- } catch {
4327
+ } catch (err) {
4328
+ unreadable.push({ anchor: name, reason: `${name} could not be read (${fsErrorCode(err)})` });
4292
4329
  }
4293
4330
  }
4294
4331
  return null;
@@ -4319,7 +4356,15 @@ var c7ArtifactVerifiable = {
4319
4356
  `C7 channel-matcher control FAILED on \`${controls.channelMatcher.sample}\` \u2014 matcher broken, verdict untrustworthy`
4320
4357
  );
4321
4358
  }
4322
- 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
+ }
4323
4368
  if (!doc) {
4324
4369
  return {
4325
4370
  id: this.id,
@@ -5582,7 +5627,7 @@ var c16UntrustedOutput = {
5582
5627
  };
5583
5628
 
5584
5629
  // src/detectors/c17-supply-chain.ts
5585
- 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";
5586
5631
  import { dirname as dirname3, join as join6, relative as relative4, resolve } from "path";
5587
5632
  var PIN4 = STANDARD_PIN;
5588
5633
  var LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"];
@@ -5616,15 +5661,26 @@ function judgeProvenanceRecord(line, name, version) {
5616
5661
  }
5617
5662
  return { ok: true, why: "" };
5618
5663
  }
5619
- function findProvenanceRecord(root, name, version) {
5664
+ function findProvenanceRecord(root, name, version, unreadable) {
5620
5665
  let firstBad = null;
5621
5666
  for (const recordFile of PROVENANCE_RECORD_FILES) {
5622
5667
  const path = join6(root, recordFile);
5623
5668
  if (!existsSync5(path)) continue;
5624
5669
  let text;
5625
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
+ }
5626
5678
  text = readFileSync6(path, "utf8");
5627
- } catch {
5679
+ } catch (err) {
5680
+ unreadable.push({
5681
+ anchor: recordFile,
5682
+ reason: `${recordFile} could not be read (${fsErrorCode(err)})`
5683
+ });
5628
5684
  continue;
5629
5685
  }
5630
5686
  const lines = text.split("\n");
@@ -5677,7 +5733,7 @@ function findLockfile(root) {
5677
5733
  }
5678
5734
  return null;
5679
5735
  }
5680
- function findProvenanceClaim(tree, root) {
5736
+ function findProvenanceClaim(tree, root, unreadable) {
5681
5737
  const pkg = tree.packageJson;
5682
5738
  if (pkg) {
5683
5739
  const publishConfig = pkg.json.publishConfig;
@@ -5691,16 +5747,29 @@ function findProvenanceClaim(tree, root) {
5691
5747
  let entries = [];
5692
5748
  try {
5693
5749
  entries = readdirSync5(workflowsDir).sort();
5694
- } 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
+ });
5695
5755
  continue;
5696
5756
  }
5697
5757
  for (const name of entries) {
5698
5758
  if (!/\.ya?ml$/.test(name)) continue;
5699
5759
  const path = join6(workflowsDir, name);
5700
5760
  let text;
5761
+ const shown = `${relative4(root, workflowsDir) || WORKFLOW_DIR}/${name}`;
5701
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
+ }
5702
5767
  text = readFileSync6(path, "utf8");
5703
- } catch {
5768
+ } catch (err) {
5769
+ unreadable.push({
5770
+ anchor: shown,
5771
+ reason: `${shown} could not be read (${fsErrorCode(err)})`
5772
+ });
5704
5773
  continue;
5705
5774
  }
5706
5775
  const hit = workflowStatements(text).find(([, l]) => PROVENANCE_RE2.test(l));
@@ -5749,10 +5818,11 @@ var c17SupplyChainInner = {
5749
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)"
5750
5819
  };
5751
5820
  }
5752
- const claim = findProvenanceClaim(tree, tree.root);
5821
+ const unreadable = [];
5822
+ const claim = findProvenanceClaim(tree, tree.root, unreadable);
5753
5823
  const pkgVersion = typeof tree.packageJson?.json.version === "string" ? tree.packageJson.json.version : void 0;
5754
5824
  const pkgName = typeof tree.packageJson?.json.name === "string" ? tree.packageJson.json.name : void 0;
5755
- const record = claim ? null : findProvenanceRecord(tree.root, pkgName, pkgVersion);
5825
+ const record = claim ? null : findProvenanceRecord(tree.root, pkgName, pkgVersion, unreadable);
5756
5826
  if (!claim && record?.ok) {
5757
5827
  return {
5758
5828
  id: this.id,
@@ -5769,6 +5839,13 @@ var c17SupplyChainInner = {
5769
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)`
5770
5840
  };
5771
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
+ }
5772
5849
  if (!claim) {
5773
5850
  return {
5774
5851
  id: this.id,
@@ -5790,7 +5867,7 @@ var c17SupplyChain = guardUnreadablePackageJson(c17SupplyChainInner, {
5790
5867
  });
5791
5868
 
5792
5869
  // src/detectors/c18-structure-manifest.ts
5793
- import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
5870
+ import { existsSync as existsSync6, readFileSync as readFileSync7, statSync as statSync8 } from "fs";
5794
5871
  import { join as join7 } from "path";
5795
5872
  var PIN5 = STANDARD_PIN;
5796
5873
  function manifestPath(root) {
@@ -5815,16 +5892,31 @@ function rootCandidates(root) {
5815
5892
  for (const name of SERVER_MANIFEST_NAMES2) {
5816
5893
  const path = join7(root, name);
5817
5894
  if (!existsSync6(path)) continue;
5818
- const json = readJson(path);
5819
- 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
+ });
5820
5903
  }
5821
5904
  return out;
5822
5905
  }
5823
5906
  function readJson(path) {
5907
+ let text;
5824
5908
  try {
5825
- return JSON.parse(readFileSync7(path, "utf8"));
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
+ }
5916
+ try {
5917
+ return { json: JSON.parse(text) };
5826
5918
  } catch {
5827
- return null;
5919
+ return { json: null };
5828
5920
  }
5829
5921
  }
5830
5922
  function c18PositiveControls() {
@@ -5883,6 +5975,14 @@ var c18StructureManifestInner = {
5883
5975
  let manifestName = kind === "extension" ? "extension.json" : "mcp.json";
5884
5976
  if (kind === "server") {
5885
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
+ }
5886
5986
  const bad = cands.find((c) => c.role === "unparseable");
5887
5987
  if (bad) {
5888
5988
  return {
@@ -5923,7 +6023,15 @@ var c18StructureManifestInner = {
5923
6023
  verdict: "no extension.json found at the tree root -> the extension manifest is required (C-18)"
5924
6024
  };
5925
6025
  }
5926
- 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;
5927
6035
  if (!manifest) {
5928
6036
  return {
5929
6037
  id: this.id,
@@ -6368,7 +6476,7 @@ var c21ProgressiveDisclosure = {
6368
6476
  };
6369
6477
 
6370
6478
  // src/detectors/c22-test-ratio-gate.ts
6371
- import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
6479
+ import { existsSync as existsSync7, readFileSync as readFileSync8, statSync as statSync9 } from "fs";
6372
6480
  import { join as join8 } from "path";
6373
6481
  var DOC_FILES2 = ["README.md", "README.fr.md", "CHANGELOG.md"];
6374
6482
  var RATIO_RE = /\b(?:coverage|ratio de tests?|test ratio|couverture)\b[^\n%]{0,40}?(\d{1,3}(?:\.\d+)?\s?%)/i;
@@ -6391,15 +6499,20 @@ function c22PositiveControls() {
6391
6499
  identifierClearsBareRatio: !IDENTIFIER_RE.test(bareSample)
6392
6500
  };
6393
6501
  }
6394
- function findRatioCitations(root) {
6502
+ function findRatioCitations(root, unreadable) {
6395
6503
  const hits = [];
6396
6504
  for (const name of DOC_FILES2) {
6397
6505
  const path = join8(root, name);
6398
6506
  if (!existsSync7(path)) continue;
6399
6507
  let text;
6400
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
+ }
6401
6513
  text = readFileSync8(path, "utf8");
6402
- } catch {
6514
+ } catch (err) {
6515
+ unreadable.push({ anchor: name, reason: `${name} could not be read (${fsErrorCode(err)})` });
6403
6516
  continue;
6404
6517
  }
6405
6518
  const lines = text.split("\n");
@@ -6417,44 +6530,48 @@ var c22TestRatioGate = {
6417
6530
  rule: "C-22",
6418
6531
  pin: STANDARD_PIN,
6419
6532
  run(tree) {
6420
- const controls = c22PositiveControls();
6421
- if (!controls.ratioMatcher.matched || !controls.identifierMatcher.matched) {
6422
- throw new Error(
6423
- `C22 positive control FAILED: ratio=${controls.ratioMatcher.matched} identifier=${controls.identifierMatcher.matched} \u2014 matcher broken, verdict untrustworthy`
6424
- );
6425
- }
6426
- if (!controls.identifierClearsBareRatio) {
6427
- throw new Error(
6428
- "C22 identifier-matcher control FAILED: must NOT flag a bare ratio with no identifier token \u2014 verdict untrustworthy"
6429
- );
6430
- }
6431
- const hits = findRatioCitations(tree.root);
6432
- if (hits.length === 0) {
6433
- return {
6434
- id: this.id,
6435
- anchor: "-",
6436
- state: "absent",
6437
- verdict: "no test/coverage ratio cited in README/CHANGELOG; test-ratio-gate check n/a"
6438
- };
6439
- }
6440
- const bare = hits.find((h) => !h.hasIdentifier);
6441
- 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`;
6442
6566
  return {
6443
6567
  id: this.id,
6444
- anchor: `${bare.file}:${bare.line}`,
6445
- state: "non-conforming",
6446
- 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}`
6447
6572
  };
6448
- }
6449
- const withId = hits[0];
6450
- 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`;
6451
- return {
6452
- id: this.id,
6453
- anchor: `${withId.file}:${withId.line}`,
6454
- state: "could-not-judge",
6455
- reason,
6456
- verdict: `could not judge: ${reason}`
6457
- };
6573
+ })();
6574
+ return settle(this.id, decided, unreadable);
6458
6575
  }
6459
6576
  };
6460
6577
 
@@ -6949,7 +7066,7 @@ import {
6949
7066
  readFileSync as readFileSync9,
6950
7067
  readdirSync as readdirSync6,
6951
7068
  rmSync,
6952
- statSync as statSync5,
7069
+ statSync as statSync10,
6953
7070
  writeFileSync
6954
7071
  } from "fs";
6955
7072
  import { tmpdir } from "os";
@@ -6975,15 +7092,17 @@ function walkForConfigs(dir, depth, seen, onSkip) {
6975
7092
  let entries;
6976
7093
  try {
6977
7094
  entries = readdirSync6(dir);
6978
- } catch {
7095
+ } catch (err) {
7096
+ onSkip(dir, depth, `could not be listed (${fsErrorCode(err)}) \u2014 not read`);
6979
7097
  return;
6980
7098
  }
6981
7099
  for (const name of entries) {
6982
7100
  const full = join9(dir, name);
6983
7101
  let st;
6984
7102
  try {
6985
- st = statSync5(full);
6986
- } catch {
7103
+ st = statSync10(full);
7104
+ } catch (err) {
7105
+ onSkip(full, depth + 1, `could not be read (${fsErrorCode(err)}) \u2014 not read`);
6987
7106
  continue;
6988
7107
  }
6989
7108
  if (st.isDirectory()) {
@@ -6993,6 +7112,10 @@ function walkForConfigs(dir, depth, seen, onSkip) {
6993
7112
  }
6994
7113
  walkForConfigs(full, depth + 1, seen, onSkip);
6995
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
+ }
6996
7119
  if (!seen.has(dir)) {
6997
7120
  seen.set(dir, { dir, configFile: name, configPath: full });
6998
7121
  }
@@ -7012,7 +7135,7 @@ function probeForCandidate(dir, depth) {
7012
7135
  const full = join9(dir, name);
7013
7136
  let st;
7014
7137
  try {
7015
- st = statSync5(full);
7138
+ st = statSync10(full);
7016
7139
  } catch {
7017
7140
  continue;
7018
7141
  }
@@ -7054,12 +7177,12 @@ function resolveRailwayRootDirectory(configPath) {
7054
7177
  function c26DeriveScope(root, tree) {
7055
7178
  const seen = /* @__PURE__ */ new Map();
7056
7179
  const skipped = [];
7057
- const onSkip = (skippedDir, depth) => {
7058
- const entry = tree ? recordSkip(tree, skippedDir, root) : {
7180
+ const onSkip = (skippedDir, depth, reason) => {
7181
+ const entry = tree ? recordSkip(tree, skippedDir, root, reason) : {
7059
7182
  path: relative5(root, skippedDir).split(sep4).join("/"),
7060
- reason: SKIP_DIR_INFO[basename2(skippedDir)]?.reason ?? "skipped"
7183
+ reason: reason ?? SKIP_DIR_INFO[basename2(skippedDir)]?.reason ?? "skipped"
7061
7184
  };
7062
- if (SKIP_DIR_INFO[basename2(skippedDir)]?.authored) {
7185
+ if (reason === void 0 && SKIP_DIR_INFO[basename2(skippedDir)]?.authored) {
7063
7186
  const hit = probeForCandidate(skippedDir, depth);
7064
7187
  if (hit) entry.candidate = relative5(root, hit).split(sep4).join("/");
7065
7188
  }
@@ -7185,8 +7308,22 @@ function gateSite(site) {
7185
7308
  }
7186
7309
  const isolated = mkdtempSync(join9(tmpdir(), "mcp-doctor-c26-"));
7187
7310
  try {
7188
- const manifestText = readFileSync9(pkgPath, "utf8");
7189
- 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
+ }
7190
7327
  writeFileSync(join9(isolated, "package.json"), manifestText);
7191
7328
  writeFileSync(join9(isolated, lock.name), lockText);
7192
7329
  let proc;
@@ -7247,6 +7384,7 @@ function renderCoverage(scope, verdicts) {
7247
7384
  return verdicts.map((v) => {
7248
7385
  const rel = relative5(process.cwd(), v.site.dir) || v.site.dir;
7249
7386
  if (v.status === "skipped") return `SKIPPED ${rel} (${v.reason})`;
7387
+ if (v.status === "unreadable") return `UNREAD ${rel} (${v.reason})`;
7250
7388
  return `ANALYSED ${rel} -> ${v.ok ? "GREEN" : "RED"}: ${v.detail}`;
7251
7389
  }).join(" | ");
7252
7390
  }
@@ -7289,6 +7427,17 @@ var c26SubpackageLockfile = {
7289
7427
  const verdicts = scope.sites.map(gateSite);
7290
7428
  const inventory = renderCoverage(scope.sites, verdicts);
7291
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
+ }
7292
7441
  if (analysed.length === 0) {
7293
7442
  if (hidden.length > 0) return unjudgedHidden("no analysable site; ");
7294
7443
  return {
@@ -7428,22 +7577,21 @@ var c27SubpackageTypes = {
7428
7577
  const workflows = governingWorkflows(tree);
7429
7578
  const reds = [];
7430
7579
  const oks = [];
7580
+ const unreadDocs = [];
7431
7581
  for (const site of tsSites) {
7432
7582
  const siteRel = relative6(base, site.dir) || ".";
7433
7583
  const docs = [];
7434
7584
  const dockerfile = join10(site.dir, "Dockerfile");
7435
7585
  if (existsSync9(dockerfile)) {
7436
- docs.push({
7437
- where: `${siteRel}/Dockerfile`,
7438
- segments: dockerSegments(readText(dockerfile) ?? "")
7439
- });
7586
+ const text = readText(dockerfile);
7587
+ if (text === null) unreadDocs.push(`${siteRel}/Dockerfile`);
7588
+ else docs.push({ where: `${siteRel}/Dockerfile`, segments: dockerSegments(text) });
7440
7589
  }
7441
7590
  const railway = join10(site.dir, "railway.json");
7442
7591
  if (existsSync9(railway)) {
7443
- docs.push({
7444
- where: `${siteRel}/railway.json`,
7445
- segments: railwayBuildCommand(readText(railway) ?? "")
7446
- });
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) });
7447
7595
  }
7448
7596
  for (const w of workflows) {
7449
7597
  if (siteRel === "." || w.text.includes(siteRel)) {
@@ -7456,6 +7604,7 @@ var c27SubpackageTypes = {
7456
7604
  oks.push(`${ok.d.where} (${siteRel})`);
7457
7605
  continue;
7458
7606
  }
7607
+ if (unreadDocs.some((u) => u.startsWith(`${siteRel}/`))) continue;
7459
7608
  const unfrozen = verdicts.find((v) => v.phase === "unfrozen");
7460
7609
  const early = verdicts.find((v) => v.phase === "before-install");
7461
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)";
@@ -7468,6 +7617,13 @@ var c27SubpackageTypes = {
7468
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)`
7469
7618
  );
7470
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
+ }
7471
7627
  return couldNotJudge(
7472
7628
  this.id,
7473
7629
  oks[0],
@@ -7697,7 +7853,7 @@ var c30SkillsOrgBound = {
7697
7853
  };
7698
7854
 
7699
7855
  // src/detectors/c31-plugin-installable.ts
7700
- 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";
7701
7857
  import { basename as basename3, dirname as dirname4, join as join11, relative as relative8, sep as sep5 } from "path";
7702
7858
  var MIN_README_WORDS = 40;
7703
7859
  var KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -7898,12 +8054,17 @@ function judgeFolder(tree, folder, manifestPath2, out) {
7898
8054
  }
7899
8055
  const hasLicenseField = typeof manifest.license === "string" && manifest.license.trim() !== "";
7900
8056
  let hasLicenseFile = false;
8057
+ let folderListed = true;
7901
8058
  try {
7902
8059
  hasLicenseFile = readdirSync8(folder).some((n) => /^LICEN[CS]E/i.test(n));
7903
- } catch {
7904
- 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
+ });
7905
8066
  }
7906
- if (!hasLicenseField && !hasLicenseFile) {
8067
+ if (!hasLicenseField && !hasLicenseFile && folderListed) {
7907
8068
  out.reds.push({
7908
8069
  anchor: a,
7909
8070
  text: "no licence: plugin.json has no `license` and the folder has no LICENSE file"
@@ -7911,7 +8072,12 @@ function judgeFolder(tree, folder, manifestPath2, out) {
7911
8072
  }
7912
8073
  const readmePath = join11(folder, "README.md");
7913
8074
  const readme = readText(readmePath);
7914
- 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) {
7915
8081
  out.reds.push({ anchor: rel(folder), text: "no README.md at the plugin folder root" });
7916
8082
  } else {
7917
8083
  const n = proseWordCount(readme);
@@ -7971,13 +8137,20 @@ function judgeFolder(tree, folder, manifestPath2, out) {
7971
8137
  judgeServers(servers, mcpAnchor, out);
7972
8138
  }
7973
8139
  try {
7974
- if (statSync6(join11(folder, "bin")).isDirectory()) {
8140
+ if (statSync11(join11(folder, "bin")).isDirectory()) {
7975
8141
  out.reds.push({
7976
8142
  anchor: rel(join11(folder, "bin")),
7977
8143
  text: "a top-level bin/ directory (a plugin ships no bin/ at its top level)"
7978
8144
  });
7979
8145
  }
7980
- } 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
+ }
7981
8154
  }
7982
8155
  const jsons = [[a, manifest]];
7983
8156
  if (servers) jsons.push([mcpAnchor, servers]);
@@ -8757,7 +8930,7 @@ var d7Inner = {
8757
8930
  var d7 = guardUnreadablePackageJson(d7Inner, { authoritative: true });
8758
8931
 
8759
8932
  // src/detectors/d8.ts
8760
- import { existsSync as existsSync11, statSync as statSync7 } from "fs";
8933
+ import { existsSync as existsSync11, statSync as statSync12 } from "fs";
8761
8934
  import { dirname as dirname5, join as join12, relative as relative9 } from "path";
8762
8935
  var POSITIVE_CONTROL2 = 'server.registerTool("list_items", { ... }, handler);';
8763
8936
  function d8PositiveControl() {
@@ -8770,7 +8943,7 @@ function d8PositiveControl() {
8770
8943
  function resolveBaseDir(tree) {
8771
8944
  if (tree.packageJson) return dirname5(tree.packageJson.path);
8772
8945
  try {
8773
- return statSync7(tree.root).isDirectory() ? tree.root : dirname5(tree.root);
8946
+ return statSync12(tree.root).isDirectory() ? tree.root : dirname5(tree.root);
8774
8947
  } catch {
8775
8948
  return tree.root;
8776
8949
  }
@@ -8904,6 +9077,13 @@ var d8 = {
8904
9077
  verdict: "server advertises no tools at runtime -> n/a for eval coverage"
8905
9078
  };
8906
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
+ }
8907
9087
  const summary = runtime.tools.map((t) => `${t.name}=${t.evalCaseCount ?? 0}`).join(" ");
8908
9088
  const under = runtime.tools.filter((t) => (t.evalCaseCount ?? 0) < 3);
8909
9089
  if (under.length > 0) {
@@ -9529,7 +9709,7 @@ var d15 = {
9529
9709
  };
9530
9710
 
9531
9711
  // src/detectors/d16.ts
9532
- 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";
9533
9713
  import { dirname as dirname8, join as join15 } from "path";
9534
9714
  var DISCOVER_RE2 = /server\/discover|DiscoverResult|ServerDiscover|handleServerDiscover/;
9535
9715
  var HEALTH_ROUTE_FRAMEWORK_RE = /\b[A-Za-z_$][\w$]*\.(?:get|post|put|patch|delete|all|route|use)\s*\(\s*["'`][^"'`]*\/health/;
@@ -9851,10 +10031,11 @@ var d16 = {
9851
10031
  };
9852
10032
  function readPackageVersion(root) {
9853
10033
  try {
9854
- let dir = statSync8(root).isDirectory() ? root : dirname8(root);
10034
+ let dir = statSync13(root).isDirectory() ? root : dirname8(root);
9855
10035
  for (let depth = 0; depth < 8; depth++) {
9856
10036
  const candidate = join15(dir, "package.json");
9857
10037
  if (existsSync14(candidate)) {
10038
+ if (!statSync13(candidate).isFile()) return null;
9858
10039
  const pkg = JSON.parse(readFileSync10(candidate, "utf8"));
9859
10040
  return typeof pkg.version === "string" ? pkg.version : null;
9860
10041
  }
@@ -9869,7 +10050,7 @@ function readPackageVersion(root) {
9869
10050
  }
9870
10051
 
9871
10052
  // src/detectors/dAppI18n.ts
9872
- import { existsSync as existsSync15, readFileSync as readFileSync11 } from "fs";
10053
+ import { existsSync as existsSync15, readFileSync as readFileSync11, statSync as statSync14 } from "fs";
9873
10054
  import { dirname as dirname9, join as join16 } from "path";
9874
10055
  function flattenKeys(obj, prefix = "") {
9875
10056
  if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
@@ -9903,6 +10084,7 @@ function dAppI18nPositiveControls() {
9903
10084
  }
9904
10085
  function readJson2(path) {
9905
10086
  try {
10087
+ if (!statSync14(path).isFile()) return { ok: false };
9906
10088
  return { ok: true, value: JSON.parse(readFileSync11(path, "utf8")) };
9907
10089
  } catch {
9908
10090
  return { ok: false };
@@ -10101,14 +10283,14 @@ var dDebugOptIn = {
10101
10283
  };
10102
10284
 
10103
10285
  // src/detectors/extManifests.ts
10104
- import { readdirSync as readdirSync9, statSync as statSync9 } from "fs";
10286
+ import { readdirSync as readdirSync9, statSync as statSync15 } from "fs";
10105
10287
  import { join as join17 } from "path";
10106
10288
  function findExtensionManifests(tree) {
10107
10289
  const out = [];
10108
10290
  const root = tree.root;
10109
10291
  let base;
10110
10292
  try {
10111
- base = statSync9(root).isDirectory() ? root : join17(root, "..");
10293
+ base = statSync15(root).isDirectory() ? root : join17(root, "..");
10112
10294
  } catch {
10113
10295
  return out;
10114
10296
  }
@@ -10116,15 +10298,17 @@ function findExtensionManifests(tree) {
10116
10298
  let entries;
10117
10299
  try {
10118
10300
  entries = readdirSync9(dir);
10119
- } catch {
10301
+ } catch (err) {
10302
+ recordSkip(tree, dir, base, `could not be listed (${fsErrorCode(err)}) \u2014 not read`);
10120
10303
  return;
10121
10304
  }
10122
10305
  for (const name of entries) {
10123
10306
  const full = join17(dir, name);
10124
10307
  let st;
10125
10308
  try {
10126
- st = statSync9(full);
10127
- } catch {
10309
+ st = statSync15(full);
10310
+ } catch (err) {
10311
+ recordSkip(tree, full, base, `could not be read (${fsErrorCode(err)}) \u2014 not read`);
10128
10312
  continue;
10129
10313
  }
10130
10314
  if (st.isDirectory()) {
@@ -10134,6 +10318,10 @@ function findExtensionManifests(tree) {
10134
10318
  }
10135
10319
  walk2(full);
10136
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
+ }
10137
10325
  out.push(full);
10138
10326
  }
10139
10327
  }
@@ -10750,8 +10938,8 @@ var dExt3 = {
10750
10938
  };
10751
10939
 
10752
10940
  // src/detectors/dExt4.ts
10753
- import { readdirSync as readdirSync10, statSync as statSync10 } from "fs";
10754
- 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";
10755
10943
  function canonical(name) {
10756
10944
  return name.replace(/[-_]/g, "").toLowerCase();
10757
10945
  }
@@ -10770,28 +10958,39 @@ function dExt4PositiveControls() {
10770
10958
  }
10771
10959
  };
10772
10960
  }
10773
- function findHooksDirs(tree) {
10961
+ function findHooksDirs(tree, unreadable, gaps) {
10774
10962
  const root = tree.root;
10775
10963
  const out = [];
10776
10964
  let base;
10777
10965
  try {
10778
- base = statSync10(root).isDirectory() ? root : join19(root, "..");
10966
+ base = statSync16(root).isDirectory() ? root : join19(root, "..");
10779
10967
  } catch {
10780
10968
  return out;
10781
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
+ };
10782
10978
  const walk2 = (dir) => {
10783
10979
  let entries;
10784
10980
  try {
10785
10981
  entries = readdirSync10(dir);
10786
- } catch {
10982
+ } catch (err) {
10983
+ noteUnread(dir, "could not be listed", err);
10787
10984
  return;
10788
10985
  }
10789
10986
  for (const name of entries) {
10790
10987
  const full = join19(dir, name);
10791
10988
  let st;
10792
10989
  try {
10793
- st = statSync10(full);
10794
- } catch {
10990
+ st = statSync16(full);
10991
+ } catch (err) {
10992
+ if (fsErrorCode(err) !== "ENOENT" && fsErrorCode(err) !== "ELOOP")
10993
+ noteUnread(full, "could not be read", err);
10795
10994
  continue;
10796
10995
  }
10797
10996
  if (!st.isDirectory()) continue;
@@ -10804,7 +11003,8 @@ function findHooksDirs(tree) {
10804
11003
  let files;
10805
11004
  try {
10806
11005
  files = readdirSync10(full);
10807
- } catch {
11006
+ } catch (err) {
11007
+ noteUnread(full, "could not be listed", err);
10808
11008
  files = [];
10809
11009
  }
10810
11010
  for (const f of files) {
@@ -10882,7 +11082,8 @@ var dExt4 = {
10882
11082
  }
10883
11083
  const unreadable = [];
10884
11084
  const decided = (() => {
10885
- const hooksDirs = findHooksDirs(tree);
11085
+ const hooksDirGaps = { count: 0 };
11086
+ const hooksDirs = findHooksDirs(tree, unreadable, hooksDirGaps);
10886
11087
  const { sites, unresolved } = findFactorySites(tree);
10887
11088
  for (const anchor of unresolved) {
10888
11089
  unreadable.push({
@@ -10926,6 +11127,7 @@ var dExt4 = {
10926
11127
  continue;
10927
11128
  }
10928
11129
  const phantom = [...declared].filter((h) => !implemented.has(h));
11130
+ if (phantom.length > 0 && hooksDirGaps.count > 0) continue;
10929
11131
  if (phantom.length > 0) {
10930
11132
  return {
10931
11133
  id: this.id,
@@ -11313,30 +11515,41 @@ var dExt8 = {
11313
11515
  };
11314
11516
 
11315
11517
  // src/detectors/dExt9.ts
11316
- import { readFileSync as readFileSync13, readdirSync as readdirSync11, statSync as statSync11 } from "fs";
11317
- import { join as join23 } from "path";
11318
- 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) {
11319
11521
  const root = tree.root;
11320
11522
  const out = [];
11321
11523
  let base;
11322
11524
  try {
11323
- base = statSync11(root).isDirectory() ? root : join23(root, "..");
11525
+ base = statSync17(root).isDirectory() ? root : join23(root, "..");
11324
11526
  } catch {
11325
11527
  return out;
11326
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
+ };
11327
11537
  const walk2 = (dir) => {
11328
11538
  let entries;
11329
11539
  try {
11330
11540
  entries = readdirSync11(dir);
11331
- } catch {
11541
+ } catch (err) {
11542
+ noteUnread(dir, "could not be listed", err);
11332
11543
  return;
11333
11544
  }
11334
11545
  for (const name of entries) {
11335
11546
  const full = join23(dir, name);
11336
11547
  let st;
11337
11548
  try {
11338
- st = statSync11(full);
11339
- } catch {
11549
+ st = statSync17(full);
11550
+ } catch (err) {
11551
+ if (fsErrorCode(err) !== "ENOENT" && fsErrorCode(err) !== "ELOOP")
11552
+ noteUnread(full, "could not be read", err);
11340
11553
  continue;
11341
11554
  }
11342
11555
  if (!st.isDirectory()) continue;
@@ -11348,7 +11561,8 @@ function collectHooksDirSources(tree, unreadable) {
11348
11561
  let files;
11349
11562
  try {
11350
11563
  files = readdirSync11(full);
11351
- } catch {
11564
+ } catch (err) {
11565
+ noteUnread(full, "could not be listed", err);
11352
11566
  files = [];
11353
11567
  }
11354
11568
  for (const f of files) {
@@ -11358,6 +11572,7 @@ function collectHooksDirSources(tree, unreadable) {
11358
11572
  const filePath = join23(full, f);
11359
11573
  let body;
11360
11574
  try {
11575
+ if (!statSync17(filePath).isFile()) throw new Error("not a regular file");
11361
11576
  body = readFileSync13(filePath, "utf8");
11362
11577
  } catch {
11363
11578
  const shortPath = filePath.split("/").slice(-2).join("/");
@@ -11461,8 +11676,9 @@ var dExt9 = {
11461
11676
  }
11462
11677
  const unreadable = [];
11463
11678
  const decided = (() => {
11679
+ const hooksDirGaps = { count: 0 };
11464
11680
  const hookSources = [
11465
- ...collectHooksDirSources(tree, unreadable),
11681
+ ...collectHooksDirSources(tree, unreadable, hooksDirGaps),
11466
11682
  ...collectFactorySources(tree)
11467
11683
  ];
11468
11684
  const implByToken = /* @__PURE__ */ new Map();
@@ -11511,6 +11727,7 @@ var dExt9 = {
11511
11727
  const hasImplTimeout = implSources.some((s) => hasImplTimeoutGuard(s.body));
11512
11728
  if (!hasManifestTimeout && !hasImplTimeout) noTimeout.push(hook);
11513
11729
  }
11730
+ if (noTimeout.length > 0 && hooksDirGaps.count > 0) continue;
11514
11731
  if (noTimeout.length > 0) {
11515
11732
  return {
11516
11733
  id: this.id,
@@ -12524,7 +12741,6 @@ var dServer14 = {
12524
12741
  };
12525
12742
 
12526
12743
  // src/detectors/e5-extension-isolation.ts
12527
- import { readFileSync as readFileSync14 } from "fs";
12528
12744
  var GLOBAL_RES = [
12529
12745
  /\b(?:globalThis|global|window|self)\s*(?:\.\s*[\w$]+|\[[^\]]+\])\s*=(?!=)/,
12530
12746
  /Object\.(?:assign|defineProperty|defineProperties|setPrototypeOf)\(\s*(?:globalThis|global|window|self)\b/,
@@ -12555,11 +12771,8 @@ var e5ExtensionIsolation = {
12555
12771
  const manifests = findExtensionManifests(tree);
12556
12772
  const own = /* @__PURE__ */ new Set();
12557
12773
  for (const m of manifests) {
12558
- try {
12559
- const j = JSON.parse(readFileSync14(m, "utf8"));
12560
- if (typeof j.identifier === "string") own.add(j.identifier);
12561
- } catch {
12562
- }
12774
+ const j = readJsonObject(m);
12775
+ if (j && typeof j.identifier === "string") own.add(j.identifier);
12563
12776
  }
12564
12777
  const defined = tree.files.some(
12565
12778
  (f) => isProductSource(f.rel) && uncommentedLines(f).some((r) => /\bdefineExtension\s*\(/.test(r))
@@ -12989,8 +13202,8 @@ var e10MrtrOnly = {
12989
13202
  };
12990
13203
 
12991
13204
  // src/detectors/e11-surfaces-derived.ts
12992
- import { existsSync as existsSync20, statSync as statSync12 } from "fs";
12993
- 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";
12994
13207
  var ALL = ["chat", "cowork", "claude-code"];
12995
13208
  function excludedSurfaces(c) {
12996
13209
  const cause = /* @__PURE__ */ new Map();
@@ -13006,10 +13219,17 @@ function excludedSurfaces(c) {
13006
13219
  if (c.localMcp) exclude("chat", "a local MCP command excludes chat");
13007
13220
  return cause;
13008
13221
  }
13009
- function isDir(p) {
13222
+ function isDir(p, anchor, unreadable) {
13010
13223
  try {
13011
- return statSync12(p).isDirectory();
13012
- } 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
+ }
13013
13233
  return false;
13014
13234
  }
13015
13235
  }
@@ -13048,7 +13268,7 @@ var e11SurfacesDerived = {
13048
13268
  continue;
13049
13269
  }
13050
13270
  const folder = dirname14(m.path);
13051
- const rel = (p) => relative10(tree.root, p).split(sep6).join("/") || ".";
13271
+ const rel = (p) => relative12(tree.root, p).split(sep6).join("/") || ".";
13052
13272
  if (!Array.isArray(j.surfaces)) {
13053
13273
  reds.push({
13054
13274
  anchor: m.rel,
@@ -13084,9 +13304,9 @@ var e11SurfacesDerived = {
13084
13304
  }
13085
13305
  }
13086
13306
  const cause = excludedSurfaces({
13087
- bin: isDir(join24(folder, "bin")),
13088
- hooks: isDir(join24(folder, "hooks")),
13089
- 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),
13090
13310
  localMcp
13091
13311
  });
13092
13312
  for (const s of declared) {
@@ -15432,8 +15652,8 @@ var s27ElicitationFloor = {
15432
15652
  };
15433
15653
 
15434
15654
  // src/detectors/s28-claim-forwarded.ts
15435
- import { existsSync as existsSync21, readFileSync as readFileSync15, readdirSync as readdirSync12, statSync as statSync13 } from "fs";
15436
- 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";
15437
15657
  var PIN11 = STANDARD_PIN;
15438
15658
  var CLAIM_TOKEN = /\bverified(?:Org|Person)\b/;
15439
15659
  var ANY_CLAIM = /\bverified(?:Actor|Org|Person)\b/g;
@@ -15491,11 +15711,12 @@ function splitTop(m, from, to) {
15491
15711
  if (start < to && m.slice(start, to).trim() !== "") parts.push([start, to]);
15492
15712
  return parts;
15493
15713
  }
15494
- function listTs(dir, out = []) {
15714
+ function listTs(dir, unread, out = []) {
15495
15715
  let names;
15496
15716
  try {
15497
15717
  names = readdirSync12(dir);
15498
- } catch {
15718
+ } catch (err) {
15719
+ unread.push(`${dir} could not be listed (${fsErrorCode(err)})`);
15499
15720
  return out;
15500
15721
  }
15501
15722
  for (const n of names) {
@@ -15503,19 +15724,23 @@ function listTs(dir, out = []) {
15503
15724
  const p = join25(dir, n);
15504
15725
  let st;
15505
15726
  try {
15506
- st = statSync13(p);
15507
- } catch {
15727
+ st = statSync19(p);
15728
+ } catch (err) {
15729
+ unread.push(`${p} could not be read (${fsErrorCode(err)})`);
15508
15730
  continue;
15509
15731
  }
15510
- if (st.isDirectory()) listTs(p, out);
15511
- 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
+ }
15512
15737
  }
15513
15738
  return out;
15514
15739
  }
15515
15740
  function discoverBackend(root) {
15516
15741
  let dir = root;
15517
15742
  try {
15518
- if (!statSync13(root).isDirectory()) dir = dirname15(root);
15743
+ if (!statSync19(root).isDirectory()) dir = dirname15(root);
15519
15744
  } catch {
15520
15745
  return void 0;
15521
15746
  }
@@ -15528,22 +15753,23 @@ function discoverBackend(root) {
15528
15753
  }
15529
15754
  return void 0;
15530
15755
  }
15531
- function readDoors(backendRoot) {
15756
+ function readDoors(backendRoot, unread) {
15532
15757
  const doors = /* @__PURE__ */ new Map();
15533
- for (const path of listTs(backendRoot)) {
15758
+ for (const path of listTs(backendRoot, unread)) {
15534
15759
  let text;
15535
15760
  try {
15536
- text = readFileSync15(path, "utf8");
15537
- } catch {
15761
+ text = readFileSync14(path, "utf8");
15762
+ } catch (err) {
15763
+ unread.push(`${path} could not be read (${fsErrorCode(err)})`);
15538
15764
  continue;
15539
15765
  }
15540
15766
  const file = {
15541
15767
  path,
15542
- rel: relative11(backendRoot, path),
15768
+ rel: relative13(backendRoot, path),
15543
15769
  lines: text.split("\n").map((raw, i) => ({ n: i + 1, raw, comment: false }))
15544
15770
  };
15545
15771
  const m = masked(file);
15546
- const module = relative11(backendRoot, path).replace(/\.ts$/, "").split("\\").join("/");
15772
+ const module = relative13(backendRoot, path).replace(/\.ts$/, "").split("\\").join("/");
15547
15773
  const re = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:query|mutation|action)\s*\(/g;
15548
15774
  for (let hit = re.exec(m); hit; hit = re.exec(m)) {
15549
15775
  const open = hit.index + hit[0].length - 1;
@@ -15656,7 +15882,9 @@ function scanS28(tree) {
15656
15882
  const scan = { violate: [], conform: [], cnj: [], calls: 0, backend: void 0 };
15657
15883
  const backendRoot = tree.backendRoot ?? discoverBackend(tree.root);
15658
15884
  scan.backend = backendRoot;
15659
- 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("; ")}` : "";
15660
15888
  const files = tree.files.map((f) => ({ f, text: rawText(f), m: masked(f) }));
15661
15889
  const actorDoors = /* @__PURE__ */ new Set();
15662
15890
  for (const { text } of files) {
@@ -15776,7 +16004,7 @@ function scanS28(tree) {
15776
16004
  if (!info) {
15777
16005
  scan.cnj.push(
15778
16006
  site(
15779
- `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}`
15780
16008
  )
15781
16009
  );
15782
16010
  continue;
@@ -16623,12 +16851,12 @@ var detectors = registered.map(
16623
16851
 
16624
16852
  // src/runtime/launch.ts
16625
16853
  import { spawnSync as spawnSync2 } from "child_process";
16626
- 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";
16627
16855
  import { dirname as dirname16, isAbsolute, join as join26, resolve as resolve2 } from "path";
16628
16856
  function findPackageDir(target) {
16629
16857
  let dir;
16630
16858
  try {
16631
- dir = statSync14(target).isDirectory() ? target : dirname16(target);
16859
+ dir = statSync20(target).isDirectory() ? target : dirname16(target);
16632
16860
  } catch {
16633
16861
  return null;
16634
16862
  }
@@ -16658,7 +16886,10 @@ function deriveLaunchPlan(target) {
16658
16886
  }
16659
16887
  let pkg;
16660
16888
  try {
16661
- 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"));
16662
16893
  } catch (err) {
16663
16894
  return {
16664
16895
  error: `could not parse ${join26(pkgDir, "package.json")}: ${err instanceof Error ? err.message : String(err)}`
@@ -16796,29 +17027,41 @@ send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "
16796
17027
  function readEvalCounts(target) {
16797
17028
  const counts = /* @__PURE__ */ new Map();
16798
17029
  const pkgDir = findPackageDir(target) ?? (statSyncSafe(target) ? target : null);
16799
- if (!pkgDir) return counts;
17030
+ if (!pkgDir) return { counts };
16800
17031
  let dir = pkgDir;
16801
17032
  for (let depth = 0; depth < 8; depth++) {
16802
17033
  const candidate = join26(dir, "evals", "evals.json");
16803
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
+ }
16804
17047
  try {
16805
- const parsed = JSON.parse(readFileSync16(candidate, "utf8"));
17048
+ const parsed = JSON.parse(text);
16806
17049
  for (const c of parsed.cases ?? []) {
16807
17050
  if (c && typeof c.tool === "string") counts.set(c.tool, (counts.get(c.tool) ?? 0) + 1);
16808
17051
  }
16809
17052
  } catch {
16810
17053
  }
16811
- return counts;
17054
+ return { counts };
16812
17055
  }
16813
17056
  const parent = dirname16(dir);
16814
17057
  if (parent === dir) break;
16815
17058
  dir = parent;
16816
17059
  }
16817
- return counts;
17060
+ return { counts };
16818
17061
  }
16819
17062
  function statSyncSafe(p) {
16820
17063
  try {
16821
- return statSync14(p).isDirectory();
17064
+ return statSync20(p).isDirectory();
16822
17065
  } catch {
16823
17066
  return false;
16824
17067
  }
@@ -16926,7 +17169,7 @@ function launchAndIntrospect(target) {
16926
17169
  if (parsed.launchError) {
16927
17170
  return { root: target, tools: [], launchError: parsed.launchError };
16928
17171
  }
16929
- const evalCounts = readEvalCounts(target);
17172
+ const { counts: evalCounts, unreadable: evalsUnreadable } = readEvalCounts(target);
16930
17173
  const tools = (parsed.tools ?? []).map((t) => {
16931
17174
  const name = t.name;
16932
17175
  const count = evalCounts.get(name);
@@ -16940,6 +17183,7 @@ function launchAndIntrospect(target) {
16940
17183
  tools,
16941
17184
  ...parsed.serverInfo ? { serverInfo: parsed.serverInfo } : {},
16942
17185
  ...expectedCommit ? { expectedCommit } : {},
17186
+ ...evalsUnreadable ? { evalsUnreadable } : {},
16943
17187
  ...Array.isArray(parsed.cursorProbes) ? { cursorProbes: parsed.cursorProbes.map(readCursorProbe) } : {}
16944
17188
  };
16945
17189
  }
@@ -17198,7 +17442,7 @@ var USAGE = [
17198
17442
  function readVersion() {
17199
17443
  try {
17200
17444
  const here = dirname17(fileURLToPath2(import.meta.url));
17201
- const manifest = JSON.parse(readFileSync17(join27(here, "..", "package.json"), "utf8"));
17445
+ const manifest = JSON.parse(readFileSync16(join27(here, "..", "package.json"), "utf8"));
17202
17446
  return manifest.version ?? null;
17203
17447
  } catch {
17204
17448
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vantageos/mcp-doctor",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Static MCP conformance checker: audits an MCP server source tree against the MCP 2026-07-28 standard and emits a conformance-diff table (file:line | present | absent | non-conforming | verdict).",
5
5
  "type": "module",
6
6
  "main": "./dist/doctor.js",