@massu/core 0.9.1 → 1.0.0

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 (51) hide show
  1. package/agents/massu-security-reviewer.md +2 -2
  2. package/dist/cli.js +10519 -1661
  3. package/dist/hooks/auto-learning-pipeline.js +99 -19
  4. package/dist/hooks/classify-failure.js +99 -19
  5. package/dist/hooks/cost-tracker.js +97 -11
  6. package/dist/hooks/fix-detector.js +99 -19
  7. package/dist/hooks/incident-pipeline.js +97 -11
  8. package/dist/hooks/post-edit-context.js +97 -11
  9. package/dist/hooks/post-tool-use.js +101 -20
  10. package/dist/hooks/pre-compact.js +97 -11
  11. package/dist/hooks/pre-delete-check.js +97 -11
  12. package/dist/hooks/quality-event.js +97 -11
  13. package/dist/hooks/rule-enforcement-pipeline.js +97 -11
  14. package/dist/hooks/session-end.js +97 -11
  15. package/dist/hooks/session-start.js +98 -12
  16. package/dist/hooks/user-prompt.js +98 -43
  17. package/package.json +13 -3
  18. package/reference/hook-execution-order.md +17 -25
  19. package/src/cli.ts +2 -1
  20. package/src/commands/doctor.ts +1 -29
  21. package/src/commands/init.ts +752 -216
  22. package/src/config.ts +168 -12
  23. package/src/detect/domain-inferrer.ts +142 -0
  24. package/src/detect/drift.ts +199 -0
  25. package/src/detect/framework-detector.ts +281 -0
  26. package/src/detect/index.ts +174 -0
  27. package/src/detect/migrate.ts +278 -0
  28. package/src/detect/monorepo-detector.ts +347 -0
  29. package/src/detect/package-detector.ts +728 -0
  30. package/src/detect/source-dir-detector.ts +264 -0
  31. package/src/detect/vr-command-map.ts +167 -0
  32. package/src/hooks/auto-learning-pipeline.ts +2 -2
  33. package/src/hooks/classify-failure.ts +2 -2
  34. package/src/hooks/fix-detector.ts +2 -2
  35. package/src/hooks/session-start.ts +1 -1
  36. package/src/hooks/user-prompt.ts +1 -21
  37. package/src/knowledge-indexer.ts +1 -1
  38. package/src/license.ts +1 -2
  39. package/src/memory-db.ts +0 -5
  40. package/src/memory-file-ingest.ts +6 -13
  41. package/src/tools.ts +0 -8
  42. package/templates/multi-runtime/massu.config.yaml +80 -0
  43. package/templates/python-django/massu.config.yaml +51 -0
  44. package/templates/python-fastapi/massu.config.yaml +50 -0
  45. package/templates/rust-actix/massu.config.yaml +38 -0
  46. package/templates/swift-ios/massu.config.yaml +37 -0
  47. package/templates/ts-nestjs/massu.config.yaml +43 -0
  48. package/templates/ts-nextjs/massu.config.yaml +43 -0
  49. package/README.md +0 -40
  50. package/src/claude-md-templates.ts +0 -342
  51. package/src/mcp-bridge-tools.ts +0 -458
package/src/config.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  // Copyright (c) 2026 Massu. All rights reserved.
2
2
  // Licensed under BSL 1.1 - see LICENSE file for details.
3
3
 
4
+ // ============================================================
5
+ // P2-000 (2026-04-19): Strategy A chosen — extend config.ts in-place.
6
+ // Per Phase 0 artifact (.massu/agent-results/phase0-discovery.md)
7
+ // the CR-10 blast radius is 52 non-test consumers, with only 1 CHANGE callsite
8
+ // (tools.ts) and zero INVESTIGATE items. No file split. All schema v2 extensions
9
+ // live below and preserve the v1 `framework.{type,router,orm,ui}` access paths
10
+ // so existing consumers (tools.ts:89,192,246) keep working unchanged.
11
+ // ============================================================
12
+
4
13
  import { resolve, dirname } from 'path';
5
14
  import { existsSync, readFileSync } from 'fs';
6
15
  import { homedir } from 'os';
