From d89fd6bc7a82389318ce0469b53057175682bd01 Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Sun, 12 Apr 2026 20:39:01 +0200 Subject: [PATCH 1/4] fix(core): remove canonical layer and simplify CLI output Remove the .agents/rules/devw/ canonical output layer that duplicated bridge outputs. Add migration cleanup to detect and remove existing canonical directories on first compile. Simplify init output to a single success line plus hint. --- packages/cli/src/commands/compile.ts | 57 ++------- packages/cli/src/commands/doctor.ts | 127 +------------------- packages/cli/src/commands/init.ts | 36 +----- packages/cli/src/core/canonical.ts | 63 ---------- packages/cli/src/core/cleanup.ts | 42 ++++++- packages/cli/tests/commands/compile.test.ts | 45 +------ packages/cli/tests/commands/doctor.test.ts | 58 +-------- packages/cli/tests/commands/init.test.ts | 3 +- packages/cli/tests/core/canonical.test.ts | 93 -------------- packages/cli/tests/ui/output.test.ts | 2 +- 10 files changed, 64 insertions(+), 462 deletions(-) delete mode 100644 packages/cli/src/core/canonical.ts delete mode 100644 packages/cli/tests/core/canonical.test.ts diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 6bff71d..c75f294 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -16,8 +16,7 @@ import { windsurfBridge } from '../bridges/windsurf.js'; import { copilotBridge } from '../bridges/copilot.js'; import { mergeMarkedContent, removeMarkedBlock } from '../core/markers.js'; import { cleanStaleFiles } from '../core/scope-filename.js'; -import { detectLegacyFiles, migrateLegacyFiles } from '../core/cleanup.js'; -import { buildCanonicalOutputs, writeCanonical } from '../core/canonical.js'; +import { detectLegacyFiles, migrateLegacyFiles, detectCanonicalDir, removeCanonicalDir } from '../core/cleanup.js'; import { fileExists } from '../utils/fs.js'; import { resolveContext } from '../core/resolve-context.js'; import * as ui from '../utils/ui.js'; @@ -53,8 +52,6 @@ export interface CompileResult { globalRuleCount: number; projectRuleCount: number; overriddenRuleIds: string[]; - canonicalFileCount: number; - canonicalError?: string; assetPaths: string[]; elapsedMs: number; staleResults: StaleFileResult[]; @@ -168,6 +165,15 @@ export async function executePipeline(options: PipelineOptions): Promise r.enabled); @@ -298,35 +304,6 @@ export async function executePipeline(options: PipelineOptions): Promise 0) { - for (const relativePath of errorPaths) { - results.push({ bridgeId: 'canonical', outputPath: relativePath, success: false, error: canonicalError }); - } - } else { - results.push({ bridgeId: 'canonical', outputPath: '.agents/rules/devw', success: false, error: canonicalError }); - } - } - } else { - for (const [relativePath, content] of canonicalOutputs) { - canonicalPaths.push(relativePath); - results.push({ bridgeId: 'canonical', outputPath: relativePath, success: true, content }); - } - } - let assetPaths: string[] = []; if (write) { const hash = computeRulesHash(activeRules); @@ -343,8 +320,6 @@ export async function executePipeline(options: PipelineOptions): Promise { const fileCount = result.results.filter((r) => r.success).length; ui.newline(); ui.info( - `Would generate ${String(fileCount)} file${fileCount !== 1 ? 's' : ''} (${String(result.canonicalFileCount)} canonical) from ${String(result.activeRuleCount)} rules`, + `Would generate ${String(fileCount)} file${fileCount !== 1 ? 's' : ''} from ${String(result.activeRuleCount)} rules`, ); return; } const result = await executePipeline({ cwd, tool: options.tool }); - if (options.tool) { - ui.info('Note: canonical output is always refreshed in .agents/rules/devw'); - } - - if (result.canonicalError) { - ui.warn(`Canonical write failed: ${result.canonicalError}`); - ui.warn('Tool-specific outputs were still written'); - } - const summaryTable = renderTable( ['bridge', 'generated', 'failed'], toCompileSummaryRows(result), @@ -439,7 +405,6 @@ export async function runCompile(options: CompileOptions): Promise { ui.newline(); ui.success(`Compiled ${String(result.activeRuleCount)} rules ${ICONS.arrow} ${String(allPaths.length)} file${allPaths.length !== 1 ? 's' : ''} ${ui.timing(result.elapsedMs)}`); - ui.info(`Canonical files: ${String(result.canonicalFileCount)}`); ui.log(summaryTable); if (options.verbose && result.overriddenRuleIds.length > 0) { ui.info(`Project overrides (${String(result.overriddenRuleIds.length)}): ${result.overriddenRuleIds.join(', ')}`); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 953dbd0..2dc9879 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,5 +1,5 @@ import { lstat, readFile, readdir } from 'node:fs/promises'; -import { basename, join, relative } from 'node:path'; +import { join, relative } from 'node:path'; import type { Command } from 'commander'; import { parse } from 'yaml'; import { readConfig, readRules } from '../core/parser.js'; @@ -14,7 +14,6 @@ import { getBridgeOutputPaths, isDirectoryBridge } from '../bridges/types.js'; import { fileExists } from '../utils/fs.js'; import { resolveContext } from '../core/resolve-context.js'; import { isValidScope } from '../core/schema.js'; -import { buildCanonicalOutputs } from '../core/canonical.js'; import { detectLegacyFiles } from '../core/cleanup.js'; import * as ui from '../utils/ui.js'; @@ -268,12 +267,6 @@ export async function checkHashSync(cwd: string, rules: Rule[]): Promise { - const canonicalDir = join(cwd, '.agents', 'rules', 'devw'); - - let entries: string[]; - try { - entries = await readdir(canonicalDir); - } catch { - return { - passed: false, - message: '.agents/rules/devw not found — run "devw compile"', - }; - } - - const canonicalFiles = entries.filter((entry) => entry.startsWith('dwf-') && entry.endsWith('.md')); - if (canonicalFiles.length === 0) { - return { - passed: false, - message: '.agents/rules/devw has no canonical files — run "devw compile"', - }; - } - - return { - passed: true, - message: `Canonical files exist (${String(canonicalFiles.length)} file${canonicalFiles.length === 1 ? '' : 's'})`, - }; -} - -export async function checkCanonicalSync(cwd: string, rules: Rule[], config: ProjectConfig): Promise { - const directoryBridges = getConfiguredDirectoryBridges(config); - - if (directoryBridges.length === 0) { - return { - passed: true, - message: 'Canonical sync skipped (no directory tools configured)', - skipped: true, - }; - } - - const canonicalOutputs = buildCanonicalOutputs(rules); - if (canonicalOutputs.size === 0) { - return { - passed: true, - message: 'Canonical sync skipped (no active scope outputs)', - skipped: true, - }; - } - - const mismatches: string[] = []; - let compared = 0; - - for (const bridge of directoryBridges) { - const expectedNativeFiles = new Set(); - - for (const [canonicalPath, canonicalContent] of canonicalOutputs) { - const canonicalFilename = basename(canonicalPath); - const scopeName = canonicalFilename.slice('dwf-'.length, canonicalFilename.length - '.md'.length); - const nativeFilename = `${bridge.filePrefix}${scopeName}${bridge.fileExtension}`; - expectedNativeFiles.add(nativeFilename); - - const nativePath = join(cwd, bridge.outputDir, nativeFilename); - if (!(await fileExists(nativePath))) { - mismatches.push(`${bridge.id}: missing ${nativeFilename}`); - continue; - } - - const nativeRaw = await readFile(nativePath, 'utf-8'); - const normalizedNative = normalizeComparableContent(nativeRaw); - const normalizedCanonical = normalizeComparableContent(canonicalContent); - - compared += 1; - if (normalizedNative !== normalizedCanonical) { - mismatches.push(`${bridge.id}: modified ${nativeFilename}`); - } - } - - const bridgeDir = join(cwd, bridge.outputDir); - let entries: string[] = []; - try { - entries = await readdir(bridgeDir); - } catch { - entries = []; - } - - for (const entry of entries) { - if (!entry.startsWith(bridge.filePrefix) || !entry.endsWith(bridge.fileExtension)) { - continue; - } - if (!expectedNativeFiles.has(entry)) { - mismatches.push(`${bridge.id}: unexpected ${entry}`); - } - } - } - - if (mismatches.length > 0) { - return { - passed: false, - message: `Canonical/native mismatch: ${mismatches.join(', ')}`, - }; - } - - return { - passed: true, - message: `Canonical and native files are in sync (${String(compared)} files compared)`, - }; -} - export async function checkLegacyMigration(cwd: string): Promise { const legacyFiles = await detectLegacyFiles(cwd); if (legacyFiles.length === 0) { @@ -570,17 +457,7 @@ export async function runDoctor(): Promise { const hashResult = await checkHashSync(effectiveCwd, rules); results.push(hashResult); - // Check 11: Canonical output exists (skip if no rules) - if (rules.length > 0) { - const canonicalExistsResult = await checkCanonicalExists(effectiveCwd); - results.push(canonicalExistsResult); - - // Check 12: Canonical and native outputs are synchronized - const canonicalSyncResult = await checkCanonicalSync(effectiveCwd, rules, config); - results.push(canonicalSyncResult); - } - - // Check 13: Legacy migration has no pending files + // Check 11: Legacy migration has no pending files const legacyResult = await checkLegacyMigration(effectiveCwd); results.push(legacyResult); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 54701b7..e1680bc 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -3,7 +3,6 @@ import { join, basename } from 'node:path'; import { homedir } from 'node:os'; import type { Command } from 'commander'; import { stringify } from 'yaml'; -import pc from 'picocolors'; import { detectTools, SUPPORTED_TOOLS } from '../utils/detect-tools.js'; import * as ui from '../utils/ui.js'; import type { ToolId } from '../utils/detect-tools.js'; @@ -12,7 +11,6 @@ import { selectPrompt, multiselectPrompt, introPrompt, - notePrompt, outroPrompt, spinnerTask, isInteractiveSession, @@ -210,41 +208,15 @@ export async function runInit(options: InitOptions): Promise { }, }); - // Ensure canonical global output dir exists for global mode. - if (scope === 'global') { - await spinnerTask({ - label: 'Preparing canonical global output', - task: async () => { - await mkdir(join(rootDir, '.agents', 'rules', 'devw'), { recursive: true }); - }, - }); - } else { + if (scope !== 'global') { await appendToGitignore(cwd); } // Success summary + const dwfPath = scope === 'global' ? '~/.dwf/' : '.dwf/'; ui.newline(); - ui.header('dev-workflows'); - ui.newline(); - ui.success(`Initialized ${scope === 'global' ? '~/.dwf/' : '.dwf/'} successfully`); - ui.newline(); - ui.keyValue('Project:', pc.bold(projectName)); - ui.keyValue('Scope:', scope); - ui.keyValue('Tools:', pc.cyan(tools.join(', '))); - ui.keyValue('Mode:', mode); - ui.newline(); - ui.header("What's next"); - ui.newline(); - console.log(` 1. Browse available rules ${pc.cyan('devw add --list')}`); - console.log(` 2. Add a rule ${pc.cyan('devw add /')}`); - console.log(` 3. Or write your own rules in ${pc.cyan(scope === 'global' ? '~/.dwf/rules/' : '.dwf/rules/')}`); - console.log(` 4. When ready, compile ${pc.cyan('devw compile')}`); - - notePrompt( - `Project: ${projectName}\nScope: ${scope}\nTools: ${tools.join(', ')}\nMode: ${mode}`, - 'Initialized', - ); - outroPrompt(`Ready: ${scope === 'global' ? '~/.dwf/' : '.dwf/'}`); + ui.success(`Initialized ${dwfPath} — ${tools.join(', ')} (${mode} mode)`); + outroPrompt('Run "devw add" to browse and install rules.'); if (options.preset) { ui.newline(); diff --git a/packages/cli/src/core/canonical.ts b/packages/cli/src/core/canonical.ts deleted file mode 100644 index 75de964..0000000 --- a/packages/cli/src/core/canonical.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { mkdir, writeFile } from 'node:fs/promises'; -import { basename, join } from 'node:path'; -import type { Rule } from '../bridges/types.js'; -import { filterRules, formatScopeHeading, groupByScope } from './helpers.js'; -import { cleanStaleFiles, scopeToFilename } from './scope-filename.js'; - -const GENERATED_COMMENT = ''; -const CANONICAL_DIR_PARTS = ['.agents', 'rules', 'devw'] as const; -const CANONICAL_PREFIX = 'dwf-'; -const CANONICAL_EXTENSION = '.md'; - -export function buildCanonicalMarkdown(scope: string, rules: Rule[]): string { - const lines: string[] = [GENERATED_COMMENT, `# ${formatScopeHeading(scope)}`, '']; - - for (const rule of rules) { - const contentLines = rule.content.split('\n'); - const first = contentLines[0]; - if (first !== undefined) { - lines.push(`- ${first}`); - } - - for (let i = 1; i < contentLines.length; i++) { - const line = contentLines[i]; - if (line !== undefined) { - lines.push(line.length > 0 ? ` ${line}` : ''); - } - } - } - - lines.push(''); - return lines.join('\n'); -} - -export function buildCanonicalOutputs(rules: Rule[]): Map { - const output = new Map(); - const filtered = filterRules(rules); - const grouped = groupByScope(filtered); - - for (const [scope, scopeRules] of grouped) { - const filename = scopeToFilename(scope, CANONICAL_PREFIX, CANONICAL_EXTENSION); - const relativePath = join(...CANONICAL_DIR_PARTS, filename); - output.set(relativePath, buildCanonicalMarkdown(scope, scopeRules)); - } - - return output; -} - -export async function writeCanonical(cwd: string, rulesOrOutputs: Rule[] | Map): Promise { - const canonicalDir = join(cwd, ...CANONICAL_DIR_PARTS); - await mkdir(canonicalDir, { recursive: true }); - - const outputs = rulesOrOutputs instanceof Map ? rulesOrOutputs : buildCanonicalOutputs(rulesOrOutputs); - const writtenFilenames = new Set(); - - for (const [relativePath, content] of outputs) { - const filename = basename(relativePath); - writtenFilenames.add(filename); - await writeFile(join(cwd, relativePath), content, 'utf-8'); - } - - await cleanStaleFiles(canonicalDir, CANONICAL_PREFIX, CANONICAL_EXTENSION, writtenFilenames); - return [...outputs.keys()]; -} diff --git a/packages/cli/src/core/cleanup.ts b/packages/cli/src/core/cleanup.ts index 3a7f56e..94173a3 100644 --- a/packages/cli/src/core/cleanup.ts +++ b/packages/cli/src/core/cleanup.ts @@ -1,8 +1,11 @@ -import { readFile, unlink, writeFile } from 'node:fs/promises'; +import { readFile, readdir, rm, unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { fileExists } from '../utils/fs.js'; import { removeMarkedBlock } from './markers.js'; +const CANONICAL_DIR_PARTS = ['.agents', 'rules', 'devw'] as const; +const CANONICAL_PREFIX = 'dwf-'; + export interface LegacyFile { path: string; type: 'marker' | 'full-file'; @@ -110,3 +113,40 @@ export async function removeLegacyMarkerBlock(filePath: string): Promise { + const canonicalDir = join(cwd, ...CANONICAL_DIR_PARTS); + + if (!(await fileExists(canonicalDir))) { + return null; + } + + let entries: string[]; + try { + entries = await readdir(canonicalDir); + } catch { + return null; + } + + const hasCanonicalFiles = entries.some((entry) => entry.startsWith(CANONICAL_PREFIX)); + return hasCanonicalFiles ? canonicalDir : null; +} + +/** + * Remove the old canonical directory `.agents/rules/devw/` if it exists + * and contains devw-generated files. Returns true if removal was performed. + */ +export async function removeCanonicalDir(cwd: string): Promise { + const canonicalDir = await detectCanonicalDir(cwd); + if (!canonicalDir) { + return false; + } + + await rm(canonicalDir, { recursive: true, force: true }); + return true; +} diff --git a/packages/cli/tests/commands/compile.test.ts b/packages/cli/tests/commands/compile.test.ts index 4098556..58b0e17 100644 --- a/packages/cli/tests/commands/compile.test.ts +++ b/packages/cli/tests/commands/compile.test.ts @@ -113,34 +113,14 @@ describe('executePipeline', () => { assert.equal(cursorResult.success, true); }); - it('tool option filters bridge but still includes canonical outputs', async () => { + it('tool option filters to only specified bridge', async () => { await setupProject(tmpDir, VALID_CONFIG, { 'conventions.yml': VALID_RULES }); const result = await executePipeline({ cwd: tmpDir, tool: 'claude' }); const bridgeIds = new Set(result.results.map((r) => r.bridgeId)); - assert.equal(bridgeIds.size, 2); + assert.equal(bridgeIds.size, 1); assert.ok(bridgeIds.has('claude')); - assert.ok(bridgeIds.has('canonical')); - }); - - it('keeps bridge outputs when canonical write fails', async () => { - await setupProject(tmpDir, VALID_CONFIG, { 'conventions.yml': VALID_RULES }); - - await mkdir(join(tmpDir, '.agents', 'rules'), { recursive: true }); - await writeFile(join(tmpDir, '.agents', 'rules', 'devw'), 'blocking file', 'utf-8'); - - const result = await executePipeline({ cwd: tmpDir, tool: 'claude' }); - - assert.ok(await fileExists(join(tmpDir, '.claude', 'rules', 'dwf-conventions.md'))); - assert.ok(result.canonicalError); - - const claudeResults = result.results.filter((r) => r.bridgeId === 'claude'); - const canonicalResults = result.results.filter((r) => r.bridgeId === 'canonical'); - - assert.ok(claudeResults.every((r) => r.success)); - assert.ok(canonicalResults.length > 0); - assert.ok(canonicalResults.every((r) => !r.success)); }); it('throws on invalid tool filter', async () => { @@ -230,12 +210,9 @@ describe('executePipeline DirectoryBridge multi-file output', () => { assert.ok(await fileExists(join(tmpDir, '.claude', 'rules', 'dwf-conventions.md'))); assert.ok(await fileExists(join(tmpDir, '.claude', 'rules', 'dwf-security.md'))); - assert.ok(await fileExists(join(tmpDir, '.agents', 'rules', 'devw', 'dwf-conventions.md'))); - assert.ok(await fileExists(join(tmpDir, '.agents', 'rules', 'devw', 'dwf-security.md'))); const claudeResults = result.results.filter((r) => r.bridgeId === 'claude'); assert.equal(claudeResults.length, 2); - assert.equal(result.canonicalFileCount, 2); }); it('creates output directories automatically', async () => { @@ -257,11 +234,6 @@ describe('executePipeline DirectoryBridge multi-file output', () => { const content = await readFile(join(tmpDir, '.claude', 'rules', 'dwf-conventions.md'), 'utf-8'); assert.ok(content.includes('paths:')); assert.ok(content.includes('"src/"')); - - const canonicalContent = await readFile(join(tmpDir, '.agents', 'rules', 'devw', 'dwf-conventions.md'), 'utf-8'); - assert.ok(canonicalContent.startsWith('')); - assert.ok(!canonicalContent.startsWith('---')); - assert.ok(!canonicalContent.includes('paths:')); }); }); @@ -369,8 +341,6 @@ blocks: [] // Pre-populate stale file await mkdir(join(tmpDir, '.claude', 'rules'), { recursive: true }); await writeFile(join(tmpDir, '.claude', 'rules', 'dwf-testing.md'), 'old content'); - await mkdir(join(tmpDir, '.agents', 'rules', 'devw'), { recursive: true }); - await writeFile(join(tmpDir, '.agents', 'rules', 'devw', 'dwf-testing.md'), 'old content'); const result = await executePipeline({ cwd: tmpDir }); @@ -378,7 +348,6 @@ blocks: [] assert.ok(await fileExists(join(tmpDir, '.claude', 'rules', 'dwf-conventions.md'))); // Stale file should be removed assert.ok(!(await fileExists(join(tmpDir, '.claude', 'rules', 'dwf-testing.md')))); - assert.ok(!(await fileExists(join(tmpDir, '.agents', 'rules', 'devw', 'dwf-testing.md')))); // Should report stale files assert.ok(result.staleResults.length > 0); @@ -565,21 +534,14 @@ describe('executePipeline dry-run', () => { const result = await executePipeline({ cwd: tmpDir, write: false }); const claudeResults = result.results.filter((r) => r.bridgeId === 'claude'); - const canonicalResults = result.results.filter((r) => r.bridgeId === 'canonical'); assert.ok(claudeResults.length > 0); - assert.ok(canonicalResults.length > 0); for (const r of claudeResults) { assert.ok(r.content); assert.ok(r.outputPath.includes('.claude/rules/')); } - for (const r of canonicalResults) { - assert.ok(r.content); - assert.ok(r.outputPath.includes('.agents/rules/devw/')); - } // No files should be written assert.ok(!(await fileExists(join(tmpDir, '.claude', 'rules')))); - assert.ok(!(await fileExists(join(tmpDir, '.agents', 'rules', 'devw')))); }); it('shows files for MarkerBridge without writing', async () => { @@ -664,7 +626,7 @@ rules: assert.ok(!compiled.includes('Global rule content.')); }); - it('writes native and canonical outputs to home directories in global mode', async () => { + it('writes native outputs to home directories in global mode', async () => { const fakeHome = join(tmpDir, 'home'); process.env['HOME'] = fakeHome; @@ -700,7 +662,6 @@ rules: const result = await executePipeline({ cwd: globalDwfDir, tool: 'claude' }); assert.ok(await fileExists(join(fakeHome, '.claude', 'rules', 'dwf-conventions.md'))); - assert.ok(await fileExists(join(fakeHome, '.agents', 'rules', 'devw', 'dwf-conventions.md'))); assert.ok(!(await fileExists(join(globalDwfDir, '.claude', 'rules', 'dwf-conventions.md')))); assert.ok(!(await fileExists(join(fakeHome, '.claude', 'rules', 'dwf-testing.md')))); assert.ok(result.staleResults.some((entry) => entry.bridgeId === 'claude')); diff --git a/packages/cli/tests/commands/doctor.test.ts b/packages/cli/tests/commands/doctor.test.ts index e2311cf..cc9e785 100644 --- a/packages/cli/tests/commands/doctor.test.ts +++ b/packages/cli/tests/commands/doctor.test.ts @@ -12,14 +12,12 @@ import { checkBridgesAvailable, checkSymlinks, checkHashSync, - checkCanonicalExists, - checkCanonicalSync, checkLegacyMigration, checkNativeFrontmatter, } from '../../src/commands/doctor.js'; import { computeRulesHash, writeHash } from '../../src/core/hash.js'; import { executePipeline } from '../../src/commands/compile.js'; -import { readConfig, readRules } from '../../src/core/parser.js'; +import { readConfig } from '../../src/core/parser.js'; import type { Rule, ProjectConfig } from '../../src/bridges/types.js'; const VALID_CONFIG = `version: "0.1" @@ -354,60 +352,6 @@ blocks: [] }); }); - describe('checkCanonicalExists', () => { - it('fails when canonical directory does not exist', async () => { - const result = await checkCanonicalExists(tmpDir); - assert.equal(result.passed, false); - assert.ok(result.message.includes('.agents/rules/devw')); - }); - - it('passes when canonical files exist', async () => { - await mkdir(join(tmpDir, '.agents', 'rules', 'devw'), { recursive: true }); - await writeFile(join(tmpDir, '.agents', 'rules', 'devw', 'dwf-conventions.md'), 'content', 'utf-8'); - - const result = await checkCanonicalExists(tmpDir); - assert.equal(result.passed, true); - assert.ok(result.message.includes('1 file')); - }); - }); - - describe('checkCanonicalSync', () => { - it('passes when canonical and native files are aligned', async () => { - await mkdir(join(tmpDir, '.dwf', 'rules'), { recursive: true }); - await writeFile(join(tmpDir, '.dwf', 'config.yml'), VALID_CONFIG); - await writeFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), VALID_RULES); - - await executePipeline({ cwd: tmpDir }); - - const config = await readConfig(tmpDir); - const rules = await readRules(tmpDir); - const result = await checkCanonicalSync(tmpDir, rules, config); - - assert.equal(result.passed, true); - assert.ok(result.message.includes('in sync')); - }); - - it('fails when native file was manually edited', async () => { - await mkdir(join(tmpDir, '.dwf', 'rules'), { recursive: true }); - await writeFile(join(tmpDir, '.dwf', 'config.yml'), VALID_CONFIG); - await writeFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), VALID_RULES); - - await executePipeline({ cwd: tmpDir }); - await writeFile( - join(tmpDir, '.claude', 'rules', 'dwf-conventions.md'), - '\n# Conventions\n\n- Tampered content\n', - 'utf-8', - ); - - const config = await readConfig(tmpDir); - const rules = await readRules(tmpDir); - const result = await checkCanonicalSync(tmpDir, rules, config); - - assert.equal(result.passed, false); - assert.ok(result.message.includes('Canonical/native mismatch')); - }); - }); - describe('checkLegacyMigration', () => { it('passes when no legacy files are present', async () => { const result = await checkLegacyMigration(tmpDir); diff --git a/packages/cli/tests/commands/init.test.ts b/packages/cli/tests/commands/init.test.ts index f5438ca..3a7c542 100644 --- a/packages/cli/tests/commands/init.test.ts +++ b/packages/cli/tests/commands/init.test.ts @@ -59,13 +59,12 @@ describe('runInit', () => { assert.ok(!(await fileExists(join(fakeHome, '.dwf', 'config.yml')))); }); - it('initializes global mode with --global and creates canonical directory', async () => { + it('initializes global mode with --global', async () => { await runInit({ global: true, tools: 'claude', mode: 'copy', yes: true }); const globalConfigPath = join(fakeHome, '.dwf', 'config.yml'); assert.ok(await fileExists(globalConfigPath)); assert.ok(await fileExists(join(fakeHome, '.dwf', 'rules', 'conventions.yml'))); - assert.ok(await fileExists(join(fakeHome, '.agents', 'rules', 'devw'))); assert.ok(!(await fileExists(join(projectDir, '.dwf', 'config.yml')))); const config = await readFile(globalConfigPath, 'utf-8'); diff --git a/packages/cli/tests/core/canonical.test.ts b/packages/cli/tests/core/canonical.test.ts deleted file mode 100644 index fc3117f..0000000 --- a/packages/cli/tests/core/canonical.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, it, beforeEach, afterEach } from 'node:test'; -import assert from 'node:assert/strict'; -import { access, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { buildCanonicalMarkdown, writeCanonical } from '../../src/core/canonical.js'; -import type { Rule } from '../../src/bridges/types.js'; - -async function fileExists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} - -function makeRule(overrides: Partial = {}): Rule { - return { - id: 'test-rule', - scope: 'conventions', - severity: 'error', - content: 'Always use named exports.', - enabled: true, - ...overrides, - }; -} - -describe('canonical writer', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'devw-canonical-')); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - describe('buildCanonicalMarkdown', () => { - it('produces deterministic markdown without frontmatter', () => { - const rules = [ - makeRule({ id: 'named-exports', content: 'Always use named exports.' }), - makeRule({ id: 'explicit-return', content: 'Declare return types.\nNever use implicit any.' }), - ]; - - const outputA = buildCanonicalMarkdown('conventions', rules); - const outputB = buildCanonicalMarkdown('conventions', rules); - - assert.equal(outputA, outputB); - assert.ok(outputA.startsWith('')); - assert.ok(outputA.includes('# Conventions')); - assert.ok(outputA.includes('- Always use named exports.')); - assert.ok(outputA.includes('- Declare return types.')); - assert.ok(outputA.includes(' Never use implicit any.')); - assert.ok(!outputA.includes(' \n')); - assert.ok(!outputA.startsWith('---')); - assert.ok(!outputA.includes('paths:')); - assert.ok(!outputA.includes('globs:')); - }); - }); - - describe('writeCanonical', () => { - it('writes canonical files and removes stale dwf files only', async () => { - const canonicalDir = join(tmpDir, '.agents', 'rules', 'devw'); - await mkdir(canonicalDir, { recursive: true }); - await writeFile(join(canonicalDir, 'dwf-old-scope.md'), 'stale', 'utf-8'); - await writeFile(join(canonicalDir, 'my-custom-notes.md'), 'keep me', 'utf-8'); - - const rules = [ - makeRule({ id: 'named-exports', scope: 'conventions' }), - makeRule({ id: 'no-eval', scope: 'security', content: 'Never use eval().' }), - makeRule({ id: 'info-rule', scope: 'architecture', severity: 'info', content: 'Informational only.' }), - ]; - - const written = await writeCanonical(tmpDir, rules); - - assert.deepEqual(written, [ - '.agents/rules/devw/dwf-conventions.md', - '.agents/rules/devw/dwf-security.md', - ]); - - assert.equal(await fileExists(join(canonicalDir, 'dwf-conventions.md')), true); - assert.equal(await fileExists(join(canonicalDir, 'dwf-security.md')), true); - assert.equal(await fileExists(join(canonicalDir, 'dwf-old-scope.md')), false); - assert.equal(await fileExists(join(canonicalDir, 'my-custom-notes.md')), true); - - const conventions = await readFile(join(canonicalDir, 'dwf-conventions.md'), 'utf-8'); - assert.ok(conventions.includes('# Conventions')); - assert.ok(!conventions.startsWith('---')); - }); - }); -}); diff --git a/packages/cli/tests/ui/output.test.ts b/packages/cli/tests/ui/output.test.ts index 1fd0455..a406782 100644 --- a/packages/cli/tests/ui/output.test.ts +++ b/packages/cli/tests/ui/output.test.ts @@ -159,7 +159,7 @@ describe('output format: doctor', () => { await writeFile(join(tmpDir, '.dwf', 'config.yml'), CONFIG_TEMPLATE(['claude'])); await writeFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), RULES_CONVENTIONS); - // Compile first so canonical checks pass; symlink check should still be skipped in copy mode + // Compile first so hash checks pass; symlink check should still be skipped in copy mode await run(['compile'], tmpDir); const result = await run(['doctor'], tmpDir); From c6261bb43cc5c430de55e01fc03c369597d45f9f Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Sun, 12 Apr 2026 20:54:22 +0200 Subject: [PATCH 2/4] fix(tests): isolate HOME in missing-config tests to prevent global fallback --- packages/cli/tests/commands/compile.test.ts | 20 +++++++++++++------- packages/cli/tests/commands/explain.test.ts | 14 ++++++++++---- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/cli/tests/commands/compile.test.ts b/packages/cli/tests/commands/compile.test.ts index 58b0e17..7b91b2c 100644 --- a/packages/cli/tests/commands/compile.test.ts +++ b/packages/cli/tests/commands/compile.test.ts @@ -136,13 +136,19 @@ describe('executePipeline', () => { }); it('throws on missing config', async () => { - await assert.rejects( - () => executePipeline({ cwd: tmpDir }), - (err: Error) => { - assert.ok(err.message.length > 0); - return true; - }, - ); + const originalHome = process.env['HOME']; + process.env['HOME'] = tmpDir; + try { + await assert.rejects( + () => executePipeline({ cwd: tmpDir }), + (err: Error) => { + assert.ok(err.message.length > 0); + return true; + }, + ); + } finally { + process.env['HOME'] = originalHome; + } }); it('throws on invalid YAML syntax', async () => { diff --git a/packages/cli/tests/commands/explain.test.ts b/packages/cli/tests/commands/explain.test.ts index 3da0c5c..17f2fa3 100644 --- a/packages/cli/tests/commands/explain.test.ts +++ b/packages/cli/tests/commands/explain.test.ts @@ -132,10 +132,16 @@ describe('devw explain', () => { }); it('errors when no config exists', async () => { - const result = await run(['explain'], tmpDir); - - assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('No devw configuration found')); + const originalHome = process.env['HOME']; + process.env['HOME'] = tmpDir; + try { + const result = await run(['explain'], tmpDir); + + assert.equal(result.exitCode, 1); + assert.ok(result.stderr.includes('No devw configuration found')); + } finally { + process.env['HOME'] = originalHome; + } }); it('errors when --tool is not configured', async () => { From d6a36c9ee9eb08b2b97cf3dee530869e182ab419 Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Sun, 12 Apr 2026 21:09:57 +0200 Subject: [PATCH 3/4] fix(init): ask to overwrite existing config instead of blocking --- packages/cli/src/commands/init.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index e1680bc..f988b93 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -10,6 +10,7 @@ import { fileExists } from '../utils/fs.js'; import { selectPrompt, multiselectPrompt, + confirmPrompt, introPrompt, outroPrompt, spinnerTask, @@ -163,11 +164,17 @@ export async function runInit(options: InitOptions): Promise { if (await fileExists(dwfDir)) { const locationHint = scope === 'global' - ? '~/.dwf/ already exists in your home directory' - : '.dwf/ already exists in this directory'; - ui.error(locationHint, 'Remove it first or run from a different directory'); - process.exitCode = 1; - return; + ? '~/.dwf/ already exists.' + : '.dwf/ already exists in this directory.'; + ui.warn(locationHint); + const overwrite = await confirmPrompt({ + message: 'Overwrite config? (rules will be preserved)', + defaultValue: false, + }); + if (!overwrite) { + outroPrompt('Init cancelled.'); + return; + } } const projectName = scope === 'global' ? 'global' : basename(cwd); From 53f79c85d06bda433349f0fa10038c83d36c4f4a Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Sun, 12 Apr 2026 21:16:53 +0200 Subject: [PATCH 4/4] fix(compile): show hint instead of empty table when no rules found --- packages/cli/src/commands/compile.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index c75f294..20bcd0f 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -404,6 +404,10 @@ export async function runCompile(options: CompileOptions): Promise { const allPaths = [...writtenPaths, ...result.assetPaths]; ui.newline(); + if (result.activeRuleCount === 0) { + ui.warn('No rules found. Run "devw add" to install rules from the registry.'); + return; + } ui.success(`Compiled ${String(result.activeRuleCount)} rules ${ICONS.arrow} ${String(allPaths.length)} file${allPaths.length !== 1 ? 's' : ''} ${ui.timing(result.elapsedMs)}`); ui.log(summaryTable); if (options.verbose && result.overriddenRuleIds.length > 0) {