code-auditor-mcp 4.0.3 → 4.1.0

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 (157) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +124 -0
  3. package/README.md +5 -5
  4. package/configs/hhra-compat.json +2 -2
  5. package/dist/analyzers/applicability.d.ts +25 -2
  6. package/dist/analyzers/applicability.d.ts.map +1 -1
  7. package/dist/analyzers/applicability.js +44 -50
  8. package/dist/analyzers/applicability.js.map +1 -1
  9. package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts +18 -49
  10. package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts.map +1 -1
  11. package/dist/analyzers/cross-language/APIContractAnalyzer.js +22 -240
  12. package/dist/analyzers/cross-language/APIContractAnalyzer.js.map +1 -1
  13. package/dist/analyzers/cross-language/SchemaValidator.d.ts +2 -14
  14. package/dist/analyzers/cross-language/SchemaValidator.d.ts.map +1 -1
  15. package/dist/analyzers/cross-language/SchemaValidator.js +0 -104
  16. package/dist/analyzers/cross-language/SchemaValidator.js.map +1 -1
  17. package/dist/analyzers/orgFilterTiers.d.ts +89 -0
  18. package/dist/analyzers/orgFilterTiers.d.ts.map +1 -0
  19. package/dist/analyzers/orgFilterTiers.js +96 -0
  20. package/dist/analyzers/orgFilterTiers.js.map +1 -0
  21. package/dist/analyzers/ruleRegistry.d.ts +15 -0
  22. package/dist/analyzers/ruleRegistry.d.ts.map +1 -1
  23. package/dist/analyzers/ruleRegistry.js +139 -185
  24. package/dist/analyzers/ruleRegistry.js.map +1 -1
  25. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts +39 -0
  26. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts.map +1 -1
  27. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js +62 -41
  28. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js.map +1 -1
  29. package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts +2 -2
  30. package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts.map +1 -1
  31. package/dist/analyzers/universal/UniversalSchemaAnalyzer.js +2 -2
  32. package/dist/analyzers/universal/UniversalSchemaAnalyzer.js.map +1 -1
  33. package/dist/analyzers/universal/UniversalSecurityAnalyzer.d.ts +86 -0
  34. package/dist/analyzers/universal/UniversalSecurityAnalyzer.d.ts.map +1 -0
  35. package/dist/analyzers/universal/UniversalSecurityAnalyzer.js +606 -0
  36. package/dist/analyzers/universal/UniversalSecurityAnalyzer.js.map +1 -0
  37. package/dist/analyzers/universal/schema/codeAnalysis.d.ts +14 -0
  38. package/dist/analyzers/universal/schema/codeAnalysis.d.ts.map +1 -1
  39. package/dist/analyzers/universal/schema/codeAnalysis.js +35 -18
  40. package/dist/analyzers/universal/schema/codeAnalysis.js.map +1 -1
  41. package/dist/analyzers/universal/schema/migrations.d.ts +14 -1
  42. package/dist/analyzers/universal/schema/migrations.d.ts.map +1 -1
  43. package/dist/analyzers/universal/schema/migrations.js +58 -14
  44. package/dist/analyzers/universal/schema/migrations.js.map +1 -1
  45. package/dist/auditRunner.d.ts.map +1 -1
  46. package/dist/auditRunner.js +70 -13
  47. package/dist/auditRunner.js.map +1 -1
  48. package/dist/churn/churnExtractor.js +5 -5
  49. package/dist/churn/churnExtractor.js.map +1 -1
  50. package/dist/cli.js +40 -9
  51. package/dist/cli.js.map +1 -1
  52. package/dist/codeIndexDB.d.ts +6 -0
  53. package/dist/codeIndexDB.d.ts.map +1 -1
  54. package/dist/codeIndexDB.js +33 -1
  55. package/dist/codeIndexDB.js.map +1 -1
  56. package/dist/config/configGeneratePath.d.ts +19 -0
  57. package/dist/config/configGeneratePath.d.ts.map +1 -0
  58. package/dist/config/configGeneratePath.js +29 -0
  59. package/dist/config/configGeneratePath.js.map +1 -0
  60. package/dist/config/configLoader.d.ts +36 -4
  61. package/dist/config/configLoader.d.ts.map +1 -1
  62. package/dist/config/configLoader.js +136 -15
  63. package/dist/config/configLoader.js.map +1 -1
  64. package/dist/config/lintConfigReader.d.ts +4 -0
  65. package/dist/config/lintConfigReader.d.ts.map +1 -1
  66. package/dist/config/lintConfigReader.js +35 -36
  67. package/dist/config/lintConfigReader.js.map +1 -1
  68. package/dist/config/staticObjectExtract.d.ts +35 -0
  69. package/dist/config/staticObjectExtract.d.ts.map +1 -0
  70. package/dist/config/staticObjectExtract.js +312 -0
  71. package/dist/config/staticObjectExtract.js.map +1 -0
  72. package/dist/constants.d.ts +1 -2
  73. package/dist/constants.d.ts.map +1 -1
  74. package/dist/constants.js +0 -2
  75. package/dist/constants.js.map +1 -1
  76. package/dist/daemon/core.js +1 -1
  77. package/dist/daemon/core.js.map +1 -1
  78. package/dist/daemon/main.js +1 -1
  79. package/dist/daemon/main.js.map +1 -1
  80. package/dist/dataPaths.d.ts +11 -0
  81. package/dist/dataPaths.d.ts.map +1 -1
  82. package/dist/dataPaths.js +24 -2
  83. package/dist/dataPaths.js.map +1 -1
  84. package/dist/generators/BaseConfigGenerator.d.ts +0 -4
  85. package/dist/generators/BaseConfigGenerator.d.ts.map +1 -1
  86. package/dist/generators/BaseConfigGenerator.js +1 -7
  87. package/dist/generators/BaseConfigGenerator.js.map +1 -1
  88. package/dist/installConfig.d.ts +3 -1
  89. package/dist/installConfig.d.ts.map +1 -1
  90. package/dist/installConfig.js +15 -2
  91. package/dist/installConfig.js.map +1 -1
  92. package/dist/installer.d.ts +10 -0
  93. package/dist/installer.d.ts.map +1 -1
  94. package/dist/installer.js +14 -2
  95. package/dist/installer.js.map +1 -1
  96. package/dist/languages/LanguageOrchestrator.js +6 -6
  97. package/dist/languages/LanguageOrchestrator.js.map +1 -1
  98. package/dist/languages/RuntimeManager.d.ts +4 -2
  99. package/dist/languages/RuntimeManager.d.ts.map +1 -1
  100. package/dist/languages/RuntimeManager.js +77 -44
  101. package/dist/languages/RuntimeManager.js.map +1 -1
  102. package/dist/languages/go/{analyzer → analyzer-darwin-amd64} +0 -0
  103. package/dist/languages/go/analyzer-darwin-arm64 +0 -0
  104. package/dist/languages/go/analyzer-linux-amd64 +0 -0
  105. package/dist/languages/go/analyzer-linux-arm64 +0 -0
  106. package/dist/languages/go/analyzer-windows-amd64.exe +0 -0
  107. package/dist/languages/goBinary.d.ts +8 -7
  108. package/dist/languages/goBinary.d.ts.map +1 -1
  109. package/dist/languages/goBinary.js +8 -7
  110. package/dist/languages/goBinary.js.map +1 -1
  111. package/dist/mcp-tools-shared.d.ts.map +1 -1
  112. package/dist/mcp-tools-shared.js +7 -2
  113. package/dist/mcp-tools-shared.js.map +1 -1
  114. package/dist/mcp-ui-simple.d.ts +5 -1
  115. package/dist/mcp-ui-simple.d.ts.map +1 -1
  116. package/dist/mcp-ui-simple.js +81 -29
  117. package/dist/mcp-ui-simple.js.map +1 -1
  118. package/dist/mcp.js +19 -4
  119. package/dist/mcp.js.map +1 -1
  120. package/dist/mcpAuditJobs.d.ts.map +1 -1
  121. package/dist/mcpAuditJobs.js +1 -0
  122. package/dist/mcpAuditJobs.js.map +1 -1
  123. package/dist/nextFileIncremental.js +1 -1
  124. package/dist/nextFileIncremental.js.map +1 -1
  125. package/dist/pipeline.d.ts.map +1 -1
  126. package/dist/pipeline.js +13 -2
  127. package/dist/pipeline.js.map +1 -1
  128. package/dist/pipelineAdapters.d.ts +15 -0
  129. package/dist/pipelineAdapters.d.ts.map +1 -1
  130. package/dist/pipelineAdapters.js +140 -6
  131. package/dist/pipelineAdapters.js.map +1 -1
  132. package/dist/reporting/htmlReportGenerator.js +2 -2
  133. package/dist/reporting/htmlReportGenerator.js.map +1 -1
  134. package/dist/ruleAliases.d.ts.map +1 -1
  135. package/dist/ruleAliases.js +45 -0
  136. package/dist/ruleAliases.js.map +1 -1
  137. package/dist/styles/tailwindConfigLoader.d.ts +4 -0
  138. package/dist/styles/tailwindConfigLoader.d.ts.map +1 -1
  139. package/dist/styles/tailwindConfigLoader.js +54 -36
  140. package/dist/styles/tailwindConfigLoader.js.map +1 -1
  141. package/dist/types.d.ts +55 -2
  142. package/dist/types.d.ts.map +1 -1
  143. package/dist/types.js +15 -0
  144. package/dist/types.js.map +1 -1
  145. package/dist/version.generated.d.ts +1 -1
  146. package/dist/version.generated.js +1 -1
  147. package/examples/.auditrc.example.json +2 -2
  148. package/examples/.auditrc.json +3 -3
  149. package/examples/nextjs.auditrc.json +1 -1
  150. package/examples/node-api.auditrc.json +1 -1
  151. package/examples/react.auditrc.json +2 -2
  152. package/examples/tolerant-solid.auditrc.json +1 -1
  153. package/package.json +2 -1
  154. package/plugin/.claude-plugin/plugin.json +1 -1
  155. package/plugin/scripts/hook-common.sh +27 -27
  156. package/plugin/skills/code-auditor/SKILL.md +1 -1
  157. package/server.json +2 -2
