code-auditor-mcp 3.4.11 → 3.4.13

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 (254) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +119 -1
  3. package/dist/analyzers/__verify_self_probe.d.ts +2 -0
  4. package/dist/analyzers/__verify_self_probe.d.ts.map +1 -0
  5. package/dist/analyzers/__verify_self_probe.js +5 -0
  6. package/dist/analyzers/__verify_self_probe.js.map +1 -0
  7. package/dist/analyzers/analyzerUtils.d.ts +34 -1
  8. package/dist/analyzers/analyzerUtils.d.ts.map +1 -1
  9. package/dist/analyzers/analyzerUtils.js +61 -22
  10. package/dist/analyzers/analyzerUtils.js.map +1 -1
  11. package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts +18 -23
  12. package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts.map +1 -1
  13. package/dist/analyzers/cross-language/APIContractAnalyzer.js +198 -186
  14. package/dist/analyzers/cross-language/APIContractAnalyzer.js.map +1 -1
  15. package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts +78 -55
  16. package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts.map +1 -1
  17. package/dist/analyzers/cross-language/DependencyGraphBuilder.js +244 -243
  18. package/dist/analyzers/cross-language/DependencyGraphBuilder.js.map +1 -1
  19. package/dist/analyzers/cross-language/SchemaValidator.d.ts +27 -37
  20. package/dist/analyzers/cross-language/SchemaValidator.d.ts.map +1 -1
  21. package/dist/analyzers/cross-language/SchemaValidator.js +314 -275
  22. package/dist/analyzers/cross-language/SchemaValidator.js.map +1 -1
  23. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts +17 -46
  24. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts.map +1 -1
  25. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js +620 -523
  26. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js.map +1 -1
  27. package/dist/analyzers/documentationAnalyzer.d.ts +7 -2
  28. package/dist/analyzers/documentationAnalyzer.d.ts.map +1 -1
  29. package/dist/analyzers/documentationAnalyzer.js +259 -198
  30. package/dist/analyzers/documentationAnalyzer.js.map +1 -1
  31. package/dist/analyzers/invariantsAnalyzer.d.ts.map +1 -1
  32. package/dist/analyzers/invariantsAnalyzer.js +59 -46
  33. package/dist/analyzers/invariantsAnalyzer.js.map +1 -1
  34. package/dist/analyzers/orm/adapterRegistry.d.ts +10 -0
  35. package/dist/analyzers/orm/adapterRegistry.d.ts.map +1 -1
  36. package/dist/analyzers/orm/adapterRegistry.js +10 -0
  37. package/dist/analyzers/orm/adapterRegistry.js.map +1 -1
  38. package/dist/analyzers/orm/drizzleAdapter.d.ts +22 -0
  39. package/dist/analyzers/orm/drizzleAdapter.d.ts.map +1 -1
  40. package/dist/analyzers/orm/drizzleAdapter.js +42 -44
  41. package/dist/analyzers/orm/drizzleAdapter.js.map +1 -1
  42. package/dist/analyzers/orm/prismaAdapter.d.ts +30 -0
  43. package/dist/analyzers/orm/prismaAdapter.d.ts.map +1 -1
  44. package/dist/analyzers/orm/prismaAdapter.js +30 -0
  45. package/dist/analyzers/orm/prismaAdapter.js.map +1 -1
  46. package/dist/analyzers/provenance.d.ts +72 -2
  47. package/dist/analyzers/provenance.d.ts.map +1 -1
  48. package/dist/analyzers/provenance.js +462 -338
  49. package/dist/analyzers/provenance.js.map +1 -1
  50. package/dist/analyzers/reactAnalyzer.d.ts +11 -0
  51. package/dist/analyzers/reactAnalyzer.d.ts.map +1 -1
  52. package/dist/analyzers/reactAnalyzer.js +188 -125
  53. package/dist/analyzers/reactAnalyzer.js.map +1 -1
  54. package/dist/analyzers/ruleRegistry.d.ts +118 -1
  55. package/dist/analyzers/ruleRegistry.d.ts.map +1 -1
  56. package/dist/analyzers/ruleRegistry.js +1717 -104
  57. package/dist/analyzers/ruleRegistry.js.map +1 -1
  58. package/dist/analyzers/ruleTiming.d.ts +54 -0
  59. package/dist/analyzers/ruleTiming.d.ts.map +1 -0
  60. package/dist/analyzers/ruleTiming.js +71 -0
  61. package/dist/analyzers/ruleTiming.js.map +1 -0
  62. package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts +13 -0
  63. package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts.map +1 -1
  64. package/dist/analyzers/universal/UniversalConventionsAnalyzer.js +362 -283
  65. package/dist/analyzers/universal/UniversalConventionsAnalyzer.js.map +1 -1
  66. package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts +26 -68
  67. package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts.map +1 -1
  68. package/dist/analyzers/universal/UniversalDRYAnalyzer.js +430 -391
  69. package/dist/analyzers/universal/UniversalDRYAnalyzer.js.map +1 -1
  70. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts +24 -186
  71. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts.map +1 -1
  72. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js +1081 -1061
  73. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js.map +1 -1
  74. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts +3 -49
  75. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts.map +1 -1
  76. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js +414 -340
  77. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js.map +1 -1
  78. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts +48 -5
  79. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts.map +1 -1
  80. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js +262 -134
  81. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js.map +1 -1
  82. package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts +23 -212
  83. package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts.map +1 -1
  84. package/dist/analyzers/universal/UniversalSchemaAnalyzer.js +139 -1518
  85. package/dist/analyzers/universal/UniversalSchemaAnalyzer.js.map +1 -1
  86. package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts +78 -79
  87. package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts.map +1 -1
  88. package/dist/analyzers/universal/UniversalStylesAnalyzer.js +613 -472
  89. package/dist/analyzers/universal/UniversalStylesAnalyzer.js.map +1 -1
  90. package/dist/analyzers/universal/schema/codeAnalysis.d.ts +307 -0
  91. package/dist/analyzers/universal/schema/codeAnalysis.d.ts.map +1 -0
  92. package/dist/analyzers/universal/schema/codeAnalysis.js +965 -0
  93. package/dist/analyzers/universal/schema/codeAnalysis.js.map +1 -0
  94. package/dist/analyzers/universal/schema/config.d.ts +31 -0
  95. package/dist/analyzers/universal/schema/config.d.ts.map +1 -0
  96. package/dist/analyzers/universal/schema/config.js +57 -0
  97. package/dist/analyzers/universal/schema/config.js.map +1 -0
  98. package/dist/analyzers/universal/schema/discovery.d.ts +167 -0
  99. package/dist/analyzers/universal/schema/discovery.d.ts.map +1 -0
  100. package/dist/analyzers/universal/schema/discovery.js +616 -0
  101. package/dist/analyzers/universal/schema/discovery.js.map +1 -0
  102. package/dist/analyzers/universal/schema/jsonSchema.d.ts +27 -0
  103. package/dist/analyzers/universal/schema/jsonSchema.d.ts.map +1 -0
  104. package/dist/analyzers/universal/schema/jsonSchema.js +368 -0
  105. package/dist/analyzers/universal/schema/jsonSchema.js.map +1 -0
  106. package/dist/analyzers/universal/schema/migrations.d.ts +90 -0
  107. package/dist/analyzers/universal/schema/migrations.d.ts.map +1 -0
  108. package/dist/analyzers/universal/schema/migrations.js +238 -0
  109. package/dist/analyzers/universal/schema/migrations.js.map +1 -0
  110. package/dist/analyzers/universal/schema/types.d.ts +131 -0
  111. package/dist/analyzers/universal/schema/types.d.ts.map +1 -0
  112. package/dist/analyzers/universal/schema/types.js +10 -0
  113. package/dist/analyzers/universal/schema/types.js.map +1 -0
  114. package/dist/analyzers/universal/schema/violations.d.ts +44 -0
  115. package/dist/analyzers/universal/schema/violations.d.ts.map +1 -0
  116. package/dist/analyzers/universal/schema/violations.js +54 -0
  117. package/dist/analyzers/universal/schema/violations.js.map +1 -0
  118. package/dist/auditRunner.d.ts.map +1 -1
  119. package/dist/auditRunner.js +101 -39
  120. package/dist/auditRunner.js.map +1 -1
  121. package/dist/cli.js +348 -40
  122. package/dist/cli.js.map +1 -1
  123. package/dist/config/configLoader.d.ts.map +1 -1
  124. package/dist/config/configLoader.js +9 -7
  125. package/dist/config/configLoader.js.map +1 -1
  126. package/dist/config/defaults.d.ts +12 -3
  127. package/dist/config/defaults.d.ts.map +1 -1
  128. package/dist/config/defaults.js +15 -5
  129. package/dist/config/defaults.js.map +1 -1
  130. package/dist/config/effectiveConfig.d.ts +75 -0
  131. package/dist/config/effectiveConfig.d.ts.map +1 -0
  132. package/dist/config/effectiveConfig.js +213 -0
  133. package/dist/config/effectiveConfig.js.map +1 -0
  134. package/dist/config/pathProfiles.d.ts +7 -4
  135. package/dist/config/pathProfiles.d.ts.map +1 -1
  136. package/dist/config/pathProfiles.js +8 -10
  137. package/dist/config/pathProfiles.js.map +1 -1
  138. package/dist/config/thresholdRationales.d.ts +45 -0
  139. package/dist/config/thresholdRationales.d.ts.map +1 -0
  140. package/dist/config/thresholdRationales.js +67 -0
  141. package/dist/config/thresholdRationales.js.map +1 -0
  142. package/dist/conventions/conventionMiner.d.ts.map +1 -1
  143. package/dist/conventions/conventionMiner.js +6 -2
  144. package/dist/conventions/conventionMiner.js.map +1 -1
  145. package/dist/enforcement/diffGate.d.ts +55 -0
  146. package/dist/enforcement/diffGate.d.ts.map +1 -0
  147. package/dist/enforcement/diffGate.js +136 -0
  148. package/dist/enforcement/diffGate.js.map +1 -0
  149. package/dist/enforcement/gate.d.ts +36 -0
  150. package/dist/enforcement/gate.d.ts.map +1 -0
  151. package/dist/enforcement/gate.js +72 -0
  152. package/dist/enforcement/gate.js.map +1 -0
  153. package/dist/enforcement/suppressions.d.ts +41 -0
  154. package/dist/enforcement/suppressions.d.ts.map +1 -0
  155. package/dist/enforcement/suppressions.js +170 -0
  156. package/dist/enforcement/suppressions.js.map +1 -0
  157. package/dist/fingerprint.d.ts.map +1 -1
  158. package/dist/fingerprint.js +4 -1
  159. package/dist/fingerprint.js.map +1 -1
  160. package/dist/grammars/tree-sitter-scss.wasm +0 -0
  161. package/dist/invariants/ruleEngine.d.ts +3 -4
  162. package/dist/invariants/ruleEngine.d.ts.map +1 -1
  163. package/dist/invariants/ruleEngine.js +2 -2
  164. package/dist/invariants/ruleEngine.js.map +1 -1
  165. package/dist/languages/LanguageOrchestrator.d.ts +14 -0
  166. package/dist/languages/LanguageOrchestrator.d.ts.map +1 -1
  167. package/dist/languages/LanguageOrchestrator.js +93 -55
  168. package/dist/languages/LanguageOrchestrator.js.map +1 -1
  169. package/dist/languages/LanguageRegistry.d.ts +8 -0
  170. package/dist/languages/LanguageRegistry.d.ts.map +1 -1
  171. package/dist/languages/LanguageRegistry.js +8 -0
  172. package/dist/languages/LanguageRegistry.js.map +1 -1
  173. package/dist/languages/RuntimeManager.d.ts +82 -60
  174. package/dist/languages/RuntimeManager.d.ts.map +1 -1
  175. package/dist/languages/RuntimeManager.js +404 -349
  176. package/dist/languages/RuntimeManager.js.map +1 -1
  177. package/dist/languages/UniversalAnalyzer.d.ts +51 -6
  178. package/dist/languages/UniversalAnalyzer.d.ts.map +1 -1
  179. package/dist/languages/UniversalAnalyzer.js +127 -88
  180. package/dist/languages/UniversalAnalyzer.js.map +1 -1
  181. package/dist/languages/adapterBridge.d.ts +36 -0
  182. package/dist/languages/adapterBridge.d.ts.map +1 -1
  183. package/dist/languages/adapterBridge.js +89 -35
  184. package/dist/languages/adapterBridge.js.map +1 -1
  185. package/dist/languages/go/GoAdapter.d.ts +66 -23
  186. package/dist/languages/go/GoAdapter.d.ts.map +1 -1
  187. package/dist/languages/go/GoAdapter.js +553 -474
  188. package/dist/languages/go/GoAdapter.js.map +1 -1
  189. package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts +38 -9
  190. package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts.map +1 -1
  191. package/dist/languages/tree-sitter/TreeSitterCssAdapter.js +111 -74
  192. package/dist/languages/tree-sitter/TreeSitterCssAdapter.js.map +1 -1
  193. package/dist/languages/tree-sitter/converter.d.ts +18 -0
  194. package/dist/languages/tree-sitter/converter.d.ts.map +1 -1
  195. package/dist/languages/tree-sitter/converter.js +18 -0
  196. package/dist/languages/tree-sitter/converter.js.map +1 -1
  197. package/dist/languages/tree-sitter/parser.d.ts +39 -2
  198. package/dist/languages/tree-sitter/parser.d.ts.map +1 -1
  199. package/dist/languages/tree-sitter/parser.js +95 -12
  200. package/dist/languages/tree-sitter/parser.js.map +1 -1
  201. package/dist/languages/types.d.ts +67 -2
  202. package/dist/languages/types.d.ts.map +1 -1
  203. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts +253 -62
  204. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts.map +1 -1
  205. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js +1461 -791
  206. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js.map +1 -1
  207. package/dist/mcp-tools/whitelistTools.d.ts +1 -1
  208. package/dist/mcp-tools-shared.d.ts.map +1 -1
  209. package/dist/mcp-tools-shared.js +1 -0
  210. package/dist/mcp-tools-shared.js.map +1 -1
  211. package/dist/mcpAuditJobs.d.ts.map +1 -1
  212. package/dist/mcpAuditJobs.js +1 -0
  213. package/dist/mcpAuditJobs.js.map +1 -1
  214. package/dist/pipeline.d.ts +30 -1
  215. package/dist/pipeline.d.ts.map +1 -1
  216. package/dist/pipeline.js +527 -203
  217. package/dist/pipeline.js.map +1 -1
  218. package/dist/pipelineAdapters.d.ts +5 -12
  219. package/dist/pipelineAdapters.d.ts.map +1 -1
  220. package/dist/pipelineAdapters.js +175 -100
  221. package/dist/pipelineAdapters.js.map +1 -1
  222. package/dist/presets/presets.d.ts +54 -0
  223. package/dist/presets/presets.d.ts.map +1 -0
  224. package/dist/presets/presets.js +181 -0
  225. package/dist/presets/presets.js.map +1 -0
  226. package/dist/reporting/sarifReportGenerator.d.ts.map +1 -1
  227. package/dist/reporting/sarifReportGenerator.js +0 -2
  228. package/dist/reporting/sarifReportGenerator.js.map +1 -1
  229. package/dist/ruleAliases.d.ts +67 -0
  230. package/dist/ruleAliases.d.ts.map +1 -0
  231. package/dist/ruleAliases.js +79 -0
  232. package/dist/ruleAliases.js.map +1 -0
  233. package/dist/styles/cssAstExtractor.d.ts +20 -5
  234. package/dist/styles/cssAstExtractor.d.ts.map +1 -1
  235. package/dist/styles/cssAstExtractor.js +247 -9
  236. package/dist/styles/cssAstExtractor.js.map +1 -1
  237. package/dist/styles/styleIndexer.js +13 -4
  238. package/dist/styles/styleIndexer.js.map +1 -1
  239. package/dist/styles/tailwindConfigLoader.d.ts +8 -6
  240. package/dist/styles/tailwindConfigLoader.d.ts.map +1 -1
  241. package/dist/styles/tailwindConfigLoader.js +61 -15
  242. package/dist/styles/tailwindConfigLoader.js.map +1 -1
  243. package/dist/styles/tailwindUtilityExpander.d.ts.map +1 -1
  244. package/dist/styles/tailwindUtilityExpander.js +13 -1
  245. package/dist/styles/tailwindUtilityExpander.js.map +1 -1
  246. package/dist/types.d.ts +201 -1
  247. package/dist/types.d.ts.map +1 -1
  248. package/dist/types.js +8 -0
  249. package/dist/types.js.map +1 -1
  250. package/package.json +7 -4
  251. package/plugin/.claude-plugin/plugin.json +1 -1
  252. package/plugin/hooks/hooks.json +1 -1
  253. package/plugin/scripts/hook-audit.sh +25 -7
  254. package/plugin/skills/code-auditor/SKILL.md +5 -5
