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.
- package/CHANGELOG.md +135 -0
- package/CONTRIBUTING.md +27 -20
- package/README.md +177 -448
- package/docs/anci/v0.1-DRAFT.md +132 -2
- package/docs/api/README.md +124 -0
- package/docs/api/did_we_discuss_this.md +36 -0
- package/docs/api/dismiss_gap.md +39 -0
- package/docs/api/dismiss_suggestion.md +34 -0
- package/docs/api/explain_change_in_natural_language.md +33 -0
- package/docs/api/find_consumers_of_api.md +36 -0
- package/docs/api/get_action_patterns.md +34 -0
- package/docs/api/get_active_drift.md +32 -0
- package/docs/api/get_active_suggestions.md +25 -0
- package/docs/api/get_ai_cost_attribution.md +32 -0
- package/docs/api/get_arch_events.md +44 -0
- package/docs/api/get_architectural_drift.md +39 -0
- package/docs/api/get_architecture.md +25 -0
- package/docs/api/get_blast_radius.md +36 -0
- package/docs/api/get_canonical_pattern.md +41 -0
- package/docs/api/get_change_plan.md +34 -0
- package/docs/api/get_change_velocity.md +34 -0
- package/docs/api/get_churn_vs_blast_radius.md +34 -0
- package/docs/api/get_complexity_trend.md +41 -0
- package/docs/api/get_context.md +34 -0
- package/docs/api/get_conventions.md +34 -0
- package/docs/api/get_cross_domain.md +25 -0
- package/docs/api/get_cross_language_call_graph.md +25 -0
- package/docs/api/get_cross_repo_blast_radius.md +36 -0
- package/docs/api/get_cross_team_coupling.md +25 -0
- package/docs/api/get_data_flow.md +35 -0
- package/docs/api/get_dead_code_with_confidence.md +32 -0
- package/docs/api/get_decision_log.md +34 -0
- package/docs/api/get_dependency_surface.md +25 -0
- package/docs/api/get_domain.md +34 -0
- package/docs/api/get_domain_evolution.md +41 -0
- package/docs/api/get_domain_health.md +34 -0
- package/docs/api/get_domains_list.md +25 -0
- package/docs/api/get_drift_digest.md +32 -0
- package/docs/api/get_env_vars.md +32 -0
- package/docs/api/get_evolution_delta.md +36 -0
- package/docs/api/get_file_ownership.md +33 -0
- package/docs/api/get_file_receipts.md +34 -0
- package/docs/api/get_file_summary.md +34 -0
- package/docs/api/get_gaps.md +52 -0
- package/docs/api/get_high_impact_files.md +32 -0
- package/docs/api/get_hot_in_prod_no_tests.md +34 -0
- package/docs/api/get_hotspot_files.md +39 -0
- package/docs/api/get_iac_resources.md +25 -0
- package/docs/api/get_intent.md +25 -0
- package/docs/api/get_interface_contract.md +33 -0
- package/docs/api/get_intervention_history.md +34 -0
- package/docs/api/get_invariants.md +39 -0
- package/docs/api/get_llm_enrichment.md +33 -0
- package/docs/api/get_microservice_cut_points.md +32 -0
- package/docs/api/get_microservices_migration_cut_points.md +27 -0
- package/docs/api/get_minimal_context_for_intent.md +39 -0
- package/docs/api/get_models.md +32 -0
- package/docs/api/get_neighbors.md +41 -0
- package/docs/api/get_org_architecture.md +27 -0
- package/docs/api/get_org_domain_mapping.md +27 -0
- package/docs/api/get_pending_decisions.md +34 -0
- package/docs/api/get_predictive_risk.md +32 -0
- package/docs/api/get_progressive_disclosure_tree.md +25 -0
- package/docs/api/get_recent_decisions.md +39 -0
- package/docs/api/get_risk_weighted_blast_radius.md +32 -0
- package/docs/api/get_routes.md +25 -0
- package/docs/api/get_safety_checklist.md +33 -0
- package/docs/api/get_semantic_diff.md +34 -0
- package/docs/api/get_service_boundary_violations.md +27 -0
- package/docs/api/get_service_dependency_graph.md +27 -0
- package/docs/api/get_session_context.md +34 -0
- package/docs/api/get_similar_patterns.md +39 -0
- package/docs/api/get_stale_docs.md +25 -0
- package/docs/api/get_structure.md +25 -0
- package/docs/api/get_temporal_context.md +36 -0
- package/docs/api/get_test_coverage_map.md +25 -0
- package/docs/api/get_token_budget_report.md +36 -0
- package/docs/api/get_upgrade_risk.md +25 -0
- package/docs/api/get_working_memory.md +25 -0
- package/docs/api/history.md +78 -0
- package/docs/api/impact.md +56 -0
- package/docs/api/ingest_otlp_traces.md +34 -0
- package/docs/api/memory.md +70 -0
- package/docs/api/org.md +51 -0
- package/docs/api/patterns.md +63 -0
- package/docs/api/scaffold_for_intent.md +34 -0
- package/docs/api/search_routes.md +34 -0
- package/docs/api/set_intent.md +45 -0
- package/docs/api/simulate_change_impact.md +39 -0
- package/docs/api/validate_change.md +39 -0
- package/docs/api/validate_diff.md +39 -0
- package/docs/concepts/anci.md +87 -0
- package/docs/concepts/blast-radius.md +66 -0
- package/docs/concepts/domains.md +116 -0
- package/docs/concepts/import-graph.md +102 -0
- package/docs/concepts/mcp-integration.md +150 -0
- package/docs/guides/adding-feature-safely.md +101 -0
- package/docs/guides/ci-integration.md +175 -0
- package/docs/guides/monorepo-setup.md +121 -0
- package/docs/guides/onboarding-new-engineer.md +121 -0
- package/docs/guides/pre-merge-review.md +139 -0
- package/docs/migration/v1-to-v2.md +110 -0
- package/docs/quickstart.md +95 -0
- package/docs/scale.md +2 -2
- package/docs/screenshots/carto-supabase-blast-radius.png +0 -0
- package/docs/troubleshooting.md +180 -0
- package/package.json +11 -4
- package/scripts/gen-api-docs.js +202 -0
- package/src/acp/agent.js +83 -11
- package/src/acp/config.js +64 -0
- package/src/acp/persistence.js +146 -0
- package/src/acp/providers/anthropic.js +179 -27
- package/src/acp/providers/index.js +15 -2
- package/src/acp/providers/openai.js +164 -38
- package/src/acp/providers/sse.js +82 -0
- package/src/acp/safety.js +128 -0
- package/src/acp/session.js +73 -0
- package/src/adjacent/call-graph.js +170 -0
- package/src/adjacent/iac.js +167 -0
- package/src/adjacent/llm-enrich.js +35 -0
- package/src/adjacent/runtime.js +216 -0
- package/src/adjacent/semantic-diff.js +143 -0
- package/src/agents/domains.js +72 -24
- package/src/agents/scan-structure.js +1 -1
- package/src/ai/context-builder.js +215 -0
- package/src/ai/retrieval/lexical.js +122 -0
- package/src/ai/retrieval/rrf.js +121 -0
- package/src/ai/retrieval/semantic.js +35 -0
- package/src/ai/retrieval/structural.js +82 -0
- package/src/ai/tools.js +425 -0
- package/src/anci/consumer.js +83 -3
- package/src/anci/emit.js +20 -3
- package/src/anci/git-meta.js +171 -0
- package/src/anci/pack.js +288 -0
- package/src/anci/serialize.js +77 -6
- package/src/anci/staleness.js +161 -0
- package/src/bitmap/index.js +1 -1
- package/src/bitmap/tools.js +2 -2
- package/src/brain/conventions/index.js +299 -0
- package/src/brain/index.js +31 -0
- package/src/brain/invariants/index.js +306 -0
- package/src/brain/procedural/index.js +181 -0
- package/src/brain/suggestions/index.js +153 -0
- package/src/brain/working/index.js +170 -0
- package/src/cli/anci.js +27 -1
- package/src/cli/check.js +47 -1
- package/src/cli/diff.js +83 -0
- package/src/cli/doctor.js +339 -0
- package/src/cli/explain.js +61 -0
- package/src/cli/export.js +128 -0
- package/src/cli/index.js +120 -1
- package/src/cli/init.js +227 -6
- package/src/cli/load.js +125 -0
- package/src/cli/org.js +172 -0
- package/src/cli/pr-impact.js +59 -2
- package/src/cli/serve.js +1 -1
- package/src/cli/status.js +211 -0
- package/src/cli/sync.js +2 -2
- package/src/cli/temporal.js +188 -0
- package/src/cli/validate.js +201 -0
- package/src/cli/watch.js +4 -4
- package/src/cli/why.js +101 -0
- package/src/extractors/frameworks.js +236 -0
- package/src/extractors/imports.js +35 -10
- package/src/extractors/languages/dart.js +138 -0
- package/src/extractors/languages/go.js +11 -1
- package/src/extractors/languages/javascript.js +21 -7
- package/src/extractors/languages/kotlin.js +169 -0
- package/src/extractors/languages/php.js +195 -0
- package/src/extractors/languages/prisma.js +1 -1
- package/src/extractors/languages/python.js +12 -1
- package/src/extractors/languages/swift.js +140 -0
- package/src/extractors/languages/typescript.js +28 -51
- package/src/extractors/plugin-api.js +102 -0
- package/src/extractors/schemas.js +60 -0
- package/src/extractors/tree-sitter-parser.js +39 -0
- package/src/mcp/change-plan.js +131 -12
- package/src/mcp/files-without-tests.js +285 -0
- package/src/mcp/impact.js +119 -0
- package/src/mcp/middleware/index.js +451 -0
- package/src/mcp/server.js +2918 -0
- package/src/mcp/validate.js +1 -1
- package/src/org/detect.js +262 -0
- package/src/org/queries.js +144 -0
- package/src/org/store.js +173 -0
- package/src/org/sync.js +106 -0
- package/src/predictive/cut-points.js +83 -0
- package/src/predictive/drift-digest.js +88 -0
- package/src/predictive/ownership.js +145 -0
- package/src/predictive/risk-score.js +121 -0
- package/src/predictive/validate-change.js +55 -0
- package/src/rules/concepts/auth-middleware.md +111 -0
- package/src/rules/concepts/money-as-float.md +80 -0
- package/src/rules/engine.js +178 -0
- package/src/rules/intent.js +178 -0
- package/src/rules/registry.js +24 -0
- package/src/rules/rules/auth-missing-on-mutating-route.js +255 -0
- package/src/rules/rules/money-as-float.js +166 -0
- package/src/store/config-loader.js +77 -3
- package/src/store/sqlite-store.js +248 -7
- package/src/store/store-adapter.js +3 -3
- package/src/store/{sync-v2.js → sync.js} +122 -52
- package/src/temporal/backfill.js +211 -0
- package/src/temporal/delta.js +85 -0
- package/src/temporal/events.js +180 -0
- package/src/temporal/queries.js +358 -0
- package/src/temporal/snapshot.js +151 -0
- package/src/temporal/store.js +400 -0
- 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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
31
|
+
await runSync({
|
|
32
32
|
projectRoot,
|
|
33
33
|
output: opts.writeOutputs ? path.resolve(projectRoot, 'AGENTS.md') : null,
|
|
34
34
|
});
|