@@ -26,6 +26,7 @@ import { isReactComponent, detectComponentType, getComponentName, } from './util
26
26
  import { findTableReferences, checkNamingConventions, checkQueryPatterns, checkSQLInjection, checkUnresolvedQueries, getNearestTableSuggestions, } from './analyzers/universal/schema/codeAnalysis.js';
27
27
  import { passesFileGate, extractTablesFromRegistry, } from './analyzers/universal/schema/discovery.js';
28
28
  import { applyMigrationOps, stripIdentifier } from './analyzers/universal/schema/migrations.js';
29
+ import { buildOrgFilterTierSet, tableRequiresOrgFilter, } from './analyzers/orgFilterTiers.js';
29
30
  // ── Rule ID helpers ──────────────────────────────────────────────────────────
30
31
  function getRuleIdsFor(name) {
31
32
  return Object.entries(RULE_REGISTRY)
@@ -120,14 +121,91 @@ export function createDataAccessVisitor() {
120
121
  getRuleIds: () => getRuleIdsFor('data-access'),
121
122
  async visit(ast, adapter, context, sourceCode) {
122
123
  const a = await getAnalyzer();
123
- const violations = await a.analyzeAST(ast, adapter, context.config, sourceCode);
124
- return { violations, facts: {} };
124
+ // Spec 62 Amendment B — emit the extracted query facts (per-file) for the
125
+ // Stage-4 missing-org-filter reducer to join against the tenant tiers.
126
+ // The violations returned here exclude missing-org-filter (moved to the
127
+ // derived reducer); the calls carry resolved table + filter presence +
128
+ // DB provenance + the query-site location.
129
+ const { violations, calls } = await a.analyzeWithFacts(ast, adapter, context.config, sourceCode);
130
+ const facts = {};
131
+ if (calls.length > 0) {
132
+ facts[context.filePath] = { calls };
133
+ }
134
+ return { violations, facts };
125
135
  },
126
136
  defaultConfig: {},
127
137
  description: 'Analyzes database access patterns and data layer interactions',
128
138
  category: 'security',
129
139
  };
130
140
  }
