-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathconfig.ts
More file actions
475 lines (451 loc) · 15.7 KB
/
Copy pathconfig.ts
File metadata and controls
475 lines (451 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { z } from "zod";
import {
resolveStateDir,
STATE_CONFIG_BASENAMES,
STATE_DB_NAME,
} from "./application/state-dir";
async function readJsonFile(filePath: string): Promise<unknown> {
if (typeof Bun !== "undefined") {
return Bun.file(filePath).json();
}
const text = await readFile(filePath, "utf-8");
return JSON.parse(text) as unknown;
}
/**
* Default glob patterns for indexing (relative to project root).
* Override with `include` in `codemap.config`.
*/
export const DEFAULT_INCLUDE_PATTERNS = [
"**/*.{ts,tsx,js,jsx,cjs,mjs,mts,cts}",
"**/*.css",
"**/*.{md,mdx,mdc}",
"**/*.{json,yml,yaml}",
"**/*.sh",
] as const;
/**
* Directory **names** excluded when they appear as any path segment (not full paths).
* Override with `excludeDirNames` in `codemap.config`.
*/
export const DEFAULT_EXCLUDE_DIR_NAMES = [
"node_modules",
".git",
// Audit worktrees materialised by `codemap audit --base <ref>`. Each
// entry under .codemap/audit-cache/<sha>/ is a snapshot of the whole
// project — indexing them would duplicate every row N× per snapshot.
"audit-cache",
"dist",
"build",
".next",
".output",
"coverage",
"storybook-static",
".turbo",
".cache",
".parcel-cache",
"vendor",
] as const;
/**
* Zod schema for user config (`<state-dir>/config.{ts,js,json}`, `defineConfig`, API).
* Unknown keys are rejected (`.strict()`).
*/
export const codemapUserConfigSchema = z
.object({
root: z
.string()
.optional()
.describe("Project root. Defaults via CLI `--root` or `process.cwd()`."),
databasePath: z
.string()
.optional()
.describe(
"SQLite database path, relative to root or absolute. Default: `<state-dir>/index.db` (i.e. `.codemap/index.db`).",
),
include: z
.array(z.string())
.optional()
.describe(
"Glob patterns relative to root; replaces default include list when set.",
),
excludeDirNames: z
.array(z.string())
.optional()
.describe(
"Directory name segments to skip; replaces default exclude list when set.",
),
tsconfigPath: z
.union([z.string(), z.null()])
.optional()
.describe(
"Path to `tsconfig.json` for import alias resolution. Use `null` to disable.",
),
fts5: z
.boolean()
.optional()
.describe(
"Enable FTS5 full-text indexing of file content into the `source_fts` virtual table. Default `false` — FTS5 grows `.codemap/index.db` ~30–50% on text-heavy projects. Override at the CLI with `--with-fts` (CLI wins; logs a stderr line on override).",
),
recipeRecency: z
.boolean()
.optional()
.describe(
"Track per-recipe `last_run_at` + `run_count` in the `recipe_recency` table; surfaces inline on `--recipes-json` for agent-host ranking. Default `true` (opt-out). Set `false` to short-circuit every write — no rows ever land. Local-only — no upload primitive. See `docs/architecture.md` § `recipe_recency`.",
),
churn: z
.object({
halfLifeDays: z
.number()
.positive()
.optional()
.describe(
"Exponential half-life (days) for `file_churn.weighted_commits`. Default `90`.",
),
since: z
.string()
.min(1)
.optional()
.describe(
"Git revision (commit/tag/branch) — only commits after this ref contribute to churn. CLI `--churn-since` overrides.",
),
file: z
.string()
.min(1)
.optional()
.describe(
"JSON array of `file_churn` rows (relative to project root). Used when git churn is unavailable; also loadable via `codemap ingest-churn`.",
),
})
.strict()
.optional()
.describe(
"Git churn ingest for `file_churn` (runs after every index pass).",
),
synthesis: z
.object({
heuristicCalls: z
.boolean()
.optional()
.describe(
"Post-index heuristic `calls` edges (v1: JSX parent→child). Default `false` — AST `calls` row counts unchanged until enabled.",
),
})
.strict()
.optional()
.describe("Optional synthesis passes after bindings/call resolve."),
apply: z
.object({
autoApplyRecipes: z
.array(z.string().min(1))
.optional()
.describe(
"Recipe ids allowed with `--yes` without interactive confirm. Empty/omitted = no allowlist gate. `--force` bypasses.",
),
})
.strict()
.optional()
.describe("Apply-engine policy (substrate-shaped fix executor)."),
boundaries: z
.array(
z
.object({
name: z
.string()
.min(1)
.describe(
"Stable identifier surfaced in `boundary-violations` rows + SARIF rule.id suffix.",
),
from_glob: z
.string()
.min(1)
.describe(
"SQLite GLOB matched against `dependencies.from_path` (the file doing the import).",
),
to_glob: z
.string()
.min(1)
.describe(
"SQLite GLOB matched against `dependencies.to_path` (the file being imported).",
),
action: z
.enum(["deny", "allow"])
.optional()
.describe(
"`deny` rules surface as violations; `allow` rules reserve the slot for future whitelist semantics. Defaults to `deny` when omitted.",
),
})
.strict(),
)
.optional()
.describe(
"Architecture-boundary rules. Each row is reconciled into the `boundary_rules` table at index time and joined against `dependencies` by the bundled `boundary-violations` recipe.",
),
})
.strict();
/**
* Inferred from {@link codemapUserConfigSchema}.
*/
export type CodemapUserConfig = z.infer<typeof codemapUserConfigSchema>;
function formatCodemapConfigError(error: z.ZodError): string {
return error.issues
.map((issue) => {
const path =
issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
return `${path}: ${issue.message}`;
})
.join("; ");
}
/**
* Fully resolved config (defaults filled, paths absolute) — stored in the
* process-global runtime by {@link initCodemap} and read by every layer.
*/
export interface ResolvedCodemapConfig {
/** Absolute project root (from CLI `--root`, env, or `process.cwd()`). */
readonly root: string;
/**
* Absolute path to the codemap state directory (`<root>/.codemap` by default).
* Overridable via `--state-dir <path>` or `CODEMAP_STATE_DIR`. Holds every
* codemap-managed file: `index.db` (+ WAL/SHM), `audit-cache/`, `recipes/`,
* `config.{ts,js,json}`, `.gitignore` (self-managed).
*/
readonly stateDir: string;
/** Absolute path to the SQLite database file (default `<stateDir>/index.db`). */
readonly databasePath: string;
/** Glob patterns relative to `root`; either user-supplied or {@link DEFAULT_INCLUDE_PATTERNS}. */
readonly include: readonly string[];
/** Directory **names** (any segment) to skip — either user-supplied or {@link DEFAULT_EXCLUDE_DIR_NAMES}. */
readonly excludeDirNames: ReadonlySet<string>;
/** Absolute path to `tsconfig.json` for alias resolution, or `null` to disable. */
readonly tsconfigPath: string | null;
/**
* FTS5 full-text indexing toggle. `true` populates the `source_fts`
* virtual table at index time; `false` (default) leaves it empty.
* Resolved from `.codemap/config.ts` `fts5` plus the `--with-fts` CLI
* flag; CLI wins. See `docs/plans/fts5-mermaid.md`.
*/
readonly fts5: boolean;
/**
* Recipe-recency tracking toggle. `true` (default) populates the
* `recipe_recency` table on every successful recipe run and inlines
* `last_run_at` / `run_count` on `--recipes-json` reads. `false`
* short-circuits every write — no rows ever land. Local-only — no
* upload primitive. See `docs/architecture.md` § `recipe_recency`.
*/
readonly recipeRecency: boolean;
/**
* Reconciled into the `boundary_rules` table on every index pass. The
* bundled `boundary-violations` recipe joins this against `dependencies`
* via SQLite GLOB.
*/
readonly boundaries: ReadonlyArray<{
readonly name: string;
readonly from_glob: string;
readonly to_glob: string;
readonly action: "deny" | "allow";
}>;
/** Git churn ingest tuning for `file_churn`. */
readonly churn: {
readonly halfLifeDays: number;
/** When set, only commits after this git ref are counted. */
readonly since: string | null;
/** Optional JSON churn import when git is unavailable. */
readonly file: string | null;
};
/** When `heuristicCalls` is true, runs callback-synthesis after `resolveCalls`. */
readonly synthesis: {
readonly heuristicCalls: boolean;
};
/**
* When non-empty, `codemap apply --yes <recipe>` requires the recipe id to
* appear here unless `--force` is passed.
*/
readonly applyAutoApplyRecipes: readonly string[] | undefined;
}
/**
* Runtime validation for {@link CodemapUserConfig} (from JSON, `defineConfig`, or API).
*
* @throws TypeError when the shape is invalid or unknown keys are present.
*/
export function parseCodemapUserConfig(config: unknown): CodemapUserConfig {
const result = codemapUserConfigSchema.safeParse(config);
if (!result.success) {
throw new TypeError(
`Codemap config: ${formatCodemapConfigError(result.error)}`,
);
}
return result.data;
}
/**
* Helper for `export default defineConfig({ ... })` in `.codemap/config.ts`.
*/
export function defineConfig(config: CodemapUserConfig): CodemapUserConfig {
return parseCodemapUserConfig(config);
}
export interface ResolveCodemapConfigOpts {
/**
* Pre-resolved state-dir (from CLI `--state-dir` or `CODEMAP_STATE_DIR`).
* When omitted the default `<root>/.codemap` is used. Resolved at the
* bootstrap layer (NOT via the user config — the config file lives
* inside `<state-dir>/` so we'd hit a chicken-and-egg).
*/
stateDir?: string | undefined;
/**
* CLI override for `fts5` — when `true`, forces the toggle on
* regardless of `.codemap/config.ts`. When `undefined` (default), the
* config value (or false default) wins. Set by `--with-fts` argv
* parsing in the bootstrap layer.
*/
fts5Cli?: boolean | undefined;
/** CLI `--churn-since <ref>` — overrides config `churn.since`. */
churnSinceCli?: string | undefined;
}
/**
* Merge user config with defaults (absolute paths, default DB location, tsconfig discovery).
*
* Three-arg form (`opts.stateDir`) lets the bootstrap pass the resolved
* state directory through; legacy two-arg call sites keep working with the
* default `<root>/.codemap`. User-supplied `databasePath` (escape hatch for
* non-standard layouts) wins over the state-dir derivation.
*/
export function resolveCodemapConfig(
root: string,
user: CodemapUserConfig | undefined,
opts: ResolveCodemapConfigOpts = {},
): ResolvedCodemapConfig {
const parsed = user !== undefined ? parseCodemapUserConfig(user) : undefined;
const absRoot = resolve(root);
const stateDir = opts.stateDir
? resolve(opts.stateDir)
: resolveStateDir({ root: absRoot });
const databasePath = parsed?.databasePath
? resolve(absRoot, parsed.databasePath)
: join(stateDir, STATE_DB_NAME);
const include =
parsed?.include !== undefined
? [...parsed.include]
: [...DEFAULT_INCLUDE_PATTERNS];
const excludeDirNames = new Set<string>(
parsed?.excludeDirNames !== undefined
? parsed.excludeDirNames
: DEFAULT_EXCLUDE_DIR_NAMES,
);
let tsconfigPath: string | null;
if (parsed?.tsconfigPath === null) {
tsconfigPath = null;
} else if (parsed?.tsconfigPath) {
tsconfigPath = resolve(absRoot, parsed.tsconfigPath);
} else {
const d = join(absRoot, "tsconfig.json");
tsconfigPath = existsSync(d) ? d : null;
}
// CLI > config (mirrors `--root` / `--state-dir`); explicit log on
// override so quiet-divergence from `.codemap/config.ts` is visible.
let fts5: boolean;
if (opts.fts5Cli === true) {
fts5 = true;
if (parsed?.fts5 === false) {
console.error("[fts5] CLI override: enabled despite config fts5=false");
}
} else {
fts5 = parsed?.fts5 === true;
}
const boundaries = (parsed?.boundaries ?? []).map((rule) => ({
name: rule.name,
from_glob: rule.from_glob,
to_glob: rule.to_glob,
action: rule.action ?? "deny",
}));
// Default ON (opt-out, not opt-in). Only `false` disables.
const recipeRecency = parsed?.recipeRecency !== false;
const heuristicCalls = parsed?.synthesis?.heuristicCalls === true;
const churnHalfLifeDays = parsed?.churn?.halfLifeDays ?? 90;
const churnFile = parsed?.churn?.file
? resolve(absRoot, parsed.churn.file)
: null;
let churnSince: string | null = parsed?.churn?.since ?? null;
if (opts.churnSinceCli !== undefined && opts.churnSinceCli !== "") {
churnSince = opts.churnSinceCli;
if (parsed?.churn?.since && parsed.churn.since !== opts.churnSinceCli) {
console.error(
`[churn] CLI override: --churn-since ${opts.churnSinceCli} (config churn.since ignored)`,
);
}
}
const autoApply = parsed?.apply?.autoApplyRecipes;
const applyAutoApplyRecipes =
autoApply !== undefined && autoApply.length > 0
? [...autoApply]
: undefined;
return {
root: absRoot,
stateDir,
databasePath,
include,
excludeDirNames,
tsconfigPath,
fts5,
boundaries,
recipeRecency,
churn: {
halfLifeDays: churnHalfLifeDays,
since: churnSince,
file: churnFile,
},
synthesis: { heuristicCalls },
applyAutoApplyRecipes,
};
}
/**
* Load `<state-dir>/config.{ts,js,json}` (D8 order) — or `explicitPath`
* when CLI `--config` is set. Pre-v1: legacy `<root>/codemap.config.{ts,json}`
* paths are not searched; the changelog notes the one-line move.
*
* Three-arg form (`opts.stateDir`) lets the bootstrap pass the resolved
* state directory through; legacy two-arg form (`loadUserConfig(root)`)
* defaults to `<root>/.codemap/`.
*/
export async function loadUserConfig(
root: string,
explicitPath?: string,
opts: { stateDir?: string | undefined } = {},
): Promise<CodemapUserConfig | undefined> {
const tryImport = async (
file: string,
): Promise<CodemapUserConfig | undefined> => {
if (!existsSync(file)) return undefined;
const mod = await import(pathToFileURL(file).href);
const def = mod.default;
if (typeof def === "function") {
const out = await def();
return parseCodemapUserConfig(out);
}
if (def && typeof def === "object") {
return parseCodemapUserConfig(def);
}
return undefined;
};
if (explicitPath) {
if (explicitPath.endsWith(".json")) {
if (!existsSync(explicitPath)) return undefined;
const raw = await readJsonFile(explicitPath);
return parseCodemapUserConfig(raw);
}
return tryImport(explicitPath);
}
const stateDir = opts.stateDir ?? resolveStateDir({ root });
for (const basename of STATE_CONFIG_BASENAMES) {
const candidate = join(stateDir, basename);
if (basename.endsWith(".json")) {
if (existsSync(candidate)) {
const raw = await readJsonFile(candidate);
return parseCodemapUserConfig(raw);
}
continue;
}
const fromImport = await tryImport(candidate);
if (fromImport) return parseCodemapUserConfig(fromImport);
}
return undefined;
}