carto-md 2.0.9 → 2.1.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 (209) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/CONTRIBUTING.md +27 -20
  3. package/README.md +177 -448
  4. package/docs/anci/v0.1-DRAFT.md +132 -2
  5. package/docs/api/README.md +124 -0
  6. package/docs/api/did_we_discuss_this.md +36 -0
  7. package/docs/api/dismiss_gap.md +39 -0
  8. package/docs/api/dismiss_suggestion.md +34 -0
  9. package/docs/api/explain_change_in_natural_language.md +33 -0
  10. package/docs/api/find_consumers_of_api.md +36 -0
  11. package/docs/api/get_action_patterns.md +34 -0
  12. package/docs/api/get_active_drift.md +32 -0
  13. package/docs/api/get_active_suggestions.md +25 -0
  14. package/docs/api/get_ai_cost_attribution.md +32 -0
  15. package/docs/api/get_arch_events.md +44 -0
  16. package/docs/api/get_architectural_drift.md +39 -0
  17. package/docs/api/get_architecture.md +25 -0
  18. package/docs/api/get_blast_radius.md +36 -0
  19. package/docs/api/get_canonical_pattern.md +41 -0
  20. package/docs/api/get_change_plan.md +34 -0
  21. package/docs/api/get_change_velocity.md +34 -0
  22. package/docs/api/get_churn_vs_blast_radius.md +34 -0
  23. package/docs/api/get_complexity_trend.md +41 -0
  24. package/docs/api/get_context.md +34 -0
  25. package/docs/api/get_conventions.md +34 -0
  26. package/docs/api/get_cross_domain.md +25 -0
  27. package/docs/api/get_cross_language_call_graph.md +25 -0
  28. package/docs/api/get_cross_repo_blast_radius.md +36 -0
  29. package/docs/api/get_cross_team_coupling.md +25 -0
  30. package/docs/api/get_data_flow.md +35 -0
  31. package/docs/api/get_dead_code_with_confidence.md +32 -0
  32. package/docs/api/get_decision_log.md +34 -0
  33. package/docs/api/get_dependency_surface.md +25 -0
  34. package/docs/api/get_domain.md +34 -0
  35. package/docs/api/get_domain_evolution.md +41 -0
  36. package/docs/api/get_domain_health.md +34 -0
  37. package/docs/api/get_domains_list.md +25 -0
  38. package/docs/api/get_drift_digest.md +32 -0
  39. package/docs/api/get_env_vars.md +32 -0
  40. package/docs/api/get_evolution_delta.md +36 -0
  41. package/docs/api/get_file_ownership.md +33 -0
  42. package/docs/api/get_file_receipts.md +34 -0
  43. package/docs/api/get_file_summary.md +34 -0
  44. package/docs/api/get_gaps.md +52 -0
  45. package/docs/api/get_high_impact_files.md +32 -0
  46. package/docs/api/get_hot_in_prod_no_tests.md +34 -0
  47. package/docs/api/get_hotspot_files.md +39 -0
  48. package/docs/api/get_iac_resources.md +25 -0
  49. package/docs/api/get_intent.md +25 -0
  50. package/docs/api/get_interface_contract.md +33 -0
  51. package/docs/api/get_intervention_history.md +34 -0
  52. package/docs/api/get_invariants.md +39 -0
  53. package/docs/api/get_llm_enrichment.md +33 -0
  54. package/docs/api/get_microservice_cut_points.md +32 -0
  55. package/docs/api/get_microservices_migration_cut_points.md +27 -0
  56. package/docs/api/get_minimal_context_for_intent.md +39 -0
  57. package/docs/api/get_models.md +32 -0
  58. package/docs/api/get_neighbors.md +41 -0
  59. package/docs/api/get_org_architecture.md +27 -0
  60. package/docs/api/get_org_domain_mapping.md +27 -0
  61. package/docs/api/get_pending_decisions.md +34 -0
  62. package/docs/api/get_predictive_risk.md +32 -0
  63. package/docs/api/get_progressive_disclosure_tree.md +25 -0
  64. package/docs/api/get_recent_decisions.md +39 -0
  65. package/docs/api/get_risk_weighted_blast_radius.md +32 -0
  66. package/docs/api/get_routes.md +25 -0
  67. package/docs/api/get_safety_checklist.md +33 -0
  68. package/docs/api/get_semantic_diff.md +34 -0
  69. package/docs/api/get_service_boundary_violations.md +27 -0
  70. package/docs/api/get_service_dependency_graph.md +27 -0
  71. package/docs/api/get_session_context.md +34 -0
  72. package/docs/api/get_similar_patterns.md +39 -0
  73. package/docs/api/get_stale_docs.md +25 -0
  74. package/docs/api/get_structure.md +25 -0
  75. package/docs/api/get_temporal_context.md +36 -0
  76. package/docs/api/get_test_coverage_map.md +25 -0
  77. package/docs/api/get_token_budget_report.md +36 -0
  78. package/docs/api/get_upgrade_risk.md +25 -0
  79. package/docs/api/get_working_memory.md +25 -0
  80. package/docs/api/history.md +78 -0
  81. package/docs/api/impact.md +56 -0
  82. package/docs/api/ingest_otlp_traces.md +34 -0
  83. package/docs/api/memory.md +70 -0
  84. package/docs/api/org.md +51 -0
  85. package/docs/api/patterns.md +63 -0
  86. package/docs/api/scaffold_for_intent.md +34 -0
  87. package/docs/api/search_routes.md +34 -0
  88. package/docs/api/set_intent.md +45 -0
  89. package/docs/api/simulate_change_impact.md +39 -0
  90. package/docs/api/validate_change.md +39 -0
  91. package/docs/api/validate_diff.md +39 -0
  92. package/docs/concepts/anci.md +87 -0
  93. package/docs/concepts/blast-radius.md +66 -0
  94. package/docs/concepts/domains.md +116 -0
  95. package/docs/concepts/import-graph.md +102 -0
  96. package/docs/concepts/mcp-integration.md +150 -0
  97. package/docs/guides/adding-feature-safely.md +101 -0
  98. package/docs/guides/ci-integration.md +175 -0
  99. package/docs/guides/monorepo-setup.md +121 -0
  100. package/docs/guides/onboarding-new-engineer.md +121 -0
  101. package/docs/guides/pre-merge-review.md +139 -0
  102. package/docs/migration/v1-to-v2.md +110 -0
  103. package/docs/quickstart.md +95 -0
  104. package/docs/scale.md +2 -2
  105. package/docs/screenshots/carto-supabase-blast-radius.png +0 -0
  106. package/docs/troubleshooting.md +180 -0
  107. package/package.json +11 -4
  108. package/scripts/gen-api-docs.js +202 -0
  109. package/src/acp/agent.js +83 -11
  110. package/src/acp/config.js +64 -0
  111. package/src/acp/persistence.js +146 -0
  112. package/src/acp/providers/anthropic.js +179 -27
  113. package/src/acp/providers/index.js +15 -2
  114. package/src/acp/providers/openai.js +164 -38
  115. package/src/acp/providers/sse.js +82 -0
  116. package/src/acp/safety.js +128 -0
  117. package/src/acp/session.js +73 -0
  118. package/src/adjacent/call-graph.js +170 -0
  119. package/src/adjacent/iac.js +167 -0
  120. package/src/adjacent/llm-enrich.js +35 -0
  121. package/src/adjacent/runtime.js +216 -0
  122. package/src/adjacent/semantic-diff.js +143 -0
  123. package/src/agents/domains.js +72 -24
  124. package/src/agents/scan-structure.js +1 -1
  125. package/src/ai/context-builder.js +215 -0
  126. package/src/ai/retrieval/lexical.js +122 -0
  127. package/src/ai/retrieval/rrf.js +121 -0
  128. package/src/ai/retrieval/semantic.js +35 -0
  129. package/src/ai/retrieval/structural.js +82 -0
  130. package/src/ai/tools.js +425 -0
  131. package/src/anci/consumer.js +83 -3
  132. package/src/anci/emit.js +20 -3
  133. package/src/anci/git-meta.js +171 -0
  134. package/src/anci/pack.js +288 -0
  135. package/src/anci/serialize.js +77 -6
  136. package/src/anci/staleness.js +161 -0
  137. package/src/bitmap/index.js +1 -1
  138. package/src/bitmap/tools.js +2 -2
  139. package/src/brain/conventions/index.js +299 -0
  140. package/src/brain/index.js +31 -0
  141. package/src/brain/invariants/index.js +306 -0
  142. package/src/brain/procedural/index.js +181 -0
  143. package/src/brain/suggestions/index.js +153 -0
  144. package/src/brain/working/index.js +170 -0
  145. package/src/cli/anci.js +27 -1
  146. package/src/cli/check.js +47 -1
  147. package/src/cli/diff.js +83 -0
  148. package/src/cli/doctor.js +339 -0
  149. package/src/cli/explain.js +61 -0
  150. package/src/cli/export.js +128 -0
  151. package/src/cli/index.js +120 -1
  152. package/src/cli/init.js +227 -6
  153. package/src/cli/load.js +125 -0
  154. package/src/cli/org.js +172 -0
  155. package/src/cli/pr-impact.js +59 -2
  156. package/src/cli/serve.js +1 -1
  157. package/src/cli/status.js +211 -0
  158. package/src/cli/sync.js +2 -2
  159. package/src/cli/temporal.js +188 -0
  160. package/src/cli/validate.js +201 -0
  161. package/src/cli/watch.js +4 -4
  162. package/src/cli/why.js +101 -0
  163. package/src/extractors/frameworks.js +236 -0
  164. package/src/extractors/imports.js +35 -10
  165. package/src/extractors/languages/dart.js +138 -0
  166. package/src/extractors/languages/go.js +11 -1
  167. package/src/extractors/languages/javascript.js +21 -7
  168. package/src/extractors/languages/kotlin.js +169 -0
  169. package/src/extractors/languages/php.js +195 -0
  170. package/src/extractors/languages/prisma.js +1 -1
  171. package/src/extractors/languages/python.js +12 -1
  172. package/src/extractors/languages/swift.js +140 -0
  173. package/src/extractors/languages/typescript.js +28 -51
  174. package/src/extractors/plugin-api.js +102 -0
  175. package/src/extractors/schemas.js +60 -0
  176. package/src/extractors/tree-sitter-parser.js +39 -0
  177. package/src/mcp/change-plan.js +131 -12
  178. package/src/mcp/files-without-tests.js +285 -0
  179. package/src/mcp/impact.js +119 -0
  180. package/src/mcp/middleware/index.js +451 -0
  181. package/src/mcp/server.js +2918 -0
  182. package/src/mcp/validate.js +1 -1
  183. package/src/org/detect.js +262 -0
  184. package/src/org/queries.js +144 -0
  185. package/src/org/store.js +173 -0
  186. package/src/org/sync.js +106 -0
  187. package/src/predictive/cut-points.js +83 -0
  188. package/src/predictive/drift-digest.js +88 -0
  189. package/src/predictive/ownership.js +145 -0
  190. package/src/predictive/risk-score.js +121 -0
  191. package/src/predictive/validate-change.js +55 -0
  192. package/src/rules/concepts/auth-middleware.md +111 -0
  193. package/src/rules/concepts/money-as-float.md +80 -0
  194. package/src/rules/engine.js +178 -0
  195. package/src/rules/intent.js +178 -0
  196. package/src/rules/registry.js +24 -0
  197. package/src/rules/rules/auth-missing-on-mutating-route.js +255 -0
  198. package/src/rules/rules/money-as-float.js +166 -0
  199. package/src/store/config-loader.js +77 -3
  200. package/src/store/sqlite-store.js +248 -7
  201. package/src/store/store-adapter.js +3 -3
  202. package/src/store/{sync-v2.js → sync.js} +122 -52
  203. package/src/temporal/backfill.js +211 -0
  204. package/src/temporal/delta.js +85 -0
  205. package/src/temporal/events.js +180 -0
  206. package/src/temporal/queries.js +358 -0
  207. package/src/temporal/snapshot.js +151 -0
  208. package/src/temporal/store.js +400 -0
  209. package/src/mcp/server-v2.js +0 -986
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * money-as-float
5
+ *
6
+ * Money fields (`price`, `amount`, `total`, `balance`, `fee`, ...)
7
+ * should never be stored in a floating-point type. Floats lose
8
+ * precision at scale — a checkout total that ends in
9
+ * `0.30000000000000004` is the friendly version; the ugly version is
10
+ * an accounting ledger that doesn't balance. Use fixed-point instead:
11
+ * Prisma's `Decimal`, Postgres `numeric(12,2)`, or minor-units integers.
12
+ *
13
+ * This rule reads the `models` table (specifically the `fields_json`
14
+ * blob) and fires per bad field — one gap per (model, field) pair,
15
+ * not one per model. `line` is left null; the extractor doesn't
16
+ * currently record per-field line numbers and we don't fabricate one.
17
+ *
18
+ * The rule stays conservative so we don't burn trust:
19
+ * - Only Prisma / Zod / TS-interface / Pydantic / Drizzle /
20
+ * SQLAlchemy models. Generic Go/Rust structs stay silent —
21
+ * `float64 Balance` in a physics sim is a legitimate use.
22
+ * - Both predicates must hold: a money-shaped field name AND a
23
+ * floating-point type. `amount_cents Int` never fires.
24
+ * - `Decimal` and `numeric(...)` types are treated as safe.
25
+ * - Names match as whole tokens, never as substrings — `sprinkler`
26
+ * doesn't collide with `price`.
27
+ */
28
+
29
+ // Money field names → matched as whole-word tokens.
30
+ // Keep this list conservative. Adding a name here means every
31
+ // Float/Number model field with that name will fire — false positives
32
+ // destroy trust permanently, so err on the side of missing a case.
33
+ const MONEY_TOKENS = new Set([
34
+ 'price',
35
+ 'amount',
36
+ 'total',
37
+ 'subtotal',
38
+ 'balance',
39
+ 'fee',
40
+ 'cost',
41
+ 'tax',
42
+ 'discount',
43
+ 'refund',
44
+ 'payout',
45
+ 'salary',
46
+ 'wage',
47
+ 'revenue',
48
+ 'profit',
49
+ 'charge',
50
+ ]);
51
+
52
+ // Field kinds this rule looks at. Skipping unrestricted struct kinds
53
+ // keeps FP rate at zero for repos that mix domain code with numeric
54
+ // / scientific work.
55
+ const APPLICABLE_KINDS = new Set([
56
+ 'prisma',
57
+ 'zod',
58
+ 'typescript',
59
+ 'ts-interface',
60
+ 'pydantic',
61
+ 'drizzle',
62
+ 'sqlalchemy',
63
+ ]);
64
+
65
+ // Floating-point-ish types across the model kinds we look at.
66
+ // Match is case-insensitive and takes whichever cleaned type token
67
+ // appears first in the field's declared type.
68
+ const FLOAT_TYPES = new Set([
69
+ 'float',
70
+ 'double',
71
+ 'number',
72
+ 'real',
73
+ 'floating',
74
+ // Prisma-specific:
75
+ 'float32',
76
+ 'float64',
77
+ ]);
78
+
79
+ function tokenize(name) {
80
+ if (!name || typeof name !== 'string') return [];
81
+ // Split camelCase, snake_case, kebab-case → lowercase tokens.
82
+ return name
83
+ .replace(/([a-z])([A-Z])/g, '$1_$2')
84
+ .toLowerCase()
85
+ .split(/[^a-z0-9]+/)
86
+ .filter(Boolean);
87
+ }
88
+
89
+ function looksLikeMoney(fieldName) {
90
+ const tokens = tokenize(fieldName);
91
+ for (const t of tokens) {
92
+ if (MONEY_TOKENS.has(t)) return t;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function cleanType(raw) {
98
+ if (!raw || typeof raw !== 'string') return '';
99
+ // Strip Prisma modifiers: `Float?`, `Float[]`, `Decimal(10, 2)`
100
+ // Strip TS generics/unions: `number | null`
101
+ const first = raw.trim().split(/[\s|<[\(]/)[0];
102
+ return first.replace(/[?!]$/, '').toLowerCase();
103
+ }
104
+
105
+ function isFloating(rawType) {
106
+ return FLOAT_TYPES.has(cleanType(rawType));
107
+ }
108
+
109
+ function parseFieldsJson(row) {
110
+ if (!row || !row.fields_json) return [];
111
+ try {
112
+ const arr = JSON.parse(row.fields_json);
113
+ return Array.isArray(arr) ? arr : [];
114
+ } catch {
115
+ return [];
116
+ }
117
+ }
118
+
119
+ module.exports = {
120
+ id: 'money-as-float',
121
+ severity: 'HIGH',
122
+ reversibility: 'moderate',
123
+ concept: 'money-as-float',
124
+ description: 'A model field whose name means money is declared as a floating-point type. Money must use fixed-point (Decimal / minor-units BigInt).',
125
+
126
+ appliesWhen(intent) {
127
+ return intent && intent.product_type === 'saas-with-auth';
128
+ },
129
+
130
+ run({ store }) {
131
+ if (!store) return [];
132
+ let rows = [];
133
+ try {
134
+ rows = store.db
135
+ .prepare(
136
+ `SELECT m.name AS model_name, m.kind AS model_kind, m.fields_json,
137
+ f.path AS file
138
+ FROM models m JOIN files f ON m.file_id = f.id`
139
+ )
140
+ .all();
141
+ } catch {
142
+ return [];
143
+ }
144
+
145
+ const gaps = [];
146
+ for (const row of rows) {
147
+ const kind = (row.model_kind || '').toLowerCase();
148
+ if (!APPLICABLE_KINDS.has(kind)) continue;
149
+
150
+ const fields = parseFieldsJson(row);
151
+ for (const field of fields) {
152
+ if (!field || typeof field !== 'object') continue;
153
+ const moneyToken = looksLikeMoney(field.name);
154
+ if (!moneyToken) continue;
155
+ if (!isFloating(field.type)) continue;
156
+
157
+ gaps.push({
158
+ file: row.file,
159
+ line: null,
160
+ evidence: `Model ${row.model_name}.${field.name} is declared as ${field.type} — money-named field on a floating-point type. Use Decimal (or minor-units integer) to avoid precision loss.`,
161
+ });
162
+ }
163
+ }
164
+ return gaps;
165
+ },
166
+ };
@@ -39,11 +39,15 @@ function loadCartoConfig(projectRoot) {
39
39
  const key = name.toUpperCase();
40
40
  if (Array.isArray(value)) {
41
41
  // Legacy flat-array form: ["keyword1", "keyword2"]
42
- domains[key] = { keywords: value.map(String), anchor: [] };
42
+ domains[key] = { keywords: value.map(String), anchor: [], globs: [] };
43
43
  } else if (value && typeof value === 'object') {
44
44
  domains[key] = {
45
45
  keywords: Array.isArray(value.keywords) ? value.keywords.map(String) : [],
46
46
  anchor: Array.isArray(value.anchor) ? value.anchor.map(String) : [],
47
+ // Declared path globs → domain. These are the PRIMARY, deterministic
48
+ // source of truth: any file matching a domain's globs is assigned that
49
+ // domain regardless of what inference would have guessed (CF-3).
50
+ globs: Array.isArray(value.globs) ? value.globs.map(String) : [],
47
51
  };
48
52
  }
49
53
  }
@@ -51,6 +55,75 @@ function loadCartoConfig(projectRoot) {
51
55
  return { domains };
52
56
  }
53
57
 
58
+ /**
59
+ * globToRegExp(glob) → RegExp
60
+ *
61
+ * Minimal, dependency-free glob → RegExp compiler for path matching.
62
+ * Supports the subset needed for domain declarations:
63
+ * ** → any number of path segments (including none)
64
+ * * → any run of characters except '/'
65
+ * ? → a single character except '/'
66
+ * Everything else is matched literally. Paths are compared with '/'
67
+ * separators (callers normalize backslashes first). Anchored full-match.
68
+ */
69
+ function globToRegExp(glob) {
70
+ let re = '';
71
+ for (let i = 0; i < glob.length; i++) {
72
+ const c = glob[i];
73
+ if (c === '*') {
74
+ if (glob[i + 1] === '*') {
75
+ // '**' → match across segment boundaries.
76
+ i++;
77
+ if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; }
78
+ else re += '.*';
79
+ } else {
80
+ re += '[^/]*';
81
+ }
82
+ } else if (c === '?') {
83
+ re += '[^/]';
84
+ } else if ('.+^${}()|[]\\'.includes(c)) {
85
+ re += '\\' + c;
86
+ } else {
87
+ re += c;
88
+ }
89
+ }
90
+ return new RegExp('^' + re + '$');
91
+ }
92
+
93
+ /**
94
+ * applyDeclaredGlobs(fileAssignments, config, confidenceByFile?) → fileAssignments
95
+ *
96
+ * Repo-declared globs are the PRIMARY source: any file whose path matches a
97
+ * domain's declared globs is force-assigned to that domain (confidence 1.0),
98
+ * overriding whatever inference produced. When a file matches globs from more
99
+ * than one domain, the first matching domain in declaration order wins (config
100
+ * authors control precedence by ordering). Mutates and returns fileAssignments.
101
+ */
102
+ function applyDeclaredGlobs(fileAssignments, config, confidenceByFile = null) {
103
+ if (!config || !config.domains) return fileAssignments;
104
+
105
+ // Precompile globs per domain, preserving declaration order.
106
+ const compiled = [];
107
+ for (const [domainName, domainConfig] of Object.entries(config.domains)) {
108
+ const globs = (domainConfig.globs || []).map(globToRegExp);
109
+ if (globs.length > 0) compiled.push([domainName, globs]);
110
+ }
111
+ if (compiled.length === 0) return fileAssignments;
112
+
113
+ for (const fp of fileAssignments.keys()) {
114
+ const normalized = fp.replace(/\\/g, '/');
115
+ for (const [domainName, globs] of compiled) {
116
+ if (globs.some(re => re.test(normalized))) {
117
+ fileAssignments.set(fp, domainName);
118
+ if (confidenceByFile instanceof Map) confidenceByFile.set(fp, 1.0);
119
+ break;
120
+ }
121
+ }
122
+ }
123
+
124
+ return fileAssignments;
125
+ }
126
+
54
127
  /**
55
128
  * applyAnchors(fileAssignments, config) → fileAssignments (mutated)
56
129
  *
@@ -58,7 +131,7 @@ function loadCartoConfig(projectRoot) {
58
131
  * Then propagates via 2-pass graph expansion from the existing
59
132
  * buildFileAssignments logic in domains.js.
60
133
  */
61
- function applyAnchors(fileAssignments, config) {
134
+ function applyAnchors(fileAssignments, config, confidenceByFile = null) {
62
135
  if (!config || !config.domains) return fileAssignments;
63
136
 
64
137
  for (const [domainName, domainConfig] of Object.entries(config.domains)) {
@@ -67,6 +140,7 @@ function applyAnchors(fileAssignments, config) {
67
140
  const normalized = anchorPath.replace(/\\/g, '/');
68
141
  if (fileAssignments.has(normalized)) {
69
142
  fileAssignments.set(normalized, domainName);
143
+ if (confidenceByFile instanceof Map) confidenceByFile.set(normalized, 1.0);
70
144
  }
71
145
  }
72
146
  }
@@ -74,4 +148,4 @@ function applyAnchors(fileAssignments, config) {
74
148
  return fileAssignments;
75
149
  }
76
150
 
77
- module.exports = { loadCartoConfig, applyAnchors };
151
+ module.exports = { loadCartoConfig, applyAnchors, applyDeclaredGlobs, globToRegExp };
@@ -4,11 +4,15 @@ const Database = require('better-sqlite3');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
6
 
7
- // Schema version 3 — adds the Episodic Memory tables
8
- // (ai_sessions, decisions, interventions). The full _ensureSchema() body
9
- // is idempotent (CREATE TABLE IF NOT EXISTS), so existing v1/v2 DBs
10
- // cleanly pick up the new tables on next open.
11
- const SCHEMA_VERSION = '3';
7
+ // Schema version 4 — adds the `gaps` table (derived view of what
8
+ // the rule engine currently finds). Prior versions:
9
+ // 1 initial schema
10
+ // 2 reverse_deps + centrality
11
+ // 3 episodic memory (ai_sessions, decisions, interventions)
12
+ // 4 — gaps table for the rule engine
13
+ // _ensureSchema() is idempotent (CREATE TABLE IF NOT EXISTS on every
14
+ // table), so older DBs cleanly pick up the new tables on next open.
15
+ const SCHEMA_VERSION = '4';
12
16
 
13
17
  /**
14
18
  * normalizePath(p) — Canonicalize a relative path for storage and query.
@@ -319,6 +323,39 @@ class SQLiteStore {
319
323
  CREATE INDEX IF NOT EXISTS idx_interventions_session ON interventions(session_id);
320
324
  `);
321
325
 
326
+ // ─── Rule Engine gaps ───────────────────────────────────────────
327
+ // A gap is a claim, grounded in a Carto fact, that the code
328
+ // violates a rule ("SHOULD − IS"). The `gaps` table is derived
329
+ // state — it's completely replaced on every rule-engine run
330
+ // (`replaceGaps()`). Persistence lets get_gaps be a cheap query
331
+ // instead of re-running the engine on every MCP call.
332
+ //
333
+ // gap_hash — sha1(rule_id + file + line). Stable across
334
+ // runs; used to dedup against dismissals.
335
+ // dismissed — 1 if the user marked this gap intentional
336
+ // (via dismiss_gap). Preserved across gap
337
+ // replacements — see replaceGaps() logic.
338
+ // reason — user's dismissal reason, free text.
339
+ this._db.exec(`
340
+ CREATE TABLE IF NOT EXISTS gaps (
341
+ id INTEGER PRIMARY KEY,
342
+ gap_hash TEXT UNIQUE NOT NULL,
343
+ rule_id TEXT NOT NULL,
344
+ file TEXT,
345
+ line INTEGER,
346
+ severity TEXT NOT NULL,
347
+ reversibility TEXT,
348
+ concept TEXT,
349
+ evidence TEXT,
350
+ detected_at INTEGER NOT NULL,
351
+ dismissed INTEGER DEFAULT 0,
352
+ reason TEXT
353
+ );
354
+ CREATE INDEX IF NOT EXISTS idx_gaps_rule ON gaps(rule_id);
355
+ CREATE INDEX IF NOT EXISTS idx_gaps_file ON gaps(file);
356
+ CREATE INDEX IF NOT EXISTS idx_gaps_dismissed ON gaps(dismissed);
357
+ `);
358
+
322
359
  this.setMeta('schema_version', SCHEMA_VERSION);
323
360
  }
324
361
 
@@ -423,7 +460,13 @@ class SQLiteStore {
423
460
  for (const imp of data.imports) {
424
461
  const toFileId = imp.resolvedFileId || null;
425
462
  const resolved = toFileId ? 1 : 0;
426
- ins.run(fileId, toFileId, imp.path, imp.symbol || null, resolved);
463
+ // Normalize to_path to POSIX at the write boundary. Import
464
+ // resolvers use path.relative() which yields native separators
465
+ // (backslashes on Windows); files.path is always POSIX. Without
466
+ // this, resolveUnresolvedImports() (WHERE path = to_path) never
467
+ // matches on Windows → imports stay unresolved → blast radius
468
+ // reports zero dependents. Same code, same results, all OSes.
469
+ ins.run(fileId, toFileId, normalizePath(imp.path), imp.symbol || null, resolved);
427
470
  }
428
471
  }
429
472
 
@@ -456,7 +499,11 @@ class SQLiteStore {
456
499
  'INSERT INTO models (file_id, name, kind, fields_json) VALUES (?,?,?,?)'
457
500
  );
458
501
  for (const m of data.models) {
459
- const name = m.name || m.model || (typeof m === 'string' ? m : null);
502
+ // Extractors are inconsistent: prisma/zod/interface/etc. emit
503
+ // `className`, drizzle emits both `name` and `className`,
504
+ // pydantic emits `className`. Fall through the whole chain so
505
+ // no extractor's models silently vanish.
506
+ const name = m.name || m.className || m.model || (typeof m === 'string' ? m : null);
460
507
  if (!name) continue; // skip models without a name
461
508
  const fields = m.fields ? JSON.stringify(m.fields) : null;
462
509
  ins.run(fileId, name, m.kind || m.type || 'unknown', fields);
@@ -830,15 +877,26 @@ class SQLiteStore {
830
877
  }
831
878
 
832
879
  assignFileToDomain(fileId, domainId, confidence = 1.0) {
880
+ // domain_assignments is the single source of truth. files.domain_id is a
881
+ // denormalized mirror written here in the same call so that every reader —
882
+ // whether it queries domain_assignments (getDomainForFile, getCrossDomainDeps,
883
+ // the bitmap sidecar) or JOINs on files.domain_id (brain/predictive/retrieval
884
+ // SQL) — always sees the same domain for a file. The two can never drift
885
+ // because they are written together and cleared together (see
886
+ // clearDomainAssignments). This is the CF-3 single-source-of-truth fix.
833
887
  this._db.prepare(`
834
888
  INSERT OR REPLACE INTO domain_assignments (file_id, domain_id, confidence)
835
889
  VALUES (?, ?, ?)
836
890
  `).run(fileId, domainId, confidence);
891
+ this._db.prepare('UPDATE files SET domain_id = ? WHERE id = ?').run(domainId, fileId);
837
892
  }
838
893
 
839
894
  clearDomainAssignments() {
840
895
  this._db.prepare('DELETE FROM domain_assignments').run();
841
896
  this._db.prepare('DELETE FROM domains').run();
897
+ // Reset the denormalized mirror so files that lose an assignment on the
898
+ // next full re-cluster don't retain a stale domain_id.
899
+ this._db.prepare('UPDATE files SET domain_id = NULL').run();
842
900
  }
843
901
 
844
902
  _getDomainForFileId(fileId) {
@@ -857,6 +915,23 @@ class SQLiteStore {
857
915
  return this._getDomainForFileId(file.id);
858
916
  }
859
917
 
918
+ /**
919
+ * getDomainOf(fileIdOrPath) → domain name (string) or null
920
+ *
921
+ * The single canonical accessor every tool should use to answer
922
+ * "what domain is this file in?". Accepts either a numeric file id or a
923
+ * relative path, and always resolves against domain_assignments (the source
924
+ * of truth) so no two tools can disagree. Returns null when the file is
925
+ * unknown or unassigned; callers decide whether to display 'CORE'.
926
+ */
927
+ getDomainOf(fileIdOrPath) {
928
+ if (fileIdOrPath == null) return null;
929
+ if (typeof fileIdOrPath === 'number') {
930
+ return this._getDomainForFileId(fileIdOrPath);
931
+ }
932
+ return this.getDomainForFile(String(fileIdOrPath));
933
+ }
934
+
860
935
  // ─── Reverse deps (blast radius) ──────────────────────────────────────
861
936
 
862
937
  /**
@@ -1145,6 +1220,172 @@ class SQLiteStore {
1145
1220
  ).all();
1146
1221
  }
1147
1222
 
1223
+ // ─── Rule Engine gaps ─────────────────────────────────────────────────
1224
+
1225
+ /**
1226
+ * replaceGaps(newGaps) → { inserted, preserved, removed }
1227
+ *
1228
+ * The rule engine calls this once per run. Semantics:
1229
+ * - Rows for gaps present in `newGaps` are refreshed (detected_at
1230
+ * bumped). If the same gap_hash was previously dismissed, the
1231
+ * `dismissed` + `reason` fields carry over — dismissals persist
1232
+ * across runs.
1233
+ * - Rows whose gap_hash is NOT in `newGaps` are deleted. Gaps
1234
+ * that no longer fire disappear from the table.
1235
+ *
1236
+ * `newGaps` is an array of `{ gap_hash, rule_id, file, line,
1237
+ * severity, reversibility, concept, evidence }`.
1238
+ *
1239
+ * Runs in a single transaction. Requires a writable connection.
1240
+ */
1241
+ replaceGaps(newGaps) {
1242
+ if (!Array.isArray(newGaps)) return { inserted: 0, preserved: 0, removed: 0 };
1243
+ const now = Date.now();
1244
+ let inserted = 0;
1245
+ let preserved = 0;
1246
+ let removed = 0;
1247
+
1248
+ const tx = this._db.transaction(() => {
1249
+ // 1. Snapshot existing dismissals so they survive the rewrite.
1250
+ const priorDismissals = new Map();
1251
+ const priorRows = this._db.prepare(
1252
+ 'SELECT gap_hash, dismissed, reason FROM gaps WHERE dismissed = 1'
1253
+ ).all();
1254
+ for (const r of priorRows) priorDismissals.set(r.gap_hash, { dismissed: r.dismissed, reason: r.reason });
1255
+
1256
+ // 2. Collect surviving hashes.
1257
+ const surviving = new Set(newGaps.map((g) => g.gap_hash));
1258
+
1259
+ // 3. Delete rows no longer emitted by the engine.
1260
+ const existing = this._db.prepare('SELECT gap_hash FROM gaps').all();
1261
+ const del = this._db.prepare('DELETE FROM gaps WHERE gap_hash = ?');
1262
+ for (const r of existing) {
1263
+ if (!surviving.has(r.gap_hash)) {
1264
+ del.run(r.gap_hash);
1265
+ removed++;
1266
+ }
1267
+ }
1268
+
1269
+ // 4. Upsert the new set. Preserve dismissed/reason when the row
1270
+ // was already dismissed.
1271
+ const upsert = this._db.prepare(`
1272
+ INSERT INTO gaps (gap_hash, rule_id, file, line, severity, reversibility, concept, evidence, detected_at, dismissed, reason)
1273
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1274
+ ON CONFLICT(gap_hash) DO UPDATE SET
1275
+ rule_id = excluded.rule_id,
1276
+ file = excluded.file,
1277
+ line = excluded.line,
1278
+ severity = excluded.severity,
1279
+ reversibility = excluded.reversibility,
1280
+ concept = excluded.concept,
1281
+ evidence = excluded.evidence,
1282
+ detected_at = excluded.detected_at
1283
+ `);
1284
+ for (const g of newGaps) {
1285
+ const wasDismissed = priorDismissals.get(g.gap_hash);
1286
+ const dismissed = wasDismissed ? 1 : 0;
1287
+ const reason = wasDismissed ? wasDismissed.reason : null;
1288
+ const info = upsert.run(
1289
+ g.gap_hash,
1290
+ g.rule_id,
1291
+ g.file ? normalizePath(g.file) : null,
1292
+ g.line != null ? g.line : null,
1293
+ g.severity,
1294
+ g.reversibility || null,
1295
+ g.concept || null,
1296
+ g.evidence || null,
1297
+ now,
1298
+ dismissed,
1299
+ reason
1300
+ );
1301
+ if (info.changes === 1 && info.lastInsertRowid) {
1302
+ if (wasDismissed) preserved++;
1303
+ else inserted++;
1304
+ }
1305
+ }
1306
+ });
1307
+ tx();
1308
+ return { inserted, preserved, removed };
1309
+ }
1310
+
1311
+ /**
1312
+ * getGaps({ includeDismissed = false, rule_id, file, severity }?) → [row, ...]
1313
+ *
1314
+ * Read-only. Returns the current gap set, filtered by the given
1315
+ * predicates. Dismissed gaps are excluded by default — `get_gaps`
1316
+ * surfaces "what still fires and hasn't been intentionally accepted."
1317
+ * Ranked by severity (HIGH > MEDIUM > LOW) then by detected_at DESC.
1318
+ */
1319
+ getGaps(opts = {}) {
1320
+ const where = [];
1321
+ const params = [];
1322
+ if (!opts.includeDismissed) where.push('dismissed = 0');
1323
+ if (opts.rule_id) { where.push('rule_id = ?'); params.push(String(opts.rule_id)); }
1324
+ if (opts.file) { where.push('file = ?'); params.push(normalizePath(opts.file)); }
1325
+ if (opts.severity) { where.push('severity = ?'); params.push(String(opts.severity)); }
1326
+ const clause = where.length > 0 ? 'WHERE ' + where.join(' AND ') : '';
1327
+ return this._db.prepare(`
1328
+ SELECT id, gap_hash, rule_id, file, line, severity, reversibility, concept, evidence, detected_at, dismissed, reason
1329
+ FROM gaps
1330
+ ${clause}
1331
+ ORDER BY
1332
+ CASE severity WHEN 'HIGH' THEN 0 WHEN 'MEDIUM' THEN 1 WHEN 'LOW' THEN 2 ELSE 3 END,
1333
+ detected_at DESC,
1334
+ id DESC
1335
+ `).all(...params);
1336
+ }
1337
+
1338
+ /**
1339
+ * getGapByHash(gap_hash) → row | null
1340
+ */
1341
+ getGapByHash(gap_hash) {
1342
+ if (!gap_hash) return null;
1343
+ return this._db.prepare(
1344
+ 'SELECT id, gap_hash, rule_id, file, line, severity, reversibility, concept, evidence, detected_at, dismissed, reason FROM gaps WHERE gap_hash = ?'
1345
+ ).get(String(gap_hash)) || null;
1346
+ }
1347
+
1348
+ /**
1349
+ * dismissGap(gap_hash, reason) → { dismissed: boolean, gap: row | null }
1350
+ *
1351
+ * Marks a gap as intentionally accepted by the user. Idempotent —
1352
+ * re-dismissing the same gap updates the reason and stamps a new
1353
+ * timestamp but doesn't create a second row. Requires a writable
1354
+ * connection. Returns { dismissed: false, gap: null } if the hash
1355
+ * doesn't correspond to a known gap (fresh hashes only enter the
1356
+ * table via replaceGaps).
1357
+ */
1358
+ dismissGap(gap_hash, reason) {
1359
+ if (!gap_hash) return { dismissed: false, gap: null };
1360
+ const gap = this.getGapByHash(gap_hash);
1361
+ if (!gap) return { dismissed: false, gap: null };
1362
+ this._db.prepare(
1363
+ 'UPDATE gaps SET dismissed = 1, reason = ? WHERE gap_hash = ?'
1364
+ ).run(reason ? String(reason).slice(0, 2000) : null, String(gap_hash));
1365
+ return { dismissed: true, gap: this.getGapByHash(gap_hash) };
1366
+ }
1367
+
1368
+ /**
1369
+ * countGaps({ includeDismissed = false }?) → { total, bySeverity, byRule }
1370
+ */
1371
+ countGaps(opts = {}) {
1372
+ const clause = opts.includeDismissed ? '' : 'WHERE dismissed = 0';
1373
+ const total = this._db.prepare(`SELECT COUNT(*) as n FROM gaps ${clause}`).get().n;
1374
+ const bySeverity = {};
1375
+ for (const r of this._db.prepare(
1376
+ `SELECT severity, COUNT(*) as n FROM gaps ${clause} GROUP BY severity`
1377
+ ).all()) {
1378
+ bySeverity[r.severity] = r.n;
1379
+ }
1380
+ const byRule = {};
1381
+ for (const r of this._db.prepare(
1382
+ `SELECT rule_id, COUNT(*) as n FROM gaps ${clause} GROUP BY rule_id`
1383
+ ).all()) {
1384
+ byRule[r.rule_id] = r.n;
1385
+ }
1386
+ return { total, bySeverity, byRule };
1387
+ }
1388
+
1148
1389
  // ─── Lifecycle ─────────────────────────────────────────────────────────
1149
1390
 
1150
1391
  close() {
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { SQLiteStore } = require('./sqlite-store');
6
- const { runSyncV2 } = require('./sync-v2');
6
+ const { runSync } = require('./sync');
7
7
 
8
8
  /**
9
9
  * StoreAdapter — V1-compatible API wrapping SQLiteStore.
@@ -18,7 +18,7 @@ class StoreAdapter {
18
18
 
19
19
  /**
20
20
  * index(projectRoot, opts?)
21
- * Opens the SQLite DB; runs runSyncV2 if DB is missing.
21
+ * Opens the SQLite DB; runs runSync if DB is missing.
22
22
  * opts.force — re-extract all files
23
23
  * opts.writeOutputs — if false, skip AGENTS.md + context file generation (default: false)
24
24
  */
@@ -28,7 +28,7 @@ class StoreAdapter {
28
28
  const dbExists = fs.existsSync(dbPath);
29
29
 
30
30
  if (!dbExists || opts.force) {
31
- await runSyncV2({
31
+ await runSync({
32
32
  projectRoot,
33
33
  output: opts.writeOutputs ? path.resolve(projectRoot, 'AGENTS.md') : null,
34
34
  });