141
+ // ── Data-Access org-filter reducer (Spec 62 Amendment B) ─────────────────────
142
+ /**
143
+ * Stage-4 derived reducer for `missing-org-filter`. It joins the data-access
144
+ * visitor's per-query facts (resolved tables + org-filter presence + query-site
145
+ * location) against the tenant tiers resolved from config + the schema
146
+ * reducer's corpus-wide DDL column catalog. Moving the rule here (out of the
147
+ * Stage-2 analyzer) is the fix for the Amendment B defect: the firing predicate
148
+ * needed the DDL tier only Stage 3 produces, but the Stage-2 analyzer could only
149
+ * read config tiers — so a DDL-declared multi-tenant codebase was un-suppressed
150
+ * without ever firing, and reported `clean` on a real leak.
151
+ *
152
+ * Firing and applicability both derive from {@link buildOrgFilterTierSet}, so
153
+ * they read the identical tier set (Tier 1 config / Tier 2 schema / Tier 3 DDL).
154
+ */
155
+ export function createOrgFilterReducer() {
156
+ return {
157
+ name: 'data-access-org-filter',
158
+ stage: 'derivedReducer',
159
+ // Hard dependency on the data-access visitor's per-query facts. The schema
160
+ // table catalog (`allFacts['schema'].tableColumns`) is read opportunistically
161
+ // so an MCP-default run (which omits `schema`) degrades to Tier 1–2
162
+ // (config-only) instead of throwing — see buildOrgFilterTierSet.
163
+ consumes: ['data-access'],
164
+ getRuleIds: () => getRuleIdsFor('data-access-org-filter'),
165
+ async reduce(allFacts, context) {
166
+ const violations = [];
167
+ const schemaFacts = allFacts['schema'];
168
+ const ddlTableColumns = schemaFacts?.tableColumns;
169
+ // One tier source of truth — the same function the applicability predicate
170
+ // reads, so firing and applicability cannot drift to different tier sets.
171
+ const tierSet = buildOrgFilterTierSet(context.config, ddlTableColumns);
172
+ const daFacts = (allFacts['data-access'] ?? {});
173
+ for (const fileFacts of Object.values(daFacts)) {
174
+ for (const call of fileFacts.calls ?? []) {
175
+ if (call.hasOrganizationFilter)
176
+ continue;
177
+ if (!call.tables || call.tables.length === 0)
178
+ continue;
179
+ if (!tableRequiresOrgFilter(call.tables, tierSet))
180
+ continue;
181
+ const symbol = `${call.enclosingFunction ?? 'top-level'}:${call.method}`;
182
+ violations.push({
183
+ file: call.file,
184
+ line: call.line,
185
+ column: call.column,
186
+ severity: 'critical',
187
+ message: `Query on ${call.tables.join(', ')} has no organization/tenant predicate`,
188
+ suggestion: 'Add the tenant column (organization_id / org_id) to the WHERE predicate so this query is scoped to the current organization, not just by primary key.',
189
+ // Structured next action (Spec 37 R1) — the rule is `resolvable`, so
190
+ // the gate requires a `resolution`, not just the `suggestion` string.
191
+ resolution: {
192
+ action: 'add-tenant-predicate',
193
+ summary: `Add the tenant column (organization_id / org_id) to the WHERE predicate on ${call.tables.join(', ')} so this query is scoped to the current organization, not just by primary key.`,
194
+ symbols: call.tables,
195
+ },
196
+ rule: 'missing-org-filter',
197
+ analyzer: 'data-access',
198
+ functionName: symbol,
199
+ });
200
+ }
201
+ }
202
+ return { violations, facts: {} };
203
+ },
204
+ defaultConfig: {},
205
+ description: 'Detects queries on tenant-scoped tables that omit an organization/tenant predicate',
206
+ category: 'security',
207
+ };
208
+ }
131
209
  // ── Documentation visitor ────────────────────────────────────────────────────
