thumbgate 1.35.0 → 1.37.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.
Files changed (196) hide show
  1. package/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
  2. package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
  3. package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
  4. package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
  5. package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
  6. package/.claude-plugin/plugin.json +1 -1
  7. package/.well-known/llms.txt +1 -0
  8. package/.well-known/mcp/server-card.json +1 -1
  9. package/CONTRIBUTING.md +95 -0
  10. package/README.md +195 -632
  11. package/THIRD_PARTY_NOTICES.md +89 -0
  12. package/adapters/claude/.mcp.json +2 -2
  13. package/adapters/forge/forge.yaml +3 -3
  14. package/adapters/future-agi/.mcp.json +8 -0
  15. package/adapters/future-agi/FUTURE_AGI.md +23 -0
  16. package/adapters/future-agi/config.toml +3 -0
  17. package/adapters/future-agi/future-agi-bridge.js +9 -0
  18. package/adapters/future-agi/opencode.json +8 -0
  19. package/adapters/herdr/herdr-plugin.toml +18 -0
  20. package/adapters/mcp/server-stdio.js +238 -25
  21. package/adapters/opencode/opencode.json +1 -1
  22. package/adapters/workos/WORKOS.md +52 -0
  23. package/bin/cli.js +336 -3
  24. package/bin/futureagi-bridge +9 -0
  25. package/config/gate-templates.json +581 -4
  26. package/config/gates/actor-critic-audit.json +34 -0
  27. package/config/gates/default.json +9 -3
  28. package/config/gates/five-walls-governance.json +34 -0
  29. package/config/gates/future-agi-guardrails.json +34 -0
  30. package/config/gates/radware-threat-defense-2026.json +61 -0
  31. package/config/gates/simatree-data-governance.json +33 -0
  32. package/config/mcp-allowlists.json +4 -0
  33. package/config/merge-quality-checks.json +6 -0
  34. package/config/model-candidates.json +312 -29
  35. package/config/model-tiers.json +18 -0
  36. package/config/post-deploy-marketing-pages.json +10 -0
  37. package/config/progressive/01-wire-only.json +11 -0
  38. package/config/progressive/02-dashboard-empty-ok.json +10 -0
  39. package/config/progressive/03-one-lesson.json +10 -0
  40. package/config/progressive/04-warn-fires.json +11 -0
  41. package/config/progressive/05-strict-optional.json +11 -0
  42. package/config/progressive/README.md +15 -0
  43. package/config/schemas/broker-execution-receipt.schema.json +139 -0
  44. package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
  45. package/conformance/provider-attestation/vectors.json +320 -0
  46. package/docs/specs/provider-execution-attestation-v1.md +69 -0
  47. package/openapi/openapi.yaml +15 -0
  48. package/package.json +390 -148
  49. package/public/about.html +2 -2
  50. package/public/ai-malpractice-prevention.html +7 -7
  51. package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
  52. package/public/blog/a-receipt-is-not-world-state.html +388 -0
  53. package/public/blog/git-at-agent-scale.html +374 -0
  54. package/public/blog/no-llm-in-the-gate.html +133 -0
  55. package/public/blog.html +80 -0
  56. package/public/case-studies.html +16 -1
  57. package/public/compare.html +28 -0
  58. package/public/diagnostic.html +216 -7
  59. package/public/docs/connectors.html +39 -0
  60. package/public/federal.html +2 -2
  61. package/public/founders.html +639 -0
  62. package/public/index.html +87 -9
  63. package/public/install.html +8 -8
  64. package/public/learn.html +39 -0
  65. package/public/numbers.html +2 -2
  66. package/public/peter.html +310 -0
  67. package/public/platform-partners.html +119 -0
  68. package/public/pricing.html +24 -3
  69. package/public/privacy.html +117 -0
  70. package/public/pro.html +17 -0
  71. package/public/support.html +62 -0
  72. package/public/terms.html +130 -0
  73. package/public/third-party-notices.html +95 -0
  74. package/public/yt.html +351 -0
  75. package/scripts/action-receipts.js +133 -3
  76. package/scripts/adaptive-governance-arena.js +349 -0
  77. package/scripts/admin-override.js +205 -0
  78. package/scripts/agent-action-inventory.js +869 -0
  79. package/scripts/agent-audit-trace.js +42 -2
  80. package/scripts/agent-egress-policy.js +1117 -0
  81. package/scripts/agent-memory-lifecycle.js +141 -2
  82. package/scripts/agent-operations-planner.js +441 -1
  83. package/scripts/agent-readiness.js +68 -0
  84. package/scripts/agent-security-central.js +647 -0
  85. package/scripts/allowlist-bridge-honesty.js +417 -0
  86. package/scripts/async-job-runner.js +102 -11
  87. package/scripts/audit-trail.js +212 -0
  88. package/scripts/billing.js +1 -1
  89. package/scripts/broker-execution-receipts.js +719 -0
  90. package/scripts/budget-aware-gates-proof.js +423 -0
  91. package/scripts/claude-feedback-sync.js +29 -3
  92. package/scripts/claw-harness-production.js +237 -0
  93. package/scripts/cli-schema.js +163 -1
  94. package/scripts/codex-runbook-flywheel.js +318 -0
  95. package/scripts/context-footprint.js +186 -0
  96. package/scripts/contextfs.js +143 -61
  97. package/scripts/dashboard.js +251 -32
  98. package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
  99. package/scripts/docker-sandbox-planner.js +18 -0
  100. package/scripts/double-blind-eval-protocol.js +252 -0
  101. package/scripts/edotenv-rl-gateway.js +259 -0
  102. package/scripts/ensure-production-search-corpus.js +162 -0
  103. package/scripts/eval-holdout.js +311 -0
  104. package/scripts/feedback-aggregate.js +21 -2
  105. package/scripts/feedback-loop.js +87 -5
  106. package/scripts/feedback-quality.js +9 -0
  107. package/scripts/file-ledger-lock.js +4 -1
  108. package/scripts/financial-control-plane.js +41 -1
  109. package/scripts/find-dormant-requires.js +118 -0
  110. package/scripts/fs-utils.js +84 -8
  111. package/scripts/gates-engine.js +810 -63
  112. package/scripts/generate-case-study-outreach.js +24 -15
  113. package/scripts/git-at-scale.js +628 -0
  114. package/scripts/governance-conflict-audit.js +1650 -0
  115. package/scripts/governance-difficulty-curriculum.js +328 -0
  116. package/scripts/graphrag-retrieval.js +275 -0
  117. package/scripts/gurobi-optimizer.js +324 -0
  118. package/scripts/gurobi_optimizer.py +485 -0
  119. package/scripts/harness-selector.js +82 -1
  120. package/scripts/hidden-entry-points.js +284 -0
  121. package/scripts/human-escalation.js +199 -1
  122. package/scripts/hybrid-feedback-context.js +152 -19
  123. package/scripts/intent-governed-execution.js +602 -0
  124. package/scripts/intervention-policy.js +123 -20
  125. package/scripts/jit-harness-compose.js +628 -0
  126. package/scripts/jsonl-watcher.js +10 -0
  127. package/scripts/lesson-embedding-index.js +95 -12
  128. package/scripts/lesson-retrieval.js +105 -19
  129. package/scripts/local-model-profile.js +19 -2
  130. package/scripts/mailer/resend-mailer.js +1 -1
  131. package/scripts/matryoshka-embedding.js +235 -0
  132. package/scripts/mcp-oauth.js +42 -4
  133. package/scripts/mcp-session-handles.js +1016 -0
  134. package/scripts/mcp-wiring-doctor.js +314 -0
  135. package/scripts/memory-firewall.js +115 -2
  136. package/scripts/memory-scope-readiness.js +299 -0
  137. package/scripts/memory-vs-rag-route.js +161 -0
  138. package/scripts/model-tier-router.js +148 -21
  139. package/scripts/nvidia-specdecode-al-doctor.js +536 -0
  140. package/scripts/openui-catalog-compose-honesty.js +593 -0
  141. package/scripts/operational-integrity.js +19 -1
  142. package/scripts/override-audit.js +213 -0
  143. package/scripts/package-manager-honesty-doctor.js +458 -0
  144. package/scripts/pr-manager.js +63 -1
  145. package/scripts/prove-herdr-adapter.js +52 -0
  146. package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
  147. package/scripts/prove-workos.js +73 -0
  148. package/scripts/provider-attestation-conformance.js +192 -0
  149. package/scripts/provider-receipt-contract.js +136 -0
  150. package/scripts/qwen38-max-cost-optimizer.js +401 -0
  151. package/scripts/radware-threat-defense.js +280 -0
  152. package/scripts/rag-embedding-identity.js +221 -0
  153. package/scripts/rag-precision-guardrails.js +112 -2
  154. package/scripts/remote-feedback-capture.js +159 -0
  155. package/scripts/research-agent-harness.js +256 -0
  156. package/scripts/rsi-safety-hillclimb.js +200 -0
  157. package/scripts/rule-sprawl.js +188 -0
  158. package/scripts/schedule-manager.js +147 -0
  159. package/scripts/self-heal.js +8 -0
  160. package/scripts/session-lease.js +415 -0
  161. package/scripts/simatree-data-governance.js +347 -0
  162. package/scripts/slo-alert-engine.js +172 -7
  163. package/scripts/solver-parity.js +539 -0
  164. package/scripts/stealth-memory-injection-gate.js +333 -0
  165. package/scripts/switchyard-router.js +366 -0
  166. package/scripts/telemetry-analytics.js +84 -27
  167. package/scripts/temporal-decay-weighting.js +138 -0
  168. package/scripts/test-all.js +165 -0
  169. package/scripts/token-savings.js +42 -0
  170. package/scripts/tool-kpi-tracker.js +108 -5
  171. package/scripts/tool-registry.js +193 -5
  172. package/scripts/universal-claim-evaluator.js +14 -2
  173. package/scripts/vector-store.js +279 -9
  174. package/scripts/workflow-notebook.js +391 -0
  175. package/scripts/workflow-sentinel.js +111 -12
  176. package/scripts/workos-production-guard.js +260 -0
  177. package/scripts/workspace-search-route.js +515 -0
  178. package/server.json +2 -2
  179. package/src/agent-identity-boundary.js +76 -0
  180. package/src/agent-retrieval-cache.js +155 -0
  181. package/src/alert-noise-ledger.js +502 -0
  182. package/src/api/server.js +724 -153
  183. package/src/git-fast-cache.js +220 -0
  184. package/src/git-wal-sync.js +156 -0
  185. package/src/hash-anchored-edit.js +82 -0
  186. package/src/hermes-platform-protocol.js +475 -0
  187. package/src/hermes-sync-plane.js +241 -0
  188. package/src/index.js +30 -1
  189. package/src/iso42001-compliance-guard.js +97 -0
  190. package/src/latency-budget.js +244 -0
  191. package/src/mcp-writeguard.js +316 -0
  192. package/src/miminions-adapter.js +106 -0
  193. package/src/pipeline-compass.js +104 -0
  194. package/src/ppl-alert-pipeline.js +284 -0
  195. package/src/rendezvous-router.js +90 -0
  196. package/src/security-questionnaire.js +195 -0
