@mettlecast/domain-cli 0.2.61 → 0.2.62

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 CHANGED
@@ -13,6 +13,8 @@ import { runAddModule } from './commands/add-module.js';
13
13
  import { runBuildFlows } from './commands/build-flows.js';
14
14
  import { runDoctor } from './commands/doctor.js';
15
15
  import { runCheckHashes } from './commands/check-hashes.js';
16
+ import { runUpdateAll } from './commands/update-all.js';
17
+ import { runRegenerateModulesHashes } from './commands/regenerate-modules-hashes.js';
16
18
  import { runUpgradeBackend } from './commands/upgrade-backend.js';
17
19
  import { runAddPage } from './commands/add-page.js';
18
20
  import { runCreateProject } from './commands/create-project.js';
@@ -173,10 +175,26 @@ program
173
175
  program
174
176
  .command('check-hashes')
175
177
  .description('Verify infra/modules/ has not been hand-edited since last scaffold')
176
- .action(async () => {
177
- const result = await runCheckHashes({});
178
+ .option('--write', 'Regenerate modules-hashes.json from current disk state instead of verifying')
179
+ .action(async (opts) => {
180
+ const result = await runCheckHashes({ write: opts.write });
178
181
  process.exit(result.ok ? 0 : 1);
179
182
  });
183
+ program
184
+ .command('update-all')
185
+ .description('Full refresh: build all domains, build catalog, build flows, build UI, regenerate hashes, run doctor')
186
+ .option('--project-root <path>', 'Root of the project (defaults to cwd)')
187
+ .action(async (opts) => {
188
+ const result = await runUpdateAll({ projectRoot: opts.projectRoot });
189
+ process.exit(result.success ? 0 : 1);
190
+ });
191
+ program
192
+ .command('regenerate-modules-hashes')
193
+ .description('Walk infra/modules/, compute SHA256 hashes, and write .mc/modules-hashes.json')
194
+ .option('--project-root <path>', 'Root of the project (defaults to cwd)')
195
+ .action(async (opts) => {
196
+ await runRegenerateModulesHashes({ projectRoot: opts.projectRoot });
197
+ });
180
198
  program
181
199
  .command('upgrade-backend <target-major>')
182
200
  .description('Run jscodeshift/ts-morph migrations between major versions of domain-runtime')
@@ -4,6 +4,8 @@
4
4
  export interface CheckHashesOptions {
5
5
  /** Root directory of the project (defaults to cwd). */
6
6
  projectRoot?: string;
7
+ /** If true, regenerate .mc/modules-hashes.json from current infra/modules/ before checking. */
8
+ write?: boolean;
7
9
  }
8
10
  /**
9
11
  * A single hash check result.
@@ -10,6 +10,14 @@ import { cliLogger } from '../utils/logger.js';
10
10
  */
11
11
  export async function runCheckHashes(opts = {}) {
12
12
  const root = opts.projectRoot ?? process.cwd();
13
+ // --write bootstraps the manifest from current infra/modules/ before verification.
14
+ // Must run before the manifest read below so it can create a missing manifest.
15
+ if (opts.write) {
16
+ const { runRegenerateModulesHashes } = await import('./regenerate-modules-hashes.js');
17
+ const outPath = await runRegenerateModulesHashes({ projectRoot: root });
18
+ cliLogger.info({ outPath }, 'modules-hashes.json regenerated');
19
+ return { ok: true, drifted: [], missing: [], unexpected: [] };
20
+ }
13
21
  const manifestPath = join(root, '.mc', 'modules-hashes.json');
14
22
  const modulesDir = join(root, 'infra', 'modules');
15
23
  let manifest;
@@ -520,9 +520,9 @@ async function checkScaffoldConfigMatchesDisk(projectRoot) {
520
520
  catch {
521
521
  return {
522
522
  name: 'scaffold-config.json matches on-disk',
523
- status: 'FAIL',
524
- message: 'Could not read .mc/scaffold-config.json',
525
- fixHint: 'Ensure .mc/scaffold-config.json exists and is valid JSON',
523
+ status: 'WARN',
524
+ message: '.mc/scaffold-config.json not found or unreadable — skipping domain list sync check (expected on fresh clones before first scaffold)',
525
+ fixHint: 'Run mc-domain-module update-all to regenerate scaffold metadata',
526
526
  kNodeRef: 'K:runbook:add-domain',
527
527
  };
528
528
  }
@@ -634,7 +634,7 @@ async function checkRootLevelFlows(projectRoot) {
634
634
  }
635
635
  return {
636
636
  name: 'No root-level flows (deprecated)',
637
- status: 'WARN',
637
+ status: 'FAIL',
638
638
  message: `${flowFiles.length} flow(s) still in root-level flows/: ${flowFiles.join(', ')}`,
639
639
  fixHint: 'Move these flows to domains/{owningDomain}/flows/ and delete the root-level copies.',
640
640
  kNodeRef: 'K:convention:flow-vs-subscriber-rule',
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Options for the regenerate-modules-hashes command.
3
+ */
4
+ export interface RegenerateModulesHashesOptions {
5
+ /** Root directory of the project (defaults to cwd). */
6
+ projectRoot?: string;
7
+ }
8
+ /**
9
+ * Manifest of infra/modules/ files with their SHA256 hashes.
10
+ */
11
+ export interface ModulesHashesManifest {
12
+ /** Schema version. */
13
+ version: '1';
14
+ /** ISO timestamp when the manifest was generated. */
15
+ generatedAt: string;
16
+ /** Map of relative file path to SHA256 hex digest. */
17
+ files: Record<string, string>;
18
+ }
19
+ /**
20
+ * Walk infra/modules/, compute a SHA256 for every file, and write the result
21
+ * to .mc/modules-hashes.json. Used to refresh the scaffold drift baseline.
22
+ * @param opts Options including project root directory.
23
+ * @returns Absolute path to the written manifest file.
24
+ */
25
+ export declare function runRegenerateModulesHashes(opts?: RegenerateModulesHashesOptions): Promise<string>;
@@ -0,0 +1,58 @@
1
+ import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { cliLogger } from '../utils/logger.js';
5
+ /**
6
+ * Walk infra/modules/, compute a SHA256 for every file, and write the result
7
+ * to .mc/modules-hashes.json. Used to refresh the scaffold drift baseline.
8
+ * @param opts Options including project root directory.
9
+ * @returns Absolute path to the written manifest file.
10
+ */
11
+ export async function runRegenerateModulesHashes(opts = {}) {
12
+ const root = opts.projectRoot ?? process.cwd();
13
+ const mcDir = join(root, '.mc');
14
+ const manifestPath = join(mcDir, 'modules-hashes.json');
15
+ const modulesDir = join(root, 'infra', 'modules');
16
+ // Files/directories to skip when walking infra/modules/
17
+ const SKIP_NAMES = new Set(['node_modules', 'dist', 'cdk.out', 'package-lock.json', '.npmrc']);
18
+ /** Normalize CRLF → LF so hashes match across platforms (Windows vs Linux). */
19
+ function normalizeLineEndings(buf) {
20
+ const str = buf.toString('utf8');
21
+ if (!str.includes('\r\n'))
22
+ return buf;
23
+ return Buffer.from(str.replace(/\r\n/g, '\n'), 'utf8');
24
+ }
25
+ // Walk infra/modules/ and collect every file path
26
+ async function walk(dir) {
27
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
28
+ const files = [];
29
+ for (const entry of entries) {
30
+ if (SKIP_NAMES.has(entry.name))
31
+ continue;
32
+ const full = join(dir, entry.name);
33
+ if (entry.isDirectory())
34
+ files.push(...await walk(full));
35
+ else if (entry.isFile())
36
+ files.push(full);
37
+ }
38
+ return files;
39
+ }
40
+ const onDiskFiles = await walk(modulesDir);
41
+ // Compute SHA256 for each file (line-ending-normalised)
42
+ const files = {};
43
+ for (const absPath of onDiskFiles) {
44
+ const relPath = relative(root, absPath).replace(/\\/g, '/');
45
+ const content = await readFile(absPath);
46
+ const normalized = normalizeLineEndings(content);
47
+ files[relPath] = createHash('sha256').update(normalized).digest('hex');
48
+ }
49
+ const manifest = {
50
+ version: '1',
51
+ generatedAt: new Date().toISOString(),
52
+ files,
53
+ };
54
+ await mkdir(mcDir, { recursive: true });
55
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
56
+ cliLogger.info({ path: manifestPath, fileCount: Object.keys(files).length }, 'modules-hashes manifest regenerated');
57
+ return manifestPath;
58
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Run the full scaffold update pipeline:
3
+ * 1. Build each domain registry from `.mc/scaffold-config.json`
4
+ * 2. Build the combined domain catalog
5
+ * 3. Build flows registry
6
+ * 4. Build UI manifests
7
+ * 5. Regenerate module hashes
8
+ * 6. Run doctor
9
+ *
10
+ * Per-domain build failures are logged and skipped so that one bad domain does
11
+ * not abort the whole pipeline. The remaining steps still run.
12
+ *
13
+ * @param opts - Optional projectRoot override. Defaults to cwd.
14
+ * @returns success derived from doctor pass/fail and a human-readable summary.
15
+ */
16
+ export declare function runUpdateAll(opts?: {
17
+ projectRoot?: string;
18
+ }): Promise<{
19
+ success: boolean;
20
+ summary: string;
21
+ }>;
@@ -0,0 +1,62 @@
1
+ import { join } from 'node:path';
2
+ import { cliLogger } from '../utils/logger.js';
3
+ import { readScaffoldConfig } from '../utils/scaffold-config.js';
4
+ import { runBuild } from './build.js';
5
+ import { runBuildCatalog } from './build-catalog.js';
6
+ import { runBuildFlows } from './build-flows.js';
7
+ import { runBuildUi } from './build-ui.js';
8
+ import { runRegenerateModulesHashes } from './regenerate-modules-hashes.js';
9
+ import { runDoctor } from './doctor.js';
10
+ /**
11
+ * Run the full scaffold update pipeline:
12
+ * 1. Build each domain registry from `.mc/scaffold-config.json`
13
+ * 2. Build the combined domain catalog
14
+ * 3. Build flows registry
15
+ * 4. Build UI manifests
16
+ * 5. Regenerate module hashes
17
+ * 6. Run doctor
18
+ *
19
+ * Per-domain build failures are logged and skipped so that one bad domain does
20
+ * not abort the whole pipeline. The remaining steps still run.
21
+ *
22
+ * @param opts - Optional projectRoot override. Defaults to cwd.
23
+ * @returns success derived from doctor pass/fail and a human-readable summary.
24
+ */
25
+ export async function runUpdateAll(opts) {
26
+ const projectRoot = opts?.projectRoot ?? process.cwd();
27
+ cliLogger.info({ projectRoot }, 'update-all: starting');
28
+ // 1. Read scaffold-config to discover domains
29
+ const scaffoldConfig = await readScaffoldConfig(projectRoot);
30
+ const domainIds = scaffoldConfig.domainIds ?? [];
31
+ cliLogger.info({ domainCount: domainIds.length }, 'update-all: building domain registries');
32
+ // 2. Build each domain. Per-domain failures are logged and skipped.
33
+ for (const domainId of domainIds) {
34
+ const domainRoot = join(projectRoot, 'domains', domainId);
35
+ try {
36
+ cliLogger.info({ domainId, domainRoot }, 'update-all: building domain');
37
+ await runBuild({ domainRoot });
38
+ }
39
+ catch (err) {
40
+ cliLogger.error({ domainId, err: err instanceof Error ? err.message : String(err) }, 'update-all: domain build failed — continuing');
41
+ }
42
+ }
43
+ // 3. Build catalog from .mc
44
+ const mcDir = join(projectRoot, '.mc');
45
+ cliLogger.info({ mcDir }, 'update-all: building domain catalog');
46
+ await runBuildCatalog(mcDir);
47
+ // 4. Build flows
48
+ cliLogger.info({ projectRoot }, 'update-all: building flows registry');
49
+ await runBuildFlows({ projectRoot });
50
+ // 5. Build UI manifests
51
+ cliLogger.info({ projectRoot }, 'update-all: building UI manifests');
52
+ await runBuildUi({ projectRoot });
53
+ // 6. Regenerate module hashes
54
+ cliLogger.info({ projectRoot }, 'update-all: regenerating module hashes');
55
+ await runRegenerateModulesHashes({ projectRoot });
56
+ // 7. Run doctor — final gate
57
+ cliLogger.info({ projectRoot }, 'update-all: running doctor');
58
+ const report = await runDoctor({ projectRoot });
59
+ cliLogger.info({ pass: report.pass, exitCode: report.exitCode, checkCount: report.checks.length }, 'update-all: doctor complete');
60
+ const summary = `Updated ${domainIds.length} domains; doctor ${report.pass ? 'PASS' : 'FAIL'}`;
61
+ return { success: report.pass, summary };
62
+ }
@@ -26,3 +26,4 @@ export declare function upsertManifestFile(manifest: TibManifest, entry: Manifes
26
26
  export declare function removeManifestFile(manifest: TibManifest, filePath: string): void;
27
27
  export declare function getManifestFile(manifest: TibManifest, filePath: string): ManifestFileEntry | undefined;
28
28
  export declare function isScaffoldManaged(manifest: TibManifest, filePath: string): boolean;
29
+ export declare function pruneManifest(projectRoot: string, manifest: TibManifest): Promise<number>;
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, mkdir } from 'fs/promises';
1
+ import { readFile, writeFile, mkdir, access } from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { cliLogger } from './logger.js';
4
4
  const MANIFEST_PATH = '.mc/manifest.json';
@@ -132,3 +132,18 @@ export function isScaffoldManaged(manifest, filePath) {
132
132
  // Returns true only when the file is in the manifest with policy 'managed'
133
133
  return entry != null && entry.policy === 'managed';
134
134
  }
135
+ export async function pruneManifest(projectRoot, manifest) {
136
+ let pruned = 0;
137
+ const survivors = [];
138
+ for (const entry of manifest.files) {
139
+ try {
140
+ await access(path.join(projectRoot, entry.path));
141
+ survivors.push(entry);
142
+ }
143
+ catch {
144
+ pruned++;
145
+ }
146
+ }
147
+ manifest.files = survivors;
148
+ return pruned;
149
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-cli",
3
- "version": "0.2.61",
3
+ "version": "0.2.62",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -151,4 +151,59 @@ describe('build-flows command', () => {
151
151
 
152
152
  expect(registry.flows[0]?.trigger?.eventId).toBe('domain.event');
153
153
  });
154
+
155
+ it('picks up flow from domains/auth/flows/ with owningDomain=\'auth\'', async () => {
156
+ const domainFlowsDir = join(tmpDir, 'domains', 'auth', 'flows');
157
+ await mkdir(domainFlowsDir, { recursive: true });
158
+
159
+ const flow = {
160
+ id: 'onboarding-flow',
161
+ name: 'Onboarding Flow',
162
+ steps: [
163
+ {
164
+ type: 'flow-control',
165
+ control: 'succeed',
166
+ name: 'End',
167
+ },
168
+ ],
169
+ };
170
+
171
+ await writeFile(join(domainFlowsDir, 'onboarding.json'), JSON.stringify(flow));
172
+
173
+ const outFile = await runBuildFlows({ projectRoot: tmpDir });
174
+ const content = await readFile(outFile, 'utf8');
175
+ const registry = JSON.parse(content) as FlowRegistry;
176
+
177
+ expect(registry.flows).toHaveLength(1);
178
+ expect(registry.flows[0]?.id).toBe('onboarding-flow');
179
+ expect(registry.flows[0]?.owningDomain).toBe('auth');
180
+ });
181
+
182
+ it('detects ID collision between root-level and domain-level flows (FlowIdCollisionError)', async () => {
183
+ // Same flow ID in BOTH root flows/ and a domain's flows/.
184
+ const rootFlowsDir = join(tmpDir, 'flows');
185
+ await mkdir(rootFlowsDir, { recursive: true });
186
+
187
+ const domainFlowsDir = join(tmpDir, 'domains', 'auth', 'flows');
188
+ await mkdir(domainFlowsDir, { recursive: true });
189
+
190
+ const flow = {
191
+ id: 'shared-flow',
192
+ name: 'Shared Flow',
193
+ steps: [
194
+ {
195
+ type: 'flow-control',
196
+ control: 'succeed',
197
+ name: 'End',
198
+ },
199
+ ],
200
+ };
201
+
202
+ await writeFile(join(rootFlowsDir, 'shared.json'), JSON.stringify(flow));
203
+ await writeFile(join(domainFlowsDir, 'shared.json'), JSON.stringify(flow));
204
+
205
+ await expect(runBuildFlows({ projectRoot: tmpDir }))
206
+ .rejects
207
+ .toThrow(FlowIdCollisionError);
208
+ });
154
209
  });
@@ -139,4 +139,35 @@ describe('check-hashes', () => {
139
139
  /cannot read.*modules-hashes\.json/
140
140
  );
141
141
  });
142
+
143
+ it('--write regenerates manifest from disk (even if manifest was missing)', async () => {
144
+ // Set up infra/modules/ with a file, but NO .mc/modules-hashes.json.
145
+ await mkdir(join(tempDir, '.mc'), { recursive: true });
146
+ await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
147
+
148
+ const content = 'export const MyStack = {};';
149
+ await writeFile(join(tempDir, 'infra', 'modules', 'fresh.ts'), content);
150
+
151
+ // Confirm the manifest doesn't exist yet.
152
+ const { existsSync } = await import('node:fs');
153
+ expect(existsSync(join(tempDir, '.mc', 'modules-hashes.json'))).toBe(false);
154
+
155
+ // --write should bootstrap the manifest from disk and succeed.
156
+ const result = await runCheckHashes({ projectRoot: tempDir, write: true });
157
+ expect(result.ok).toBe(true);
158
+ expect(result.drifted).toHaveLength(0);
159
+ expect(result.missing).toHaveLength(0);
160
+ expect(result.unexpected).toHaveLength(0);
161
+
162
+ // The manifest should now exist on disk with the file's hash.
163
+ expect(existsSync(join(tempDir, '.mc', 'modules-hashes.json'))).toBe(true);
164
+ const manifestContent = JSON.parse(
165
+ await (await import('node:fs/promises')).readFile(
166
+ join(tempDir, '.mc', 'modules-hashes.json'),
167
+ 'utf8'
168
+ )
169
+ );
170
+ const expectedHash = createHash('sha256').update(content).digest('hex');
171
+ expect(manifestContent.files['infra/modules/fresh.ts']).toBe(expectedHash);
172
+ });
142
173
  });
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtemp, writeFile, mkdir } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import { readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { rmSync } from 'node:fs';
8
+ import { runRegenerateModulesHashes } from '../../commands/regenerate-modules-hashes.js';
9
+
10
+ describe('regenerate-modules-hashes', () => {
11
+ let tempDir: string;
12
+
13
+ beforeEach(async () => {
14
+ tempDir = await mkdtemp(join(tmpdir(), 'tib-regenerate-modules-hashes-'));
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(tempDir, { recursive: true, force: true });
19
+ });
20
+
21
+ it('writes modules-hashes.json with correct version and structure', async () => {
22
+ await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
23
+
24
+ const content = 'export const MyStack = {};';
25
+ await writeFile(join(tempDir, 'infra', 'modules', 'test.ts'), content);
26
+
27
+ const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
28
+
29
+ expect(manifestPath).toBe(join(tempDir, '.mc', 'modules-hashes.json'));
30
+
31
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
32
+ expect(manifest.version).toBe('1');
33
+ expect(typeof manifest.generatedAt).toBe('string');
34
+ expect(new Date(manifest.generatedAt).toISOString()).toBe(manifest.generatedAt);
35
+ expect(manifest.files).toEqual({
36
+ 'infra/modules/test.ts': createHash('sha256').update(content).digest('hex'),
37
+ });
38
+ });
39
+
40
+ it('computes correct SHA-256 hashes for every file', async () => {
41
+ await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
42
+
43
+ const files: Record<string, string> = {
44
+ 'infra/modules/a.ts': 'export const a = 1;',
45
+ 'infra/modules/b.ts': 'export const b = 2;\nexport const c = 3;',
46
+ 'infra/modules/nested/c.ts': 'export const c = "hello world";',
47
+ };
48
+
49
+ await mkdir(join(tempDir, 'infra', 'modules', 'nested'), { recursive: true });
50
+ for (const [rel, content] of Object.entries(files)) {
51
+ await writeFile(join(tempDir, rel), content);
52
+ }
53
+
54
+ const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
55
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
56
+
57
+ expect(Object.keys(manifest.files).sort()).toEqual(Object.keys(files).sort());
58
+ for (const [rel, content] of Object.entries(files)) {
59
+ const expected = createHash('sha256').update(content).digest('hex');
60
+ expect(manifest.files[rel]).toBe(expected);
61
+ }
62
+ });
63
+
64
+ it('skips node_modules, dist, cdk.out, package-lock.json, and .npmrc', async () => {
65
+ const root = tempDir;
66
+ const modules = join(root, 'infra', 'modules');
67
+ await mkdir(modules, { recursive: true });
68
+
69
+ // Real file we want hashed
70
+ await writeFile(join(modules, 'real.ts'), 'export const real = true;');
71
+
72
+ // Files/dirs that must be excluded
73
+ await mkdir(join(modules, 'node_modules', 'pkg'), { recursive: true });
74
+ await writeFile(join(modules, 'node_modules', 'pkg', 'index.js'), 'module.exports = {};');
75
+
76
+ await mkdir(join(modules, 'dist'), { recursive: true });
77
+ await writeFile(join(modules, 'dist', 'bundle.js'), 'var x = 1;');
78
+
79
+ await mkdir(join(modules, 'cdk.out'), { recursive: true });
80
+ await writeFile(join(modules, 'cdk.out', 'template.json'), '{}');
81
+
82
+ await writeFile(join(modules, 'package-lock.json'), '{}');
83
+ await writeFile(join(modules, '.npmrc'), 'registry=https://example.com');
84
+
85
+ // Also nested exclude
86
+ await mkdir(join(modules, 'sub', 'node_modules'), { recursive: true });
87
+ await writeFile(join(modules, 'sub', 'node_modules', 'dep.js'), '// skip');
88
+
89
+ await writeFile(join(modules, 'sub', 'kept.ts'), 'export const kept = 1;');
90
+
91
+ const manifestPath = await runRegenerateModulesHashes({ projectRoot: root });
92
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
93
+
94
+ expect(manifest.files).toEqual({
95
+ 'infra/modules/real.ts': createHash('sha256').update('export const real = true;').digest('hex'),
96
+ 'infra/modules/sub/kept.ts': createHash('sha256').update('export const kept = 1;').digest('hex'),
97
+ });
98
+
99
+ // None of the excluded entries appear
100
+ const keys = Object.keys(manifest.files);
101
+ expect(keys.some((k) => k.includes('node_modules'))).toBe(false);
102
+ expect(keys.some((k) => k.includes('dist/'))).toBe(false);
103
+ expect(keys.some((k) => k.includes('cdk.out/'))).toBe(false);
104
+ expect(keys.some((k) => k.endsWith('package-lock.json'))).toBe(false);
105
+ expect(keys.some((k) => k.endsWith('.npmrc'))).toBe(false);
106
+ });
107
+
108
+ it('handles empty infra/modules/ directory by emitting empty files map', async () => {
109
+ await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
110
+
111
+ const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
112
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
113
+
114
+ expect(manifest.version).toBe('1');
115
+ expect(typeof manifest.generatedAt).toBe('string');
116
+ expect(manifest.files).toEqual({});
117
+ });
118
+
119
+ it('handles missing infra/modules/ directory gracefully', async () => {
120
+ // Do NOT create infra/modules/ — only the project root exists
121
+ const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
122
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
123
+
124
+ expect(manifestPath).toBe(join(tempDir, '.mc', 'modules-hashes.json'));
125
+ expect(manifest.version).toBe('1');
126
+ expect(typeof manifest.generatedAt).toBe('string');
127
+ expect(manifest.files).toEqual({});
128
+ });
129
+
130
+ it('is idempotent — second run produces identical file map and structure', async () => {
131
+ await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
132
+
133
+ await writeFile(join(tempDir, 'infra', 'modules', 'a.ts'), 'export const a = 1;');
134
+ await mkdir(join(tempDir, 'infra', 'modules', 'sub'), { recursive: true });
135
+ await writeFile(join(tempDir, 'infra', 'modules', 'sub', 'b.ts'), 'export const b = 2;');
136
+
137
+ const firstPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
138
+ const first = JSON.parse(readFileSync(firstPath, 'utf8'));
139
+
140
+ // Wait long enough that a fresh ISO timestamp would differ
141
+ await new Promise((r) => setTimeout(r, 10));
142
+
143
+ const secondPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
144
+ const second = JSON.parse(readFileSync(secondPath, 'utf8'));
145
+
146
+ expect(secondPath).toBe(firstPath);
147
+ expect(second.version).toBe(first.version);
148
+ expect(second.files).toEqual(first.files);
149
+
150
+ // Timestamps may differ, but the structure (ignoring generatedAt) is identical
151
+ const { generatedAt: _g1, ...firstRest } = first;
152
+ const { generatedAt: _g2, ...secondRest } = second;
153
+ expect(secondRest).toEqual(firstRest);
154
+ });
155
+
156
+ it('includes deeply nested files under infra/modules/', async () => {
157
+ await mkdir(join(tempDir, 'infra', 'modules', 'flows', 'sub'), { recursive: true });
158
+
159
+ const nestedContent = 'export const nested = true;';
160
+ const nestedPath = join(tempDir, 'infra', 'modules', 'flows', 'sub', 'nested.ts');
161
+ await writeFile(nestedPath, nestedContent);
162
+
163
+ const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
164
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
165
+
166
+ expect(manifest.files['infra/modules/flows/sub/nested.ts']).toBe(
167
+ createHash('sha256').update(nestedContent).digest('hex'),
168
+ );
169
+ });
170
+ });