132
210
  export function createDocumentationVisitor() {
133
211
  const getAnalyzer = lazySingleton(() => import('./analyzers/universal/UniversalDocumentationAnalyzer.js').then((m) => new m.UniversalDocumentationAnalyzer()));
@@ -162,6 +240,23 @@ export function createSecretsVisitor() {
162
240
  category: 'security',
163
241
  };
164
242
  }
243
+ // ── Security visitor (Spec 61 R6) ────────────────────────────────────────────
244
+ export function createSecurityVisitor() {
245
+ const getAnalyzer = lazySingleton(() => import('./analyzers/universal/UniversalSecurityAnalyzer.js').then((m) => new m.UniversalSecurityAnalyzer()));
246
+ return {
247
+ name: 'security',
248
+ stage: 'visitor',
249
+ getRuleIds: () => getRuleIdsFor('security'),
250
+ async visit(ast, adapter, context, sourceCode) {
251
+ const a = await getAnalyzer();
252
+ const violations = await a.analyzeAST(ast, adapter, context.config, sourceCode);
253
+ return { violations, facts: {} };
254
+ },
255
+ defaultConfig: {},
256
+ description: 'Detects command injection, dynamic require of project paths, and unescaped HTML interpolation',
257
+ category: 'security',
258
+ };
259
+ }
165
260
  // ── Function-Index visitor (infrastructure) ──────────────────────────────────
