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.
Files changed (87) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/CONTRIBUTING.md +11 -8
  3. package/README.md +165 -213
  4. package/docs/anci/v0.1-DRAFT.md +132 -2
  5. package/docs/api/README.md +27 -2
  6. package/docs/api/did_we_discuss_this.md +2 -0
  7. package/docs/api/dismiss_gap.md +39 -0
  8. package/docs/api/find_consumers_of_api.md +2 -0
  9. package/docs/api/get_action_patterns.md +2 -0
  10. package/docs/api/get_arch_events.md +2 -0
  11. package/docs/api/get_architectural_drift.md +2 -0
  12. package/docs/api/get_blast_radius.md +2 -0
  13. package/docs/api/get_canonical_pattern.md +2 -0
  14. package/docs/api/get_change_velocity.md +2 -0
  15. package/docs/api/get_churn_vs_blast_radius.md +2 -0
  16. package/docs/api/get_complexity_trend.md +2 -0
  17. package/docs/api/get_conventions.md +2 -0
  18. package/docs/api/get_cross_repo_blast_radius.md +2 -0
  19. package/docs/api/get_data_flow.md +2 -0
  20. package/docs/api/get_decision_log.md +2 -0
  21. package/docs/api/get_domain_evolution.md +2 -0
  22. package/docs/api/get_domain_health.md +2 -0
  23. package/docs/api/get_file_receipts.md +34 -0
  24. package/docs/api/get_gaps.md +52 -0
  25. package/docs/api/get_hotspot_files.md +2 -0
  26. package/docs/api/get_intent.md +25 -0
  27. package/docs/api/get_intervention_history.md +2 -0
  28. package/docs/api/get_invariants.md +2 -0
  29. package/docs/api/get_microservices_migration_cut_points.md +2 -0
  30. package/docs/api/get_neighbors.md +2 -0
  31. package/docs/api/get_org_architecture.md +2 -0
  32. package/docs/api/get_org_domain_mapping.md +2 -0
  33. package/docs/api/get_pending_decisions.md +2 -0
  34. package/docs/api/get_recent_decisions.md +2 -0
  35. package/docs/api/get_service_boundary_violations.md +2 -0
  36. package/docs/api/get_service_dependency_graph.md +2 -0
  37. package/docs/api/get_session_context.md +2 -0
  38. package/docs/api/get_temporal_context.md +2 -0
  39. package/docs/api/history.md +78 -0
  40. package/docs/api/impact.md +56 -0
  41. package/docs/api/memory.md +70 -0
  42. package/docs/api/org.md +51 -0
  43. package/docs/api/patterns.md +63 -0
  44. package/docs/api/set_intent.md +45 -0
  45. package/docs/api/simulate_change_impact.md +2 -0
  46. package/docs/concepts/domains.md +75 -50
  47. package/docs/concepts/mcp-integration.md +4 -2
  48. package/docs/guides/pre-merge-review.md +2 -2
  49. package/docs/migration/v1-to-v2.md +2 -2
  50. package/docs/screenshots/carto-supabase-blast-radius.png +0 -0
  51. package/package.json +2 -1
  52. package/scripts/gen-api-docs.js +51 -19
  53. package/src/agents/domains.js +72 -24
  54. package/src/ai/tools.js +4 -2
  55. package/src/anci/consumer.js +83 -3
  56. package/src/anci/emit.js +18 -1
  57. package/src/anci/git-meta.js +171 -0
  58. package/src/anci/pack.js +288 -0
  59. package/src/anci/serialize.js +77 -6
  60. package/src/anci/staleness.js +161 -0
  61. package/src/brain/conventions/index.js +134 -20
  62. package/src/brain/invariants/index.js +76 -22
  63. package/src/cli/anci.js +27 -1
  64. package/src/cli/doctor.js +69 -0
  65. package/src/cli/export.js +128 -0
  66. package/src/cli/index.js +19 -1
  67. package/src/cli/init.js +84 -1
  68. package/src/cli/load.js +125 -0
  69. package/src/extractors/imports.js +35 -10
  70. package/src/extractors/languages/javascript.js +8 -4
  71. package/src/extractors/languages/prisma.js +1 -1
  72. package/src/extractors/languages/typescript.js +13 -49
  73. package/src/extractors/schemas.js +60 -0
  74. package/src/extractors/tree-sitter-parser.js +39 -0
  75. package/src/mcp/change-plan.js +131 -12
  76. package/src/mcp/impact.js +119 -0
  77. package/src/mcp/server.js +708 -82
  78. package/src/rules/concepts/auth-middleware.md +111 -0
  79. package/src/rules/concepts/money-as-float.md +80 -0
  80. package/src/rules/engine.js +178 -0
  81. package/src/rules/intent.js +178 -0
  82. package/src/rules/registry.js +24 -0
  83. package/src/rules/rules/auth-missing-on-mutating-route.js +255 -0
  84. package/src/rules/rules/money-as-float.js +166 -0
  85. package/src/store/config-loader.js +77 -3
  86. package/src/store/sqlite-store.js +248 -7
  87. 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
- * - File naming patterns per directory (e.g. *.service.ts)
8
- * - Import bundles that co-occur (e.g. `useState` always with `useEffect`)
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
- * - Domain co-location (file X and file Y always live in same domain)
11
+ * - Directory language (e.g. src/* is all TypeScript)
11
12
  *
12
- * Each convention has a confidence score; only conventions with confidence
13
- * >= 0.75 are returned by default.
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 < 4) continue;
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((1 - defaultRatio) * 100) / 100,
112
- evidence_count: r.total - (r.defaults || 0),
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 dir = path.posix.dirname(filePath);
171
- return all.filter(c => {
172
- if (c.kind === 'filename_suffix') return c.scope === dir;
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 domain = store.db.prepare('SELECT name FROM domains WHERE id = ?').get(file.domain_id);
179
- return domain && domain.name === c.scope;
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 = { mineConventions, conventionsForFile, DEFAULT_THRESHOLD };
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
- // Find the file with the most routes that also has high blast_radius
209
- // that's likely the canonical handler.
210
- const row = store.db.prepare(`
211
- SELECT f.path, f.centrality, COUNT(r.id) as route_count
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 WHERE d.name = ?' : ''}
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
- ORDER BY route_count DESC, f.centrality DESC
217
- LIMIT 1
218
- `).get(...(domain ? [domain] : []));
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: row.path,
223
- route_count: row.route_count,
224
- blast_radius: row.centrality,
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 row = store.db.prepare(`
231
- SELECT f.path, f.centrality, COUNT(m.id) as model_count
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 WHERE d.name = ?' : ''}
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
- ORDER BY model_count DESC
237
- LIMIT 1
238
- `).get(...(domain ? [domain] : []));
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: row.path,
243
- model_count: row.model_count,
244
- blast_radius: row.centrality,
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
- console.log(`[CARTO] ANCI valid: ${header.anci.version}, ${body.size} files indexed.`);
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 => {