@@ -22,9 +31,13 @@ const DomainConfigSchema = z.object({
22
31
  export type DomainConfig = z.infer<typeof DomainConfigSchema>;
23
32
 
24
33
  // --- Pattern Rule Config ---
34
+ // P2-003: optional `language` discriminator so rules can be scoped to a specific
35
+ // runtime (e.g., only apply to Python files). Rules without `language` continue
36
+ // to apply to all files for v1 backcompat.
25
37
  const PatternRuleConfigSchema = z.object({
26
38
  pattern: z.string().default('**'),
27
39
  rules: z.array(z.string()).default([]),
40
+ language: z.string().optional(),
28
41
  });
29
42
  export type PatternRuleConfig = z.infer<typeof PatternRuleConfigSchema>;
30
43
 
@@ -253,19 +266,94 @@ const PathsConfigSchema = z.object({
253
266
  hooks: z.string().optional(),
254
267
  });
255
268
 
269
+ // --- P2-002: Multi-runtime per-language entry ---
270
+ // Each entry declares the framework + test framework + optional router/orm/ui
271
+ // used for that language slot. Used when `framework.type === 'multi'` via
272
+ // `framework.languages`.
273
+ const LanguageFrameworkEntrySchema = z.object({
274
+ framework: z.string().optional(),
275
+ test_framework: z.string().optional(),
276
+ test: z.string().optional(),
277
+ runtime: z.string().optional(),
278
+ orm: z.string().optional(),
279
+ router: z.string().optional(),
280
+ ui: z.string().optional(),
281
+ }).passthrough();
282
+ export type LanguageFrameworkEntry = z.infer<typeof LanguageFrameworkEntrySchema>;
283
+
284
+ // --- P2-002: Framework schema (supports v1 single-language AND v2 multi-runtime) ---
285
+ // v1: { type: 'typescript', router: 'none', orm: 'none', ui: 'none' }
286
+ // v2: { type: 'multi', primary: 'python', languages: { python: {...}, typescript: {...} } }
287
+ // Legacy top-level router/orm/ui keys are PRESERVED in both modes so that
288
+ // existing consumers (tools.ts:89,192,246) keep working without refactor.
289
+ const FrameworkConfigSchema = z.object({
290
+ type: z.string().default('typescript'),
291
+ primary: z.string().optional(),
292
+ router: z.string().default('none'),
293
+ orm: z.string().default('none'),
294
+ ui: z.string().default('none'),
295
+ languages: z.record(z.string(), LanguageFrameworkEntrySchema).optional(),
296
+ }).passthrough();
297
+ export type FrameworkConfig = z.infer<typeof FrameworkConfigSchema>;
298
+
299
+ // --- P2-004: Verification command map ---
300
+ // Map of language name -> command strings for each verification type.
301
+ // User entries take precedence over any Phase 1 auto-defaults.
302
+ const VerificationEntrySchema = z.object({
303
+ type: z.string().optional(),
304
+ test: z.string().optional(),
305
+ syntax: z.string().optional(),
306
+ lint: z.string().optional(),
307
+ build: z.string().optional(),
308
+ }).passthrough();
309
+ export type VerificationEntry = z.infer<typeof VerificationEntrySchema>;
310
+
311
+ const VerificationConfigSchema = z.record(z.string(), VerificationEntrySchema).optional();
312
+ export type VerificationConfig = z.infer<typeof VerificationConfigSchema>;
313
+
314
+ // --- P2-005: Canonical paths extension (free-form string map) ---
315
+ const CanonicalPathsSchema = z.record(z.string(), z.string()).optional();
316
+ export type CanonicalPaths = z.infer<typeof CanonicalPathsSchema>;
317
+
318
+ // --- P2-006: VR-* types extension (user-declared verification types) ---
319
+ const VerificationTypesSchema = z.record(z.string(), z.string()).optional();
320
+ export type VerificationTypes = z.infer<typeof VerificationTypesSchema>;
321
+
322
+ // --- P2-008: Detection rules extension ---
323
+ // Users may add or override built-in detection patterns. Phase 1 detectors
324
+ // merge this with the inline DETECTION_RULES table.
325
+ const DetectionRuleEntrySchema = z.object({
326
+ signals: z.array(z.string()).default([]),
327
+ priority: z.number().optional(),
328
+ }).passthrough();
329
+
330
+ const DetectionConfigSchema = z.object({
331
+ rules: z.record(
332
+ z.string(), // language
333
+ z.record(z.string(), DetectionRuleEntrySchema) // framework -> rule entry
334
+ ).optional(),
335
+ signal_weights: z.record(z.string(), z.number()).optional(),
336
+ disable_builtin: z.boolean().optional(),
337
+ }).passthrough().optional();
338
+ export type DetectionConfig = z.infer<typeof DetectionConfigSchema>;
339
+
256
340
  // --- Top-level Raw Config Schema ---
257
341
  // This validates the raw YAML output, coercing types and providing defaults.
342
+ // P2-001: schema_version tracks the config shape version. Defaults to 1 so
343
+ // existing v1 configs (without the field) keep loading unchanged. New v2
344
+ // configs set `schema_version: 2` explicitly.
258
345
  const RawConfigSchema = z.object({
346
+ schema_version: z.union([z.literal(1), z.literal(2)]).default(1),
259
347
  project: z.object({
260
348
  name: z.string().default('my-project'),
261
349
  root: z.string().default('auto'),
262
350
  }).default({ name: 'my-project', root: 'auto' }),
263
- framework: z.object({
264
- type: z.string().default('typescript'),
265
- router: z.string().default('none'),
266
- orm: z.string().default('none'),
267
- ui: z.string().default('none'),
268
- }).default({ type: 'typescript', router: 'none', orm: 'none', ui: 'none' }),
351
+ framework: FrameworkConfigSchema.default({
352
+ type: 'typescript',
353
+ router: 'none',
354
+ orm: 'none',
355
+ ui: 'none',
356
+ }),
269
357
  paths: PathsConfigSchema.default({ source: 'src', aliases: { '@': 'src' } }),
270
358
  toolPrefix: z.string().default('massu'),
271
359
  dbAccessPattern: z.string().optional(),
@@ -280,14 +368,30 @@ const RawConfigSchema = z.object({
280
368
  regression: RegressionConfigSchema,
281
369
  cloud: CloudConfigSchema,
282
370
  conventions: ConventionsConfigSchema,
283
- python: PythonConfigSchema,
284
371
  autoLearning: AutoLearningConfigSchema,
372
+ python: PythonConfigSchema,
373
+ // P2-004 / P2-005 / P2-006 / P2-008: v2 extensions (all optional)
374
+ verification: VerificationConfigSchema,
375
+ canonical_paths: CanonicalPathsSchema,
376
+ verification_types: VerificationTypesSchema,
377
+ detection: DetectionConfigSchema,
285
378
  }).passthrough();
286
379
 
287
380
  // --- Final Config interface (derived from Zod) ---
381
+ // Legacy v1 access paths (framework.type/router/orm/ui) are preserved here so
382
+ // that existing consumers (tools.ts:89,192,246 etc.) keep working regardless
383
+ // of whether the config is v1 or v2 multi-runtime shape.
288
384
  export interface Config {
385
+ schema_version: 1 | 2;
289
386
  project: { name: string; root: string };
290
- framework: { type: string; router: string; orm: string; ui: string };
387
+ framework: {
388
+ type: string;
389
+ router: string;
390
+ orm: string;
391
+ ui: string;
392
+ primary?: string;
393
+ languages?: Record<string, LanguageFrameworkEntry>;
394
+ };
291
395
  paths: z.infer<typeof PathsConfigSchema>;
292
396
  toolPrefix: string;
293
397
  dbAccessPattern?: string;
@@ -302,8 +406,13 @@ export interface Config {
302
406
  regression?: RegressionConfig;
303
407
  cloud?: CloudConfig;
304
408
  conventions?: ConventionsConfig;
305
- python?: PythonConfig;
306
409
  autoLearning?: AutoLearningConfig;
410
+ python?: PythonConfig;
411
+ // P2-004/005/006/008: optional v2 extensions
412
+ verification?: VerificationConfig;
413
+ canonical_paths?: CanonicalPaths;
414
+ verification_types?: VerificationTypes;
415
+ detection?: DetectionConfig;
307
416
  }
308
417
 
309
418
  let _config: Config | null = null;
@@ -372,20 +481,62 @@ export function getConfig(): Config {
372
481
  rawYaml = parseYaml(content) ?? {};
373
482
  }
374
483
 
375
- // Validate with Zod provides defaults and type coercion
376
- const parsed = RawConfigSchema.parse(rawYaml);
484
+ // P2-007: Validate with Zod and surface actionable errors on failure.
485
+ // Every Zod issue is translated into "<field path>: <message> (received <type>)"
486
+ // lines, grouped under a single Error message that names massu.config.yaml.
487
+ const result = RawConfigSchema.safeParse(rawYaml);
488
+ if (!result.success) {
489
+ const issues = result.error.issues.map(i => {
490
+ const path = i.path.length > 0 ? i.path.join('.') : '(root)';
491
+ const received = 'received' in i && i.received !== undefined
492
+ ? ` (received ${JSON.stringify(i.received)})`
493
+ : '';
494
+ return ` - ${path}: ${i.message}${received}`;
495
+ }).join('\n');
496
+ throw new Error(
497
+ `Invalid massu.config.yaml at ${configPath}:\n${issues}\n` +
498
+ `Hint: run \`massu config refresh\` to regenerate a valid config or fix the listed fields manually.`
499
+ );
500
+ }
501
+ const parsed = result.data;
377
502
 
378
503
  // Resolve project root path
379
504
  const projectRoot = parsed.project.root === 'auto' || !parsed.project.root
380
505
  ? root
381
506
  : resolve(root, parsed.project.root);
382
507
 
508
+ // P2-002 backcompat: when `framework.type === 'multi'`, mirror router/orm/ui
509
+ // from the primary language entry so that tools.ts:89,192,246 keep resolving.
510
+ // User-provided top-level keys always win — only fall back to the primary
511
+ // language when the top-level value is missing OR still the schema default
512
+ // 'none' (meaning the user didn't set it explicitly).
513
+ const fw = parsed.framework;
514
+ let router = fw.router;
515
+ let orm = fw.orm;
516
+ let ui = fw.ui;
517
+ if (fw.type === 'multi' && fw.primary && fw.languages) {
518
+ const primaryEntry = fw.languages[fw.primary];
519
+ if (primaryEntry) {
520
+ if (router === 'none' && primaryEntry.router) router = primaryEntry.router;
521
+ if (orm === 'none' && primaryEntry.orm) orm = primaryEntry.orm;
522
+ if (ui === 'none' && primaryEntry.ui) ui = primaryEntry.ui;
523
+ }
524
+ }
525
+
383
526
  _config = {
527
+ schema_version: parsed.schema_version,
384
528
  project: {
385
529
  name: parsed.project.name,
386
530
  root: projectRoot,
387
531
  },
388
- framework: parsed.framework,
532
+ framework: {
533
+ type: fw.type,
534
+ router,
535
+ orm,
536
+ ui,
537
+ primary: fw.primary,
538
+ languages: fw.languages,
539
+ },
389
540
  paths: parsed.paths,
390
541
  toolPrefix: parsed.toolPrefix,
391
542
  dbAccessPattern: parsed.dbAccessPattern,
@@ -400,7 +551,12 @@ export function getConfig(): Config {
400
551
  regression: parsed.regression,
401
552
  cloud: parsed.cloud,
402
553
  conventions: parsed.conventions,
554
+ autoLearning: parsed.autoLearning,
403
555
  python: parsed.python,
556
+ verification: parsed.verification,
557
+ canonical_paths: parsed.canonical_paths,
558
+ verification_types: parsed.verification_types,
559
+ detection: parsed.detection,
404
560
  };
405
561
 
406
562
  // Allow environment variable override for API key (security best practice)
@@ -0,0 +1,142 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Domain Inferrer (P1-006)
6
+ * ========================
7
+ *
8
+ * Suggests `DomainConfig[]` entries based on monorepo + source-dir discovery.
9
+ * Each workspace package (apps/*, packages/*, services/*, libs/*, modules/*)
10
+ * becomes one suggested domain. In single-package repos, top-level
11
+ * `src/<subdir>/` candidates are suggested as domains.
12
+ *
13
+ * Output matches the existing `DomainConfig` type from `config.ts` so init
14
+ * and refresh can write it directly into `massu.config.yaml`. Suggested
15
+ * `allowedImportsFrom` is always empty — the user fills relationships.
16
+ *
17
+ * Deterministic ordering: alphabetical by domain name.
18
+ *
19
+ * Usage:
20
+ * ```ts
21
+ * import { inferDomains } from './detect/domain-inferrer.ts';
22
+ * const domains = inferDomains('/repo', monorepo, sourceDirs);
23
+ * ```
24
+ */
25
+
26
+ import { existsSync, readdirSync } from 'fs';
27
+ import { join } from 'path';
28
+ import type { DomainConfig } from '../config.ts';
29
+ import type { MonorepoInfo, WorkspacePackage } from './monorepo-detector.ts';
30
+ import type { SourceDirMap } from './source-dir-detector.ts';
31
+
32
+ const IGNORED_SUBDIRS = new Set([
33
+ 'node_modules',
34
+ '__pycache__',
35
+ 'dist',
36
+ 'build',
37
+ '.build',
38
+ 'target',
39
+ '.next',
40
+ '.git',
41
+ '.massu',
42
+ 'coverage',
43
+ 'tests',
44
+ 'test',
45
+ '__tests__',
46
+ ]);
47
+
48
+ function titleCase(s: string): string {
49
+ if (!s) return s;
50
+ return s
51
+ .split(/[-_\s]+/)
52
+ .filter(Boolean)
53
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
54
+ .join(' ');
55
+ }
56
+
57
+ function domainFromWorkspace(pkg: WorkspacePackage): DomainConfig {
58
+ // Prefer the explicit package name; fall back to the final path segment.
59
+ const pathTail = pkg.path.split('/').pop() ?? pkg.path;
60
+ const name = pkg.name ?? titleCase(pathTail);
61
+ return {
62
+ name,
63
+ routers: [],
64
+ pages: [],
65
+ tables: [],
66
+ allowedImportsFrom: [],
67
+ };
68
+ }
69
+
70
+ function topLevelSrcSubdirs(root: string): string[] {
71
+ const srcDir = join(root, 'src');
72
+ if (!existsSync(srcDir)) return [];
73
+ try {
74
+ return readdirSync(srcDir, { withFileTypes: true })
75
+ .filter((e) => e.isDirectory() && !IGNORED_SUBDIRS.has(e.name))
76
+ .map((e) => e.name)
77
+ .sort();
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Produce a suggested `DomainConfig[]`.
85
+ *
86
+ * @param projectRoot absolute path
87
+ * @param monorepo output of P1-004 detectMonorepo
88
+ * @param sourceDirs output of P1-003 detectSourceDirs
89
+ */
90
+ export function inferDomains(
91
+ projectRoot: string,
92
+ monorepo: MonorepoInfo,
93
+ sourceDirs: SourceDirMap
94
+ ): DomainConfig[] {
95
+ const domains: DomainConfig[] = [];
96
+
97
+ if (monorepo.type !== 'single' && monorepo.packages.length > 0) {
98
+ // Monorepo: one domain per workspace package.
99
+ for (const pkg of monorepo.packages) {
100
+ domains.push(domainFromWorkspace(pkg));
101
+ }
102
+ } else {
103
+ // Single repo: suggest one domain per top-level src/<subdir>/ if src/ exists.
104
+ const subdirs = topLevelSrcSubdirs(projectRoot);
105
+ for (const s of subdirs) {
106
+ domains.push({
107
+ name: titleCase(s),
108
+ routers: [],
109
+ pages: [],
110
+ tables: [],
111
+ allowedImportsFrom: [],
112
+ });
113
+ }
114
+ // If no src/ subdirs, emit a single-language-based domain when sourceDirs has entries.
115
+ if (domains.length === 0) {
116
+ const langs = Object.keys(sourceDirs);
117
+ for (const lang of langs.sort()) {
118
+ domains.push({
119
+ name: titleCase(lang),
120
+ routers: [],
121
+ pages: [],
122
+ tables: [],
123
+ allowedImportsFrom: [],
124
+ });
125
+ }
126
+ }
127
+ }
128
+
129
+ // Deterministic alphabetical order by name.
130
+ domains.sort((a, b) => a.name.localeCompare(b.name));
131
+
132
+ // Dedup by name (monorepo workspaces may coincidentally share a name).
133
+ const seen = new Set<string>();
134
+ const dedup: DomainConfig[] = [];
135
+ for (const d of domains) {
136
+ if (seen.has(d.name)) continue;
137
+ seen.add(d.name);
138
+ dedup.push(d);
139
+ }
140
+
141
+ return dedup;
142
+ }
@@ -0,0 +1,199 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Drift detection logic (P7-004).
6
+ *
7
+ * Phase 5 runtime (drift runner / auto-rerun) is NOT in the MVP cut, but the
8
+ * pure detection LOGIC is tested here so the runtime layer lands without bugs.
9
+ *
10
+ * Two primary entry points:
11
+ *
12
+ * 1. `computeFingerprint(detectionResult)` — deterministic SHA-256 of the
13
+ * normalized JSON form of (languages, frameworks, source_dirs, manifest
14
+ * paths sorted). Any meaningful change in the detected stack changes the
15
+ * fingerprint.
16
+ *
17
+ * 2. `detectDrift(currentConfig, actualDetection)` — returns
18
+ * `{ drifted: boolean, changes: Array<{field, before, after}> }`.
19
+ *
20
+ * No filesystem / network / child_process.
21
+ */
22
+
23
+ import { createHash } from 'crypto';
24
+ import type { AnyConfig } from './migrate.ts';
25
+ import type { DetectionResult, SupportedLanguage } from './index.ts';
26
+
27
+ export interface DriftChange {
28
+ field: string;
29
+ before: unknown;
30
+ after: unknown;
31
+ }
32
+
33
+ export interface DriftReport {
34
+ drifted: boolean;
35
+ changes: DriftChange[];
36
+ }
37
+
38
+ /** Stable-sorted, normalized summary of a DetectionResult used for hashing. */
39
+ interface DetectionFingerprintData {
40
+ languages: string[];
41
+ frameworks: Record<string, { framework: string | null; test_framework: string | null; orm: string | null }>;
42
+ source_dirs: Record<string, string[]>;
43
+ manifests: string[];
44
+ monorepo: string;
45
+ workspaces: string[];
46
+ }
47
+
48
+ function summarizeDetection(det: DetectionResult): DetectionFingerprintData {
49
+ const languages = Array.from(new Set(det.manifests.map((m) => m.language))).sort();
50
+ const frameworks: DetectionFingerprintData['frameworks'] = {};
51
+ for (const lang of languages) {
52
+ const fw = det.frameworks[lang as SupportedLanguage];
53
+ frameworks[lang] = {
54
+ framework: fw?.framework ?? null,
55
+ test_framework: fw?.test_framework ?? null,
56
+ orm: fw?.orm ?? null,
57
+ };
58
+ }
59
+ const sourceDirs: Record<string, string[]> = {};
60
+ for (const lang of languages) {
61
+ const info = det.sourceDirs[lang as SupportedLanguage];
62
+ sourceDirs[lang] = [...(info?.source_dirs ?? [])].sort();
63
+ }
64
+ const manifests = [...det.manifests.map((m) => m.relativePath)].sort();
65
+ const workspaces = [...det.monorepo.packages.map((p) => p.path)].sort();
66
+ return {
67
+ languages,
68
+ frameworks,
69
+ source_dirs: sourceDirs,
70
+ manifests,
71
+ monorepo: det.monorepo.type,
72
+ workspaces,
73
+ };
74
+ }
75
+
76
+ /** Deterministic SHA-256 of a detection result. Stable across runs for the
77
+ * same inputs. */
78
+ export function computeFingerprint(det: DetectionResult): string {
79
+ const data = summarizeDetection(det);
80
+ const stable = JSON.stringify(data, Object.keys(data).sort());
81
+ return createHash('sha256').update(stable).digest('hex');
82
+ }
83
+
84
+ function stringOf(v: unknown): string | null {
85
+ if (typeof v === 'string') return v;
86
+ if (v === null || v === undefined) return null;
87
+ return String(v);
88
+ }
89
+
90
+ /**
91
+ * Compute drift between a currently-loaded config and a freshly-run detection.
92
+ *
93
+ * A non-empty `changes[]` means fields in the config no longer agree with
94
+ * what the repository actually contains. Callers decide whether to auto-repair
95
+ * (via `migrateV1ToV2`) or surface to the user.
96
+ */
97
+ export function detectDrift(
98
+ currentConfig: AnyConfig,
99
+ actualDetection: DetectionResult
100
+ ): DriftReport {
101
+ const changes: DriftChange[] = [];
102
+
103
+ const configFw = (currentConfig.framework && typeof currentConfig.framework === 'object')
104
+ ? (currentConfig.framework as Record<string, unknown>)
105
+ : {};
106
+ const configLanguages = (configFw.languages && typeof configFw.languages === 'object')
107
+ ? (configFw.languages as Record<string, Record<string, unknown>>)
108
+ : {};
109
+
110
+ const detectedLanguages = Array.from(
111
+ new Set(actualDetection.manifests.map((m) => m.language))
112
+ ) as SupportedLanguage[];
113
+
114
+ const configLangKeys = Object.keys(configLanguages).sort();
115
+ const detectedLangKeys = [...detectedLanguages].sort();
116
+
117
+ // 1. Language set drift.
118
+ if (JSON.stringify(configLangKeys) !== JSON.stringify(detectedLangKeys)) {
119
+ changes.push({
120
+ field: 'framework.languages',
121
+ before: configLangKeys,
122
+ after: detectedLangKeys,
123
+ });
124
+ }
125
+
126
+ // 2. Per-language framework/test_framework drift.
127
+ for (const lang of detectedLanguages) {
128
+ const detFw = actualDetection.frameworks[lang];
129
+ const cfgEntry = configLanguages[lang];
130
+ if (!cfgEntry) continue;
131
+ const cfgFramework = stringOf(cfgEntry.framework);
132
+ const detFramework = detFw?.framework ?? null;
133
+ if (cfgFramework !== detFramework && detFramework !== null) {
134
+ changes.push({
135
+ field: `framework.languages.${lang}.framework`,
136
+ before: cfgFramework,
137
+ after: detFramework,
138
+ });
139
+ }
140
+ const cfgTest = stringOf(cfgEntry.test_framework);
141
+ const detTest = detFw?.test_framework ?? null;
142
+ if (cfgTest !== detTest && detTest !== null) {
143
+ changes.push({
144
+ field: `framework.languages.${lang}.test_framework`,
145
+ before: cfgTest,
146
+ after: detTest,
147
+ });
148
+ }
149
+ }
150
+
151
+ // 3. Manifest set drift: new/removed manifest files.
152
+ const detectedManifestPaths = new Set(actualDetection.manifests.map((m) => m.relativePath));
153
+ const declaredManifestPaths = new Set<string>();
154
+ // v2 configs don't record manifest paths directly; we look at
155
+ // `canonical_paths.manifest_paths` (string comma-separated) OR a `manifests`
156
+ // top-level array. When neither is present we skip this check.
157
+ const canonical = currentConfig.canonical_paths as Record<string, unknown> | undefined;
158
+ if (canonical && typeof canonical.manifest_paths === 'string') {
159
+ for (const p of (canonical.manifest_paths as string).split(',').map((s) => s.trim())) {
160
+ if (p) declaredManifestPaths.add(p);
161
+ }
162
+ }
163
+ if (Array.isArray(currentConfig.manifests)) {
164
+ for (const p of currentConfig.manifests as unknown[]) {
165
+ if (typeof p === 'string') declaredManifestPaths.add(p);
166
+ }
167
+ }
168
+ if (declaredManifestPaths.size > 0) {
169
+ const added = [...detectedManifestPaths].filter((p) => !declaredManifestPaths.has(p)).sort();
170
+ const removed = [...declaredManifestPaths].filter((p) => !detectedManifestPaths.has(p)).sort();
171
+ if (added.length > 0) {
172
+ changes.push({ field: 'manifests.added', before: [], after: added });
173
+ }
174
+ if (removed.length > 0) {
175
+ changes.push({ field: 'manifests.removed', before: removed, after: [] });
176
+ }
177
+ }
178
+
179
+ // 4. Monorepo workspace set drift.
180
+ const configWorkspaces: string[] = [];
181
+ if (Array.isArray((currentConfig.monorepo as Record<string, unknown> | undefined)?.workspaces)) {
182
+ for (const w of ((currentConfig.monorepo as Record<string, unknown>).workspaces as unknown[])) {
183
+ if (typeof w === 'string') configWorkspaces.push(w);
184
+ }
185
+ }
186
+ const detectedWorkspaces = actualDetection.monorepo.packages.map((p) => p.path).sort();
187
+ if (configWorkspaces.length > 0) {
188
+ const cfgSorted = [...configWorkspaces].sort();
189
+ if (JSON.stringify(cfgSorted) !== JSON.stringify(detectedWorkspaces)) {
190
+ changes.push({
191
+ field: 'monorepo.workspaces',
192
+ before: cfgSorted,
193
+ after: detectedWorkspaces,
194
+ });
195
+ }
196
+ }
197
+
198
+ return { drifted: changes.length > 0, changes };
199
+ }