@@ -0,0 +1,1650 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * governance-conflict-audit.js — "which of our controls report success but
6
+ * enforce nothing?"
7
+ *
8
+ * WHY THIS EXISTS
9
+ * ---------------
10
+ * Every detector in this file is a defect that was REAL in this repository.
11
+ * Not a hypothetical, not a lint rule imported from a style guide: each one
12
+ * shipped, looked green, and enforced nothing until somebody happened to read
13
+ * the code. A gate whose match block never runs still appears in the gate list.
14
+ * A CI job that has been red for eleven straight commits still appears in the
15
+ * Actions tab. An exclusion that hides five hundred lines from the scanner
16
+ * still leaves the scanner badge green.
17
+ *
18
+ * The framing — "a readiness assessment for identifying where your
19
+ * organization's governance and operational security tracks are quietly in
20
+ * conflict" — is taken from the published abstract of Noah M. Kenney's
21
+ * (Digital 520) BrightTALK webinar "Harmonizing AI Governance and Cybersecurity
22
+ * Operations". Only the abstract was available when this was written; no talk
23
+ * content was seen, so no specific technique below is attributable to him. The
24
+ * design choice to keep every check deterministic and model-free follows the
25
+ * argument in Ralph Villanueva's (Carnival Corp) abstract "10 Common Sense
26
+ * Solutions to App Sec Challenges — AI Not Required".
27
+ *
28
+ * THE DETECTORS
29
+ * -------------
30
+ * D1 Inert-or-overmatching gate shape. A gate config declaring a `patterns`
31
+ * ARRAY while scripts/gates-engine.js reads `gate.pattern` (SINGULAR, see
32
+ * the `if (gate.pattern)` branch). The engine's match block is skipped
33
+ * entirely, so the gate stops narrowing and fires on EVERY tool listed in
34
+ * its `toolNames` — or on every tool call at all, when `toolNames` is also
35
+ * absent.
36
+ *
37
+ * D2 Regex that cannot compile. A `pattern` containing an inline flag group
38
+ * such as `(?i)`. JavaScript has no inline flag syntax; `new RegExp(p)`
39
+ * throws, and gates-engine.js wraps the compile in `try { … } catch {
40
+ * return { matched: false } }`. The throw is swallowed as "did not match",
41
+ * so the gate is silently inert. Detected by attempting compilation, never
42
+ * by pattern-matching the pattern.
43
+ *
44
+ * D3 Silently-red non-required check. A check required by NEITHER
45
+ * `branches/<branch>/protection.required_status_checks.contexts` NOR any
46
+ * repository ruleset (`rules/branches/<branch>`), which has failed on N or
47
+ * more consecutive recent commits. Both surfaces are read and UNIONED:
48
+ * reading only branch protection would report a ruleset-required check as
49
+ * blocking nothing, which is the same fabricated-verdict failure this file
50
+ * exists to catch. Real case: the `deploy` and `verify` jobs (workflows
51
+ * "Deploy to Railway" / "Verify Production Deploy") failed on 18
52
+ * consecutive main commits between 2026-08-19 and 2026-08-21 while `test`
53
+ * stayed green and nothing blocked a merge.
54
+ *
55
+ * D4 Analysis blindspots. Entries in `sonar.exclusions` /
56
+ * `sonar.coverage.exclusions` that hide non-trivial code from scanning.
57
+ * Real case: commit 0943e9b9 added four Future AGI scripts to BOTH lists
58
+ * before the cause of the Sonar failure was known. An exclusion is only
59
+ * counted when it actually intersects `sonar.sources` — hiding
60
+ * `node_modules` hides nothing, and reporting it as a blindspot would be
61
+ * the same kind of noise this tool exists to remove.
62
+ *
63
+ * D5 Zero production call sites. A module reached from the package entry
64
+ * that no production code path requires, or an entry-level export that no
65
+ * production code path ever calls.
66
+ * BOUNDED BY WHAT A LOCAL CORPUS CAN KNOW: when the repository publishes an
67
+ * npm package, its entry exports ARE the public API and their callers are
68
+ * downstream consumers no local search can see. Those are listed as NOT
69
+ * INSPECTED and the detector reports PARTIAL — calling them dead would be
70
+ * a fabricated verdict, and calling them clean would be the opposite lie.
71
+ * A finding is only emitted where "no caller" is actually provable: a
72
+ * private/unpublished package, or a module the entry pulls in without
73
+ * re-exporting anything from it.
74
+ *
75
+ * D6 Main-check broken under symlink. The bare
76
+ * `path.resolve(process.argv[1]) === path.resolve(__filename)` form, which
77
+ * silently no-ops when the file is invoked through an npm bin shim (argv[1]
78
+ * is the symlink in node_modules/.bin, __filename is the real path).
79
+ * Severity is keyed on whether the file is an actual `package.json#bin`
80
+ * target — a script nobody ships as a bin is a latent problem, not a live
81
+ * one, and reporting it as high would be crying wolf.
82
+ *
83
+ * THE HONESTY RULES THIS FILE ENFORCES
84
+ * ------------------------------------
85
+ * 1. CLEAN and UNAVAILABLE are different facts and must LOOK different.
86
+ * `status: 'ran'` with zero findings means "inspected, nothing found".
87
+ * `status: 'unavailable'` means "could not inspect" and never counts as a
88
+ * pass. `status: 'partial'` means some of the surface was inspected and
89
+ * some was not, and names which. A detector that cannot run is reported,
90
+ * never skipped.
91
+ * 2. Counts are measured, never estimated. Every finding carries the evidence
92
+ * that produced it — the thrown compiler message, the list of failing SHAs,
93
+ * the line count, the file that was searched.
94
+ * 3. Severity comes from a documented rule (see SEVERITY_RULES), not from a
95
+ * feeling about how bad something looks.
96
+ *
97
+ * All functions here are read-only. Nothing in this module writes to disk, and
98
+ * the only process it spawns is `gh api` for D3.
99
+ */
100
+
101
+ const fs = require('node:fs');
102
+ const path = require('node:path');
103
+ const { execFileSync } = require('node:child_process');
104
+
105
+ // Bounded reads only. An unbounded fs.readFileSync on a JSONL or a generated
106
+ // artifact is itself a defect this repository has already had to fix.
107
+ const { readTextTail } = require('./fs-utils');
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Constants
111
+ // ---------------------------------------------------------------------------
112
+
113
+ const DETECTOR_STATUS = Object.freeze({
114
+ RAN: 'ran',
115
+ PARTIAL: 'partial',
116
+ UNAVAILABLE: 'unavailable',
117
+ });
118
+
119
+ const SEVERITY = Object.freeze({
120
+ CRITICAL: 'critical',
121
+ HIGH: 'high',
122
+ MEDIUM: 'medium',
123
+ LOW: 'low',
124
+ });
125
+
126
+ const SEVERITY_ORDER = Object.freeze([
127
+ SEVERITY.CRITICAL,
128
+ SEVERITY.HIGH,
129
+ SEVERITY.MEDIUM,
130
+ SEVERITY.LOW,
131
+ ]);
132
+
133
+ /**
134
+ * Every severity assignment in this file must trace back to one of these rules.
135
+ * They are exported so a reader can audit the mapping instead of trusting it.
136
+ */
137
+ const SEVERITY_RULES = Object.freeze({
138
+ 'D1.no-tool-names': 'critical — the gate has no toolNames either, so with the match block skipped it fires on every tool call',
139
+ 'D1.overmatching': 'high — the match block is skipped, so the gate fires on every tool in its toolNames',
140
+ 'D1.shadowed': 'medium — a `pattern` is also present and wins; the `patterns` array is dead config the engine never reads',
141
+ 'D2.uncompilable': 'high — new RegExp() throws and gates-engine.js swallows the throw as "no match", leaving the gate inert',
142
+ 'D3.still-failing': 'high — the failure streak reaches the newest scanned commit, so the check is red right now and blocks nothing',
143
+ 'D3.recovered': 'medium — the streak has since recovered, but it ran unnoticed for N+ commits because nothing required this check',
144
+ 'D4.hidden-from-both': 'high — the path is excluded from issue analysis AND coverage, so it is invisible to the scanner in both dimensions',
145
+ 'D4.hidden-from-one': 'medium — the path is excluded from one analysis dimension',
146
+ 'D5.module-unused': 'medium — the module is reachable from the package entry but no production file requires it',
147
+ 'D5.export-uncalled': 'medium — the entry re-exports the symbol but no production file references it',
148
+ 'D6.bin-target': 'high — the file is a package.json#bin target, so it is reached through an npm shim where this check silently no-ops',
149
+ 'D6.not-bin-target': 'low — the file is not currently a bin target, so the bug is latent rather than live',
150
+ });
151
+
152
+ const DEFAULT_CONSECUTIVE_FAILURE_THRESHOLD = 3;
153
+ const DEFAULT_EXCLUDED_LINE_THRESHOLD = 50;
154
+ const DEFAULT_COMMIT_LIMIT = 20;
155
+ const DEFAULT_BRANCH = 'main';
156
+
157
+ // Generous per-file ceiling. No source file in this repository is near it, but
158
+ // the read is still bounded and any truncation is surfaced rather than hidden.
159
+ const MAX_FILE_BYTES = 16 * 1024 * 1024;
160
+
161
+ /**
162
+ * Keys a gate config might plausibly use to hold its matcher. Exactly one of
163
+ * these is read by the engine; any other one that carries a value is config
164
+ * that looks enforced and is not.
165
+ */
166
+ const MATCHER_KEYS = Object.freeze(['pattern', 'patterns']);
167
+
168
+ /** Normalise a matcher value to a list of non-empty strings. */
169
+ function matcherValues(value) {
170
+ if (typeof value === 'string') return value.length > 0 ? [value] : [];
171
+ if (Array.isArray(value)) return value.filter((v) => typeof v === 'string' && v.length > 0);
172
+ return [];
173
+ }
174
+
175
+ const GATES_CONFIG_DIR = path.join('config', 'gates');
176
+ const SONAR_PROPERTIES = 'sonar-project.properties';
177
+
178
+ /** Roots treated as production code for D5/D6. Tests are deliberately absent. */
179
+ const DEFAULT_PRODUCTION_ROOTS = Object.freeze(['src', 'scripts', 'bin', 'adapters', 'hooks', 'commands']);
180
+
181
+ /** Directories never walked, at any depth. */
182
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'coverage', '.claude', '.thumbgate', 'dist', 'build']);
183
+
184
+ const JS_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
185
+
186
+ /**
187
+ * The bare main-check form. Deliberately written to tolerate whitespace but NOT
188
+ * to tolerate a realpathSync() on either side — a file that resolves the
189
+ * symlink is doing the right thing and must not be reported.
190
+ */
191
+ const BARE_MAIN_CHECK = /path\s*\.\s*resolve\(\s*process\s*\.\s*argv\[\s*1\s*\]\s*\)\s*===\s*path\s*\.\s*resolve\(\s*__filename\s*\)/;
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Small shared helpers
195
+ // ---------------------------------------------------------------------------
196
+
197
+ function toPosix(p) {
198
+ return String(p).split(path.sep).join('/');
199
+ }
200
+
201
+ /**
202
+ * Read a text file with a bounded tail. Returns status alongside content so a
203
+ * caller can tell "not there" from "there and empty" from "there and too big
204
+ * to read whole".
205
+ */
206
+ function readBounded(absPath, maxBytes = MAX_FILE_BYTES) {
207
+ try {
208
+ const { text, truncated } = readTextTail(absPath, maxBytes);
209
+ return { ok: true, text, truncated };
210
+ } catch (err) {
211
+ return {
212
+ ok: false,
213
+ text: '',
214
+ truncated: false,
215
+ error: String(err && err.message ? err.message : err),
216
+ code: err && err.code,
217
+ };
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Blank out comments so a lexical search cannot be fooled by prose.
223
+ *
224
+ * Necessary, not cosmetic: this file's own header names the dead symbols it
225
+ * detects while explaining detector D5, and without this pass the auditor
226
+ * counted its own documentation as a production call site and reported a dead
227
+ * module as live. A detector that a comment can silence is exactly the class of
228
+ * defect it exists to find.
229
+ *
230
+ * Characters are replaced with spaces rather than deleted so offsets and line
231
+ * counts stay aligned with the original text.
232
+ */
233
+ function stripComments(source, options = {}) {
234
+ const stripStrings = Boolean(options.stripStrings);
235
+ const out = source.split('');
236
+ const n = source.length;
237
+ let i = 0;
238
+ let lastSignificant = '';
239
+ while (i < n) {
240
+ const c = source[i];
241
+ const next = source[i + 1];
242
+
243
+ if (c === '/' && next === '/') {
244
+ while (i < n && source[i] !== '\n') { out[i] = ' '; i += 1; }
245
+ continue;
246
+ }
247
+ if (c === '/' && next === '*') {
248
+ while (i < n && !(source[i] === '*' && source[i + 1] === '/')) {
249
+ if (source[i] !== '\n') out[i] = ' ';
250
+ i += 1;
251
+ }
252
+ if (i < n) { out[i] = ' '; out[i + 1] = ' '; i += 2; }
253
+ continue;
254
+ }
255
+ if (c === '"' || c === "'" || c === '`') {
256
+ const bodyStart = i + 1;
257
+ i += 1;
258
+ while (i < n) {
259
+ if (source[i] === '\\') { i += 2; continue; }
260
+ if (source[i] === c) { break; }
261
+ i += 1;
262
+ }
263
+ if (stripStrings) {
264
+ for (let k = bodyStart; k < i && k < n; k += 1) {
265
+ if (source[k] !== '\n') out[k] = ' ';
266
+ }
267
+ }
268
+ i += 1; // step past the closing quote (or off the end of an unterminated string)
269
+ lastSignificant = c;
270
+ continue;
271
+ }
272
+ // A `/` in operand position starts a regex literal, whose body may contain
273
+ // `//` (as in /https?:\/\//). Skipping it prevents a false comment start.
274
+ if (c === '/' && (lastSignificant === '' || '=(,:[!&|?{};+*%<>~^'.includes(lastSignificant))) {
275
+ i += 1;
276
+ let inClass = false;
277
+ while (i < n) {
278
+ if (source[i] === '\\') { i += 2; continue; }
279
+ if (source[i] === '[') inClass = true;
280
+ else if (source[i] === ']') inClass = false;
281
+ else if (source[i] === '/' && !inClass) { i += 1; break; }
282
+ else if (source[i] === '\n') break;
283
+ i += 1;
284
+ }
285
+ lastSignificant = '/';
286
+ continue;
287
+ }
288
+ if (!/\s/.test(c)) lastSignificant = c;
289
+ i += 1;
290
+ }
291
+ return out.join('');
292
+ }
293
+
294
+ /** Non-blank line count. Blank-line padding is not code and must not inflate a count. */
295
+ function countCodeLines(text) {
296
+ let n = 0;
297
+ for (const line of text.split('\n')) {
298
+ if (line.trim()) n += 1;
299
+ }
300
+ return n;
301
+ }
302
+
303
+ /** Recursively list files under `absRoot`, skipping SKIP_DIRS. */
304
+ function walkFiles(absRoot, out = []) {
305
+ let entries;
306
+ try {
307
+ entries = fs.readdirSync(absRoot, { withFileTypes: true });
308
+ } catch {
309
+ return out;
310
+ }
311
+ for (const entry of entries) {
312
+ if (entry.isSymbolicLink()) continue; // never follow; a cycle would hang the audit
313
+ const full = path.join(absRoot, entry.name);
314
+ if (entry.isDirectory()) {
315
+ if (SKIP_DIRS.has(entry.name)) continue;
316
+ walkFiles(full, out);
317
+ } else if (entry.isFile()) {
318
+ out.push(full);
319
+ }
320
+ }
321
+ return out;
322
+ }
323
+
324
+ /**
325
+ * Translate a Sonar/npm-style glob to an anchored RegExp.
326
+ * `**` crosses directory boundaries; `*` and `?` do not.
327
+ */
328
+ function globToRegExp(glob) {
329
+ const g = toPosix(glob).trim();
330
+ let out = '';
331
+ for (let i = 0; i < g.length; i += 1) {
332
+ const c = g[i];
333
+ if (c === '*') {
334
+ if (g[i + 1] === '*') {
335
+ // `**/` may match zero segments; a bare `**` matches anything.
336
+ if (g[i + 2] === '/') {
337
+ out += '(?:.*/)?';
338
+ i += 2;
339
+ } else {
340
+ out += '.*';
341
+ i += 1;
342
+ }
343
+ } else {
344
+ out += '[^/]*';
345
+ }
346
+ } else if (c === '?') {
347
+ out += '[^/]';
348
+ } else if ('\\^$.|+()[]{}'.includes(c)) {
349
+ out += `\\${c}`;
350
+ } else {
351
+ out += c;
352
+ }
353
+ }
354
+ // A trailing slash means "this directory and everything under it".
355
+ if (g.endsWith('/')) out += '.*';
356
+ return new RegExp(`^${out}$`);
357
+ }
358
+
359
+ function severityRank(sev) {
360
+ const idx = SEVERITY_ORDER.indexOf(sev);
361
+ return idx === -1 ? SEVERITY_ORDER.length : idx;
362
+ }
363
+
364
+ function makeFinding({ detector, severity, rule, location, appears, actually, evidence }) {
365
+ return { detector, severity, rule, location, appears, actually, evidence };
366
+ }
367
+
368
+ function detectorResult(id, title, extra = {}) {
369
+ return {
370
+ id,
371
+ title,
372
+ status: DETECTOR_STATUS.RAN,
373
+ reason: null,
374
+ inspected: {},
375
+ notInspected: [],
376
+ findings: [],
377
+ ...extra,
378
+ };
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // D1 + D2 — gate config shape and regex compilability
383
+ // ---------------------------------------------------------------------------
384
+
385
+ /**
386
+ * Both detectors read the same files, so they share one pass. They are still
387
+ * reported as two detectors because they fail independently: a config
388
+ * directory that parses fine can hold an uncompilable pattern, and vice versa.
389
+ *
390
+ * `patternKeyReadByEngine` is a parameter, not a constant, because the whole
391
+ * point of D1 is that the config and the engine disagree about the key name.
392
+ * Hardcoding 'pattern' here would make this detector believe its own
393
+ * documentation instead of the engine.
394
+ */
395
+ function auditGateConfigs({ repoRoot, gatesDir, patternKeyReadByEngine = 'pattern' }) {
396
+ const d1 = detectorResult('D1', 'Inert-or-overmatching gate shape');
397
+ const d2 = detectorResult('D2', 'Regex that cannot compile');
398
+
399
+ const absDir = path.isAbsolute(gatesDir) ? gatesDir : path.join(repoRoot, gatesDir);
400
+ let entries;
401
+ try {
402
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
403
+ } catch (err) {
404
+ const reason = err && err.code === 'ENOENT'
405
+ ? `${toPosix(gatesDir)} does not exist under ${repoRoot}`
406
+ : `${toPosix(gatesDir)} could not be listed: ${err && err.message ? err.message : err}`;
407
+ for (const d of [d1, d2]) {
408
+ d.status = DETECTOR_STATUS.UNAVAILABLE;
409
+ d.reason = reason;
410
+ }
411
+ return { d1, d2 };
412
+ }
413
+
414
+ const files = entries
415
+ .filter((e) => e.isFile() && e.name.endsWith('.json'))
416
+ .map((e) => e.name)
417
+ .sort();
418
+
419
+ const unreadable = [];
420
+ let gatesChecked = 0;
421
+ let patternsCompiled = 0;
422
+
423
+ for (const name of files) {
424
+ const abs = path.join(absDir, name);
425
+ const rel = toPosix(path.join(gatesDir, name));
426
+ const read = readBounded(abs);
427
+ if (!read.ok) {
428
+ unreadable.push(`${rel} (${read.error})`);
429
+ continue;
430
+ }
431
+ if (read.truncated) {
432
+ unreadable.push(`${rel} (larger than the ${MAX_FILE_BYTES}-byte read ceiling; only its tail was read, so it was not parsed)`);
433
+ continue;
434
+ }
435
+ let parsed;
436
+ try {
437
+ parsed = JSON.parse(read.text);
438
+ } catch (err) {
439
+ unreadable.push(`${rel} (not valid JSON: ${err && err.message ? err.message : err})`);
440
+ continue;
441
+ }
442
+ const gates = Array.isArray(parsed && parsed.gates) ? parsed.gates : [];
443
+ gates.forEach((gate, index) => {
444
+ if (!gate || typeof gate !== 'object') return;
445
+ gatesChecked += 1;
446
+ const gateId = typeof gate.id === 'string' && gate.id ? gate.id : `<no id>`;
447
+ const location = `${rel} → gates[${index}] (${gateId})`;
448
+ const toolNames = Array.isArray(gate.toolNames) ? gate.toolNames : [];
449
+
450
+ // The matcher values the engine actually reads, and the ones it does not.
451
+ // Both string and array shapes are accepted for the engine key, so the
452
+ // detector still works if gates-engine.js ever switches to an array. Its
453
+ // job is to compare config against engine, not to enforce one spelling.
454
+ const engineValues = matcherValues(gate[patternKeyReadByEngine]);
455
+ const ignoredKeys = MATCHER_KEYS
456
+ .filter((k) => k !== patternKeyReadByEngine)
457
+ .filter((k) => matcherValues(gate[k]).length > 0);
458
+
459
+ // ---- D1 -----------------------------------------------------------
460
+ if (ignoredKeys.length > 0) {
461
+ const ignoredDetail = ignoredKeys.map((k) => `${k}=${JSON.stringify(gate[k])}`).join('; ');
462
+ if (engineValues.length === 0) {
463
+ const overmatches = toolNames.length > 0
464
+ ? `every tool in toolNames (${toolNames.join(', ')})`
465
+ : 'every tool call, because toolNames is empty too';
466
+ d1.findings.push(makeFinding({
467
+ detector: 'D1',
468
+ severity: toolNames.length > 0 ? SEVERITY.HIGH : SEVERITY.CRITICAL,
469
+ rule: toolNames.length > 0 ? 'D1.overmatching' : 'D1.no-tool-names',
470
+ location,
471
+ appears: `The gate declares ${ignoredDetail}, so it reads as narrowly scoped to those strings.`,
472
+ actually: `scripts/gates-engine.js reads gate.${patternKeyReadByEngine}. With that key absent the whole match block is skipped, so the gate matches ${overmatches}.`,
473
+ evidence: `declared but unread: ${ignoredKeys.join(', ')}; key read by engine: ${patternKeyReadByEngine}=absent; toolNames=${toolNames.length}`,
474
+ }));
475
+ } else {
476
+ d1.findings.push(makeFinding({
477
+ detector: 'D1',
478
+ severity: SEVERITY.MEDIUM,
479
+ rule: 'D1.shadowed',
480
+ location,
481
+ appears: `The gate declares BOTH ${patternKeyReadByEngine} and ${ignoredKeys.join('/')}, reading as if all of them are enforced.`,
482
+ actually: `Only gate.${patternKeyReadByEngine} is read. The ${ignoredKeys.join('/')} entries are dead config that no code path consults.`,
483
+ evidence: `${patternKeyReadByEngine}=${JSON.stringify(gate[patternKeyReadByEngine])}; ignored ${ignoredDetail}`,
484
+ }));
485
+ }
486
+ }
487
+
488
+ // ---- D2 -----------------------------------------------------------
489
+ // Detected by attempting compilation, not by looking for `(?i)`. A
490
+ // pattern-match for a known-bad token would miss every other way a regex
491
+ // can fail to compile.
492
+ for (const value of engineValues) {
493
+ patternsCompiled += 1;
494
+ try {
495
+ // eslint-disable-next-line no-new
496
+ new RegExp(value);
497
+ } catch (err) {
498
+ const message = String(err && err.message ? err.message : err);
499
+ const inlineFlags = /\(\?[a-zA-Z]+\)/.exec(value);
500
+ d2.findings.push(makeFinding({
501
+ detector: 'D2',
502
+ severity: SEVERITY.HIGH,
503
+ rule: 'D2.uncompilable',
504
+ location,
505
+ appears: `The gate declares ${patternKeyReadByEngine}: ${JSON.stringify(value)}, so it reads as an active matcher.`,
506
+ actually: 'new RegExp() throws on this pattern. gates-engine.js compiles it inside a try/catch whose catch returns { matched: false }, so the throw is swallowed and the gate never fires.',
507
+ evidence: inlineFlags
508
+ ? `new RegExp() threw: ${message} — inline flag group ${inlineFlags[0]} is not JavaScript regex syntax`
509
+ : `new RegExp() threw: ${message}`,
510
+ }));
511
+ }
512
+ }
513
+ });
514
+ }
515
+
516
+ const inspected = {
517
+ gatesDir: toPosix(gatesDir),
518
+ configFiles: files.length,
519
+ configFilesRead: files.length - unreadable.length,
520
+ gatesChecked,
521
+ };
522
+ d1.inspected = { ...inspected, patternKeyReadByEngine };
523
+ d2.inspected = { ...inspected, patternsCompiled };
524
+
525
+ if (unreadable.length > 0) {
526
+ for (const d of [d1, d2]) {
527
+ d.status = DETECTOR_STATUS.PARTIAL;
528
+ d.reason = `${unreadable.length} of ${files.length} config file(s) could not be parsed; the gates they define were NOT checked`;
529
+ d.notInspected = unreadable.slice();
530
+ }
531
+ } else if (files.length === 0) {
532
+ for (const d of [d1, d2]) {
533
+ d.status = DETECTOR_STATUS.UNAVAILABLE;
534
+ d.reason = `${toPosix(gatesDir)} exists but contains no .json gate configs`;
535
+ }
536
+ }
537
+
538
+ return { d1, d2 };
539
+ }
540
+
541
+ // ---------------------------------------------------------------------------
542
+ // D3 — silently-red non-required check
543
+ // ---------------------------------------------------------------------------
544
+
545
+ /**
546
+ * Fixed install locations for the `gh` CLI, in probe order.
547
+ *
548
+ * The binary is resolved to an ABSOLUTE path before it is executed rather than
549
+ * being handed to the OS as the bare name `gh`. A bare name is resolved by
550
+ * walking $PATH, and $PATH is attacker-writable in plenty of environments — a
551
+ * directory prepended to it can substitute a different executable entirely
552
+ * (SonarQube S4036 / CWE-426). An auditor whose own GitHub reader can be
553
+ * swapped out from the environment would be one more control that reports
554
+ * success while enforcing nothing.
555
+ */
556
+ const GH_BINARY_CANDIDATES = Object.freeze([
557
+ '/opt/homebrew/bin/gh', // Homebrew, Apple silicon
558
+ '/usr/local/bin/gh', // Homebrew, Intel macOS
559
+ '/home/linuxbrew/.linuxbrew/bin/gh', // Homebrew, Linux
560
+ '/usr/bin/gh', // apt / dnf, and the GitHub Actions runner images
561
+ '/bin/gh',
562
+ '/opt/local/bin/gh', // MacPorts
563
+ '/snap/bin/gh',
564
+ ]);
565
+
566
+ /** First candidate that exists and is executable, or null when none is. */
567
+ function resolveGhBinary(candidates = GH_BINARY_CANDIDATES) {
568
+ for (const candidate of candidates) {
569
+ try {
570
+ if (!fs.statSync(candidate).isFile()) continue;
571
+ fs.accessSync(candidate, fs.constants.X_OK);
572
+ return candidate;
573
+ } catch { /* not installed here; try the next fixed location */ }
574
+ }
575
+ return null;
576
+ }
577
+
578
+ /** Default GitHub reader. Injectable so tests never touch the network. */
579
+ function makeGhApiReader(ghBinary = resolveGhBinary()) {
580
+ if (!ghBinary) {
581
+ // A reader that cannot run must SAY it cannot run. Returning a stub that
582
+ // quietly produced empty data would make D3 render as clean over a surface
583
+ // it never read — the exact defect class this file exists to detect.
584
+ const missing = new Error(`the \`gh\` CLI was not found in any fixed install location (${GH_BINARY_CANDIDATES.join(', ')})`);
585
+ missing.code = 'ENOENT';
586
+ return function ghApiUnavailable() { throw missing; };
587
+ }
588
+ return function ghApi(apiPath) {
589
+ const raw = execFileSync(ghBinary, ['api', apiPath], {
590
+ encoding: 'utf8',
591
+ maxBuffer: 32 * 1024 * 1024,
592
+ stdio: ['ignore', 'pipe', 'pipe'],
593
+ });
594
+ return JSON.parse(raw);
595
+ };
596
+ }
597
+
598
+ function ghErrorText(err) {
599
+ if (!err) return 'unknown error';
600
+ if (err.code === 'ENOENT') return `the \`gh\` CLI could not be executed: ${String(err.message || err).split('\n')[0]}`;
601
+ const stderr = err.stderr ? String(err.stderr).trim().split('\n')[0] : '';
602
+ const message = String(err.message || err).split('\n')[0];
603
+ const text = stderr || message;
604
+ return text.length > 300 ? `${text.slice(0, 300)}…` : text;
605
+ }
606
+
607
+ function auditSilentlyRedChecks({
608
+ gitHubRepo,
609
+ branch = DEFAULT_BRANCH,
610
+ consecutiveFailureThreshold,
611
+ commitLimit,
612
+ ghApi,
613
+ }) {
614
+ const d3 = detectorResult('D3', 'Silently-red non-required check');
615
+
616
+ if (!gitHubRepo) {
617
+ d3.status = DETECTOR_STATUS.UNAVAILABLE;
618
+ d3.reason = 'no GitHub repo was supplied and none could be derived from the git remote, so required-check contexts could not be read';
619
+ return d3;
620
+ }
621
+ if (typeof ghApi !== 'function') {
622
+ d3.status = DETECTOR_STATUS.UNAVAILABLE;
623
+ d3.reason = 'no GitHub API reader available';
624
+ return d3;
625
+ }
626
+
627
+ // 1. Required contexts. Without these, "non-required" is unknowable and the
628
+ // detector must not guess — a check reported as non-required when it is
629
+ // actually required is a fabricated finding.
630
+ //
631
+ // TWO INDEPENDENT SURFACES can make a check required, and reading only one
632
+ // of them is itself the defect class this file exists to find:
633
+ // a. classic branch protection → branches/<branch>/protection
634
+ // b. repository RULESETS → rules/branches/<branch>
635
+ // ThumbGate layers the "main governance" ruleset on top of classic
636
+ // protection. A check required only by the ruleset would, if only (a) were
637
+ // read, be reported as red-and-blocking-nothing while it was in fact
638
+ // blocking every merge. The two sets are UNIONED, and a ruleset read that
639
+ // fails downgrades the detector to PARTIAL instead of silently narrowing
640
+ // the definition of "required".
641
+ let protectionContexts;
642
+ try {
643
+ const protection = ghApi(`repos/${gitHubRepo}/branches/${branch}/protection`);
644
+ const contexts = protection
645
+ && protection.required_status_checks
646
+ && protection.required_status_checks.contexts;
647
+ protectionContexts = Array.isArray(contexts) ? contexts : [];
648
+ } catch (err) {
649
+ d3.status = DETECTOR_STATUS.UNAVAILABLE;
650
+ d3.reason = `could not read repos/${gitHubRepo}/branches/${branch}/protection: ${ghErrorText(err)}`;
651
+ return d3;
652
+ }
653
+
654
+ const rulesetContexts = [];
655
+ const rulesetIds = [];
656
+ let rulesetReadError = null;
657
+ try {
658
+ const rules = ghApi(`repos/${gitHubRepo}/rules/branches/${encodeURIComponent(branch)}`);
659
+ for (const rule of Array.isArray(rules) ? rules : []) {
660
+ if (!rule || rule.type !== 'required_status_checks') continue;
661
+ const id = rule.ruleset_id === undefined || rule.ruleset_id === null
662
+ ? '<unnamed ruleset>'
663
+ : String(rule.ruleset_id);
664
+ if (!rulesetIds.includes(id)) rulesetIds.push(id);
665
+ const checks = rule.parameters && rule.parameters.required_status_checks;
666
+ for (const check of Array.isArray(checks) ? checks : []) {
667
+ const context = check && typeof check.context === 'string' ? check.context : null;
668
+ if (context && !rulesetContexts.includes(context)) rulesetContexts.push(context);
669
+ }
670
+ }
671
+ } catch (err) {
672
+ rulesetReadError = ghErrorText(err);
673
+ }
674
+
675
+ const requiredContexts = Array.from(new Set([...protectionContexts, ...rulesetContexts])).sort();
676
+
677
+ // 2. Recent commits.
678
+ let commits;
679
+ try {
680
+ const raw = ghApi(`repos/${gitHubRepo}/commits?sha=${encodeURIComponent(branch)}&per_page=${commitLimit}`);
681
+ commits = Array.isArray(raw) ? raw : [];
682
+ } catch (err) {
683
+ d3.status = DETECTOR_STATUS.UNAVAILABLE;
684
+ d3.reason = `could not list commits on ${branch}: ${ghErrorText(err)}`;
685
+ return d3;
686
+ }
687
+
688
+ if (commits.length === 0) {
689
+ d3.status = DETECTOR_STATUS.UNAVAILABLE;
690
+ d3.reason = `no commits returned for ${gitHubRepo}@${branch}`;
691
+ return d3;
692
+ }
693
+
694
+ // 3. Per-commit check-run conclusions. Check-RUN names share a namespace with
695
+ // required_status_checks.contexts, which is why this compares run names
696
+ // rather than workflow names — a workflow name would need a fuzzy mapping
697
+ // and fuzzy is how false findings get made.
698
+ const commitRows = []; // newest first
699
+ const failedCommits = [];
700
+ for (const commit of commits) {
701
+ const sha = commit && commit.sha;
702
+ if (!sha) continue;
703
+ let checkRuns;
704
+ try {
705
+ const raw = ghApi(`repos/${gitHubRepo}/commits/${sha}/check-runs?per_page=100`);
706
+ checkRuns = Array.isArray(raw && raw.check_runs) ? raw.check_runs : [];
707
+ } catch (err) {
708
+ failedCommits.push(`${sha.slice(0, 8)} (${ghErrorText(err)})`);
709
+ continue;
710
+ }
711
+ const byName = new Map();
712
+ for (const run of checkRuns) {
713
+ // The API returns newest first per name; keep the first seen.
714
+ if (run && typeof run.name === 'string' && !byName.has(run.name)) {
715
+ byName.set(run.name, run.conclusion === undefined ? null : run.conclusion);
716
+ }
717
+ }
718
+ commitRows.push({
719
+ sha,
720
+ short: sha.slice(0, 8),
721
+ date: (commit.commit && commit.commit.author && commit.commit.author.date) || null,
722
+ conclusions: byName,
723
+ });
724
+ }
725
+
726
+ if (commitRows.length === 0) {
727
+ d3.status = DETECTOR_STATUS.UNAVAILABLE;
728
+ d3.reason = `check runs could not be read for any of the ${commits.length} commit(s) scanned`;
729
+ d3.notInspected = failedCommits;
730
+ return d3;
731
+ }
732
+
733
+ const requiredSet = new Set(requiredContexts);
734
+ const allNames = new Set();
735
+ for (const row of commitRows) {
736
+ for (const name of row.conclusions.keys()) allNames.add(name);
737
+ }
738
+
739
+ for (const name of Array.from(allNames).sort()) {
740
+ if (requiredSet.has(name)) continue; // required checks block the merge; they are not silent
741
+
742
+ // Longest run of consecutive 'failure', walking newest → oldest. A commit
743
+ // where the check is absent, pending, skipped, or cancelled BREAKS the run
744
+ // and is recorded — treating an absence as a failure would invent data.
745
+ let best = null;
746
+ let runStart = null;
747
+ let runLen = 0;
748
+ for (let i = 0; i < commitRows.length; i += 1) {
749
+ const row = commitRows[i];
750
+ const conclusion = row.conclusions.has(name) ? row.conclusions.get(name) : undefined;
751
+ if (conclusion === 'failure') {
752
+ if (runLen === 0) runStart = i;
753
+ runLen += 1;
754
+ if (best === null || runLen > best.length) {
755
+ best = { start: runStart, length: runLen, end: i };
756
+ }
757
+ } else {
758
+ runLen = 0;
759
+ }
760
+ }
761
+
762
+ if (!best || best.length < consecutiveFailureThreshold) continue;
763
+
764
+ const streakShas = commitRows.slice(best.start, best.end + 1).map((r) => r.short);
765
+ const stillFailing = best.start === 0;
766
+ const oldest = commitRows[best.end];
767
+ const newest = commitRows[best.start];
768
+ d3.findings.push(makeFinding({
769
+ detector: 'D3',
770
+ severity: stillFailing ? SEVERITY.HIGH : SEVERITY.MEDIUM,
771
+ rule: stillFailing ? 'D3.still-failing' : 'D3.recovered',
772
+ location: `${gitHubRepo}@${branch} → check run "${name}"`,
773
+ appears: `"${name}" runs on every push to ${branch} and shows up in the checks list, so it reads as an enforced deploy/verification step.`,
774
+ actually: stillFailing
775
+ ? `It is required by NEITHER classic branch protection NOR any repository ruleset, and it has failed on the last ${best.length} scanned commit(s) up to and including the newest. Nothing was blocked by any of those failures.`
776
+ : `It is required by NEITHER classic branch protection NOR any repository ruleset, and it failed on ${best.length} consecutive commit(s) without blocking anything. It has since recovered.`,
777
+ evidence: `streak of ${best.length} consecutive 'failure' conclusion(s), ${newest.short}${newest.date ? ` (${newest.date})` : ''} back to ${oldest.short}${oldest.date ? ` (${oldest.date})` : ''}: ${streakShas.join(', ')}; required by branch protection = [${protectionContexts.join(', ')}]; required by ruleset(s) ${rulesetIds.length > 0 ? rulesetIds.join(', ') : '(none)'} = [${rulesetContexts.join(', ')}]`,
778
+ }));
779
+ }
780
+
781
+ d3.inspected = {
782
+ repo: gitHubRepo,
783
+ branch,
784
+ commitsScanned: commitRows.length,
785
+ commitsRequested: commitLimit,
786
+ checkNamesSeen: allNames.size,
787
+ requiredContexts,
788
+ requiredByBranchProtection: protectionContexts,
789
+ requiredByRulesets: rulesetReadError ? '(NOT READ)' : rulesetContexts,
790
+ rulesetsWithRequiredChecks: rulesetReadError ? '(NOT READ)' : rulesetIds,
791
+ consecutiveFailureThreshold,
792
+ };
793
+
794
+ // Any surface that was not read has to downgrade the verdict. Both gaps are
795
+ // reported together so neither hides behind the other.
796
+ const coverageGaps = [];
797
+ if (rulesetReadError) {
798
+ coverageGaps.push(`ruleset-required contexts could NOT be read (repos/${gitHubRepo}/rules/branches/${branch}: ${rulesetReadError}), so "required" here means classic branch protection only and a check required by a ruleset alone would be misreported below as blocking nothing`);
799
+ }
800
+ if (failedCommits.length > 0) {
801
+ coverageGaps.push(`${failedCommits.length} commit(s) could not be read; their check runs were NOT inspected and any streak crossing them is under-counted`);
802
+ }
803
+ if (coverageGaps.length > 0) {
804
+ d3.status = DETECTOR_STATUS.PARTIAL;
805
+ d3.reason = coverageGaps.join('; ');
806
+ d3.notInspected = rulesetReadError
807
+ ? [`repos/${gitHubRepo}/rules/branches/${branch} (${rulesetReadError})`, ...failedCommits]
808
+ : failedCommits;
809
+ }
810
+
811
+ return d3;
812
+ }
813
+
814
+ // ---------------------------------------------------------------------------
815
+ // D4 — analysis blindspots
816
+ // ---------------------------------------------------------------------------
817
+
818
+ function parseProperties(text) {
819
+ const props = new Map();
820
+ for (const rawLine of text.split('\n')) {
821
+ const line = rawLine.trim();
822
+ if (!line || line.startsWith('#') || line.startsWith('!')) continue;
823
+ const eq = line.indexOf('=');
824
+ if (eq === -1) continue;
825
+ props.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
826
+ }
827
+ return props;
828
+ }
829
+
830
+ function splitList(value) {
831
+ if (!value) return [];
832
+ return value.split(',').map((s) => s.trim()).filter(Boolean);
833
+ }
834
+
835
+ function auditAnalysisBlindspots({ repoRoot, excludedLineThreshold, sonarFile = SONAR_PROPERTIES }) {
836
+ const d4 = detectorResult('D4', 'Analysis blindspots (scanner exclusions)');
837
+
838
+ const abs = path.join(repoRoot, sonarFile);
839
+ const read = readBounded(abs);
840
+ if (!read.ok) {
841
+ d4.status = DETECTOR_STATUS.UNAVAILABLE;
842
+ d4.reason = read.code === 'ENOENT'
843
+ ? `${sonarFile} not found under ${repoRoot}`
844
+ : `${sonarFile} could not be read: ${read.error}`;
845
+ return d4;
846
+ }
847
+
848
+ const props = parseProperties(read.text);
849
+ const sourceRoots = splitList(props.get('sonar.sources'));
850
+ if (sourceRoots.length === 0) {
851
+ d4.status = DETECTOR_STATUS.UNAVAILABLE;
852
+ d4.reason = `${sonarFile} declares no sonar.sources, so there is no analyzed surface to measure an exclusion against`;
853
+ return d4;
854
+ }
855
+
856
+ // Everything Sonar would actually analyze, with its line count. An exclusion
857
+ // that touches nothing in here hides nothing, and is not a blindspot.
858
+ const analyzed = [];
859
+ const unreadable = [];
860
+ for (const root of sourceRoots) {
861
+ const absRoot = path.join(repoRoot, root);
862
+ for (const file of walkFiles(absRoot)) {
863
+ const rel = toPosix(path.relative(repoRoot, file));
864
+ const fileRead = readBounded(file);
865
+ if (!fileRead.ok) {
866
+ unreadable.push(`${rel} (${fileRead.error})`);
867
+ continue;
868
+ }
869
+ if (fileRead.truncated) {
870
+ unreadable.push(`${rel} (exceeds the ${MAX_FILE_BYTES}-byte read ceiling; its line count would be a tail-only undercount)`);
871
+ continue;
872
+ }
873
+ analyzed.push({ rel, lines: countCodeLines(fileRead.text) });
874
+ }
875
+ }
876
+
877
+ const lists = [
878
+ { key: 'sonar.exclusions', label: 'issue analysis' },
879
+ { key: 'sonar.coverage.exclusions', label: 'coverage' },
880
+ ];
881
+
882
+ // First pass: what does each exclusion in each list actually hide?
883
+ const perList = new Map();
884
+ for (const { key } of lists) {
885
+ const entries = splitList(props.get(key));
886
+ const measured = entries.map((pattern) => {
887
+ let re;
888
+ try {
889
+ re = globToRegExp(pattern);
890
+ } catch {
891
+ return { pattern, files: 0, lines: 0, uncompilable: true };
892
+ }
893
+ let files = 0;
894
+ let lines = 0;
895
+ for (const item of analyzed) {
896
+ if (re.test(item.rel)) {
897
+ files += 1;
898
+ lines += item.lines;
899
+ }
900
+ }
901
+ return { pattern, files, lines, uncompilable: false };
902
+ });
903
+ perList.set(key, measured);
904
+ }
905
+
906
+ const inBoth = new Set();
907
+ const first = new Map((perList.get('sonar.exclusions') || []).map((m) => [m.pattern, m]));
908
+ for (const m of perList.get('sonar.coverage.exclusions') || []) {
909
+ if (first.has(m.pattern)) inBoth.add(m.pattern);
910
+ }
911
+
912
+ const reported = new Set();
913
+ for (const { key, label } of lists) {
914
+ for (const m of perList.get(key) || []) {
915
+ if (m.lines <= excludedLineThreshold) continue;
916
+ const both = inBoth.has(m.pattern);
917
+ if (both && reported.has(m.pattern)) continue;
918
+ if (both) reported.add(m.pattern);
919
+ const where = both ? 'sonar.exclusions AND sonar.coverage.exclusions' : key;
920
+ d4.findings.push(makeFinding({
921
+ detector: 'D4',
922
+ severity: both ? SEVERITY.HIGH : SEVERITY.MEDIUM,
923
+ rule: both ? 'D4.hidden-from-both' : 'D4.hidden-from-one',
924
+ location: `${sonarFile} → ${where} → "${m.pattern}"`,
925
+ appears: 'A green SonarCloud badge reads as "the analyzed code is clean".',
926
+ actually: both
927
+ ? `This entry removes ${m.lines} line(s) across ${m.files} file(s) from BOTH issue analysis and coverage, so nothing in them can ever turn the badge red or lower the coverage number.`
928
+ : `This entry removes ${m.lines} line(s) across ${m.files} file(s) from ${label}. The badge is green over a smaller surface than it appears to cover.`,
929
+ evidence: `pattern "${m.pattern}" matches ${m.files} file(s) inside sonar.sources=[${sourceRoots.join(', ')}], totalling ${m.lines} non-blank line(s); threshold is ${excludedLineThreshold}`,
930
+ }));
931
+ }
932
+ }
933
+
934
+ d4.findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || a.location.localeCompare(b.location));
935
+
936
+ d4.inspected = {
937
+ sonarFile,
938
+ sourceRoots,
939
+ analyzedFiles: analyzed.length,
940
+ analyzedLines: analyzed.reduce((sum, f) => sum + f.lines, 0),
941
+ exclusionEntries: (perList.get('sonar.exclusions') || []).length,
942
+ coverageExclusionEntries: (perList.get('sonar.coverage.exclusions') || []).length,
943
+ excludedLineThreshold,
944
+ };
945
+
946
+ if (unreadable.length > 0) {
947
+ d4.status = DETECTOR_STATUS.PARTIAL;
948
+ d4.reason = `${unreadable.length} file(s) inside sonar.sources could not be counted; every line total below is an UNDER-count by an unknown amount`;
949
+ d4.notInspected = unreadable;
950
+ }
951
+
952
+ return d4;
953
+ }
954
+
955
+ // ---------------------------------------------------------------------------
956
+ // D5 — zero production call sites
957
+ // ---------------------------------------------------------------------------
958
+
959
+ /** Collect `require('…')` specifiers and the identifiers bound from them. */
960
+ function parseEntryRequires(text) {
961
+ const out = [];
962
+ const re = /require\(\s*(['"])([^'"]+)\1\s*\)/g;
963
+ let m;
964
+ while ((m = re.exec(text)) !== null) {
965
+ out.push({ specifier: m[2], index: m.index });
966
+ }
967
+ return out;
968
+ }
969
+
970
+ /**
971
+ * Local identifiers bound from each `require('…')` declaration in the entry.
972
+ *
973
+ * Needed to answer "is this module part of the PUBLISHED surface?": a module
974
+ * whose bindings reach `module.exports` is public API, and public API cannot be
975
+ * proven dead from this repository alone — its callers are downstream npm
976
+ * consumers that no local corpus contains.
977
+ *
978
+ * The destructuring pattern is matched with `[^}]*` (not a lazy `[\s\S]*?`) so
979
+ * the scan stays linear on any input; a nested brace inside a require
980
+ * destructuring is not valid CommonJS shorthand anyway.
981
+ */
982
+ function parseEntryBindings(text) {
983
+ const out = [];
984
+ const re = /(?:const|let|var)\s+(\{[^}]*\}|[A-Za-z_$][\w$]*)\s*=\s*require\(\s*(['"])([^'"]+)\2\s*\)/g;
985
+ let m;
986
+ while ((m = re.exec(text)) !== null) {
987
+ const target = m[1];
988
+ const names = [];
989
+ if (target.startsWith('{')) {
990
+ for (const chunk of target.slice(1, -1).split(',')) {
991
+ const trimmed = chunk.trim();
992
+ if (!trimmed) continue;
993
+ const parts = trimmed.split(':');
994
+ const local = (parts.length > 1 ? parts[1] : parts[0]).trim();
995
+ if (/^[A-Za-z_$][\w$]*$/.test(local)) names.push(local);
996
+ }
997
+ } else if (/^[A-Za-z_$][\w$]*$/.test(target)) {
998
+ names.push(target);
999
+ }
1000
+ out.push({ specifier: m[3], names });
1001
+ }
1002
+ return out;
1003
+ }
1004
+
1005
+ /**
1006
+ * The text of the entry's `module.exports = …` statement, or '' when absent.
1007
+ * Shared by parseEntryExportNames and the public-API check so both read exactly
1008
+ * the same span.
1009
+ */
1010
+ function extractEntryExportStatement(text) {
1011
+ const start = text.indexOf('module.exports');
1012
+ if (start === -1) return '';
1013
+
1014
+ // End of the assignment: the first `;` or newline reached at zero
1015
+ // paren/brace/bracket depth.
1016
+ let depth = 0;
1017
+ let end = text.length;
1018
+ const eq = text.indexOf('=', start);
1019
+ for (let i = start; i < text.length; i += 1) {
1020
+ const c = text[i];
1021
+ if (c === '(' || c === '{' || c === '[') depth += 1;
1022
+ else if (c === ')' || c === '}' || c === ']') depth -= 1;
1023
+ else if (depth === 0 && (c === ';' || c === '\n') && i > eq) { end = i; break; }
1024
+ }
1025
+ return text.slice(start, end);
1026
+ }
1027
+
1028
+ /**
1029
+ * Names re-exported by the entry's `module.exports = …` statement.
1030
+ *
1031
+ * Deliberately reads EVERY object literal in the statement, not just the first.
1032
+ * `module.exports = Object.assign({}, base, { A, B })` puts an empty `{}` first;
1033
+ * a scanner that stopped at the first brace would report zero exports and then
1034
+ * silently pass D5b for a file it never actually looked at — the exact
1035
+ * failure mode this whole tool exists to catch.
1036
+ */
1037
+ function parseEntryExportNames(text) {
1038
+ const statement = extractEntryExportStatement(text);
1039
+ if (!statement) return [];
1040
+
1041
+ const names = [];
1042
+ const seen = new Set();
1043
+ for (let i = 0; i < statement.length; i += 1) {
1044
+ if (statement[i] !== '{') continue;
1045
+ let braceDepth = 0;
1046
+ let close = -1;
1047
+ for (let j = i; j < statement.length; j += 1) {
1048
+ if (statement[j] === '{') braceDepth += 1;
1049
+ else if (statement[j] === '}') {
1050
+ braceDepth -= 1;
1051
+ if (braceDepth === 0) { close = j; break; }
1052
+ }
1053
+ }
1054
+ if (close === -1) break;
1055
+ for (const chunk of statement.slice(i + 1, close).split(',')) {
1056
+ const trimmed = chunk.trim();
1057
+ if (!trimmed) continue;
1058
+ const key = trimmed.split(':')[0].trim();
1059
+ if (/^[A-Za-z_$][\w$]*$/.test(key) && !seen.has(key)) {
1060
+ seen.add(key);
1061
+ names.push(key);
1062
+ }
1063
+ }
1064
+ i = close;
1065
+ }
1066
+ return names;
1067
+ }
1068
+
1069
+ /**
1070
+ * @param {boolean} [publicApi] true when this repository IS a published npm
1071
+ * package, i.e. the entry surface has consumers
1072
+ * that no local corpus can contain
1073
+ * @param {string|null} [packageName]
1074
+ */
1075
+ function auditZeroCallSites({ repoRoot, entryFile, productionRoots, publicApi = false, packageName = null }) {
1076
+ const d5 = detectorResult('D5', 'Zero production call sites');
1077
+
1078
+ const absEntry = path.join(repoRoot, entryFile);
1079
+ const entryRead = readBounded(absEntry);
1080
+ if (entryRead.ok) entryRead.text = stripComments(entryRead.text);
1081
+ if (!entryRead.ok) {
1082
+ d5.status = DETECTOR_STATUS.UNAVAILABLE;
1083
+ d5.reason = entryRead.code === 'ENOENT'
1084
+ ? `package entry ${toPosix(entryFile)} not found under ${repoRoot}`
1085
+ : `package entry ${toPosix(entryFile)} could not be read: ${entryRead.error}`;
1086
+ return d5;
1087
+ }
1088
+
1089
+ const entryRel = toPosix(entryFile);
1090
+ const entryDir = path.dirname(absEntry);
1091
+ const exportStatement = extractEntryExportStatement(entryRead.text);
1092
+
1093
+ // Symbols and modules that ARE the published package surface. For these the
1094
+ // corpus below can only prove "no INTERNAL caller", which is not the same
1095
+ // fact as "dead" — the callers are downstream npm consumers of the package,
1096
+ // and they are outside anything this process can read. Reporting them as
1097
+ // findings would be an invented verdict, so they are listed as NOT INSPECTED
1098
+ // instead, and the detector drops to PARTIAL to say so out loud.
1099
+ const publicApiNotAnalyzable = [];
1100
+
1101
+ // Local modules the entry pulls in. Non-relative specifiers are third-party
1102
+ // and out of scope.
1103
+ const localModules = [];
1104
+ for (const { specifier } of parseEntryRequires(entryRead.text)) {
1105
+ if (!specifier.startsWith('.')) continue;
1106
+ const resolvedBase = path.resolve(entryDir, specifier);
1107
+ const candidates = [resolvedBase, `${resolvedBase}.js`, path.join(resolvedBase, 'index.js')];
1108
+ const hit = candidates.find((c) => {
1109
+ try { return fs.statSync(c).isFile(); } catch { return false; }
1110
+ });
1111
+ if (!hit) continue;
1112
+ const rel = toPosix(path.relative(repoRoot, hit));
1113
+ if (!localModules.some((m) => m.rel === rel)) {
1114
+ localModules.push({ rel, abs: hit, specifier });
1115
+ }
1116
+ }
1117
+
1118
+ // Which local modules feed a symbol into `module.exports`? Those modules are
1119
+ // re-exported, so they are public API rather than internal wiring.
1120
+ const specifierToRel = new Map(localModules.map((mod) => [mod.specifier, mod.rel]));
1121
+ const publicApiModules = new Set();
1122
+ for (const binding of parseEntryBindings(entryRead.text)) {
1123
+ const rel = specifierToRel.get(binding.specifier);
1124
+ if (!rel) continue;
1125
+ const reaches = binding.names.some(
1126
+ (name) => new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(exportStatement),
1127
+ );
1128
+ if (reaches) publicApiModules.add(rel);
1129
+ }
1130
+
1131
+ // Production corpus: everything under productionRoots except the entry file.
1132
+ const corpus = [];
1133
+ const unreadable = [];
1134
+ const roots = [];
1135
+ for (const root of productionRoots) {
1136
+ const absRoot = path.join(repoRoot, root);
1137
+ let exists = false;
1138
+ try { exists = fs.statSync(absRoot).isDirectory(); } catch { exists = false; }
1139
+ if (!exists) continue;
1140
+ roots.push(root);
1141
+ for (const file of walkFiles(absRoot)) {
1142
+ if (!JS_EXTENSIONS.has(path.extname(file))) continue;
1143
+ const rel = toPosix(path.relative(repoRoot, file));
1144
+ if (rel === entryRel) continue;
1145
+ const read = readBounded(file);
1146
+ if (!read.ok) { unreadable.push(`${rel} (${read.error})`); continue; }
1147
+ if (read.truncated) { unreadable.push(`${rel} (exceeds the read ceiling; only its tail was searched)`); continue; }
1148
+ corpus.push({ rel, abs: file, text: stripComments(read.text) });
1149
+ }
1150
+ }
1151
+
1152
+ if (roots.length === 0) {
1153
+ d5.status = DETECTOR_STATUS.UNAVAILABLE;
1154
+ d5.reason = `none of the production roots [${productionRoots.join(', ')}] exist under ${repoRoot}, so there is no corpus to search for call sites`;
1155
+ return d5;
1156
+ }
1157
+
1158
+ // --- D5a: modules with no production requirer other than the entry --------
1159
+ for (const mod of localModules) {
1160
+ const modAbs = path.resolve(mod.abs);
1161
+ const requirers = [];
1162
+ for (const file of corpus) {
1163
+ for (const { specifier } of parseEntryRequires(file.text)) {
1164
+ if (!specifier.startsWith('.')) continue;
1165
+ const base = path.resolve(path.dirname(file.abs), specifier);
1166
+ const resolved = [base, `${base}.js`, path.join(base, 'index.js')].map((c) => path.resolve(c));
1167
+ if (resolved.includes(modAbs)) { requirers.push(file.rel); break; }
1168
+ }
1169
+ }
1170
+ if (requirers.length === 0) {
1171
+ if (publicApi && publicApiModules.has(mod.rel)) {
1172
+ publicApiNotAnalyzable.push(`${mod.rel} — re-exported by ${entryRel}, so its call sites are downstream consumers of the published package${packageName ? ` \`${packageName}\`` : ''}, which this corpus cannot contain`);
1173
+ continue;
1174
+ }
1175
+ d5.findings.push(makeFinding({
1176
+ detector: 'D5',
1177
+ severity: SEVERITY.MEDIUM,
1178
+ rule: 'D5.module-unused',
1179
+ location: mod.rel,
1180
+ appears: `${entryRel} requires it, so it reads as part of the shipped runtime.`,
1181
+ actually: `No file under [${roots.join(', ')}] requires it. Its only consumers are the package entry's re-export and whatever tests exist for it.`,
1182
+ evidence: `searched ${corpus.length} production file(s) under [${roots.join(', ')}] for a require() resolving to ${mod.rel}; 0 matched`,
1183
+ }));
1184
+ }
1185
+ }
1186
+
1187
+ // --- D5b: entry exports nobody references --------------------------------
1188
+ // The file that DEFINES a symbol is not a user of it. Counting the definition
1189
+ // site as a call site is how `evaluateThreat` stayed invisible: it was
1190
+ // imported by the entry, re-exported, never called, and every naive search
1191
+ // found it in its own module and declared it live.
1192
+ const definitionSites = new Set(localModules.map((m) => m.rel));
1193
+ const callSiteCorpus = corpus.filter((f) => !definitionSites.has(f.rel));
1194
+ const exportNames = parseEntryExportNames(entryRead.text);
1195
+ for (const name of exportNames) {
1196
+ const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
1197
+ const users = [];
1198
+ for (const file of callSiteCorpus) {
1199
+ if (re.test(file.text)) { users.push(file.rel); if (users.length > 2) break; }
1200
+ }
1201
+ if (users.length === 0) {
1202
+ if (publicApi) {
1203
+ publicApiNotAnalyzable.push(`${entryRel} → export "${name}" — a published entry export${packageName ? ` of \`${packageName}\`` : ''}, so its callers are downstream npm consumers this corpus cannot contain`);
1204
+ continue;
1205
+ }
1206
+ d5.findings.push(makeFinding({
1207
+ detector: 'D5',
1208
+ severity: SEVERITY.MEDIUM,
1209
+ rule: 'D5.export-uncalled',
1210
+ location: `${entryRel} → export "${name}"`,
1211
+ appears: `The package entry re-exports ${name}, so it reads as a live part of the public runtime surface.`,
1212
+ actually: `No file under [${roots.join(', ')}] mentions ${name} at all. It is imported and re-exported without ever being called.`,
1213
+ evidence: `searched ${corpus.length} production file(s) under [${roots.join(', ')}] for /\\b${name}\\b/; 0 matched`,
1214
+ }));
1215
+ }
1216
+ }
1217
+
1218
+ d5.inspected = {
1219
+ entryFile: entryRel,
1220
+ productionRoots: roots,
1221
+ productionFilesSearched: corpus.length,
1222
+ entryLocalModules: localModules.length,
1223
+ entryExportNames: exportNames.length,
1224
+ publishedPackage: publicApi ? (packageName || 'yes') : 'no (private or unnamed)',
1225
+ publicApiNotAnalyzable: publicApiNotAnalyzable.length,
1226
+ };
1227
+
1228
+ const gaps = [];
1229
+ if (publicApiNotAnalyzable.length > 0) {
1230
+ gaps.push(`${publicApiNotAnalyzable.length} entry-surface symbol(s)/module(s) have no INTERNAL call site, but this repository publishes${packageName ? ` \`${packageName}\`` : ' a package'} and downstream npm consumers are outside every corpus this process can read; they are reported neither dead NOR clean`);
1231
+ }
1232
+ if (unreadable.length > 0) {
1233
+ gaps.push(`${unreadable.length} production file(s) could not be searched; a call site inside them would have been missed, so a "zero call sites" finding here is weaker than it looks`);
1234
+ }
1235
+ if (gaps.length > 0) {
1236
+ d5.status = DETECTOR_STATUS.PARTIAL;
1237
+ d5.reason = gaps.join('; ');
1238
+ d5.notInspected = [...publicApiNotAnalyzable, ...unreadable];
1239
+ }
1240
+
1241
+ return d5;
1242
+ }
1243
+
1244
+ // ---------------------------------------------------------------------------
1245
+ // D6 — main-check broken under symlink
1246
+ // ---------------------------------------------------------------------------
1247
+
1248
+ function auditMainCheckUnderSymlink({ repoRoot, productionRoots, binTargets }) {
1249
+ const d6 = detectorResult('D6', 'Main-check broken under symlink');
1250
+
1251
+ const binSet = new Set(binTargets.map((t) => toPosix(t)));
1252
+ const roots = [];
1253
+ const unreadable = [];
1254
+ let scanned = 0;
1255
+
1256
+ for (const root of productionRoots) {
1257
+ const absRoot = path.join(repoRoot, root);
1258
+ let exists = false;
1259
+ try { exists = fs.statSync(absRoot).isDirectory(); } catch { exists = false; }
1260
+ if (!exists) continue;
1261
+ roots.push(root);
1262
+ for (const file of walkFiles(absRoot)) {
1263
+ const ext = path.extname(file);
1264
+ // Extension-less files are included because npm bin targets often have
1265
+ // none (bin/futureagi-bridge is one).
1266
+ if (ext && !JS_EXTENSIONS.has(ext)) continue;
1267
+ const rel = toPosix(path.relative(repoRoot, file));
1268
+ const read = readBounded(file);
1269
+ if (!read.ok) { unreadable.push(`${rel} (${read.error})`); continue; }
1270
+ if (read.truncated) { unreadable.push(`${rel} (exceeds the read ceiling; only its tail was searched)`); continue; }
1271
+ scanned += 1;
1272
+ // Comments AND string literals are blanked first. A commented-out main
1273
+ // check enforces nothing, and a main check quoted inside a help string is
1274
+ // documentation, not code — reporting either would be a fabricated
1275
+ // finding. This detector caught exactly that on bin/cli.js, whose only
1276
+ // occurrence of the bare form is inside this command's own --help text.
1277
+ const code = stripComments(read.text, { stripStrings: true });
1278
+ if (!BARE_MAIN_CHECK.test(code)) continue;
1279
+ // A file that also resolves the symlink is doing the right thing.
1280
+ if (/realpathSync/.test(code)) continue;
1281
+ const isBin = binSet.has(rel);
1282
+ d6.findings.push(makeFinding({
1283
+ detector: 'D6',
1284
+ severity: isBin ? SEVERITY.HIGH : SEVERITY.LOW,
1285
+ rule: isBin ? 'D6.bin-target' : 'D6.not-bin-target',
1286
+ location: rel,
1287
+ appears: 'The file guards its CLI entrypoint with a path-resolve main check, which reads as "run this only when executed directly".',
1288
+ actually: isBin
1289
+ ? 'This file IS a package.json#bin target. npm installs bins as symlinks in node_modules/.bin, so process.argv[1] is the symlink while __filename is the real path. The comparison is false and the CLI body never runs.'
1290
+ : 'The comparison is false whenever the file is reached through a symlink (an npm bin shim). This file is not currently a bin target, so the bug is latent, not live.',
1291
+ evidence: `matched /${BARE_MAIN_CHECK.source}/ with no realpathSync() in the file; package.json#bin target: ${isBin ? 'yes' : 'no'}`,
1292
+ }));
1293
+ }
1294
+ }
1295
+
1296
+ if (roots.length === 0) {
1297
+ d6.status = DETECTOR_STATUS.UNAVAILABLE;
1298
+ d6.reason = `none of the production roots [${productionRoots.join(', ')}] exist under ${repoRoot}`;
1299
+ return d6;
1300
+ }
1301
+
1302
+ d6.findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || a.location.localeCompare(b.location));
1303
+
1304
+ d6.inspected = {
1305
+ productionRoots: roots,
1306
+ filesScanned: scanned,
1307
+ binTargets: Array.from(binSet).sort(),
1308
+ binTargetsAffected: d6.findings.filter((f) => f.rule === 'D6.bin-target').length,
1309
+ nonBinFilesAffected: d6.findings.filter((f) => f.rule === 'D6.not-bin-target').length,
1310
+ };
1311
+
1312
+ if (unreadable.length > 0) {
1313
+ d6.status = DETECTOR_STATUS.PARTIAL;
1314
+ d6.reason = `${unreadable.length} file(s) could not be searched and were NOT checked for the bare main-check form`;
1315
+ d6.notInspected = unreadable;
1316
+ }
1317
+
1318
+ return d6;
1319
+ }
1320
+
1321
+ // ---------------------------------------------------------------------------
1322
+ // Orchestration
1323
+ // ---------------------------------------------------------------------------
1324
+
1325
+ function readPackageJson(repoRoot) {
1326
+ const read = readBounded(path.join(repoRoot, 'package.json'));
1327
+ if (!read.ok || read.truncated) return null;
1328
+ try { return JSON.parse(read.text); } catch { return null; }
1329
+ }
1330
+
1331
+ /** Derive `owner/name` from the origin remote without spawning git. */
1332
+ function deriveGitHubRepo(repoRoot) {
1333
+ const candidates = [path.join(repoRoot, '.git', 'config')];
1334
+ // Worktrees store a `.git` FILE pointing at the real gitdir.
1335
+ const dotGit = path.join(repoRoot, '.git');
1336
+ try {
1337
+ const stat = fs.statSync(dotGit);
1338
+ if (stat.isFile()) {
1339
+ const pointer = readBounded(dotGit, 4096);
1340
+ const match = pointer.ok && /gitdir:\s*(.+)/.exec(pointer.text.trim());
1341
+ if (match) {
1342
+ const gitDir = path.resolve(repoRoot, match[1].trim());
1343
+ // .git/worktrees/<name>/ → the common dir is two levels up.
1344
+ candidates.push(path.join(gitDir, '..', '..', 'config'));
1345
+ }
1346
+ }
1347
+ } catch { /* no .git at all */ }
1348
+
1349
+ for (const candidate of candidates) {
1350
+ const read = readBounded(candidate, 1024 * 1024);
1351
+ if (!read.ok) continue;
1352
+ // The repository name may contain dots (`acme/foo.bar`). Capture the whole
1353
+ // non-whitespace tail and strip ONLY a terminal `.git`, rather than
1354
+ // excluding dots from the name — a `[^\s.]+` capture silently truncates
1355
+ // `foo.bar.git` to `foo`, which then queries an unrelated repository and
1356
+ // reports D3 unavailable for a reason that is not the real one.
1357
+ const m = /url\s*=\s*(?:https:\/\/github\.com\/|git@github\.com:)([^\s/]+)\/(\S+)/.exec(read.text);
1358
+ if (!m) continue;
1359
+ const owner = m[1];
1360
+ const name = m[2].replace(/\/+$/, '').replace(/\.git$/i, '');
1361
+ if (owner && name && !name.includes('/')) return `${owner}/${name}`;
1362
+ }
1363
+ return null;
1364
+ }
1365
+
1366
+ /**
1367
+ * Run every detector and return one report.
1368
+ *
1369
+ * @param {object} [opts]
1370
+ * @param {string} [opts.repoRoot] repository to audit
1371
+ * @param {string|null} [opts.gitHubRepo] "owner/name"; derived from the git remote when omitted
1372
+ * @param {string} [opts.branch] protected branch to inspect for D3
1373
+ * @param {number} [opts.consecutiveFailureThreshold] D3: consecutive failures that make a check a finding
1374
+ * @param {number} [opts.excludedLineThreshold] D4: lines an exclusion must hide to be a finding
1375
+ * @param {number} [opts.commitLimit] D3: how many commits to scan
1376
+ * @param {string[]} [opts.productionRoots] D5/D6: directories treated as production code
1377
+ * @param {Function|null} [opts.ghApi] injectable GitHub reader; null disables D3
1378
+ * @param {boolean} [opts.offline] skip D3 and report it UNAVAILABLE
1379
+ */
1380
+ function auditGovernanceConflicts(opts = {}) {
1381
+ const repoRoot = path.resolve(opts.repoRoot || process.cwd());
1382
+ const consecutiveFailureThreshold = Number.isFinite(Number(opts.consecutiveFailureThreshold))
1383
+ && Number(opts.consecutiveFailureThreshold) >= 1
1384
+ ? Math.floor(Number(opts.consecutiveFailureThreshold))
1385
+ : DEFAULT_CONSECUTIVE_FAILURE_THRESHOLD;
1386
+ const excludedLineThreshold = Number.isFinite(Number(opts.excludedLineThreshold))
1387
+ && Number(opts.excludedLineThreshold) >= 0
1388
+ ? Math.floor(Number(opts.excludedLineThreshold))
1389
+ : DEFAULT_EXCLUDED_LINE_THRESHOLD;
1390
+ const commitLimit = Number.isFinite(Number(opts.commitLimit)) && Number(opts.commitLimit) >= 1
1391
+ ? Math.min(100, Math.floor(Number(opts.commitLimit)))
1392
+ : DEFAULT_COMMIT_LIMIT;
1393
+ // A bare `--branch` flag parses to boolean true in the CLI arg parser.
1394
+ // Interpolating that into an API path would produce a nonsense request and a
1395
+ // spurious UNAVAILABLE, so only a real string is accepted.
1396
+ const branch = typeof opts.branch === 'string' && opts.branch ? opts.branch : DEFAULT_BRANCH;
1397
+ const productionRoots = Array.isArray(opts.productionRoots) && opts.productionRoots.length > 0
1398
+ ? opts.productionRoots.slice()
1399
+ : DEFAULT_PRODUCTION_ROOTS.slice();
1400
+
1401
+ const pkg = readPackageJson(repoRoot);
1402
+ const entryFile = opts.entryFile || (pkg && typeof pkg.main === 'string' ? pkg.main : 'src/index.js');
1403
+ const binTargets = pkg && pkg.bin && typeof pkg.bin === 'object'
1404
+ ? Object.values(pkg.bin).filter((v) => typeof v === 'string')
1405
+ : [];
1406
+
1407
+ let gitHubRepo;
1408
+ if (opts.gitHubRepo === undefined) gitHubRepo = deriveGitHubRepo(repoRoot);
1409
+ else if (typeof opts.gitHubRepo === 'string' && opts.gitHubRepo) gitHubRepo = opts.gitHubRepo;
1410
+ else gitHubRepo = null; // explicit null, or a bare `--github-repo` flag
1411
+
1412
+ const { d1, d2 } = auditGateConfigs({
1413
+ repoRoot,
1414
+ gatesDir: opts.gatesDir || GATES_CONFIG_DIR,
1415
+ patternKeyReadByEngine: opts.patternKeyReadByEngine || 'pattern',
1416
+ });
1417
+
1418
+ let d3;
1419
+ if (opts.offline) {
1420
+ d3 = detectorResult('D3', 'Silently-red non-required check');
1421
+ d3.status = DETECTOR_STATUS.UNAVAILABLE;
1422
+ d3.reason = 'offline mode was requested, so branch protection and check runs were not read';
1423
+ } else {
1424
+ const ghApi = opts.ghApi === undefined ? makeGhApiReader() : opts.ghApi;
1425
+ d3 = auditSilentlyRedChecks({
1426
+ gitHubRepo, branch, consecutiveFailureThreshold, commitLimit, ghApi,
1427
+ });
1428
+ }
1429
+
1430
+ const d4 = auditAnalysisBlindspots({
1431
+ repoRoot,
1432
+ excludedLineThreshold,
1433
+ sonarFile: opts.sonarFile || SONAR_PROPERTIES,
1434
+ });
1435
+ // A package with a name that is not marked private is published: its entry
1436
+ // exports have consumers outside this repository.
1437
+ const publicApi = Boolean(pkg && typeof pkg.name === 'string' && pkg.name && pkg.private !== true);
1438
+ const d5 = auditZeroCallSites({
1439
+ repoRoot,
1440
+ entryFile,
1441
+ productionRoots,
1442
+ publicApi,
1443
+ packageName: publicApi ? pkg.name : null,
1444
+ });
1445
+ const d6 = auditMainCheckUnderSymlink({ repoRoot, productionRoots, binTargets });
1446
+
1447
+ const detectors = [d1, d2, d3, d4, d5, d6];
1448
+ const findings = [];
1449
+ for (const d of detectors) findings.push(...d.findings);
1450
+ findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity)
1451
+ || a.detector.localeCompare(b.detector)
1452
+ || a.location.localeCompare(b.location));
1453
+
1454
+ const bySeverity = {};
1455
+ for (const sev of SEVERITY_ORDER) bySeverity[sev] = 0;
1456
+ for (const f of findings) bySeverity[f.severity] += 1;
1457
+
1458
+ return {
1459
+ generatedAt: new Date().toISOString(),
1460
+ repoRoot,
1461
+ gitHubRepo,
1462
+ branch,
1463
+ thresholds: { consecutiveFailureThreshold, excludedLineThreshold, commitLimit },
1464
+ detectors: detectors.reduce((acc, d) => { acc[d.id] = d; return acc; }, {}),
1465
+ detectorOrder: detectors.map((d) => d.id),
1466
+ findings,
1467
+ counts: {
1468
+ findings: findings.length,
1469
+ bySeverity,
1470
+ detectorsRan: detectors.filter((d) => d.status === DETECTOR_STATUS.RAN).length,
1471
+ detectorsPartial: detectors.filter((d) => d.status === DETECTOR_STATUS.PARTIAL).length,
1472
+ detectorsUnavailable: detectors.filter((d) => d.status === DETECTOR_STATUS.UNAVAILABLE).length,
1473
+ },
1474
+ severityRules: SEVERITY_RULES,
1475
+ };
1476
+ }
1477
+
1478
+ // ---------------------------------------------------------------------------
1479
+ // Text rendering
1480
+ // ---------------------------------------------------------------------------
1481
+
1482
+ const MAX_FINDINGS_RENDERED_PER_DETECTOR = 12;
1483
+
1484
+ function statusLabel(detector) {
1485
+ if (detector.status === DETECTOR_STATUS.UNAVAILABLE) return 'UNAVAILABLE — NOT CHECKED';
1486
+ if (detector.status === DETECTOR_STATUS.PARTIAL) return 'PARTIAL — some of the surface was NOT checked';
1487
+ return detector.findings.length === 0 ? 'RAN — CLEAN (checked, nothing found)' : `RAN — ${detector.findings.length} finding(s)`;
1488
+ }
1489
+
1490
+ function describeInspected(detector) {
1491
+ const parts = [];
1492
+ for (const [key, value] of Object.entries(detector.inspected || {})) {
1493
+ if (Array.isArray(value)) parts.push(`${key}=[${value.join(', ')}]`);
1494
+ else parts.push(`${key}=${value}`);
1495
+ }
1496
+ return parts.join(' ');
1497
+ }
1498
+
1499
+ function renderConflictAuditText(report) {
1500
+ const lines = [];
1501
+ lines.push('ThumbGate governance conflict audit — controls that report success but enforce nothing');
1502
+ lines.push(` repo : ${report.repoRoot}`);
1503
+ lines.push(` github : ${report.gitHubRepo || '(not determined)'} @ ${report.branch}`);
1504
+ lines.push(` generated : ${report.generatedAt}`);
1505
+ lines.push(` thresholds : ${report.thresholds.consecutiveFailureThreshold} consecutive failures, ${report.thresholds.excludedLineThreshold} excluded lines, ${report.thresholds.commitLimit} commits scanned`);
1506
+ lines.push('');
1507
+
1508
+ lines.push('Detectors');
1509
+ for (const id of report.detectorOrder) {
1510
+ const d = report.detectors[id];
1511
+ lines.push(` ${d.id} ${d.title}`);
1512
+ lines.push(` ${statusLabel(d)}`);
1513
+ if (d.reason) lines.push(` reason: ${d.reason}`);
1514
+ const inspected = describeInspected(d);
1515
+ if (inspected) lines.push(` inspected: ${inspected}`);
1516
+ for (const item of (d.notInspected || []).slice(0, 5)) {
1517
+ lines.push(` NOT inspected: ${item}`);
1518
+ }
1519
+ if ((d.notInspected || []).length > 5) {
1520
+ lines.push(` NOT inspected: ... and ${d.notInspected.length - 5} more`);
1521
+ }
1522
+ }
1523
+ lines.push('');
1524
+
1525
+ const unavailable = report.detectorOrder
1526
+ .map((id) => report.detectors[id])
1527
+ .filter((d) => d.status === DETECTOR_STATUS.UNAVAILABLE);
1528
+ const partial = report.detectorOrder
1529
+ .map((id) => report.detectors[id])
1530
+ .filter((d) => d.status === DETECTOR_STATUS.PARTIAL);
1531
+
1532
+ if (unavailable.length > 0) {
1533
+ lines.push('!! COVERAGE GAP — the following detectors did NOT run.');
1534
+ lines.push('!! Their surfaces are NOT reported clean. Absence of findings below says nothing about them.');
1535
+ for (const d of unavailable) lines.push(`!! ${d.id} ${d.title}: ${d.reason}`);
1536
+ lines.push('');
1537
+ }
1538
+ if (partial.length > 0) {
1539
+ lines.push('! PARTIAL COVERAGE — the following detectors ran over an incomplete surface:');
1540
+ for (const d of partial) lines.push(`! ${d.id} ${d.title}: ${d.reason}`);
1541
+ lines.push('');
1542
+ }
1543
+
1544
+ lines.push('Findings');
1545
+ const sevSummary = SEVERITY_ORDER.map((s) => `${s} ${report.counts.bySeverity[s]}`).join(' ');
1546
+ lines.push(` total ${report.counts.findings} (${sevSummary})`);
1547
+ lines.push('');
1548
+
1549
+ for (const id of report.detectorOrder) {
1550
+ const d = report.detectors[id];
1551
+ if (d.findings.length === 0) continue;
1552
+ lines.push(` ${d.id} — ${d.title}`);
1553
+ for (const f of d.findings.slice(0, MAX_FINDINGS_RENDERED_PER_DETECTOR)) {
1554
+ lines.push(` [${f.severity.toUpperCase()}] ${f.location}`);
1555
+ lines.push(` appears to : ${f.appears}`);
1556
+ lines.push(` actually : ${f.actually}`);
1557
+ lines.push(` evidence : ${f.evidence}`);
1558
+ lines.push(` severity by: ${SEVERITY_RULES[f.rule] || f.rule}`);
1559
+ }
1560
+ if (d.findings.length > MAX_FINDINGS_RENDERED_PER_DETECTOR) {
1561
+ lines.push(` ... and ${d.findings.length - MAX_FINDINGS_RENDERED_PER_DETECTOR} more ${d.id} finding(s); use --json for all of them`);
1562
+ }
1563
+ lines.push('');
1564
+ }
1565
+
1566
+ if (report.counts.findings === 0) {
1567
+ const scope = unavailable.length === 0 && partial.length === 0
1568
+ ? 'every detector ran over its full surface'
1569
+ : `${report.counts.detectorsRan} of ${report.detectorOrder.length} detector(s) ran over their full surface`;
1570
+ lines.push(` No findings — ${scope}.`);
1571
+ lines.push('');
1572
+ }
1573
+
1574
+ return lines.join('\n');
1575
+ }
1576
+
1577
+ // ---------------------------------------------------------------------------
1578
+ // Exports
1579
+ // ---------------------------------------------------------------------------
1580
+
1581
+ module.exports = {
1582
+ auditGovernanceConflicts,
1583
+ renderConflictAuditText,
1584
+ auditGateConfigs,
1585
+ auditSilentlyRedChecks,
1586
+ auditAnalysisBlindspots,
1587
+ auditZeroCallSites,
1588
+ auditMainCheckUnderSymlink,
1589
+ globToRegExp,
1590
+ parseProperties,
1591
+ parseEntryExportNames,
1592
+ parseEntryRequires,
1593
+ parseEntryBindings,
1594
+ extractEntryExportStatement,
1595
+ resolveGhBinary,
1596
+ makeGhApiReader,
1597
+ GH_BINARY_CANDIDATES,
1598
+ stripComments,
1599
+ countCodeLines,
1600
+ deriveGitHubRepo,
1601
+ DETECTOR_STATUS,
1602
+ SEVERITY,
1603
+ SEVERITY_RULES,
1604
+ BARE_MAIN_CHECK,
1605
+ DEFAULT_CONSECUTIVE_FAILURE_THRESHOLD,
1606
+ DEFAULT_EXCLUDED_LINE_THRESHOLD,
1607
+ DEFAULT_COMMIT_LIMIT,
1608
+ DEFAULT_PRODUCTION_ROOTS,
1609
+ };
1610
+
1611
+ // ---------------------------------------------------------------------------
1612
+ // CLI
1613
+ // ---------------------------------------------------------------------------
1614
+
1615
+ function runCli(argv) {
1616
+ const args = argv.slice(2);
1617
+ const flag = (name) => {
1618
+ const hit = args.find((a) => a === `--${name}` || a.startsWith(`--${name}=`));
1619
+ if (!hit) return undefined;
1620
+ if (hit === `--${name}`) return true;
1621
+ return hit.slice(`--${name}=`.length);
1622
+ };
1623
+
1624
+ const asString = (v) => (typeof v === 'string' ? v : undefined);
1625
+ const report = auditGovernanceConflicts({
1626
+ repoRoot: asString(flag('repo-root')),
1627
+ gitHubRepo: asString(flag('github-repo')),
1628
+ branch: asString(flag('branch')),
1629
+ consecutiveFailureThreshold: asString(flag('consecutive-failures')),
1630
+ excludedLineThreshold: asString(flag('excluded-lines')),
1631
+ commitLimit: asString(flag('commits')),
1632
+ offline: flag('offline') !== undefined,
1633
+ });
1634
+
1635
+ if (flag('json') !== undefined) console.log(JSON.stringify(report, null, 2));
1636
+ else process.stdout.write(`${renderConflictAuditText(report)}\n`);
1637
+ }
1638
+
1639
+ // SonarCloud S3403 flags `require.main === module` as an always-false strict
1640
+ // equality; the path-resolve form is the portable equivalent. realpathSync
1641
+ // keeps it working when this file is reached through a symlink — which is
1642
+ // exactly the defect D6 detects, so getting it wrong here would be its own
1643
+ // punchline.
1644
+ if (process.argv[1]) {
1645
+ let invoked = null;
1646
+ let self = null;
1647
+ try { invoked = fs.realpathSync(process.argv[1]); } catch { invoked = path.resolve(process.argv[1]); }
1648
+ try { self = fs.realpathSync(__filename); } catch { self = path.resolve(__filename); }
1649
+ if (invoked === self) runCli(process.argv);
1650
+ }