@massu/core 0.9.2 → 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.
- package/dist/cli.js +10519 -1661
- package/dist/hooks/auto-learning-pipeline.js +99 -19
- package/dist/hooks/classify-failure.js +99 -19
- package/dist/hooks/cost-tracker.js +97 -11
- package/dist/hooks/fix-detector.js +99 -19
- package/dist/hooks/incident-pipeline.js +97 -11
- package/dist/hooks/post-edit-context.js +97 -11
- package/dist/hooks/post-tool-use.js +101 -20
- package/dist/hooks/pre-compact.js +97 -11
- package/dist/hooks/pre-delete-check.js +97 -11
- package/dist/hooks/quality-event.js +97 -11
- package/dist/hooks/rule-enforcement-pipeline.js +97 -11
- package/dist/hooks/session-end.js +97 -11
- package/dist/hooks/session-start.js +98 -12
- package/dist/hooks/user-prompt.js +98 -43
- package/package.json +13 -3
- package/reference/hook-execution-order.md +17 -25
- package/src/cli.ts +2 -1
- package/src/commands/doctor.ts +1 -29
- package/src/commands/init.ts +752 -216
- package/src/config.ts +168 -12
- package/src/detect/domain-inferrer.ts +142 -0
- package/src/detect/drift.ts +199 -0
- package/src/detect/framework-detector.ts +281 -0
- package/src/detect/index.ts +174 -0
- package/src/detect/migrate.ts +278 -0
- package/src/detect/monorepo-detector.ts +347 -0
- package/src/detect/package-detector.ts +728 -0
- package/src/detect/source-dir-detector.ts +264 -0
- package/src/detect/vr-command-map.ts +167 -0
- package/src/hooks/auto-learning-pipeline.ts +2 -2
- package/src/hooks/classify-failure.ts +2 -2
- package/src/hooks/fix-detector.ts +2 -2
- package/src/hooks/session-start.ts +1 -1
- package/src/hooks/user-prompt.ts +1 -21
- package/src/knowledge-indexer.ts +1 -1
- package/src/license.ts +1 -2
- package/src/memory-db.ts +0 -5
- package/src/memory-file-ingest.ts +6 -13
- package/src/tools.ts +0 -8
- package/templates/multi-runtime/massu.config.yaml +80 -0
- package/templates/python-django/massu.config.yaml +51 -0
- package/templates/python-fastapi/massu.config.yaml +50 -0
- package/templates/rust-actix/massu.config.yaml +38 -0
- package/templates/swift-ios/massu.config.yaml +37 -0
- package/templates/ts-nestjs/massu.config.yaml +43 -0
- package/templates/ts-nextjs/massu.config.yaml +43 -0
- package/README.md +0 -40
- package/src/claude-md-templates.ts +0 -342
- 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:
|
|
264
|
-
type:
|
|
265
|
-
router:
|
|
266
|
-
orm:
|
|
267
|
-
ui:
|
|
268
|
-
})
|
|
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: {
|
|
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
|
|
376
|
-
|
|
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:
|
|
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
|
+
}
|