From 89b4d1fa7c264755415c250c2eb6fdf98f09d2d6 Mon Sep 17 00:00:00 2001 From: Ankur <04.ankur@gmail.com> Date: Wed, 29 Apr 2026 16:51:27 +0530 Subject: [PATCH] Extract drafting-command boilerplate into three shared helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three near-identical patterns lived across new / tech / tasks (and a chunk of one across eight non-init commands): 1. **Scan-context branch in new/tech/tasks.** The same `if (isGreenfield) { readOverview } else { scan + compactScan + describeScanWarnings }` block, three copies, same error strings modulo command name. Now `loadScanContext({ cwd, config, log, scan, readOverview, commandName })` returns `{ scanForPrompt, packageMeta, overview }`. Lives at `src/utils/scan-context.js`. 2. **Overwrite-confirm dance in new/tech/tasks.** Same `if !force && exists { TTY ? confirm-or-cancel : throw }` shape three times, with the same inline `confirm({ default: false })` prompt definition. Now `confirmOverwriteOrCancel({ targetPath, slug, file, force, isInteractive, log, confirmOverwrite })` returns a `proceed` boolean. The default prompt moved into `src/utils/overwrite-guard.js`; commands no longer carry per-file `confirmOverwrite` definitions in their `DEFAULT_PROMPTS` (test injection still works through `deps.prompts`). 3. **`.draftwise/` existence guard, eight callers.** Same three-line block (`const dir = join(cwd, '.draftwise'); if (!await pathExists(dir)) throw 'not found, run init first'`) at the top of `explain`, `list`, `new`, `scaffold`, `scan`, `show`, `tasks`, `tech`. Now `await requireDraftwiseDir(cwd)` returns the resolved path. Lives at `src/utils/draftwise-dir.js`. `init.js` is exempt — it asserts the directory does NOT exist and has its own bespoke error. Net: command files lose ~130 lines, helpers add ~80, tests gain ~250. Single source of truth for "how Draftwise loads scan data into a prompt," "how Draftwise guards a hand-edited spec from clobbering," and "how Draftwise refuses to run before init." 12 new direct tests across `test/utils/{scan-context,overwrite-guard,draftwise-dir}.test.js`. Full suite at 332 passing (was 320 before this branch). Lint clean. No public CLI behavior change — same flags, same error strings, same prompts. --- CHANGELOG.md | 2 + src/commands/explain.js | 7 +- src/commands/list.js | 8 +- src/commands/new.js | 83 ++++++-------------- src/commands/scaffold.js | 6 +- src/commands/scan.js | 9 +-- src/commands/show.js | 7 +- src/commands/tasks.js | 87 ++++++--------------- src/commands/tech.js | 87 ++++++--------------- src/utils/draftwise-dir.js | 17 ++++ src/utils/overwrite-guard.js | 46 +++++++++++ src/utils/scan-context.js | 51 ++++++++++++ test/utils/draftwise-dir.test.js | 28 +++++++ test/utils/overwrite-guard.test.js | 102 ++++++++++++++++++++++++ test/utils/scan-context.test.js | 121 +++++++++++++++++++++++++++++ 15 files changed, 448 insertions(+), 213 deletions(-) create mode 100644 src/utils/draftwise-dir.js create mode 100644 src/utils/overwrite-guard.js create mode 100644 src/utils/scan-context.js create mode 100644 test/utils/draftwise-dir.test.js create mode 100644 test/utils/overwrite-guard.test.js create mode 100644 test/utils/scan-context.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bbb848..63d8d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ Each released version is tagged in git (`v0.0.1`, `v0.1.0`, etc.) and includes t - **`loadAnswersFlag` extracted into a shared `src/utils/answers-flag.js` helper.** The same `--answers` parsing function (accept inline JSON or `@path/to/file.json`, read the file if `@`-prefixed, JSON-parse, validate it's an array of strings, throw with usage hints on failure) was defined byte-identically in `src/commands/init.js` and `src/commands/new.js` — both consume the flag for clarifying-question answers (greenfield project setup vs feature spec drafting). New module hosts it as a named export. Adds `test/utils/answers-flag.test.js` with eight direct cases (undefined, empty, inline JSON, `@file` happy path, missing `@file`, malformed JSON, non-array JSON, mixed-type array) — previously the function was only exercised indirectly through command-level tests. — Ankur +- **Drafting-command boilerplate extracted into three shared utility helpers.** Three near-identical patterns lived across `new` / `tech` / `tasks` (and a chunk of one across all eight non-init commands): (1) the greenfield/brownfield scan-context branch — read `overview.md` for greenfield, run `scan + compactScan + describeScanWarnings` for brownfield — copy-pasted three times in new/tech/tasks; (2) the overwrite-confirm dance (`if !force && exists { TTY ? confirm or cancel : throw }`) repeated three times with the same default-`false` `confirm({...})` prompt; (3) the `.draftwise/` existence guard (`if (!await pathExists(dir)) throw '.draftwise/ not found...'`) repeated at the top of eight commands. New `src/utils/scan-context.js` exposes `loadScanContext({ cwd, config, log, scan, readOverview, commandName })` returning `{ scanForPrompt, packageMeta, overview }`; `src/utils/overwrite-guard.js` exposes `confirmOverwriteOrCancel({ targetPath, slug, file, force, isInteractive, log, confirmOverwrite })` returning a `proceed` boolean (and throwing in non-TTY without `--force`); `src/utils/draftwise-dir.js` exposes `requireDraftwiseDir(cwd)` returning the resolved path. Each helper has direct test coverage (12 new tests across `test/utils/{scan-context,overwrite-guard,draftwise-dir}.test.js`). Net: production code in command files drops by ~130 lines; the helpers add ~80 lines but consolidate the single source of truth for "how Draftwise loads scan data into a prompt," "how Draftwise guards a hand-edited spec from accidental overwrite," and "how Draftwise refuses to run before init." `init.js` keeps its bespoke "refuse if `.draftwise/` exists" check — opposite invariant. The default `confirm({...})` prompt now lives in the helper module; commands no longer carry per-file `confirmOverwrite` definitions in their `DEFAULT_PROMPTS` (test injection still works through the same `deps.prompts` seam). — Ankur + ### Removed - **Dead backwards-compat aliases on the prompt modules.** `src/ai/prompts/new.js` exported `PLAN_SYSTEM` / `SPEC_SYSTEM` and `src/ai/prompts/{tech,tasks}.js` each exported a top-level `SYSTEM` — three aliases marked "Backwards compatibility — keep the old names alive" that never had real callers. Verified by grep across `src/` and `test/`: every consumer either uses the explicit `_BROWNFIELD` / `_GREENFIELD` constants or the `selectSystem` / `selectPlanSystem` / `selectSpecSystem` helpers. The package hasn't shipped a version where these were the canonical names, so there's no compat to preserve. Pre-publish hygiene: shrink the public API surface before users import what we don't intend to support. `scan.js` and `explain.js` keep their `SYSTEM` exports — those are primary, not aliases, and are imported by the matching command files. — Ankur diff --git a/src/commands/explain.js b/src/commands/explain.js index d9f92ac..f37f5c0 100644 --- a/src/commands/explain.js +++ b/src/commands/explain.js @@ -5,7 +5,7 @@ import { loadConfig as defaultLoadConfig } from '../utils/config.js'; import { complete as defaultComplete } from '../ai/provider.js'; import { describeScanWarnings } from '../utils/scan-warnings.js'; import { filterScanForFlow } from '../utils/flow-filter.js'; -import { pathExists } from '../utils/fs.js'; +import { requireDraftwiseDir } from '../utils/draftwise-dir.js'; import { AGENT_HANDOFF_PREFIX } from '../utils/agent-handoff.js'; import { compactScan } from '../utils/scan-projection.js'; import { SYSTEM, buildPrompt, buildAgentInstruction } from '../ai/prompts/explain.js'; @@ -39,10 +39,7 @@ export default async function explainCommand(args = [], deps = {}) { ); } - const draftwiseDir = join(cwd, '.draftwise'); - if (!(await pathExists(draftwiseDir))) { - throw new Error('.draftwise/ not found. Run `draftwise init` first.'); - } + const draftwiseDir = await requireDraftwiseDir(cwd); const config = await loadConfig(cwd); diff --git a/src/commands/list.js b/src/commands/list.js index 3334c56..e2a2a9f 100644 --- a/src/commands/list.js +++ b/src/commands/list.js @@ -1,7 +1,6 @@ import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; import { listSpecs as defaultListSpecs } from '../utils/specs.js'; -import { pathExists } from '../utils/fs.js'; +import { requireDraftwiseDir } from '../utils/draftwise-dir.js'; export const HELP = `draftwise list — list all specs in .draftwise/specs/ @@ -42,10 +41,7 @@ export default async function listCommand(_args = [], deps = {}) { const log = deps.log ?? ((msg) => console.log(msg)); const listSpecs = deps.listSpecs ?? defaultListSpecs; - const draftwiseDir = join(cwd, '.draftwise'); - if (!(await pathExists(draftwiseDir))) { - throw new Error('.draftwise/ not found. Run `draftwise init` first.'); - } + await requireDraftwiseDir(cwd); const specs = await listSpecs(cwd); if (specs.length === 0) { diff --git a/src/commands/new.js b/src/commands/new.js index 3aa1f31..503d5b2 100644 --- a/src/commands/new.js +++ b/src/commands/new.js @@ -1,14 +1,14 @@ import { writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { parseArgs } from 'node:util'; -import { input, select, confirm } from '@inquirer/prompts'; +import { input, select } from '@inquirer/prompts'; import { cachedScan as defaultScan } from '../utils/scan-cache.js'; import { loadConfig as defaultLoadConfig } from '../utils/config.js'; import { complete as defaultComplete } from '../ai/provider.js'; import { readOverview as defaultReadOverview } from '../utils/overview.js'; -import { describeScanWarnings } from '../utils/scan-warnings.js'; -import { pathExists } from '../utils/fs.js'; -import { compactScan } from '../utils/scan-projection.js'; +import { requireDraftwiseDir } from '../utils/draftwise-dir.js'; +import { loadScanContext } from '../utils/scan-context.js'; +import { confirmOverwriteOrCancel } from '../utils/overwrite-guard.js'; import { isInteractive as defaultIsInteractive } from '../utils/tty.js'; import { AGENT_HANDOFF_PREFIX } from '../utils/agent-handoff.js'; import { loadAnswersFlag } from '../utils/answers-flag.js'; @@ -75,11 +75,6 @@ const DEFAULT_PROMPTS = { ], default: 'declined', }), - confirmOverwrite: ({ slug, file }) => - confirm({ - message: `${slug}/${file} already exists. Overwrite?`, - default: false, - }), }; export default async function newCommand(args = [], deps = {}) { @@ -112,44 +107,21 @@ export default async function newCommand(args = [], deps = {}) { throw new Error('Missing idea. Usage: draftwise new ""'); } - const draftwiseDir = join(cwd, '.draftwise'); - if (!(await pathExists(draftwiseDir))) { - throw new Error('.draftwise/ not found. Run `draftwise init` first.'); - } + const draftwiseDir = await requireDraftwiseDir(cwd); const config = await loadConfig(cwd); const isGreenfield = config.projectState === 'greenfield'; log(`Idea: "${idea}"`); - let scanForPrompt; - let packageMeta; - let overview; - - if (isGreenfield) { - log('Reading project plan from overview.md...'); - overview = await readOverview(cwd); - if (!overview.trim()) { - throw new Error( - 'Greenfield project but .draftwise/overview.md is missing or empty. Re-run `draftwise init` to generate the plan, or switch the config to brownfield once code exists.', - ); - } - scanForPrompt = null; - packageMeta = null; - } else { - log('Scanning repo...'); - const result = await scan(cwd, { maxFiles: config.scanMaxFiles }); - if (!result.files || result.files.length === 0) { - throw new Error( - `No source files found under ${cwd}. Run \`draftwise new\` from your repo root.`, - ); - } - for (const warning of describeScanWarnings(result)) { - log(warning); - } - scanForPrompt = compactScan(result); - packageMeta = result.packageMeta; - } + const { scanForPrompt, packageMeta, overview } = await loadScanContext({ + cwd, + config, + log, + scan, + readOverview, + commandName: 'new', + }); if (config.mode === 'agent') { log(''); @@ -213,25 +185,16 @@ export default async function newCommand(args = [], deps = {}) { const slug = slugify(plan.featureSlug); const specDir = join(draftwiseDir, 'specs', slug); const productSpecPath = join(specDir, 'product-spec.md'); - if (!force && (await pathExists(productSpecPath))) { - if (isInteractive()) { - log(''); - const proceed = await prompts.confirmOverwrite({ - slug, - file: 'product-spec.md', - }); - if (!proceed) { - log( - 'Cancelled. No changes written. (Pass --force to skip this prompt.)', - ); - return; - } - } else { - throw new Error( - `${slug}/product-spec.md already exists. Pass --force to overwrite (or delete the file first).`, - ); - } - } + const proceed = await confirmOverwriteOrCancel({ + targetPath: productSpecPath, + slug, + file: 'product-spec.md', + force, + isInteractive, + log, + confirmOverwrite: prompts.confirmOverwrite, + }); + if (!proceed) return; if (plan.affectedFlows.length > 0) { log(''); diff --git a/src/commands/scaffold.js b/src/commands/scaffold.js index 48ad866..f17f830 100644 --- a/src/commands/scaffold.js +++ b/src/commands/scaffold.js @@ -3,6 +3,7 @@ import { join, dirname, resolve, sep } from 'node:path'; import { parseArgs } from 'node:util'; import { confirm } from '@inquirer/prompts'; import { pathExists } from '../utils/fs.js'; +import { requireDraftwiseDir } from '../utils/draftwise-dir.js'; import { loadConfig as defaultLoadConfig } from '../utils/config.js'; import { isInteractive as defaultIsInteractive } from '../utils/tty.js'; @@ -89,10 +90,7 @@ export default async function scaffoldCommand(args = [], deps = {}) { } const skipConfirm = Boolean(parsed.values.yes); - const draftwiseDir = join(cwd, '.draftwise'); - if (!(await pathExists(draftwiseDir))) { - throw new Error('.draftwise/ not found. Run `draftwise init` first.'); - } + const draftwiseDir = await requireDraftwiseDir(cwd); // Short-circuit for brownfield projects — scaffold has nothing to do, and // the missing-scaffold.json error message would mislead the user toward diff --git a/src/commands/scan.js b/src/commands/scan.js index 9920c05..222c72d 100644 --- a/src/commands/scan.js +++ b/src/commands/scan.js @@ -4,8 +4,8 @@ import { cachedScan as defaultScan } from '../utils/scan-cache.js'; import { loadConfig as defaultLoadConfig } from '../utils/config.js'; import { complete as defaultComplete } from '../ai/provider.js'; import { describeScanWarnings } from '../utils/scan-warnings.js'; -import { pathExists } from '../utils/fs.js'; import { compactScan } from '../utils/scan-projection.js'; +import { requireDraftwiseDir } from '../utils/draftwise-dir.js'; import { AGENT_HANDOFF_PREFIX } from '../utils/agent-handoff.js'; import { SYSTEM, buildPrompt, AGENT_INSTRUCTION } from '../ai/prompts/scan.js'; @@ -39,12 +39,7 @@ export default async function scanCommand(_args = [], deps = {}) { const loadConfig = deps.loadConfig ?? defaultLoadConfig; const complete = deps.complete ?? defaultComplete; - const draftwiseDir = join(cwd, '.draftwise'); - if (!(await pathExists(draftwiseDir))) { - throw new Error( - '.draftwise/ not found. Run `draftwise init` first.', - ); - } + const draftwiseDir = await requireDraftwiseDir(cwd); const config = await loadConfig(cwd); diff --git a/src/commands/show.js b/src/commands/show.js index ed6e5f5..ad4411f 100644 --- a/src/commands/show.js +++ b/src/commands/show.js @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; import { listSpecs as defaultListSpecs } from '../utils/specs.js'; import { pathExists } from '../utils/fs.js'; +import { requireDraftwiseDir } from '../utils/draftwise-dir.js'; export const HELP = `draftwise show [type] — print a spec to terminal @@ -50,10 +50,7 @@ export default async function showCommand(args = [], deps = {}) { ); } - const draftwiseDir = join(cwd, '.draftwise'); - if (!(await pathExists(draftwiseDir))) { - throw new Error('.draftwise/ not found. Run `draftwise init` first.'); - } + await requireDraftwiseDir(cwd); const specs = await listSpecs(cwd); const target = specs.find((s) => s.slug === slug); diff --git a/src/commands/tasks.js b/src/commands/tasks.js index de1767c..c6f8b87 100644 --- a/src/commands/tasks.js +++ b/src/commands/tasks.js @@ -1,15 +1,14 @@ import { readFile, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; import { parseArgs } from 'node:util'; -import { select, confirm } from '@inquirer/prompts'; +import { select } from '@inquirer/prompts'; import { cachedScan as defaultScan } from '../utils/scan-cache.js'; import { loadConfig as defaultLoadConfig } from '../utils/config.js'; import { complete as defaultComplete } from '../ai/provider.js'; import { listSpecs as defaultListSpecs } from '../utils/specs.js'; import { readOverview as defaultReadOverview } from '../utils/overview.js'; -import { describeScanWarnings } from '../utils/scan-warnings.js'; -import { pathExists } from '../utils/fs.js'; -import { compactScan } from '../utils/scan-projection.js'; +import { requireDraftwiseDir } from '../utils/draftwise-dir.js'; +import { loadScanContext } from '../utils/scan-context.js'; +import { confirmOverwriteOrCancel } from '../utils/overwrite-guard.js'; import { isInteractive as defaultIsInteractive } from '../utils/tty.js'; import { AGENT_HANDOFF_PREFIX } from '../utils/agent-handoff.js'; import { @@ -53,11 +52,6 @@ const DEFAULT_PROMPTS = { value: s.slug, })), }), - confirmOverwrite: ({ slug, file }) => - confirm({ - message: `${slug}/${file} already exists. Overwrite?`, - default: false, - }), }; export default async function tasksCommand(args = [], deps = {}) { @@ -71,10 +65,7 @@ export default async function tasksCommand(args = [], deps = {}) { const isInteractive = deps.isInteractive ?? defaultIsInteractive; const prompts = { ...DEFAULT_PROMPTS, ...(deps.prompts ?? {}) }; - const draftwiseDir = join(cwd, '.draftwise'); - if (!(await pathExists(draftwiseDir))) { - throw new Error('.draftwise/ not found. Run `draftwise init` first.'); - } + await requireDraftwiseDir(cwd); let parsed; try { @@ -134,57 +125,27 @@ export default async function tasksCommand(args = [], deps = {}) { // Confirm before clobbering a hand-edited tasks.md. Run before the scan so a // cancel doesn't waste the scan time. Agent mode is exempt — the host agent // does the write, not Draftwise. - if ( - !force && - config.mode !== 'agent' && - (await pathExists(target.tasks)) - ) { - if (isInteractive()) { - const proceed = await prompts.confirmOverwrite({ - slug: target.slug, - file: 'tasks.md', - }); - if (!proceed) { - log( - 'Cancelled. No changes written. (Pass --force to skip this prompt.)', - ); - return; - } - } else { - throw new Error( - `${target.slug}/tasks.md already exists. Pass --force to overwrite.`, - ); - } + if (config.mode !== 'agent') { + const proceed = await confirmOverwriteOrCancel({ + targetPath: target.tasks, + slug: target.slug, + file: 'tasks.md', + force, + isInteractive, + log, + confirmOverwrite: prompts.confirmOverwrite, + }); + if (!proceed) return; } - let scanForPrompt; - let packageMeta; - let overview; - - if (isGreenfield) { - log('Reading project plan from overview.md...'); - overview = await readOverview(cwd); - if (!overview.trim()) { - throw new Error( - 'Greenfield project but .draftwise/overview.md is missing or empty. Re-run `draftwise init` to generate the plan.', - ); - } - scanForPrompt = null; - packageMeta = null; - } else { - log('Scanning repo...'); - const result = await scan(cwd, { maxFiles: config.scanMaxFiles }); - if (!result.files || result.files.length === 0) { - throw new Error( - `No source files found under ${cwd}. Run \`draftwise tasks\` from your repo root.`, - ); - } - for (const warning of describeScanWarnings(result)) { - log(warning); - } - scanForPrompt = compactScan(result); - packageMeta = result.packageMeta; - } + const { scanForPrompt, packageMeta, overview } = await loadScanContext({ + cwd, + config, + log, + scan, + readOverview, + commandName: 'tasks', + }); if (config.mode === 'agent') { log(''); diff --git a/src/commands/tech.js b/src/commands/tech.js index 42b91cb..6f4b5e7 100644 --- a/src/commands/tech.js +++ b/src/commands/tech.js @@ -1,15 +1,14 @@ import { readFile, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; import { parseArgs } from 'node:util'; -import { select, confirm } from '@inquirer/prompts'; +import { select } from '@inquirer/prompts'; import { cachedScan as defaultScan } from '../utils/scan-cache.js'; import { loadConfig as defaultLoadConfig } from '../utils/config.js'; import { complete as defaultComplete } from '../ai/provider.js'; import { listSpecs as defaultListSpecs } from '../utils/specs.js'; import { readOverview as defaultReadOverview } from '../utils/overview.js'; -import { describeScanWarnings } from '../utils/scan-warnings.js'; -import { pathExists } from '../utils/fs.js'; -import { compactScan } from '../utils/scan-projection.js'; +import { requireDraftwiseDir } from '../utils/draftwise-dir.js'; +import { loadScanContext } from '../utils/scan-context.js'; +import { confirmOverwriteOrCancel } from '../utils/overwrite-guard.js'; import { isInteractive as defaultIsInteractive } from '../utils/tty.js'; import { AGENT_HANDOFF_PREFIX } from '../utils/agent-handoff.js'; import { @@ -55,11 +54,6 @@ const DEFAULT_PROMPTS = { value: s.slug, })), }), - confirmOverwrite: ({ slug, file }) => - confirm({ - message: `${slug}/${file} already exists. Overwrite?`, - default: false, - }), }; export default async function techCommand(args = [], deps = {}) { @@ -73,10 +67,7 @@ export default async function techCommand(args = [], deps = {}) { const isInteractive = deps.isInteractive ?? defaultIsInteractive; const prompts = { ...DEFAULT_PROMPTS, ...(deps.prompts ?? {}) }; - const draftwiseDir = join(cwd, '.draftwise'); - if (!(await pathExists(draftwiseDir))) { - throw new Error('.draftwise/ not found. Run `draftwise init` first.'); - } + await requireDraftwiseDir(cwd); let parsed; try { @@ -136,57 +127,27 @@ export default async function techCommand(args = [], deps = {}) { // Confirm before clobbering a hand-edited technical-spec.md. Run before the // scan so a cancel doesn't waste the scan time. Agent mode is exempt — the // host agent does the write, not Draftwise. - if ( - !force && - config.mode !== 'agent' && - (await pathExists(target.technicalSpec)) - ) { - if (isInteractive()) { - const proceed = await prompts.confirmOverwrite({ - slug: target.slug, - file: 'technical-spec.md', - }); - if (!proceed) { - log( - 'Cancelled. No changes written. (Pass --force to skip this prompt.)', - ); - return; - } - } else { - throw new Error( - `${target.slug}/technical-spec.md already exists. Pass --force to overwrite.`, - ); - } + if (config.mode !== 'agent') { + const proceed = await confirmOverwriteOrCancel({ + targetPath: target.technicalSpec, + slug: target.slug, + file: 'technical-spec.md', + force, + isInteractive, + log, + confirmOverwrite: prompts.confirmOverwrite, + }); + if (!proceed) return; } - let scanForPrompt; - let packageMeta; - let overview; - - if (isGreenfield) { - log('Reading project plan from overview.md...'); - overview = await readOverview(cwd); - if (!overview.trim()) { - throw new Error( - 'Greenfield project but .draftwise/overview.md is missing or empty. Re-run `draftwise init` to generate the plan.', - ); - } - scanForPrompt = null; - packageMeta = null; - } else { - log('Scanning repo...'); - const result = await scan(cwd, { maxFiles: config.scanMaxFiles }); - if (!result.files || result.files.length === 0) { - throw new Error( - `No source files found under ${cwd}. Run \`draftwise tech\` from your repo root.`, - ); - } - for (const warning of describeScanWarnings(result)) { - log(warning); - } - scanForPrompt = compactScan(result); - packageMeta = result.packageMeta; - } + const { scanForPrompt, packageMeta, overview } = await loadScanContext({ + cwd, + config, + log, + scan, + readOverview, + commandName: 'tech', + }); if (config.mode === 'agent') { log(''); diff --git a/src/utils/draftwise-dir.js b/src/utils/draftwise-dir.js new file mode 100644 index 0000000..7d01362 --- /dev/null +++ b/src/utils/draftwise-dir.js @@ -0,0 +1,17 @@ +import { join } from 'node:path'; +import { pathExists } from './fs.js'; + +// Most commands run only inside a project that has `draftwise init`-d. This +// helper returns the resolved `.draftwise/` path or throws the same friendly +// "run init first" hint everywhere, so the eight callers don't repeat the +// guard. +// +// `init.js` is the exception — it asserts the directory does NOT exist and +// has its own bespoke error. It doesn't go through this helper. +export async function requireDraftwiseDir(cwd) { + const dir = join(cwd, '.draftwise'); + if (!(await pathExists(dir))) { + throw new Error('.draftwise/ not found. Run `draftwise init` first.'); + } + return dir; +} diff --git a/src/utils/overwrite-guard.js b/src/utils/overwrite-guard.js new file mode 100644 index 0000000..1588052 --- /dev/null +++ b/src/utils/overwrite-guard.js @@ -0,0 +1,46 @@ +import { confirm } from '@inquirer/prompts'; +import { pathExists } from './fs.js'; + +const DEFAULT_CONFIRM = ({ slug, file }) => + confirm({ + message: `${slug}/${file} already exists. Overwrite?`, + default: false, + }); + +// Used by `new` / `tech` / `tasks` before they overwrite a spec file the user +// may have hand-edited. +// +// Returns `true` when the caller should proceed (no existing file, --force +// passed, or the user confirmed). Returns `false` when the user cancelled +// in a TTY. Throws in non-TTY when the file exists and --force wasn't passed +// — scripted callers must opt in explicitly. +// +// The caller decides WHEN to call this: +// - Place it before any expensive synthesis API call so a cancel doesn't burn +// tokens. +// - Skip it in agent mode (the host coding agent does the write, not +// Draftwise). The caller checks `config.mode !== 'agent'` itself. + +export async function confirmOverwriteOrCancel({ + targetPath, + slug, + file, + force, + isInteractive, + log, + confirmOverwrite = DEFAULT_CONFIRM, +}) { + if (force) return true; + if (!(await pathExists(targetPath))) return true; + if (isInteractive()) { + const proceed = await confirmOverwrite({ slug, file }); + if (!proceed) { + log('Cancelled. No changes written. (Pass --force to skip this prompt.)'); + return false; + } + return true; + } + throw new Error( + `${slug}/${file} already exists. Pass --force to overwrite.`, + ); +} diff --git a/src/utils/scan-context.js b/src/utils/scan-context.js new file mode 100644 index 0000000..441782b --- /dev/null +++ b/src/utils/scan-context.js @@ -0,0 +1,51 @@ +import { describeScanWarnings } from './scan-warnings.js'; +import { compactScan } from './scan-projection.js'; + +// Loads the scan / overview context that `new`, `tech`, and `tasks` all need +// before drafting their respective specs. Two paths: +// +// - **Greenfield** — read `.draftwise/overview.md` (the project plan written +// by `init`). The scan-shaped fields come back as `null` because there's no +// code to scan yet. +// - **Brownfield** — run the cached scan, surface any warnings, and return +// the prompt-sized projection plus package metadata. `overview` is +// `undefined` here. +// +// `commandName` flows into the brownfield "no source files" error so each +// caller's hint says "Run `draftwise ` from your repo root." + +export async function loadScanContext({ + cwd, + config, + log, + scan, + readOverview, + commandName, +}) { + if (config.projectState === 'greenfield') { + log('Reading project plan from overview.md...'); + const overview = await readOverview(cwd); + if (!overview.trim()) { + throw new Error( + 'Greenfield project but .draftwise/overview.md is missing or empty. Re-run `draftwise init` to generate the plan, or switch the config to brownfield once code exists.', + ); + } + return { scanForPrompt: null, packageMeta: null, overview }; + } + + log('Scanning repo...'); + const result = await scan(cwd, { maxFiles: config.scanMaxFiles }); + if (!result.files || result.files.length === 0) { + throw new Error( + `No source files found under ${cwd}. Run \`draftwise ${commandName}\` from your repo root.`, + ); + } + for (const warning of describeScanWarnings(result)) { + log(warning); + } + return { + scanForPrompt: compactScan(result), + packageMeta: result.packageMeta, + overview: undefined, + }; +} diff --git a/test/utils/draftwise-dir.test.js b/test/utils/draftwise-dir.test.js new file mode 100644 index 0000000..837b421 --- /dev/null +++ b/test/utils/draftwise-dir.test.js @@ -0,0 +1,28 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { requireDraftwiseDir } from '../../src/utils/draftwise-dir.js'; + +describe('requireDraftwiseDir', () => { + let dir; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'draftwise-dir-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('returns the resolved .draftwise/ path when it exists', async () => { + await mkdir(join(dir, '.draftwise')); + expect(await requireDraftwiseDir(dir)).toBe(join(dir, '.draftwise')); + }); + + it('throws the friendly init hint when .draftwise/ is missing', async () => { + await expect(requireDraftwiseDir(dir)).rejects.toThrow( + /\.draftwise\/ not found\. Run `draftwise init` first\./, + ); + }); +}); diff --git a/test/utils/overwrite-guard.test.js b/test/utils/overwrite-guard.test.js new file mode 100644 index 0000000..19b22ea --- /dev/null +++ b/test/utils/overwrite-guard.test.js @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { confirmOverwriteOrCancel } from '../../src/utils/overwrite-guard.js'; + +describe('confirmOverwriteOrCancel', () => { + let dir; + let existing; + let absent; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'draftwise-overwrite-')); + existing = join(dir, 'product-spec.md'); + absent = join(dir, 'missing.md'); + await writeFile(existing, '# previously written', 'utf8'); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('returns true when the target does not exist (nothing to overwrite)', async () => { + const confirmOverwrite = vi.fn(); + const proceed = await confirmOverwriteOrCancel({ + targetPath: absent, + slug: 'feat', + file: 'product-spec.md', + force: false, + isInteractive: () => true, + log: () => {}, + confirmOverwrite, + }); + expect(proceed).toBe(true); + expect(confirmOverwrite).not.toHaveBeenCalled(); + }); + + it('returns true when --force is passed, even with an existing file', async () => { + const confirmOverwrite = vi.fn(); + const proceed = await confirmOverwriteOrCancel({ + targetPath: existing, + slug: 'feat', + file: 'product-spec.md', + force: true, + isInteractive: () => true, + log: () => {}, + confirmOverwrite, + }); + expect(proceed).toBe(true); + expect(confirmOverwrite).not.toHaveBeenCalled(); + }); + + it('prompts and returns true when the user confirms in a TTY', async () => { + const confirmOverwrite = vi.fn().mockResolvedValue(true); + const proceed = await confirmOverwriteOrCancel({ + targetPath: existing, + slug: 'feat', + file: 'product-spec.md', + force: false, + isInteractive: () => true, + log: () => {}, + confirmOverwrite, + }); + expect(proceed).toBe(true); + expect(confirmOverwrite).toHaveBeenCalledWith({ + slug: 'feat', + file: 'product-spec.md', + }); + }); + + it('logs the cancel hint and returns false when the user declines in a TTY', async () => { + const confirmOverwrite = vi.fn().mockResolvedValue(false); + const log = vi.fn(); + const proceed = await confirmOverwriteOrCancel({ + targetPath: existing, + slug: 'feat', + file: 'product-spec.md', + force: false, + isInteractive: () => true, + log, + confirmOverwrite, + }); + expect(proceed).toBe(false); + expect(log).toHaveBeenCalledWith( + 'Cancelled. No changes written. (Pass --force to skip this prompt.)', + ); + }); + + it('throws in non-TTY when the file exists and --force wasn\'t passed', async () => { + await expect( + confirmOverwriteOrCancel({ + targetPath: existing, + slug: 'feat', + file: 'product-spec.md', + force: false, + isInteractive: () => false, + log: () => {}, + confirmOverwrite: vi.fn(), + }), + ).rejects.toThrow(/feat\/product-spec\.md already exists\. Pass --force/); + }); +}); diff --git a/test/utils/scan-context.test.js b/test/utils/scan-context.test.js new file mode 100644 index 0000000..d86b044 --- /dev/null +++ b/test/utils/scan-context.test.js @@ -0,0 +1,121 @@ +import { describe, it, expect, vi } from 'vitest'; +import { loadScanContext } from '../../src/utils/scan-context.js'; + +const fakeScanResult = (overrides = {}) => ({ + files: ['src/index.js', 'src/util.js'], + packageMeta: { name: 'demo', dependencies: ['express'] }, + frameworks: ['Express'], + orms: [], + routes: [{ method: 'GET', path: '/health', file: 'src/index.js' }], + components: [], + models: [], + truncated: false, + maxFiles: 5000, + ...overrides, +}); + +describe('loadScanContext — greenfield', () => { + it('returns the overview and leaves scan fields null', async () => { + const log = vi.fn(); + const readOverview = vi.fn().mockResolvedValue('# greenfield plan\n\nbody'); + const scan = vi.fn(); + + const ctx = await loadScanContext({ + cwd: '/p', + config: { projectState: 'greenfield' }, + log, + scan, + readOverview, + commandName: 'new', + }); + + expect(ctx).toEqual({ + scanForPrompt: null, + packageMeta: null, + overview: '# greenfield plan\n\nbody', + }); + expect(scan).not.toHaveBeenCalled(); + expect(readOverview).toHaveBeenCalledWith('/p'); + expect(log).toHaveBeenCalledWith('Reading project plan from overview.md...'); + }); + + it('throws when overview.md is missing or whitespace-only', async () => { + const readOverview = vi.fn().mockResolvedValue(' \n '); + await expect( + loadScanContext({ + cwd: '/p', + config: { projectState: 'greenfield' }, + log: () => {}, + scan: vi.fn(), + readOverview, + commandName: 'tech', + }), + ).rejects.toThrow( + /overview\.md is missing or empty.*Re-run `draftwise init`/s, + ); + }); +}); + +describe('loadScanContext — brownfield', () => { + it('returns compactScan-shaped projection plus package metadata', async () => { + const log = vi.fn(); + const scan = vi.fn().mockResolvedValue(fakeScanResult()); + + const ctx = await loadScanContext({ + cwd: '/p', + config: { projectState: 'brownfield', scanMaxFiles: 1000 }, + log, + scan, + readOverview: vi.fn(), + commandName: 'tasks', + }); + + expect(scan).toHaveBeenCalledWith('/p', { maxFiles: 1000 }); + expect(ctx.overview).toBeUndefined(); + expect(ctx.packageMeta).toEqual({ name: 'demo', dependencies: ['express'] }); + // compactScan caps + reshapes; assert the keys we rely on rather than full shape + expect(ctx.scanForPrompt).toMatchObject({ + frameworks: ['Express'], + routes: [{ method: 'GET', path: '/health', file: 'src/index.js' }], + fileCount: 2, + }); + expect(log).toHaveBeenCalledWith('Scanning repo...'); + }); + + it('throws with the command name in the hint when zero source files', async () => { + const scan = vi.fn().mockResolvedValue(fakeScanResult({ files: [] })); + await expect( + loadScanContext({ + cwd: '/p', + config: { projectState: 'brownfield' }, + log: () => {}, + scan, + readOverview: vi.fn(), + commandName: 'tech', + }), + ).rejects.toThrow( + /No source files found under \/p\. Run `draftwise tech` from your repo root\./, + ); + }); + + it('logs scan warnings when truncated', async () => { + const log = vi.fn(); + const scan = vi + .fn() + .mockResolvedValue(fakeScanResult({ truncated: true, maxFiles: 5000 })); + + await loadScanContext({ + cwd: '/p', + config: { projectState: 'brownfield' }, + log, + scan, + readOverview: vi.fn(), + commandName: 'new', + }); + + const truncationWarningLogged = log.mock.calls.some(([msg]) => + typeof msg === 'string' && msg.includes('scanner stopped at 5000'), + ); + expect(truncationWarningLogged).toBe(true); + }); +});