166
261
  // Always-on Stage 2 visitor that populates the `functions` table with
167
262
  // per-file definitions so the conventions and cross-domain reducers have data
@@ -609,6 +704,11 @@ export function createStylesCssVisitor() {
609
704
  * styles and (b) skip re-writing files whose stored rows are already current.
610
705
  */
611
706
  export function createStylesSourceVisitor() {
707
+ // Spec 61 R3.4 — a project tailwind config that exists but cannot be read
708
+ // statically is a `cannot-fire` coverage diagnostic. Emit it exactly once per
709
+ // visitor instance (the config is project-global, not per-file) so a config
710
+ // gap reads once, not once per source file.
711
+ let emittedTailwindDiagnostic = false;
612
712
  return {
613
713
  name: 'styles-source',
614
714
  stage: 'visitor',
@@ -624,15 +724,22 @@ export function createStylesSourceVisitor() {
624
724
  const filePath = context.filePath;
625
725
  // Memoized per projectRoot (the style-index sync already called it this
626
726
  // run), so this is a cache hit — never a re-walk of the project tree.
627
- const tailwindTokens = loadTailwindConfig(context.projectRoot).tokens;
727
+ const tailwindConfig = loadTailwindConfig(context.projectRoot);
728
+ const tailwindTokens = tailwindConfig.tokens;
628
729
  const declarations = extractDeclarations(filePath, tsAdapter, sourceCode, tsAst, tailwindTokens);
629
730
  const classUsage = extractClassUsage(filePath, sourceCode);
630
731
  const contentHash = createHash('sha256').update(sourceCode).digest('hex');
732
+ const diagnostic = tailwindConfig.diagnostic && !emittedTailwindDiagnostic
733
+ ? tailwindConfig.diagnostic
734
+ : undefined;
735
+ if (diagnostic)
736
+ emittedTailwindDiagnostic = true;
631
737
  return {
632
738
  violations: [],
633
739
  facts: {
634
740
  [filePath]: { declarations, classUsage, contentHash },
635
741
  },
742
+ ...(diagnostic ? { diagnostics: [diagnostic] } : {}),
636
743
  };
637
744
  },
638
745
  defaultConfig: {},
@@ -2124,13 +2231,14 @@ export function createSchemaSqlVisitor() {
2124
2231
  getRuleIds: () => [],
2125
2232
  async visit(_ast, _adapter, context, sourceCode) {
2126
2233
  const { extractMigrationOpsFromFile } = await getMigrationExtractor();
2127
- const { ops, columns, skipped, bytes } = await extractMigrationOpsFromFile(context.filePath, sourceCode);
2234
+ const { ops, columns, tableColumns, skipped, bytes } = await extractMigrationOpsFromFile(context.filePath, sourceCode);
2128
2235
  return {
2129
2236
  violations: [],
2130
2237
  facts: {
2131
2238
  [context.filePath]: {
2132
2239
  ddlOps: ops,
2133
2240
  ...(columns.length > 0 && { ddlColumns: columns }),
2241
+ ...(Object.keys(tableColumns).length > 0 && { ddlTableColumns: tableColumns }),
2134
2242
  ...(skipped && { skipped: true, bytes }),
2135
2243
  },
2136
2244
  },
@@ -2151,6 +2259,7 @@ export function createSchemaCodeVisitor() {
2151
2259
  defaults: m.DEFAULT_SCHEMA_CONFIG,
2152
2260
  parseMigrationOps: m.parseMigrationOps,
2153
2261
  extractDdlColumnNames: m.extractDdlColumnNames,
2262
+ extractDdlTableColumns: m.extractDdlTableColumns,
2154
2263
  })));
2155
2264
  return {
2156
2265
  name: 'schema-code',
@@ -2158,7 +2267,7 @@ export function createSchemaCodeVisitor() {
2158
2267
  extensions: ['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs'],
2159
2268
  getRuleIds: () => getRuleIdsFor('schema'),
2160
2269
  async visit(ast, adapter, context, sourceCode) {
2161
- const { analyzer: a, defaults, parseMigrationOps, extractDdlColumnNames } = await getAnalyzer();
2270
+ const { analyzer: a, defaults, parseMigrationOps, extractDdlColumnNames, extractDdlTableColumns } = await getAnalyzer();
2162
2271
  const pm = await _getProvenanceModule();
2163
2272
  const violations = [];
2164
2273
  const diagnostics = [];
@@ -2208,6 +2317,7 @@ export function createSchemaCodeVisitor() {
2208
2317
  }
2209
2318
  const doDDLSql = doDDL.length > 0 ? doDDL.join(';\n') : null;
2210
2319
  const doDDLColumns = doDDLSql ? extractDdlColumnNames(doDDLSql) : [];
2320
+ const doDDLTableColumns = doDDLSql ? extractDdlTableColumns(doDDLSql) : {};
2211
2321
  // Build provenance context for this file — defaults from DEFAULT_SCHEMA_CONFIG
2212
2322
  const detectionMode = schemaConfig.detection?.mode ?? 'hybrid';
2213
2323
  const provenanceContext = pm.buildProvenanceContext(ast, adapter, sourceCode, {
@@ -2224,6 +2334,8 @@ export function createSchemaCodeVisitor() {
2224
2334
  facts[context.filePath].ddlOps = parseMigrationOps(doDDLSql);
2225
2335
  if (doDDLColumns.length > 0)
2226
2336
  facts[context.filePath].ddlColumns = doDDLColumns;
2337
+ if (Object.keys(doDDLTableColumns).length > 0)
2338
+ facts[context.filePath].ddlTableColumns = doDDLTableColumns;
2227
2339
  }
2228
2340
  return { violations: [], facts };
2229
2341
  }
@@ -2302,6 +2414,8 @@ export function createSchemaCodeVisitor() {
2302
2414
  fileFacts.ddlOps = parseMigrationOps(doDDLSql);
2303
2415
  if (doDDLColumns.length > 0)
2304
2416
  fileFacts.ddlColumns = doDDLColumns;
2417
+ if (Object.keys(doDDLTableColumns).length > 0)
2418
+ fileFacts.ddlTableColumns = doDDLTableColumns;
2305
2419
  }
2306
2420
  return {
2307
2421
  violations,
@@ -2523,12 +2637,32 @@ export function createSchemaReducer() {
2523
2637
  // the reducer folds them into a single case-insensitive, deduplicated
2524
2638
  // set so downstream applicability predicates can ask "does ANY table
2525
2639
  // carry a tenant-scoping column?" without per-table column lists.
2640
+ //
2641
+ // Spec 62 Amendment B — additionally fold the per-table
2642
+ // `ddlTableColumns` into a corpus-wide `tableColumns` (Record<table,
2643
+ // string[]>) so the Stage-4 missing-org-filter reducer can answer the
2644
+ // per-query question "does THIS table carry a tenant column?" that the
2645
+ // flat set cannot.
2526
2646
  const ddlColumns = new Set();
2647
+ const tableColumnsMap = new Map();
2527
2648
  for (const [filePath, fact] of perFile()) {
2528
2649
  const cols = fact.ddlColumns ?? [];
2529
2650
  for (const c of cols)
2530
2651
  ddlColumns.add(String(c).toLowerCase());
2652
+ const perTable = fact.ddlTableColumns ?? {};
2653
+ for (const [table, tableCols] of Object.entries(perTable)) {
2654
+ let set = tableColumnsMap.get(table);
2655
+ if (!set) {
2656
+ set = new Set();
2657
+ tableColumnsMap.set(table, set);
2658
+ }
2659
+ for (const c of tableCols)
2660
+ set.add(String(c).toLowerCase());
2661
+ }
2531
2662
  }
2663
+ const tableColumns = {};
2664
+ for (const [table, set] of tableColumnsMap)
2665
+ tableColumns[table] = [...set];
2532
2666
  // ── 2. Unknown-table detection ────────────────────────────────────────
2533
2667
  //
2534
2668
  // Fail-open guardrail: we can only accuse when the table catalog is
@@ -2678,7 +2812,7 @@ export function createSchemaReducer() {
2678
2812
  }
2679
2813
  return {
2680
2814
  violations,
2681
- facts: { tableCatalog: catalogEntries, ddlColumns: [...ddlColumns].sort() },
2815
+ facts: { tableCatalog: catalogEntries, ddlColumns: [...ddlColumns].sort(), tableColumns },
2682
2816
  factsConsumed: [...perFile()].length,
2683
2817
  };
2684
2818
  },