package/dist/pipeline.js CHANGED
@@ -9,134 +9,174 @@
9
9
  *
10
10
  * Stage position IS the dependency declaration — no topological sort needed.
11
11
  */
12
- import { readFile } from 'fs/promises';
12
+ import { readFile, stat } from 'fs/promises';
13
+ import { readFileSync } from 'fs';
13
14
  import path from 'path';
14
15
  import { performance } from 'perf_hooks';
15
- import { AuditAbortedError, } from './types.js';
16
+ import { AuditAbortedError, MAX_ORPHAN_SOURCE_BYTES, } from './types.js';
17
+ import { RULE_REGISTRY } from './analyzers/ruleRegistry.js';
18
+ import { resetRuleTiming, getRuleTimingSortedDesc } from './analyzers/ruleTiming.js';
16
19
  import { LanguageRegistry } from './languages/LanguageRegistry.js';
17
20
  import { discoverFiles } from './utils/fileDiscovery.js';
18
21
  import { resolvePathProfile } from './config/pathProfiles.js';
19
22
  import { validateFactsDependencies } from './pipelineTypes.js';
20
23
  // ── Stage 1: Traverse + Parse ──────────────────────────────────────────────
21
- async function runStage1(config) {
22
- const t0 = performance.now();
23
- let readMs = 0;
24
- let parseMs = 0;
25
- // Discover files (or use explicit)
24
+ /**
25
+ * Stream files one-at-a-time into stage 2 so per-file memory can be freed
26
+ * before the next file is parsed.
27
+ *
28
+ * File discovery is eager (separate from parsing) so the total count is
29
+ * available for progress reporting throughout the parse + visit loop.
30
+ *
31
+ * @returns generator (yields tuples), total file count, and a closure to
32
+ * retrieve aggregate parse/read timing after the generator completes.
33
+ */
34
+ function runStage1(config) {
35
+ // ── Eager discovery (same as before) ─────────────────────────────────
26
36
  const projectRoot = config.projectRoot;
27
37
  let files;
28
38
  if (config.explicitFiles !== undefined) {
29
39
  files = config.explicitFiles;
30
40
  }
31
41
  else {
32
- files = await discoverFiles(projectRoot, {
33
- excludeDirs: ['node_modules', '.next', 'dist', 'build', '.git', 'coverage', '.turbo'],
34
- });
42
+ // discovery runs lazily inside the generator below
35
43
  }
36
44
  // Group by language adapter
37
45
  const registry = LanguageRegistry.getInstance();
38
- const groups = new Map();
39
- const orphans = [];
40
- for (const file of files) {
41
- const adapter = registry.getAdapterForFile(file);
42
- if (adapter) {
43
- const list = groups.get(adapter) ?? [];
44
- list.push(file);
45
- groups.set(adapter, list);
46
- }
47
- else {
48
- orphans.push(file);
49
- }
50
- }
51
- // Parse each file once
52
- const tuples = [];
46
+ let total;
53
47
  let parsed = 0;
54
- for (const [adapter, adapterFiles] of groups) {
55
- for (const file of adapterFiles) {
56
- // Check abort
57
- if (config.abortSignal?.aborted) {
58
- throw new AuditAbortedError('Audit aborted during stage 1');
48
+ let readMs = 0;
49
+ let parseMs = 0;
50
+ // Spec 32 — files that failed to parse (or be read) during stage 1, with the
51
+ // reason. A non-empty list means the audit was incomplete; surfaced in coverage
52
+ // and forces a non-zero exit so a plausible-but-wrong report is never silent.
53
+ const unparsedFiles = [];
54
+ async function* generate() {
55
+ // Lazy file discovery (only if not explicit)
56
+ const fileList = files ?? await discoverFiles(projectRoot, {
57
+ excludeDirs: ['node_modules', '.next', 'dist', 'build', '.git', 'coverage', '.turbo'],
58
+ });
59
+ total = fileList.length;
60
+ const groups = new Map();
61
+ const orphans = [];
62
+ for (const file of fileList) {
63
+ const adapter = registry.getAdapterForFile(file);
64
+ if (adapter) {
65
+ const list = groups.get(adapter) ?? [];
66
+ list.push(file);
67
+ groups.set(adapter, list);
68
+ }
69
+ else {
70
+ orphans.push(file);
59
71
  }
72
+ }
73
+ // Parse each file and yield immediately
74
+ for (const [adapter, adapterFiles] of groups) {
75
+ for (const file of adapterFiles) {
76
+ if (config.abortSignal?.aborted) {
77
+ throw new AuditAbortedError('Audit aborted during stage 1');
78
+ }
79
+ try {
80
+ const r0 = performance.now();
81
+ const content = await readFile(file, 'utf-8');
82
+ readMs += performance.now() - r0;
83
+ const p0 = performance.now();
84
+ const ast = await adapter.parse(file, content);
85
+ parseMs += performance.now() - p0;
86
+ parsed++;
87
+ yield {
88
+ kind: 'parsed',
89
+ file,
90
+ ast,
91
+ adapter,
92
+ sourceCode: content,
93
+ };
94
+ }
95
+ catch (err) {
96
+ // Spec 32 — a parse failure (including a WASM abort that survived
97
+ // parseWithRecovery) must never be silent: record it so it lands in
98
+ // coverage and forces a non-zero exit.
99
+ unparsedFiles.push({ filePath: file, reason: err?.message ?? String(err) });
100
+ if (config.progressCallback) {
101
+ config.progressCallback({
102
+ current: parsed,
103
+ total,
104
+ analyzer: 'pipeline',
105
+ phase: 'stage1',
106
+ file,
107
+ message: `Skipped: ${err.message}`,
108
+ });
109
+ }
110
+ }
111
+ }
112
+ }
113
+ // Orphan files (no LanguageAdapter)
114
+ for (const file of orphans) {
60
115
  try {
116
+ // Spec 31 — oversized orphan .sql dumps are not materialized into a
117
+ // source string; the schema-sql visitor streams them on demand (and
118
+ // skips them entirely when they contain no DDL). Restricted to .sql:
119
+ // JSON/CSS orphans must still be materialized — their visitors parse
120
+ // the content, and an empty source would silently change behavior for
121
+ // large-but-valid files (e.g. a multi-MB audit-report.json).
122
+ const st = await stat(file);
123
+ if (file.endsWith('.sql') && st.size > MAX_ORPHAN_SOURCE_BYTES) {
124
+ parsed++;
125
+ yield {
126
+ kind: 'raw',
127
+ file,
128
+ ast: null,
129
+ adapter: null,
130
+ sourceCode: '',
131
+ };
132
+ continue;
133
+ }
61
134
  const r0 = performance.now();
62
135
  const content = await readFile(file, 'utf-8');
63
136
  readMs += performance.now() - r0;
64
- const p0 = performance.now();
65
- const ast = await adapter.parse(file, content);
66
- parseMs += performance.now() - p0;
67
- tuples.push({
68
- kind: 'parsed',
137
+ parsed++;
138
+ yield {
139
+ kind: 'raw',
69
140
  file,
70
- ast,
71
- adapter,
141
+ ast: null,
142
+ adapter: null,
72
143
  sourceCode: content,
73
- });
74
- parsed++;
144
+ };
75
145
  }
76
146
  catch (err) {
77
- // File read/parse errors skip this file
78
- // Individual files that fail to parse don't abort the entire run
147
+ unparsedFiles.push({ filePath: file, reason: `read error: ${err?.message ?? String(err)}` });
79
148
  if (config.progressCallback) {
80
149
  config.progressCallback({
81
- current: parsed,
82
- total: files.length,
150
+ current: orphans.indexOf(file),
151
+ total: orphans.length,
83
152
  analyzer: 'pipeline',
84
153
  phase: 'stage1',
85
154
  file,
86
- message: `Skipped: ${err.message}`,
155
+ message: `Raw file skipped (read error): ${err.message}`,
87
156
  });
88
157
  }
89
158
  }
90
159
  }
91
- }
92
- // Include orphan files as raw tuples — no LanguageAdapter available,
93
- // but visitors that consume raw files can read sourceCode directly.
94
- for (const file of orphans) {
95
- try {
96
- const r0 = performance.now();
97
- const content = await readFile(file, 'utf-8');
98
- readMs += performance.now() - r0;
99
- tuples.push({
100
- kind: 'raw',
101
- file,
102
- ast: null,
103
- adapter: null,
104
- sourceCode: content,
160
+ const orphanIncluded = fileList.filter(f => !registry.getAdapterForFile(f)).length;
161
+ if (orphanIncluded > 0 && config.progressCallback) {
162
+ config.progressCallback({
163
+ current: orphanIncluded,
164
+ total: orphanIncluded,
165
+ analyzer: 'pipeline',
166
+ phase: 'stage1',
167
+ message: `${orphanIncluded} file(s) included as raw (no language adapter)`,
105
168
  });
106
169
  }
107
- catch (err) {
108
- if (config.progressCallback) {
109
- config.progressCallback({
110
- current: orphans.indexOf(file),
111
- total: orphans.length,
112
- analyzer: 'pipeline',
113
- phase: 'stage1',
114
- file,
115
- message: `Raw file skipped (read error): ${err.message}`,
116
- });
117
- }
118
- }
119
170
  }
120
- const orphanIncluded = tuples.filter(t => t.kind === 'raw').length;
121
- if (orphanIncluded > 0 && config.progressCallback) {
122
- config.progressCallback({
123
- current: orphanIncluded,
124
- total: orphanIncluded,
125
- analyzer: 'pipeline',
126
- phase: 'stage1',
127
- message: `${orphanIncluded} file(s) included as raw (no language adapter)`,
128
- });
129
- }
130
- const duration = performance.now() - t0;
131
171
  return {
132
- tuples,
133
- fileCount: tuples.length,
134
- parseDurationMs: duration,
135
- readDurationMs: readMs,
172
+ generator: generate(),
173
+ get fileCount() { return total; },
174
+ getUnparsedFiles: () => unparsedFiles,
175
+ getTiming: () => ({ parseDurationMs: parseMs, readDurationMs: readMs }),
136
176
  };
137
177
  }
138
178
  // ── Stage 2: Per-file visitors ─────────────────────────────────────────────
139
- async function runStage2(tuples, visitors, config) {
179
+ export async function runStage2(tuples, visitors, config, totalFiles) {
140
180
  const t0 = performance.now();
141
181
  const visitorResults = new Map();
142
182
  const allFacts = new Map();
@@ -160,109 +200,112 @@ async function runStage2(tuples, visitors, config) {
160
200
  const infra = rawConfig['_infra'] ?? {};
161
201
  const pathProfiles = infra['pathProfiles'];
162
202
  const severityOverrides = infra['severityOverrides'] ?? {};
163
- // Iterate tuples
164
- for (let i = 0; i < tuples.length; i++) {
165
- const tuple = tuples[i];
203
+ // Stream tuples from stage 1
204
+ let i = 0;
205
+ for await (const tuple of tuples) {
166
206
  // Check abort
167
207
  if (config.abortSignal?.aborted) {
168
208
  throw new AuditAbortedError('Audit aborted during stage 2');
169
209
  }
170
- // Progress
171
- if (config.progressCallback && i % 10 === 0) {
172
- config.progressCallback({
173
- current: i,
174
- total: tuples.length,
175
- analyzer: 'pipeline',
176
- phase: 'stage2',
177
- file: tuple.file,
178
- });
179
- }
180
- // Resolve path profiles for this file (non-analyzer-specific)
181
- let fileInfra = infra;
182
- let fileProfileNames = [];
183
- let fileSeverityCap;
184
- if (pathProfiles && pathProfiles.length > 0) {
185
- const resolved = resolvePathProfile(tuple.file, projectRoot, pathProfiles);
186
- if (Object.keys(resolved.overrides).length > 0) {
187
- fileInfra = { ...infra, ...resolved.overrides };
188
- }
189
- fileProfileNames = resolved.matchedProfileNames;
190
- fileSeverityCap = resolved.severityCap;
191
- }
192
- // Fan out to all visitors for this file — filter by declared extensions
193
- const fileExt = path.extname(tuple.file);
194
- for (const visitor of visitors) {
195
- // Dispatch check: backward-compat visitors see only parsed tuples.
196
- // Visitors that declare extensions see only tuples whose extension they consume.
197
- if (visitor.extensions) {
198
- if (!visitor.extensions.includes(fileExt))
199
- continue;
210
+ try {
211
+ // Progress
212
+ if (config.progressCallback && i % 10 === 0) {
213
+ config.progressCallback({
214
+ current: i,
215
+ total: totalFiles,
216
+ analyzer: 'pipeline',
217
+ phase: 'stage2',
218
+ file: tuple.file,
219
+ });
200
220
  }
201
- else {
202
- // No extensions declared → parsed tuples only (backward compat)
203
- if (tuple.kind !== 'parsed')
204
- continue;
221
+ // Resolve path profiles for this file (non-analyzer-specific)
222
+ let fileInfra = infra;
223
+ let fileProfileNames = [];
224
+ let fileGateExcluded;
225
+ if (pathProfiles && pathProfiles.length > 0) {
226
+ const resolved = resolvePathProfile(tuple.file, projectRoot, pathProfiles);
227
+ if (Object.keys(resolved.overrides).length > 0) {
228
+ fileInfra = { ...infra, ...resolved.overrides };
229
+ }
230
+ fileProfileNames = resolved.matchedProfileNames;
231
+ fileGateExcluded = resolved.excludeFromGate;
205
232
  }
206
- const visitorConfig = { ...(rawConfig[visitor.name] ?? {}), ...fileInfra };
207
- const visitorContext = {
208
- projectRoot,
209
- filePath: tuple.file,
210
- config: visitorConfig,
211
- abortSignal: config.abortSignal,
212
- };
213
- try {
214
- const v0 = performance.now();
215
- const result = await visitor.visit(tuple.ast, tuple.adapter, visitorContext, tuple.sourceCode);
216
- const vMs = performance.now() - v0;
217
- // Accumulate timing
218
- timingMap.set(visitor.name, (timingMap.get(visitor.name) ?? 0) + vMs);
219
- // Attach profile, severity overrides, analyzer name
220
- const severityOrder = ['suggestion', 'warning', 'critical'];
221
- const processedViolations = result.violations
222
- .map((v) => ({
223
- ...v,
224
- profile: fileProfileNames.length > 0
225
- ? fileProfileNames[fileProfileNames.length - 1]
226
- : v.profile,
227
- severity: (severityOverrides[v.rule] ?? v.severity),
228
- }))
229
- // Filter out violations whose severity was overridden to 'off' (Spec-11 R5)
230
- .filter((v) => v.severity !== 'off')
231
- // Apply severity cap from path profiles (Spec-20)
232
- // Applied AFTER severityOverrides so path-level caps beat global promotions
233
- .map((v) => {
234
- if (fileSeverityCap) {
235
- const capIndex = severityOrder.indexOf(fileSeverityCap);
236
- if (capIndex >= 0 && severityOrder.indexOf(v.severity) > capIndex) {
237
- return { ...v, severity: fileSeverityCap };
238
- }
239
- }
240
- return v;
241
- });
242
- // Accumulate violations
243
- const ar = visitorResults.get(visitor.name);
244
- ar.violations.push(...processedViolations);
245
- const prevFiles = ar.status.status === 'visitor-ran' ? ar.status.filesProcessed : 0;
246
- ar.status = {
247
- status: 'visitor-ran',
248
- filesProcessed: prevFiles + 1,
233
+ // Fan out to all visitors for this file — filter by declared extensions
234
+ const fileExt = path.extname(tuple.file);
235
+ for (const visitor of visitors) {
236
+ // Dispatch check: backward-compat visitors see only parsed tuples.
237
+ // Visitors that declare extensions see only tuples whose extension they consume.
238
+ if (visitor.extensions) {
239
+ if (!visitor.extensions.includes(fileExt))
240
+ continue;
241
+ }
242
+ else {
243
+ // No extensions declared → parsed tuples only (backward compat)
244
+ if (tuple.kind !== 'parsed')
245
+ continue;
246
+ }
247
+ const visitorConfig = { ...(rawConfig[visitor.name] ?? {}), ...fileInfra };
248
+ const visitorContext = {
249
+ projectRoot,
250
+ filePath: tuple.file,
251
+ config: visitorConfig,
252
+ abortSignal: config.abortSignal,
249
253
  };
250
- // Accumulate facts
251
- if (result.facts && Object.keys(result.facts).length > 0) {
252
- const existing = allFacts.get(visitor.name) ?? {};
253
- allFacts.set(visitor.name, { ...existing, ...result.facts });
254
+ try {
255
+ const v0 = performance.now();
256
+ const result = await visitor.visit(tuple.ast, tuple.adapter, visitorContext, tuple.sourceCode);
257
+ const vMs = performance.now() - v0;
258
+ // Accumulate timing
259
+ timingMap.set(visitor.name, (timingMap.get(visitor.name) ?? 0) + vMs);
260
+ // Attach profile, severity overrides, analyzer name, and gate exclusion
261
+ // (Spec 36 R4). Severity is left untouched — a path profile excludes a
262
+ // file from the blocking gate, it never softens a finding within it.
263
+ const processedViolations = result.violations
264
+ .map((v) => ({
265
+ ...v,
266
+ profile: fileProfileNames.length > 0
267
+ ? fileProfileNames[fileProfileNames.length - 1]
268
+ : v.profile,
269
+ severity: (severityOverrides[v.rule] ?? v.severity),
270
+ ...(fileGateExcluded ? { gateExcluded: true } : {}),
271
+ }))
272
+ // Filter out violations whose severity was overridden to 'off' (Spec-11 R5)
273
+ .filter((v) => v.severity !== 'off');
274
+ // Accumulate violations
275
+ const ar = visitorResults.get(visitor.name);
276
+ ar.violations.push(...processedViolations);
277
+ const prevFiles = ar.status.status === 'visitor-ran' ? ar.status.filesProcessed : 0;
278
+ ar.status = {
279
+ status: 'visitor-ran',
280
+ filesProcessed: prevFiles + 1,
281
+ };
282
+ // Accumulate facts
283
+ if (result.facts && Object.keys(result.facts).length > 0) {
284
+ const existing = allFacts.get(visitor.name) ?? {};
285
+ allFacts.set(visitor.name, { ...existing, ...result.facts });
286
+ }
287
+ // Collect index facts
288
+ if (result.indexFacts && result.indexFacts.length > 0) {
289
+ indexFacts.push(...result.indexFacts);
290
+ }
254
291
  }
255
- // Collect index facts
256
- if (result.indexFacts && result.indexFacts.length > 0) {
257
- indexFacts.push(...result.indexFacts);
292
+ catch (err) {
293
+ // Visitor error on this file — collect but don't abort
294
+ const errs = errors.get(visitor.name);
295
+ errs.push({ file: tuple.file, error: err.message });
258
296
  }
259
297
  }
260
- catch (err) {
261
- // Visitor error on this file — collect but don't abort
262
- const errs = errors.get(visitor.name);
263
- errs.push({ file: tuple.file, error: err.message });
264
- }
265
298
  }
299
+ finally {
300
+ // Free per-file memory on every exit path (Spec 32 Fix 3): reclaim the WASM
301
+ // tree + drop the source string, even if a visitor or path-profile resolve
302
+ // throws. A leaked tree keeps the Emscripten arena pinned at its high-water
303
+ // mark and is the trigger for the Aborted() ceiling.
304
+ const ast = tuple.ast;
305
+ ast?.dispose?.();
306
+ tuple.sourceCode = '';
307
+ }
308
+ i++;
266
309
  }
267
310
  // Finalize results
268
311
  const totalDuration = performance.now() - t0;
@@ -295,7 +338,7 @@ async function runStage2(tuples, visitors, config) {
295
338
  allFacts,
296
339
  indexFacts,
297
340
  visitorDurationMs: timingMap,
298
- fileCount: tuples.length,
341
+ fileCount: i,
299
342
  };
300
343
  }
301
344
  // ── Stage 3: Corpus reducers ───────────────────────────────────────────────
@@ -314,24 +357,47 @@ async function runStage3(allFacts, reducers, config, indexHandle) {
314
357
  }
315
358
  // Per-reducer namespaced config: analyzer namespace + infrastructure
316
359
  const reducerConfig = { ...(rawConfig[reducer.name] ?? {}), ...infra };
360
+ // On-demand source reader: reducers pull file text lazily via readFileSync
361
+ // instead of retaining every file's source as a fact through stage 4.
362
+ const readSource = (filePath) => {
363
+ try {
364
+ return readFileSync(filePath, 'utf-8');
365
+ }
366
+ catch {
367
+ return undefined;
368
+ }
369
+ };
317
370
  const reducerContext = {
318
371
  projectRoot: config.projectRoot,
319
372
  config: reducerConfig,
320
373
  indexHandle,
321
374
  abortSignal: config.abortSignal,
375
+ readSource,
322
376
  };
323
377
  try {
324
378
  const r0 = performance.now();
325
379
  const result = await reducer.reduce(factsObj, reducerContext);
326
380
  const rMs = performance.now() - r0;
327
- reducerResults.set(reducer.name, {
328
- violations: result.violations,
329
- status: { status: 'reducer-ran', factsConsumed: result.factsConsumed ?? factsConsumed },
330
- executionTime: rMs,
331
- analyzerName: reducer.name,
332
- });
333
- if (result.facts && Object.keys(result.facts).length > 0) {
334
- reducerFacts.set(reducer.name, result.facts);
381
+ // Allow reducers to signal notRun (e.g. invariants auto-disabled when no
382
+ // rules are configured). The reducer sets notRunReason in its result.
383
+ if (result.notRunReason) {
384
+ reducerResults.set(reducer.name, {
385
+ violations: [],
386
+ status: { status: 'notRun', reason: result.notRunReason },
387
+ executionTime: rMs,
388
+ analyzerName: reducer.name,
389
+ });
390
+ }
391
+ else {
392
+ reducerResults.set(reducer.name, {
393
+ violations: result.violations,
394
+ status: { status: 'reducer-ran', factsConsumed: result.factsConsumed ?? factsConsumed },
395
+ executionTime: rMs,
396
+ analyzerName: reducer.name,
397
+ });
398
+ if (result.facts && Object.keys(result.facts).length > 0) {
399
+ reducerFacts.set(reducer.name, result.facts);
400
+ }
335
401
  }
336
402
  }
337
403
  catch (err) {
@@ -377,12 +443,23 @@ async function runStage4(allFacts, derivedReducers, config, indexHandle) {
377
443
  const r0 = performance.now();
378
444
  const result = await dr.reduce(allFacts, reducerContext);
379
445
  const rMs = performance.now() - r0;
380
- derivedResults.set(dr.name, {
381
- violations: result.violations,
382
- status: { status: 'reducer-ran', factsConsumed: result.factsConsumed ?? factsConsumed },
383
- executionTime: rMs,
384
- analyzerName: dr.name,
385
- });
446
+ // Allow derived reducers to signal notRun (same mechanism as Stage 3)
447
+ if (result.notRunReason) {
448
+ derivedResults.set(dr.name, {
449
+ violations: [],
450
+ status: { status: 'notRun', reason: result.notRunReason },
451
+ executionTime: rMs,
452
+ analyzerName: dr.name,
453
+ });
454
+ }
455
+ else {
456
+ derivedResults.set(dr.name, {
457
+ violations: result.violations,
458
+ status: { status: 'reducer-ran', factsConsumed: result.factsConsumed ?? factsConsumed },
459
+ executionTime: rMs,
460
+ analyzerName: dr.name,
461
+ });
462
+ }
386
463
  }
387
464
  catch (err) {
388
465
  derivedResults.set(dr.name, {
@@ -416,26 +493,39 @@ export async function runPipeline(config, indexHandle) {
416
493
  const visitors = config.visitors ?? [];
417
494
  const reducers = config.reducers ?? [];
418
495
  const derivedReducers = config.derivedReducers ?? [];
496
+ // Spec 38 R2 — per-rule timing accumulator is module-global (spans stages 2+3),
497
+ // so reset it at pipeline entry to avoid cross-run contamination.
498
+ resetRuleTiming();
419
499
  // ── Validate facts dependencies ──────────────────────────────────────────
420
500
  const depErrors = validateFactsDependencies(visitors, reducers, derivedReducers);
421
501
  if (depErrors.length > 0) {
422
502
  throw new Error(`Pipeline facts dependency errors:\n${depErrors.map((e) => ` - ${e}`).join('\n')}`);
423
503
  }
424
- // ── Stage 1: Traverse + Parse ────────────────────────────────────────────
425
- const stage1 = await runStage1(config);
426
- stageTiming['stage1-parse'] = stage1.parseDurationMs;
504
+ // ── Stage 1 setup: eager file discovery, lazy parse stream ──────────────
505
+ const s1 = runStage1(config);
427
506
  if (config.progressCallback) {
428
507
  config.progressCallback({
429
- current: stage1.fileCount,
430
- total: stage1.fileCount,
508
+ current: 0,
509
+ total: s1.fileCount,
431
510
  analyzer: 'pipeline',
432
511
  phase: 'stage1-complete',
433
- message: `${stage1.fileCount} files parsed in ${stage1.parseDurationMs.toFixed(0)}ms`,
512
+ message: `${s1.fileCount} files discovered`,
434
513
  });
435
514
  }
436
- // ── Stage 2: Per-file visitors ───────────────────────────────────────────
437
- const stage2 = await runStage2(stage1.tuples, visitors, config);
438
- stageTiming['stage2-visitors'] = performance.now() - totalT0 - stage1.parseDurationMs;
515
+ // ── Stage 2: Stream parse + per-file visitors ────────────────────────────
516
+ // The generator lazily reads/parses files as stage 2 consumes them.
517
+ // Stage 1 parse time is accumulated inside the generator closure and
518
+ // retrieved via getTiming() after the stream exhausts.
519
+ const streamT0 = performance.now();
520
+ const stage2 = await runStage2(s1.generator, visitors, config, s1.fileCount);
521
+ const { parseDurationMs, readDurationMs } = s1.getTiming();
522
+ // Streaming interleaves parse + visit per file, so there is no clean
523
+ // "stage 1 then stage 2" wall-clock split. Report honest figures instead:
524
+ // two CPU accumulators (measured inside the generator) plus one combined
525
+ // wall-clock for the whole parse+visit stream.
526
+ stageTiming['parse-cpu'] = parseDurationMs;
527
+ stageTiming['read-cpu'] = readDurationMs;
528
+ stageTiming['stream-parse-visit'] = performance.now() - streamT0;
439
529
  if (config.progressCallback) {
440
530
  config.progressCallback({
441
531
  current: stage2.fileCount,
@@ -505,15 +595,49 @@ export async function runPipeline(config, indexHandle) {
505
595
  });
506
596
  }
507
597
  }
598
+ // Spec 33 Item 14 — per-rule input presence, computed once from the merged
599
+ // facts + index tables, then used to promote zero-violation rules.
600
+ const inputPresence = computeInputPresence(combinedFacts, indexHandle);
601
+ // Spec 27 — build per-rule coverage from completed pipeline results
602
+ const coverage = buildCoverageReport(analyzerResults, config, inputPresence);
603
+ // Spec 29: Extract table catalog from schema reducer facts for metadata
604
+ const schemaFacts = combinedFacts['schema'];
605
+ const tableCatalog = schemaFacts?.tableCatalog;
606
+ // Spec 31: surface oversized orphan files skipped by stage-1 streaming.
607
+ // The schema-sql visitor marks them with `skipped: true`; here they are lifted
608
+ // into metadata so a skipped file is visible in coverage without adding a
609
+ // violation (which would break the exact baseline counts).
610
+ const schemaSqlFacts = stage2.allFacts.get('schema-sql');
611
+ const skippedFiles = [];
612
+ if (schemaSqlFacts) {
613
+ for (const [filePath, fact] of Object.entries(schemaSqlFacts)) {
614
+ const f = fact;
615
+ if (f.skipped) {
616
+ skippedFiles.push({ filePath, bytes: f.bytes ?? 0, reason: 'oversized-orphan-no-ddl' });
617
+ }
618
+ }
619
+ }
508
620
  const totalDuration = performance.now() - totalT0;
621
+ // Spec 32: files that failed to parse (or be read) in stage 1. Surfaced in
622
+ // coverage and used by the CLI to force a non-zero exit — a run that silently
623
+ // skipped files must never report as clean.
624
+ const unparsedFiles = s1.getUnparsedFiles();
625
+ // Spec 38 R2 — surface per-rule timing, slowest first, only when opt-in.
626
+ const ruleTiming = getRuleTimingSortedDesc();
509
627
  return {
510
628
  analyzerResults,
511
629
  metadata: {
512
630
  auditDuration: totalDuration,
513
- filesAnalyzed: stage1.fileCount,
631
+ filesAnalyzed: s1.fileCount,
514
632
  stageTiming,
515
633
  scoped: config.isScoped,
516
634
  diagnostics,
635
+ coverage,
636
+ inputPresence,
637
+ tableCatalog,
638
+ ...(skippedFiles.length > 0 && { skippedFiles }),
639
+ ...(unparsedFiles.length > 0 && { unparsedFiles }),
640
+ ...(ruleTiming.length > 0 && { ruleTiming }),
517
641
  },
518
642
  indexFacts: stage2.indexFacts,
519
643
  };
@@ -617,4 +741,204 @@ export function isVisitorStatus(status) {
617
741
  export function isReducerStatus(status) {
618
742
  return status.status === 'reducer-ran';
619
743
  }
744
+ // ── Coverage reporting (Spec 27) ────────────────────────────────────────────
745
+ /**
746
+ * Spec 33 Item 14 — snapshot which rule-input sources are present this run.
747
+ *
748
+ * - `factKeys`: visitor/reducer names whose merged facts (stage 2 + stage 3)
749
+ * are non-empty. A reducer reading e.g. `function-index` facts has input
750
+ * only when that key is present.
751
+ * - `indexTables`: index tables (referenced by a rule's `input`) that held
752
+ * ≥1 row when coverage was built. Non-existent tables are skipped.
753
+ */
754
+ function computeInputPresence(combinedFacts, indexHandle) {
755
+ const factKeys = [];
756
+ for (const [name, facts] of Object.entries(combinedFacts)) {
757
+ if (facts && typeof facts === 'object' && Object.keys(facts).length > 0) {
758
+ factKeys.push(name);
759
+ }
760
+ }
761
+ const indexTables = [];
762
+ if (indexHandle) {
763
+ const candidates = new Set();
764
+ for (const entry of Object.values(RULE_REGISTRY)) {
765
+ for (const source of entry.input ?? []) {
766
+ if (source !== 'files')
767
+ candidates.add(source);
768
+ }
769
+ }
770
+ for (const table of candidates) {
771
+ try {
772
+ if (indexHandle.tableHasRows(table))
773
+ indexTables.push(table);
774
+ }
775
+ catch {
776
+ // Table doesn't exist (e.g. a fact-key name) — not an index table.
777
+ }
778
+ }
779
+ }
780
+ return { factKeys, indexTables };
781
+ }
782
+ /**
783
+ * Build a per-rule coverage report from completed pipeline results.
784
+ *
785
+ * Iterates every rule in the canonical {@link RULE_REGISTRY}, cross-references
786
+ * with the analyzer-results map to derive a {@link RuleCoverageState} per rule:
787
+ *
788
+ * | Analyzer status | Rule state | Reason |
789
+ * |----------------------------------------|-----------------|---------------------------------|
790
+ * | notRun | `notApplicable` | `notRun.reason` |
791
+ * | visitor-ran, filesProcessed === 0 | `notApplicable` | "no matching source files" |
792
+ * | reducer-ran, factsConsumed === 0 | `notApplicable` | "no facts consumed from upstream visitors" |
793
+ * | analyzer ran with input, count > 0 | `fired` | (none) |
794
+ * | analyzer ran with input, count === 0 | `clean`/`notApplicable` | per-rule input mapping |
795
+ *
796
+ * Spec 33 Item 14: zero-violation rules are promoted from `unassessed` to
797
+ * `clean` (mapped input present) or `notApplicable` (mapped input absent). Only
798
+ * rules with no `input` mapping (non-pipeline analyzers) remain `unassessed`.
799
+ *
800
+ * Rules whose analyzer was not enabled in `config` are omitted entirely
801
+ * (they were not part of this run — distinct from `notRun`).
802
+ */
803
+ export function buildCoverageReport(analyzerResults, config, inputPresence) {
804
+ const coverage = [];
805
+ for (const [ruleId, entry] of Object.entries(RULE_REGISTRY)) {
806
+ const { analyzer: analyzerName, field } = entry;
807
+ // Skip rules whose analyzer wasn't configured for this run
808
+ if (!(analyzerName in (config.config ?? {}))) {
809
+ continue;
810
+ }
811
+ const result = analyzerResults[analyzerName];
812
+ // Analyzer not in results → notRun-equivalent
813
+ if (!result) {
814
+ coverage.push({
815
+ ruleId,
816
+ analyzer: analyzerName,
817
+ state: 'notApplicable',
818
+ count: 0,
819
+ reason: `analyzer "${analyzerName}" not in results`,
820
+ });
821
+ continue;
822
+ }
823
+ const status = result.status;
824
+ // notRun: all rules notApplicable
825
+ if (status.status !== 'visitor-ran' && status.status !== 'reducer-ran') {
826
+ const reason = status.reason;
827
+ coverage.push({
828
+ ruleId,
829
+ analyzer: analyzerName,
830
+ state: 'notApplicable',
831
+ count: 0,
832
+ reason,
833
+ });
834
+ continue;
835
+ }
836
+ // Visitor with zero input files
837
+ if (isVisitorStatus(status) && getFilesProcessed(status) === 0) {
838
+ coverage.push({
839
+ ruleId,
840
+ analyzer: analyzerName,
841
+ state: 'notApplicable',
842
+ count: 0,
843
+ reason: 'no matching source files',
844
+ });
845
+ continue;
846
+ }
847
+ // Reducer with zero facts consumed
848
+ if (isReducerStatus(status) && getFactsConsumed(status) === 0) {
849
+ coverage.push({
850
+ ruleId,
851
+ analyzer: analyzerName,
852
+ state: 'notApplicable',
853
+ count: 0,
854
+ reason: 'no facts consumed from upstream visitors',
855
+ });
856
+ continue;
857
+ }
858
+ // Check per-rule config gate (explicitly disabled by config)
859
+ if (entry.configGate) {
860
+ const analyzerNs = (config.config ?? {})[analyzerName];
861
+ const gateValue = analyzerNs?.[entry.configGate];
862
+ if (gateValue === false) {
863
+ coverage.push({
864
+ ruleId,
865
+ analyzer: analyzerName,
866
+ state: 'notApplicable',
867
+ count: 0,
868
+ reason: `disabled by config (${analyzerName}.${entry.configGate}: false)`,
869
+ });
870
+ continue;
871
+ }
872
+ }
873
+ // Analyzer ran with input — count violations for this rule
874
+ const violations = (result.violations ?? []).filter((v) => {
875
+ if (field === 'type')
876
+ return v.type === ruleId;
877
+ if (field === 'contractType')
878
+ return v.contractType === ruleId;
879
+ if (field === 'principle')
880
+ return v.principle === ruleId;
881
+ if (field === 'violationType')
882
+ return v.violationType === ruleId;
883
+ if (field === 'ruleId')
884
+ return v.ruleId === ruleId;
885
+ return v.rule === ruleId;
886
+ });
887
+ const count = violations.length;
888
+ if (count > 0) {
889
+ coverage.push({
890
+ ruleId,
891
+ analyzer: analyzerName,
892
+ state: 'fired',
893
+ count,
894
+ });
895
+ continue;
896
+ }
897
+ // Spec 33 Item 14 — zero-violation rules are now promoted from `unassessed`
898
+ // to `clean`/`notApplicable` based on whether the rule's mapped input was
899
+ // present this run. Rules with no mapping (non-pipeline analyzers) stay
900
+ // `unassessed`.
901
+ coverage.push(resolveZeroViolationState(ruleId, analyzerName, entry.input, inputPresence));
902
+ }
903
+ return coverage;
904
+ }
905
+ /**
906
+ * Spec 33 Item 14 — classify a zero-violation rule by its declared input.
907
+ *
908
+ * - No `input` mapping → `unassessed` (input provenance unknown; non-pipeline
909
+ * analyzers only).
910
+ * - Any input source present → `clean` (the analyzer ran over the rule's real
911
+ * input and found nothing to flag).
912
+ * - All input sources absent → `notApplicable` (the input this rule reads was
913
+ * never produced this run).
914
+ *
915
+ * An input source is "present" when it is the literal `'files'` (always present
916
+ * at this branch — earlier checks already excluded empty-input analyzers), a
917
+ * fact-key in {@link InputPresence.factKeys}, or an index table in
918
+ * {@link InputPresence.indexTables}.
919
+ */
920
+ function resolveZeroViolationState(ruleId, analyzerName, input, inputPresence) {
921
+ if (!input || input.length === 0) {
922
+ return {
923
+ ruleId,
924
+ analyzer: analyzerName,
925
+ state: 'unassessed',
926
+ count: 0,
927
+ reason: 'applicability not assessed (no per-rule input mapping)',
928
+ };
929
+ }
930
+ const factKeys = new Set(inputPresence?.factKeys ?? []);
931
+ const indexTables = new Set(inputPresence?.indexTables ?? []);
932
+ const anyPresent = input.some((source) => source === 'files' || factKeys.has(source) || indexTables.has(source));
933
+ if (anyPresent) {
934
+ return { ruleId, analyzer: analyzerName, state: 'clean', count: 0 };
935
+ }
936
+ return {
937
+ ruleId,
938
+ analyzer: analyzerName,
939
+ state: 'notApplicable',
940
+ count: 0,
941
+ reason: `rule input absent (none of: ${input.join(', ')})`,
942
+ };
943
+ }
620
944
  //# sourceMappingURL=pipeline.js.map