carto-md 2.1.0 → 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 +11 -8
- package/README.md +165 -213
- package/docs/anci/v0.1-DRAFT.md +132 -2
- package/docs/api/README.md +27 -2
- package/docs/api/did_we_discuss_this.md +2 -0
- package/docs/api/dismiss_gap.md +39 -0
- package/docs/api/find_consumers_of_api.md +2 -0
- package/docs/api/get_action_patterns.md +2 -0
- package/docs/api/get_arch_events.md +2 -0
- package/docs/api/get_architectural_drift.md +2 -0
- package/docs/api/get_blast_radius.md +2 -0
- package/docs/api/get_canonical_pattern.md +2 -0
- package/docs/api/get_change_velocity.md +2 -0
- package/docs/api/get_churn_vs_blast_radius.md +2 -0
- package/docs/api/get_complexity_trend.md +2 -0
- package/docs/api/get_conventions.md +2 -0
- package/docs/api/get_cross_repo_blast_radius.md +2 -0
- package/docs/api/get_data_flow.md +2 -0
- package/docs/api/get_decision_log.md +2 -0
- package/docs/api/get_domain_evolution.md +2 -0
- package/docs/api/get_domain_health.md +2 -0
- package/docs/api/get_file_receipts.md +34 -0
- package/docs/api/get_gaps.md +52 -0
- package/docs/api/get_hotspot_files.md +2 -0
- package/docs/api/get_intent.md +25 -0
- package/docs/api/get_intervention_history.md +2 -0
- package/docs/api/get_invariants.md +2 -0
- package/docs/api/get_microservices_migration_cut_points.md +2 -0
- package/docs/api/get_neighbors.md +2 -0
- package/docs/api/get_org_architecture.md +2 -0
- package/docs/api/get_org_domain_mapping.md +2 -0
- package/docs/api/get_pending_decisions.md +2 -0
- package/docs/api/get_recent_decisions.md +2 -0
- package/docs/api/get_service_boundary_violations.md +2 -0
- package/docs/api/get_service_dependency_graph.md +2 -0
- package/docs/api/get_session_context.md +2 -0
- package/docs/api/get_temporal_context.md +2 -0
- package/docs/api/history.md +78 -0
- package/docs/api/impact.md +56 -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/set_intent.md +45 -0
- package/docs/api/simulate_change_impact.md +2 -0
- package/docs/concepts/domains.md +75 -50
- package/docs/concepts/mcp-integration.md +4 -2
- package/docs/guides/pre-merge-review.md +2 -2
- package/docs/migration/v1-to-v2.md +2 -2
- package/docs/screenshots/carto-supabase-blast-radius.png +0 -0
- package/package.json +2 -1
- package/scripts/gen-api-docs.js +51 -19
- package/src/agents/domains.js +72 -24
- package/src/ai/tools.js +4 -2
- package/src/anci/consumer.js +83 -3
- package/src/anci/emit.js +18 -1
- 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/brain/conventions/index.js +134 -20
- package/src/brain/invariants/index.js +76 -22
- package/src/cli/anci.js +27 -1
- package/src/cli/doctor.js +69 -0
- package/src/cli/export.js +128 -0
- package/src/cli/index.js +19 -1
- package/src/cli/init.js +84 -1
- package/src/cli/load.js +125 -0
- package/src/extractors/imports.js +35 -10
- package/src/extractors/languages/javascript.js +8 -4
- package/src/extractors/languages/prisma.js +1 -1
- package/src/extractors/languages/typescript.js +13 -49
- 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/impact.js +119 -0
- package/src/mcp/server.js +708 -82
- 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/sync.js +69 -39
|
@@ -4,13 +4,18 @@
|
|
|
4
4
|
* Semantic Memory — convention learning.
|
|
5
5
|
*
|
|
6
6
|
* Mines conventions from the static index:
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
7
|
+
* - Identifier-case naming per directory + extension (PascalCase components,
|
|
8
|
+
* camelCase utils, kebab-case files, …) — the actionable one for scaffolding.
|
|
9
|
+
* - File naming suffix patterns per directory (e.g. *.service.ts, *.config.*)
|
|
9
10
|
* - Export style (default vs named) per domain
|
|
10
|
-
* -
|
|
11
|
+
* - Directory language (e.g. src/* is all TypeScript)
|
|
11
12
|
*
|
|
12
|
-
* Each convention
|
|
13
|
-
*
|
|
13
|
+
* Each convention carries a `confidence` (0–1) and a derived `strength`
|
|
14
|
+
* (`high` ≥ 0.9, `medium` ≥ threshold). By default only conventions at or
|
|
15
|
+
* above `threshold` (0.75) are returned, so the caller never sees a rule the
|
|
16
|
+
* evidence doesn't support. When nothing clears the bar the caller gets an
|
|
17
|
+
* empty list and the formatter says so honestly, rather than emitting a
|
|
18
|
+
* misleading low-confidence "rule".
|
|
14
19
|
*
|
|
15
20
|
* Consumed by:
|
|
16
21
|
* - `get_conventions(file_or_dir)` — what conventions apply to this file
|
|
@@ -20,22 +25,102 @@
|
|
|
20
25
|
const path = require('path');
|
|
21
26
|
|
|
22
27
|
const DEFAULT_THRESHOLD = 0.75;
|
|
28
|
+
const MIN_EVIDENCE = 4; // need at least this many files before a pattern is a "convention"
|
|
29
|
+
|
|
30
|
+
function strengthOf(conf) {
|
|
31
|
+
return conf >= 0.9 ? 'high' : 'medium';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Classify an identifier (a filename without its extension) into a case style.
|
|
36
|
+
* Returns null for names we don't want to vote on (empty, dotfiles, purely
|
|
37
|
+
* numeric, or single ambiguous lowercase words like `index`/`utils` that carry
|
|
38
|
+
* no style signal).
|
|
39
|
+
*/
|
|
40
|
+
function classifyCase(name) {
|
|
41
|
+
if (!name) return null;
|
|
42
|
+
// Strip a leading dot for dotfiles like `.eslintrc` — no naming signal.
|
|
43
|
+
if (name.startsWith('.')) return null;
|
|
44
|
+
if (/^\d+$/.test(name)) return null;
|
|
45
|
+
|
|
46
|
+
if (name.includes('-') && /^[a-z0-9]+(-[a-z0-9]+)+$/.test(name)) return 'kebab-case';
|
|
47
|
+
if (name.includes('_') && /^[a-z0-9]+(_[a-z0-9]+)+$/.test(name)) return 'snake_case';
|
|
48
|
+
// PascalCase: starts uppercase, has a lowercase letter somewhere (excludes
|
|
49
|
+
// SCREAMING_CONST-style and single-letter tokens).
|
|
50
|
+
if (/^[A-Z][A-Za-z0-9]*$/.test(name) && /[a-z]/.test(name)) return 'PascalCase';
|
|
51
|
+
// camelCase: starts lowercase, has at least one uppercase hump.
|
|
52
|
+
if (/^[a-z][A-Za-z0-9]*$/.test(name) && /[A-Z]/.test(name)) return 'camelCase';
|
|
53
|
+
// Single all-lowercase word (index, utils, types, page) — ambiguous, skip.
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
23
56
|
|
|
24
57
|
/**
|
|
25
58
|
* mineConventions(store, opts) → Array<convention>
|
|
26
59
|
*
|
|
27
60
|
* Each convention:
|
|
28
|
-
* { id, kind, scope, rule, confidence, evidence_count }
|
|
61
|
+
* { id, kind, scope, rule, confidence, strength, evidence_count, ext? }
|
|
29
62
|
*/
|
|
30
63
|
function mineConventions(store, { threshold = DEFAULT_THRESHOLD } = {}) {
|
|
31
64
|
if (!store || !store.db) return [];
|
|
32
65
|
const out = [];
|
|
66
|
+
out.push(...mineCaseStyleConventions(store, { threshold }));
|
|
33
67
|
out.push(...mineNamingConventions(store, { threshold }));
|
|
34
68
|
out.push(...mineExportConventions(store, { threshold }));
|
|
35
69
|
out.push(...mineDirectoryConventions(store, { threshold }));
|
|
36
70
|
return out;
|
|
37
71
|
}
|
|
38
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Per (directory, extension): the dominant identifier-case of the file names.
|
|
75
|
+
* This is the convention an AI actually needs when it's told "add a component
|
|
76
|
+
* to apps/studio/components/grid" — it should learn those files are PascalCase
|
|
77
|
+
* .tsx, not merely that the directory is "typescript".
|
|
78
|
+
*/
|
|
79
|
+
function mineCaseStyleConventions(store, { threshold }) {
|
|
80
|
+
const rows = store.db.prepare('SELECT path FROM files').all();
|
|
81
|
+
// Map<dir, Map<ext, Map<style, count>>> plus a classified-total per (dir,ext).
|
|
82
|
+
const byDirExt = new Map();
|
|
83
|
+
for (const r of rows) {
|
|
84
|
+
const dir = path.posix.dirname(r.path);
|
|
85
|
+
const base = path.posix.basename(r.path);
|
|
86
|
+
const ext = path.posix.extname(base); // includes leading dot, '' if none
|
|
87
|
+
const stem = ext ? base.slice(0, -ext.length) : base;
|
|
88
|
+
const style = classifyCase(stem);
|
|
89
|
+
if (!style) continue;
|
|
90
|
+
if (!byDirExt.has(dir)) byDirExt.set(dir, new Map());
|
|
91
|
+
const extMap = byDirExt.get(dir);
|
|
92
|
+
if (!extMap.has(ext)) extMap.set(ext, new Map());
|
|
93
|
+
const styleMap = extMap.get(ext);
|
|
94
|
+
styleMap.set(style, (styleMap.get(style) || 0) + 1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const [dir, extMap] of byDirExt) {
|
|
99
|
+
for (const [ext, styleMap] of extMap) {
|
|
100
|
+
const total = Array.from(styleMap.values()).reduce((a, b) => a + b, 0);
|
|
101
|
+
if (total < MIN_EVIDENCE) continue;
|
|
102
|
+
let topStyle = null, topCount = 0;
|
|
103
|
+
for (const [style, c] of styleMap) {
|
|
104
|
+
if (c > topCount) { topStyle = style; topCount = c; }
|
|
105
|
+
}
|
|
106
|
+
const conf = topCount / total;
|
|
107
|
+
if (conf < threshold) continue;
|
|
108
|
+
const extLabel = ext || '(no ext)';
|
|
109
|
+
out.push({
|
|
110
|
+
id: `case_${dir}_${ext}_${topStyle}`,
|
|
111
|
+
kind: 'naming_case',
|
|
112
|
+
scope: dir,
|
|
113
|
+
ext,
|
|
114
|
+
rule: `${extLabel} files in ${dir}/ are typically named ${topStyle} (${topCount}/${total}).`,
|
|
115
|
+
confidence: Math.round(conf * 100) / 100,
|
|
116
|
+
strength: strengthOf(conf),
|
|
117
|
+
evidence_count: topCount,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
39
124
|
function mineNamingConventions(store, { threshold }) {
|
|
40
125
|
// Look for filename suffix patterns within each directory.
|
|
41
126
|
// E.g. if 8/10 files in src/services match *.service.ts → convention.
|
|
@@ -49,7 +134,7 @@ function mineNamingConventions(store, { threshold }) {
|
|
|
49
134
|
|
|
50
135
|
const out = [];
|
|
51
136
|
for (const [dir, files] of byDir) {
|
|
52
|
-
if (files.length <
|
|
137
|
+
if (files.length < MIN_EVIDENCE) continue;
|
|
53
138
|
// Count suffix patterns: tokenize on '.' from the end, look for the
|
|
54
139
|
// second-from-last token (`*.service.ts` → 'service').
|
|
55
140
|
const suffixCounts = new Map();
|
|
@@ -66,8 +151,9 @@ function mineNamingConventions(store, { threshold }) {
|
|
|
66
151
|
id: `naming_${dir}_${suffix}`,
|
|
67
152
|
kind: 'filename_suffix',
|
|
68
153
|
scope: dir,
|
|
69
|
-
rule: `Files in ${dir}/ typically match *.${suffix}
|
|
154
|
+
rule: `Files in ${dir}/ typically match *.${suffix}.* (${count}/${files.length}).`,
|
|
70
155
|
confidence: Math.round(conf * 100) / 100,
|
|
156
|
+
strength: strengthOf(conf),
|
|
71
157
|
evidence_count: count,
|
|
72
158
|
});
|
|
73
159
|
}
|
|
@@ -98,18 +184,22 @@ function mineExportConventions(store, { threshold }) {
|
|
|
98
184
|
id: `export_${r.domain_name}_default`,
|
|
99
185
|
kind: 'export_style',
|
|
100
186
|
scope: r.domain_name,
|
|
101
|
-
rule: `${r.domain_name} files typically use default exports.`,
|
|
187
|
+
rule: `${r.domain_name} files typically use default exports (${r.defaults}/${r.total}).`,
|
|
102
188
|
confidence: Math.round(defaultRatio * 100) / 100,
|
|
189
|
+
strength: strengthOf(defaultRatio),
|
|
103
190
|
evidence_count: r.defaults,
|
|
104
191
|
});
|
|
105
192
|
} else if ((1 - defaultRatio) >= threshold) {
|
|
193
|
+
const namedRatio = 1 - defaultRatio;
|
|
194
|
+
const named = r.total - (r.defaults || 0);
|
|
106
195
|
out.push({
|
|
107
196
|
id: `export_${r.domain_name}_named`,
|
|
108
197
|
kind: 'export_style',
|
|
109
198
|
scope: r.domain_name,
|
|
110
|
-
rule: `${r.domain_name} files typically use named exports.`,
|
|
111
|
-
confidence: Math.round(
|
|
112
|
-
|
|
199
|
+
rule: `${r.domain_name} files typically use named exports (${named}/${r.total}).`,
|
|
200
|
+
confidence: Math.round(namedRatio * 100) / 100,
|
|
201
|
+
strength: strengthOf(namedRatio),
|
|
202
|
+
evidence_count: named,
|
|
113
203
|
});
|
|
114
204
|
}
|
|
115
205
|
}
|
|
@@ -149,8 +239,9 @@ function mineDirectoryConventions(store, { threshold }) {
|
|
|
149
239
|
id: `dir_lang_${prefix}_${topLang}`,
|
|
150
240
|
kind: 'directory_language',
|
|
151
241
|
scope: prefix,
|
|
152
|
-
rule: `${prefix}/ is predominantly ${topLang}.`,
|
|
242
|
+
rule: `${prefix}/ is predominantly ${topLang} (${topCount}/${total}).`,
|
|
153
243
|
confidence: Math.round(conf * 100) / 100,
|
|
244
|
+
strength: strengthOf(conf),
|
|
154
245
|
evidence_count: topCount,
|
|
155
246
|
});
|
|
156
247
|
}
|
|
@@ -158,28 +249,51 @@ function mineDirectoryConventions(store, { threshold }) {
|
|
|
158
249
|
return out;
|
|
159
250
|
}
|
|
160
251
|
|
|
252
|
+
/**
|
|
253
|
+
* True when `filePath` lives in directory `scope` or any subdirectory of it.
|
|
254
|
+
*/
|
|
255
|
+
function underScope(filePath, scope) {
|
|
256
|
+
const dir = path.posix.dirname(filePath);
|
|
257
|
+
return dir === scope || dir.startsWith(scope + '/');
|
|
258
|
+
}
|
|
259
|
+
|
|
161
260
|
/**
|
|
162
261
|
* conventionsForFile(store, filePath, opts) → Array<convention>
|
|
163
262
|
*
|
|
164
|
-
* Returns the subset of conventions whose `scope` matches `filePath
|
|
263
|
+
* Returns the subset of conventions whose `scope` matches `filePath`, sorted by
|
|
264
|
+
* confidence (desc) so the strongest, most specific guidance comes first.
|
|
165
265
|
* Used by `get_conventions(file_or_dir)`.
|
|
166
266
|
*/
|
|
167
267
|
function conventionsForFile(store, filePath, opts = {}) {
|
|
168
268
|
if (!filePath) return [];
|
|
169
269
|
const all = mineConventions(store, opts);
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
if (c.kind === '
|
|
270
|
+
const ext = path.posix.extname(filePath);
|
|
271
|
+
const matched = all.filter(c => {
|
|
272
|
+
if (c.kind === 'naming_case') {
|
|
273
|
+
// Match same-extension case conventions in this dir or an ancestor dir.
|
|
274
|
+
return (!c.ext || c.ext === ext) && underScope(filePath, c.scope);
|
|
275
|
+
}
|
|
276
|
+
if (c.kind === 'filename_suffix') return underScope(filePath, c.scope);
|
|
173
277
|
if (c.kind === 'directory_language') return filePath.startsWith(c.scope + '/');
|
|
174
278
|
if (c.kind === 'export_style') {
|
|
175
279
|
// Match if the file belongs to the domain in `scope`.
|
|
280
|
+
const domain = store.getDomainOf ? store.getDomainOf(filePath) : null;
|
|
281
|
+
if (domain) return domain === c.scope;
|
|
282
|
+
// Fallback for older stores without getDomainOf.
|
|
176
283
|
const file = store.getFileByPath(filePath);
|
|
177
284
|
if (!file || !file.domain_id) return false;
|
|
178
|
-
const
|
|
179
|
-
return
|
|
285
|
+
const d = store.db.prepare('SELECT name FROM domains WHERE id = ?').get(file.domain_id);
|
|
286
|
+
return d && d.name === c.scope;
|
|
180
287
|
}
|
|
181
288
|
return false;
|
|
182
289
|
});
|
|
290
|
+
matched.sort((a, b) => (b.confidence - a.confidence));
|
|
291
|
+
return matched;
|
|
183
292
|
}
|
|
184
293
|
|
|
185
|
-
module.exports = {
|
|
294
|
+
module.exports = {
|
|
295
|
+
mineConventions,
|
|
296
|
+
conventionsForFile,
|
|
297
|
+
classifyCase,
|
|
298
|
+
DEFAULT_THRESHOLD,
|
|
299
|
+
};
|
|
@@ -195,53 +195,107 @@ function mineDomainNamingInvariants(store, { threshold, domain }) {
|
|
|
195
195
|
return out;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
// Path fragments that mark a file as a demo / example / test / fixture — never a
|
|
199
|
+
// canonical exemplar, even if it declares many routes or models. Matched as
|
|
200
|
+
// directory-segment substrings (SQL LIKE '%/frag/%') so we don't accidentally
|
|
201
|
+
// exclude a legitimate file whose name merely contains one of these words.
|
|
202
|
+
const NON_CANONICAL_PATH_FRAGMENTS = [
|
|
203
|
+
'demo', 'demos',
|
|
204
|
+
'example', 'examples',
|
|
205
|
+
'sample', 'samples',
|
|
206
|
+
'test', 'tests', '__tests__',
|
|
207
|
+
'fixture', 'fixtures',
|
|
208
|
+
'mock', 'mocks', '__mocks__',
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
// Build the reusable "exclude junk paths" SQL fragment + params.
|
|
212
|
+
function nonCanonicalPathFilter() {
|
|
213
|
+
const clauses = NON_CANONICAL_PATH_FRAGMENTS.map(() => 'f.path NOT LIKE ?');
|
|
214
|
+
const params = NON_CANONICAL_PATH_FRAGMENTS.map((frag) => `%/${frag}/%`);
|
|
215
|
+
return { sql: clauses.join(' AND '), params };
|
|
216
|
+
}
|
|
217
|
+
|
|
198
218
|
/**
|
|
199
219
|
* getCanonicalPattern(store, { pattern_type }) → { type, examples, confidence }
|
|
200
220
|
*
|
|
201
221
|
* Returns the canonical example of a high-level pattern. Used by
|
|
202
222
|
* `scaffold_for_intent` to find templates the AI should copy.
|
|
223
|
+
*
|
|
224
|
+
* The exemplar must be *well-connected* and *real*, not a stray demo/proxy
|
|
225
|
+
* route with zero dependents (see CF-4). We therefore:
|
|
226
|
+
* 1. Require `centrality > 0` (the file must actually be depended on).
|
|
227
|
+
* 2. Exclude demo/example/test/fixture/mock paths.
|
|
228
|
+
* 3. Rank by `count × log(dependents + 1)` — rewarding both multi-route/
|
|
229
|
+
* multi-model files and non-trivial blast radius, instead of the old
|
|
230
|
+
* raw `count DESC, centrality DESC` sort where a 1-route, 0-dependent
|
|
231
|
+
* file could win.
|
|
203
232
|
*/
|
|
204
233
|
function getCanonicalPattern(store, { pattern_type, domain = null } = {}) {
|
|
205
234
|
if (!store || !store.db || !pattern_type) return null;
|
|
206
235
|
|
|
236
|
+
// Higher weight on blast radius is more discriminating than raw count alone,
|
|
237
|
+
// so rank on count × ln(dependents + 1). Ties break on count then centrality.
|
|
238
|
+
const score = (count, centrality) => count * Math.log((centrality || 0) + 1);
|
|
239
|
+
const pickBest = (rows) => {
|
|
240
|
+
let best = null;
|
|
241
|
+
for (const row of rows) {
|
|
242
|
+
row._score = score(row._count, row.centrality);
|
|
243
|
+
if (
|
|
244
|
+
!best ||
|
|
245
|
+
row._score > best._score ||
|
|
246
|
+
(row._score === best._score && row._count > best._count) ||
|
|
247
|
+
(row._score === best._score && row._count === best._count && row.centrality > best.centrality)
|
|
248
|
+
) {
|
|
249
|
+
best = row;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return best;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const pathFilter = nonCanonicalPathFilter();
|
|
256
|
+
|
|
207
257
|
if (pattern_type === 'route_handler') {
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
const
|
|
211
|
-
SELECT f.path, f.centrality, COUNT(r.id) as
|
|
258
|
+
// Candidate route-declaring files: must have >0 dependents and not live
|
|
259
|
+
// under a demo/example/test/fixture path.
|
|
260
|
+
const rows = store.db.prepare(`
|
|
261
|
+
SELECT f.path, f.centrality, COUNT(r.id) as _count
|
|
212
262
|
FROM files f
|
|
213
263
|
JOIN routes r ON r.file_id = f.id
|
|
214
|
-
${domain ? 'JOIN domains d ON f.domain_id = d.id
|
|
264
|
+
${domain ? 'JOIN domains d ON f.domain_id = d.id' : ''}
|
|
265
|
+
WHERE f.centrality > 0
|
|
266
|
+
AND ${pathFilter.sql}
|
|
267
|
+
${domain ? 'AND d.name = ?' : ''}
|
|
215
268
|
GROUP BY f.id
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (!row) return null;
|
|
269
|
+
`).all(...pathFilter.params, ...(domain ? [domain] : []));
|
|
270
|
+
const best = pickBest(rows);
|
|
271
|
+
if (!best) return null;
|
|
220
272
|
return {
|
|
221
273
|
type: 'route_handler',
|
|
222
|
-
file:
|
|
223
|
-
route_count:
|
|
224
|
-
blast_radius:
|
|
274
|
+
file: best.path,
|
|
275
|
+
route_count: best._count,
|
|
276
|
+
blast_radius: best.centrality,
|
|
225
277
|
confidence: 0.9,
|
|
226
278
|
};
|
|
227
279
|
}
|
|
228
280
|
|
|
229
281
|
if (pattern_type === 'model_definition') {
|
|
230
|
-
const
|
|
231
|
-
SELECT f.path, f.centrality, COUNT(m.id) as
|
|
282
|
+
const rows = store.db.prepare(`
|
|
283
|
+
SELECT f.path, f.centrality, COUNT(m.id) as _count
|
|
232
284
|
FROM files f
|
|
233
285
|
JOIN models m ON m.file_id = f.id
|
|
234
|
-
${domain ? 'JOIN domains d ON f.domain_id = d.id
|
|
286
|
+
${domain ? 'JOIN domains d ON f.domain_id = d.id' : ''}
|
|
287
|
+
WHERE f.centrality > 0
|
|
288
|
+
AND ${pathFilter.sql}
|
|
289
|
+
${domain ? 'AND d.name = ?' : ''}
|
|
235
290
|
GROUP BY f.id
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (!row) return null;
|
|
291
|
+
`).all(...pathFilter.params, ...(domain ? [domain] : []));
|
|
292
|
+
const best = pickBest(rows);
|
|
293
|
+
if (!best) return null;
|
|
240
294
|
return {
|
|
241
295
|
type: 'model_definition',
|
|
242
|
-
file:
|
|
243
|
-
model_count:
|
|
244
|
-
blast_radius:
|
|
296
|
+
file: best.path,
|
|
297
|
+
model_count: best._count,
|
|
298
|
+
blast_radius: best.centrality,
|
|
245
299
|
confidence: 0.85,
|
|
246
300
|
};
|
|
247
301
|
}
|
package/src/cli/anci.js
CHANGED
|
@@ -134,8 +134,20 @@ function runShow(projectRoot) {
|
|
|
134
134
|
lines.push('');
|
|
135
135
|
lines.push(` spec version : ${h.anci.version}`);
|
|
136
136
|
lines.push(` generator : ${h.anci.generator}`);
|
|
137
|
+
lines.push(` carto version : ${h.anci.carto_version || 'n/a'}`);
|
|
137
138
|
lines.push(` generated_at : ${h.anci.generated_at}`);
|
|
138
139
|
lines.push(` body file : ${h.anci.body.file} (${formatBytes(h.anci.body.bytes)})`);
|
|
140
|
+
if (h.anci.body && h.anci.body.content_digest) {
|
|
141
|
+
lines.push(` content digest : ${h.anci.body.content_digest}`);
|
|
142
|
+
}
|
|
143
|
+
if (Array.isArray(h.anci.contains) && h.anci.contains.length) {
|
|
144
|
+
lines.push(` contains : ${h.anci.contains.join(', ')}`);
|
|
145
|
+
}
|
|
146
|
+
if (h.source && (h.source.commit || h.source.branch)) {
|
|
147
|
+
const commit = h.source.commit ? h.source.commit.slice(0, 12) : 'n/a';
|
|
148
|
+
const branch = h.source.branch ? ` (${h.source.branch})` : '';
|
|
149
|
+
lines.push(` source commit : ${commit}${branch}`);
|
|
150
|
+
}
|
|
139
151
|
lines.push('');
|
|
140
152
|
if (h.project) {
|
|
141
153
|
lines.push('Project');
|
|
@@ -223,7 +235,21 @@ function runValidate(dir) {
|
|
|
223
235
|
}
|
|
224
236
|
}
|
|
225
237
|
|
|
226
|
-
|
|
238
|
+
// Integrity: verify the content digest when the header declares one
|
|
239
|
+
// (CT-1). A mismatch means the body was altered after the manifest was
|
|
240
|
+
// written — fail loudly.
|
|
241
|
+
const { verifyBodyDigest } = require('../anci/consumer');
|
|
242
|
+
const digest = verifyBodyDigest(header, bodyBuf);
|
|
243
|
+
if (digest && digest.ok === false) {
|
|
244
|
+
console.error(
|
|
245
|
+
`[CARTO] content digest mismatch: header declares ${digest.expected}, ` +
|
|
246
|
+
`body hashes to ${digest.actual}`
|
|
247
|
+
);
|
|
248
|
+
return 1;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const digestNote = digest ? ' (digest verified)' : '';
|
|
252
|
+
console.log(`[CARTO] ANCI valid: ${header.anci.version}, ${body.size} files indexed.${digestNote}`);
|
|
227
253
|
return 0;
|
|
228
254
|
}
|
|
229
255
|
|
package/src/cli/doctor.js
CHANGED
|
@@ -143,6 +143,38 @@ function diagnose(projectRoot) {
|
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// ─── Index staleness vs git HEAD (CT-1b) ────────────────────────
|
|
147
|
+
// The ANCI manifest records the commit the index was built from. If
|
|
148
|
+
// HEAD has moved (or the working tree is dirty), the index serves
|
|
149
|
+
// numbers from an older repo state — warn with the sync fix.
|
|
150
|
+
try {
|
|
151
|
+
const { computeStaleness, stalenessBanner } = require('../anci/staleness');
|
|
152
|
+
const st = computeStaleness(projectRoot, { cartoDir });
|
|
153
|
+
const banner = stalenessBanner(st);
|
|
154
|
+
if (banner) {
|
|
155
|
+
results.push({
|
|
156
|
+
id: 'index-staleness',
|
|
157
|
+
status: 'warn',
|
|
158
|
+
label: 'Index freshness',
|
|
159
|
+
detail: st.status === 'behind'
|
|
160
|
+
? `${st.commitsBehind == null ? 'several' : st.commitsBehind} commit(s) behind HEAD` +
|
|
161
|
+
(st.uncommitted ? ` + ${st.uncommitted} uncommitted` : '')
|
|
162
|
+
: st.status === 'diverged'
|
|
163
|
+
? 'built from a commit not in the current history'
|
|
164
|
+
: `${st.uncommitted} uncommitted file(s) since the index was built`,
|
|
165
|
+
fix: 'Run `carto sync` to refresh the index to the current HEAD.',
|
|
166
|
+
});
|
|
167
|
+
} else if (st.status === 'fresh') {
|
|
168
|
+
results.push({
|
|
169
|
+
id: 'index-staleness',
|
|
170
|
+
status: 'ok',
|
|
171
|
+
label: 'Index freshness',
|
|
172
|
+
detail: 'index matches current git HEAD',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// status 'unknown' → stay quiet (no manifest / non-git / can't tell).
|
|
176
|
+
} catch { /* staleness is best-effort — never break doctor */ }
|
|
177
|
+
|
|
146
178
|
// ─── Git hooks ──────────────────────────────────────────────────
|
|
147
179
|
const gitDir = path.join(projectRoot, '.git');
|
|
148
180
|
if (fs.existsSync(gitDir)) {
|
|
@@ -174,6 +206,43 @@ function diagnose(projectRoot) {
|
|
|
174
206
|
});
|
|
175
207
|
}
|
|
176
208
|
|
|
209
|
+
// ─── Temporal store populated ───────────────────────────────────
|
|
210
|
+
// A fresh `carto init` auto-backfills git history (CF-1). If the store
|
|
211
|
+
// is empty (0 commits) but this IS a git repo, the history-based tools
|
|
212
|
+
// (drift, hotspots, velocity, predictive_risk, scaffold_for_intent) will
|
|
213
|
+
// silently return nothing — warn with the manual fix.
|
|
214
|
+
if (fs.existsSync(gitDir)) {
|
|
215
|
+
let temporalCommits = null; // null = couldn't read
|
|
216
|
+
try {
|
|
217
|
+
const { TemporalStore } = require('../temporal/store');
|
|
218
|
+
const ts = TemporalStore.openIfExists(projectRoot, { readonly: true });
|
|
219
|
+
if (ts) {
|
|
220
|
+
try { temporalCommits = ts.countCommits(); } finally { ts.close(); }
|
|
221
|
+
} else {
|
|
222
|
+
temporalCommits = 0; // no temporal db at all
|
|
223
|
+
}
|
|
224
|
+
} catch { temporalCommits = null; }
|
|
225
|
+
|
|
226
|
+
if (temporalCommits === 0) {
|
|
227
|
+
results.push({
|
|
228
|
+
id: 'temporal-store',
|
|
229
|
+
status: 'warn',
|
|
230
|
+
label: 'Temporal history',
|
|
231
|
+
detail: 'empty — history-based tools (drift, hotspots, velocity, predictive risk) will return nothing',
|
|
232
|
+
fix: 'Run `carto temporal init` to backfill git history (or re-run `carto init`, which now backfills automatically).',
|
|
233
|
+
});
|
|
234
|
+
} else if (temporalCommits > 0) {
|
|
235
|
+
results.push({
|
|
236
|
+
id: 'temporal-store',
|
|
237
|
+
status: 'ok',
|
|
238
|
+
label: 'Temporal history',
|
|
239
|
+
detail: `${temporalCommits} commit${temporalCommits === 1 ? '' : 's'} backfilled`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// temporalCommits === null → couldn't read; stay quiet rather than
|
|
243
|
+
// emit a confusing check.
|
|
244
|
+
}
|
|
245
|
+
|
|
177
246
|
// ─── .cartoignore conflicts ─────────────────────────────────────
|
|
178
247
|
const cartoIgnore = path.join(projectRoot, '.cartoignore');
|
|
179
248
|
if (fs.existsSync(cartoIgnore)) {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `carto export [--out <file.anci>]`
|
|
5
|
+
*
|
|
6
|
+
* Packs the project's ANCI pair (`.carto/anci.{yaml,bin}`) into ONE
|
|
7
|
+
* portable single-file container (`project.anci` by default) that can be
|
|
8
|
+
* copied to another machine and loaded with `carto load` — with NO
|
|
9
|
+
* re-index. This is the "build once, load anywhere" half of CT-3.
|
|
10
|
+
*
|
|
11
|
+
* If the ANCI files don't exist yet but an index does, they are emitted
|
|
12
|
+
* first (same code path as `carto anci publish`) so `carto export` works
|
|
13
|
+
* immediately after `carto init` without a manual publish step.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { packFromCartoDir } = require('../anci/pack');
|
|
19
|
+
const { ANCI_BIN_FILENAME, ANCI_YAML_FILENAME } = require('../anci/serialize');
|
|
20
|
+
|
|
21
|
+
function printUsage() {
|
|
22
|
+
console.log(`
|
|
23
|
+
Usage: carto export [--out <file.anci>]
|
|
24
|
+
|
|
25
|
+
Pack .carto/anci.{yaml,bin} into a single portable container file.
|
|
26
|
+
Copy the file to another machine and \`carto load\` it — no re-index.
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--out <file> Output path (default: project.anci in the cwd).
|
|
30
|
+
--help, -h Show this help.
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* run({ argv }) → exit code (0 ok, 1 error).
|
|
36
|
+
*/
|
|
37
|
+
function run({ argv }) {
|
|
38
|
+
let outPath = null;
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
if (a === '--help' || a === '-h') { printUsage(); return 0; }
|
|
42
|
+
else if (a === '--out' || a === '-o') { outPath = argv[++i]; }
|
|
43
|
+
else if (a.startsWith('--out=')) { outPath = a.slice('--out='.length); }
|
|
44
|
+
else { console.error(`[CARTO] export: unknown argument ${a}`); printUsage(); return 1; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const projectRoot = process.cwd();
|
|
48
|
+
const cartoDir = path.join(projectRoot, '.carto');
|
|
49
|
+
const dbPath = path.join(cartoDir, 'carto.db');
|
|
50
|
+
const yamlPath = path.join(cartoDir, ANCI_YAML_FILENAME);
|
|
51
|
+
const binPath = path.join(cartoDir, ANCI_BIN_FILENAME);
|
|
52
|
+
|
|
53
|
+
// Ensure ANCI files exist; emit them from the index if not.
|
|
54
|
+
if (!fs.existsSync(yamlPath) || !fs.existsSync(binPath)) {
|
|
55
|
+
if (!fs.existsSync(dbPath)) {
|
|
56
|
+
console.error(`[CARTO] No index found at ${dbPath}. Run \`carto init\` first.`);
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
emitFromIndex(projectRoot, cartoDir);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error(`[CARTO] export: failed to emit ANCI from index: ${err.message}`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!outPath) outPath = path.join(projectRoot, 'project.anci');
|
|
68
|
+
|
|
69
|
+
let packed;
|
|
70
|
+
try {
|
|
71
|
+
packed = packFromCartoDir(cartoDir);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error(`[CARTO] export: ${err.message}`);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const tmp = outPath + '.tmp';
|
|
79
|
+
fs.writeFileSync(tmp, packed);
|
|
80
|
+
fs.renameSync(tmp, outPath);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(`[CARTO] export: failed to write ${outPath}: ${err.message}`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Report identity so the operator can verify the container.
|
|
87
|
+
let digest = null, sourceCommit = null;
|
|
88
|
+
try {
|
|
89
|
+
const yaml = require('../anci/yaml');
|
|
90
|
+
const header = yaml.parse(fs.readFileSync(yamlPath, 'utf-8'));
|
|
91
|
+
digest = header && header.anci && header.anci.body && header.anci.body.content_digest;
|
|
92
|
+
sourceCommit = header && header.source && header.source.commit;
|
|
93
|
+
} catch { /* best-effort */ }
|
|
94
|
+
|
|
95
|
+
console.log(`[CARTO] Exported container → ${path.relative(projectRoot, outPath)} (${formatBytes(packed.length)})`);
|
|
96
|
+
if (digest) console.log(` content digest : ${digest}`);
|
|
97
|
+
if (sourceCommit) console.log(` source commit : ${sourceCommit.slice(0, 12)}`);
|
|
98
|
+
console.log(` load with : carto load ${path.basename(outPath)}`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* emitFromIndex(projectRoot, cartoDir) — re-emit anci.{yaml,bin} from the
|
|
104
|
+
* existing SQLite index + bitmap sidecar. Mirrors `carto anci publish`.
|
|
105
|
+
*/
|
|
106
|
+
function emitFromIndex(projectRoot, cartoDir) {
|
|
107
|
+
const { SQLiteStore } = require('../store/sqlite-store');
|
|
108
|
+
const { ensureBitmapFresh } = require('../bitmap/index');
|
|
109
|
+
const { emitToCartoDir } = require('../anci/emit');
|
|
110
|
+
|
|
111
|
+
const store = new SQLiteStore(projectRoot);
|
|
112
|
+
store.open();
|
|
113
|
+
try {
|
|
114
|
+
const sidecar = ensureBitmapFresh(cartoDir, store);
|
|
115
|
+
emitToCartoDir({ cartoDir, sidecar, store });
|
|
116
|
+
} finally {
|
|
117
|
+
try { store.close(); } catch {}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatBytes(n) {
|
|
122
|
+
if (n === null || n === undefined) return 'n/a';
|
|
123
|
+
if (n < 1024) return `${n} B`;
|
|
124
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
125
|
+
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { run };
|
package/src/cli/index.js
CHANGED
|
@@ -11,6 +11,8 @@ Commands:
|
|
|
11
11
|
init Detect project, write .carto/config.json, run first sync.
|
|
12
12
|
Installs git hooks (pre-commit, post-checkout, post-merge,
|
|
13
13
|
post-rewrite) so the index stays fresh on every git event.
|
|
14
|
+
Auto-backfills temporal history from git (bounded to the
|
|
15
|
+
last 200 commits). Pass --no-temporal to skip the backfill.
|
|
14
16
|
sync Read .carto/config.json, run one sync, exit
|
|
15
17
|
watch (Optional) Start file watcher for sub-second freshness.
|
|
16
18
|
Not required — git hooks + lazy MCP re-parse keep the
|
|
@@ -46,6 +48,14 @@ Commands:
|
|
|
46
48
|
anci ANCI v0.1 DRAFT export. Subcommands: publish, show,
|
|
47
49
|
validate. Writes/reads .carto/anci.{yaml,bin} — the
|
|
48
50
|
public, tool-neutral architecture description spec.
|
|
51
|
+
export Pack .carto/anci.{yaml,bin} into a single portable
|
|
52
|
+
container file (project.anci). Copy it to another
|
|
53
|
+
machine and \`carto load\` it — no re-index.
|
|
54
|
+
Options: --out <file.anci>.
|
|
55
|
+
load <file> Unpack a portable .anci container into a queryable
|
|
56
|
+
.carto/ with NO re-index; verifies the content digest.
|
|
57
|
+
Loaded contents are treated as untrusted data, never
|
|
58
|
+
instructions. Options: --into <dir>, --no-verify.
|
|
49
59
|
temporal Architectural history. Subcommands: init (backfill from
|
|
50
60
|
git), status, events. Powers temporal MCP tools (drift,
|
|
51
61
|
hotspots, evolution, complexity, churn, arch_events,
|
|
@@ -74,7 +84,7 @@ if (!command || command === '--help' || command === '-h') {
|
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
if (command === 'init') {
|
|
77
|
-
require('./init').run(process.cwd()).catch(err => {
|
|
87
|
+
require('./init').run(process.cwd(), { argv: process.argv.slice(3) }).catch(err => {
|
|
78
88
|
console.error(`[CARTO] Fatal error: ${err.message}`);
|
|
79
89
|
process.exit(1);
|
|
80
90
|
});
|
|
@@ -170,6 +180,14 @@ if (command === 'init') {
|
|
|
170
180
|
// anci has its own subcommand parser; pass argv after `carto anci`.
|
|
171
181
|
const code = require('./anci').run({ argv: process.argv.slice(3) });
|
|
172
182
|
process.exit(code);
|
|
183
|
+
} else if (command === 'export') {
|
|
184
|
+
// `carto export [--out <file.anci>]` — pack ANCI into one file.
|
|
185
|
+
const code = require('./export').run({ argv: process.argv.slice(3) });
|
|
186
|
+
process.exit(code);
|
|
187
|
+
} else if (command === 'load') {
|
|
188
|
+
// `carto load <file.anci> [--into <dir>]` — unpack, no re-index.
|
|
189
|
+
const code = require('./load').run({ argv: process.argv.slice(3) });
|
|
190
|
+
process.exit(code);
|
|
173
191
|
} else if (command === 'temporal') {
|
|
174
192
|
// `carto temporal <subcmd>` — temporal history layer.
|
|
175
193
|
require('./temporal').main(process.argv.slice(3)).catch(err => {
|