Skip to content

Commit 7c6335c

Browse files
committed
feat(cli): complete phase 3 global scope support
1 parent 5dc770d commit 7c6335c

7 files changed

Lines changed: 432 additions & 47 deletions

File tree

packages/cli/src/commands/compile.ts

Lines changed: 103 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { mkdir, writeFile, readFile, symlink, unlink } from 'node:fs/promises';
2-
import { join, dirname } from 'node:path';
2+
import { join, dirname, basename } from 'node:path';
3+
import { homedir } from 'node:os';
34
import type { Command } from 'commander';
45
import chalk from 'chalk';
5-
import { readConfig, readRules } from '../core/parser.js';
6+
import { readConfig, readConfigFromDwfDir, readRules } from '../core/parser.js';
7+
import { mergeRules } from '../core/merge.js';
68
import { computeRulesHash, writeHash } from '../core/hash.js';
79
import { deployAssets } from '../core/assets.js';
8-
import type { Bridge, DirectoryBridge } from '../bridges/types.js';
10+
import type { Bridge, DirectoryBridge, Rule } from '../bridges/types.js';
911
import { isDirectoryBridge, getBridgeOutputPaths } from '../bridges/types.js';
1012
import { claudeBridge } from '../bridges/claude.js';
1113
import { cursorBridge } from '../bridges/cursor.js';
@@ -46,6 +48,9 @@ export interface MigrationResult {
4648
export interface CompileResult {
4749
results: BridgeResult[];
4850
activeRuleCount: number;
51+
globalRuleCount: number;
52+
projectRuleCount: number;
53+
overriddenRuleIds: string[];
4954
canonicalFileCount: number;
5055
canonicalError?: string;
5156
assetPaths: string[];
@@ -72,7 +77,7 @@ function extractFilenameFromPath(relativePath: string): string {
7277
}
7378

7479
async function handleDirectoryBridgeCleanup(
75-
cwd: string,
80+
outputRoot: string,
7681
bridge: DirectoryBridge,
7782
writtenFilenames: Set<string>,
7883
write: boolean,
@@ -81,16 +86,51 @@ async function handleDirectoryBridgeCleanup(
8186
return [];
8287
}
8388

84-
const outputDir = join(cwd, bridge.outputDir);
89+
const outputDir = join(outputRoot, bridge.outputDir);
8590
return cleanStaleFiles(outputDir, bridge.filePrefix, bridge.fileExtension, writtenFilenames);
8691
}
8792

93+
interface CompileContext {
94+
configRoot: string;
95+
outputRoot: string;
96+
globalMode: boolean;
97+
}
98+
99+
async function resolveCompileContext(cwd: string): Promise<CompileContext> {
100+
const projectConfigPath = join(cwd, '.dwf', 'config.yml');
101+
if (await fileExists(projectConfigPath)) {
102+
return {
103+
configRoot: cwd,
104+
outputRoot: cwd,
105+
globalMode: false,
106+
};
107+
}
108+
109+
const inGlobalConfigDir = basename(cwd) === '.dwf';
110+
const globalConfigPath = join(cwd, 'config.yml');
111+
if (inGlobalConfigDir && await fileExists(globalConfigPath)) {
112+
return {
113+
configRoot: cwd,
114+
outputRoot: homedir(),
115+
globalMode: true,
116+
};
117+
}
118+
119+
throw new Error('.dwf/config.yml not found. Run devw init to initialize the project');
120+
}
121+
88122
export async function executePipeline(options: PipelineOptions): Promise<CompileResult> {
89123
const { cwd, tool, write = true } = options;
90124
const startTime = performance.now();
125+
const context = await resolveCompileContext(cwd);
91126

92-
const config = await readConfig(cwd);
93-
const rules = await readRules(cwd);
127+
const config = context.globalMode ? await readConfigFromDwfDir(context.configRoot) : await readConfig(context.configRoot);
128+
const projectRules = await readRules(context.configRoot);
129+
const globalRules = context.globalMode || config.global === false
130+
? []
131+
: await readRules(context.configRoot, join(homedir(), '.dwf', 'rules'));
132+
const rules = mergeRules(globalRules, projectRules);
133+
const overriddenRuleIds = getOverriddenRuleIds(globalRules, projectRules);
94134

95135
let toolIds = config.tools;
96136
if (tool) {
@@ -103,9 +143,9 @@ export async function executePipeline(options: PipelineOptions): Promise<Compile
103143
// Legacy migration — run ONCE before writing new files
104144
const migration: MigrationResult = { actions: [] };
105145
if (write) {
106-
const legacyFiles = await detectLegacyFiles(cwd);
146+
const legacyFiles = await detectLegacyFiles(context.outputRoot);
107147
if (legacyFiles.length > 0) {
108-
const actions = await migrateLegacyFiles(cwd, legacyFiles);
148+
const actions = await migrateLegacyFiles(context.outputRoot, legacyFiles);
109149
migration.actions = actions;
110150
}
111151
}
@@ -125,7 +165,7 @@ export async function executePipeline(options: PipelineOptions): Promise<Compile
125165
// DirectoryBridge flow: multi-file output with stale cleanup
126166
if (activeRules.length === 0 && write) {
127167
// No active rules → clean all dwf- files from the output dir
128-
const deleted = await handleDirectoryBridgeCleanup(cwd, bridge, new Set(), write);
168+
const deleted = await handleDirectoryBridgeCleanup(context.outputRoot, bridge, new Set(), write);
129169
if (deleted.length > 0) {
130170
staleResults.push({ bridgeId: bridge.id, deleted });
131171
}
@@ -143,11 +183,11 @@ export async function executePipeline(options: PipelineOptions): Promise<Compile
143183
continue;
144184
}
145185

146-
const absolutePath = join(cwd, relativePath);
186+
const absolutePath = join(context.outputRoot, relativePath);
147187
await mkdir(dirname(absolutePath), { recursive: true });
148188

149189
if (config.mode === 'link') {
150-
const cachePath = join(cwd, '.dwf', '.cache', relativePath);
190+
const cachePath = join(context.outputRoot, '.dwf', '.cache', relativePath);
151191
await mkdir(dirname(cachePath), { recursive: true });
152192
await writeFile(cachePath, content, 'utf-8');
153193

@@ -163,15 +203,15 @@ export async function executePipeline(options: PipelineOptions): Promise<Compile
163203
}
164204

165205
// Stale file cleanup for DirectoryBridge
166-
const deleted = await handleDirectoryBridgeCleanup(cwd, bridge, writtenFilenames, write);
206+
const deleted = await handleDirectoryBridgeCleanup(context.outputRoot, bridge, writtenFilenames, write);
167207
if (deleted.length > 0) {
168208
staleResults.push({ bridgeId: bridge.id, deleted });
169209
}
170210
} else {
171211
// MarkerBridge flow: merge content between markers in target file
172212
if (activeRules.length === 0 && write) {
173213
for (const relativePath of getBridgeOutputPaths(bridge)) {
174-
const absolutePath = join(cwd, relativePath);
214+
const absolutePath = join(context.outputRoot, relativePath);
175215
if (!(await fileExists(absolutePath))) {
176216
continue;
177217
}
@@ -192,7 +232,7 @@ export async function executePipeline(options: PipelineOptions): Promise<Compile
192232

193233
for (const [relativePath, rawContent] of outputs) {
194234
let content = rawContent;
195-
const absoluteCheck = join(cwd, relativePath);
235+
const absoluteCheck = join(context.outputRoot, relativePath);
196236
let existing: string | null = null;
197237
try {
198238
existing = await readFile(absoluteCheck, 'utf-8');
@@ -206,11 +246,11 @@ export async function executePipeline(options: PipelineOptions): Promise<Compile
206246
continue;
207247
}
208248

209-
const absolutePath = join(cwd, relativePath);
249+
const absolutePath = join(context.outputRoot, relativePath);
210250
await mkdir(dirname(absolutePath), { recursive: true });
211251

212252
if (config.mode === 'link') {
213-
const cachePath = join(cwd, '.dwf', '.cache', relativePath);
253+
const cachePath = join(context.outputRoot, '.dwf', '.cache', relativePath);
214254
await mkdir(dirname(cachePath), { recursive: true });
215255
await writeFile(cachePath, content, 'utf-8');
216256

@@ -245,7 +285,7 @@ export async function executePipeline(options: PipelineOptions): Promise<Compile
245285
let canonicalError: string | undefined;
246286
if (write) {
247287
try {
248-
canonicalPaths = await writeCanonical(cwd, canonicalOutputs);
288+
canonicalPaths = await writeCanonical(context.outputRoot, canonicalOutputs);
249289
for (const relativePath of canonicalPaths) {
250290
results.push({ bridgeId: 'canonical', outputPath: relativePath, success: true });
251291
}
@@ -270,16 +310,19 @@ export async function executePipeline(options: PipelineOptions): Promise<Compile
270310
let assetPaths: string[] = [];
271311
if (write) {
272312
const hash = computeRulesHash(activeRules);
273-
await writeHash(cwd, hash);
313+
await writeHash(context.outputRoot, hash);
274314

275-
const assetResult = await deployAssets(cwd, config);
315+
const assetResult = await deployAssets(context.outputRoot, config);
276316
assetPaths = assetResult.deployed;
277317
}
278318

279319
const elapsedMs = performance.now() - startTime;
280320
return {
281321
results,
282322
activeRuleCount: activeRules.length,
323+
globalRuleCount: globalRules.length,
324+
projectRuleCount: projectRules.length,
325+
overriddenRuleIds,
283326
canonicalFileCount: canonicalPaths.length,
284327
canonicalError,
285328
assetPaths,
@@ -292,19 +335,31 @@ export async function executePipeline(options: PipelineOptions): Promise<Compile
292335
export async function runCompile(options: CompileOptions): Promise<void> {
293336
const cwd = process.cwd();
294337

295-
if (!(await fileExists(join(cwd, '.dwf', 'config.yml')))) {
296-
ui.error('.dwf/config.yml not found', 'Run devw init to initialize the project');
297-
process.exitCode = 1;
298-
return;
299-
}
300-
301338
try {
339+
const context = await resolveCompileContext(cwd);
340+
302341
if (options.verbose) {
303-
const config = await readConfig(cwd);
304-
const rules = await readRules(cwd);
342+
const config = context.globalMode ? await readConfigFromDwfDir(context.configRoot) : await readConfig(context.configRoot);
343+
const projectRules = await readRules(context.configRoot);
344+
const globalRules = context.globalMode || config.global === false
345+
? []
346+
: await readRules(context.configRoot, join(homedir(), '.dwf', 'rules'));
347+
const mergedRules = mergeRules(globalRules, projectRules);
348+
const overriddenRuleIds = getOverriddenRuleIds(globalRules, projectRules);
349+
305350
ui.keyValue('Project:', chalk.bold(config.project.name));
351+
ui.keyValue('Scope:', context.globalMode ? 'global (~/.dwf)' : 'project (.dwf)');
306352
ui.keyValue('Mode:', config.mode);
307-
ui.keyValue('Rules:', String(rules.length));
353+
ui.keyValue('Project rules:', String(projectRules.length));
354+
if (config.global === false) {
355+
ui.keyValue('Global rules:', 'disabled by config');
356+
} else {
357+
ui.keyValue('Global rules:', String(globalRules.length));
358+
}
359+
ui.keyValue('Merged rules:', String(mergedRules.length));
360+
if (overriddenRuleIds.length > 0) {
361+
ui.keyValue('Project overrides:', String(overriddenRuleIds.length));
362+
}
308363
const toolIds = options.tool ? [options.tool] : config.tools;
309364
ui.keyValue('Tools:', chalk.cyan(toolIds.join(', ')));
310365
ui.newline();
@@ -359,6 +414,9 @@ export async function runCompile(options: CompileOptions): Promise<void> {
359414
ui.newline();
360415
ui.success(`Compiled ${String(result.activeRuleCount)} rules ${ICONS.arrow} ${String(allPaths.length)} file${allPaths.length !== 1 ? 's' : ''} ${ui.timing(result.elapsedMs)}`);
361416
ui.info(`Canonical files: ${String(result.canonicalFileCount)}`);
417+
if (options.verbose && result.overriddenRuleIds.length > 0) {
418+
ui.info(`Project overrides (${String(result.overriddenRuleIds.length)}): ${result.overriddenRuleIds.join(', ')}`);
419+
}
362420
ui.newline();
363421

364422
if (options.verbose) {
@@ -393,6 +451,22 @@ export async function runCompileFromAdd(): Promise<void> {
393451
await runCompile({});
394452
}
395453

454+
function getOverriddenRuleIds(globalRules: Rule[], projectRules: Rule[]): string[] {
455+
const globalIds = new Set<string>(globalRules.map((rule) => rule.id));
456+
const orderedOverrides: string[] = [];
457+
const seen = new Set<string>();
458+
459+
for (const rule of projectRules) {
460+
if (!globalIds.has(rule.id) || seen.has(rule.id)) {
461+
continue;
462+
}
463+
seen.add(rule.id);
464+
orderedOverrides.push(rule.id);
465+
}
466+
467+
return orderedOverrides;
468+
}
469+
396470
export function registerCompileCommand(program: Command): void {
397471
program
398472
.command('compile')

0 commit comments

Comments
 (0)