diff --git a/.changeset/daring-tunas-hum.md b/.changeset/daring-tunas-hum.md new file mode 100644 index 000000000..de1f17a51 --- /dev/null +++ b/.changeset/daring-tunas-hum.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 4277 +--- +**New `phase.tdd-applicable` query verb** — computes whether the TDD RED/GREEN/REFACTOR procedure applies to a given plan (explicit flag, plan `type: tdd` frontmatter, a task's `tdd="true"` attribute, or the `workflow.tdd_mode` config default), in one place. Also fixes `workflow.tdd_mode`, `workflow.research`, and `workflow.nyquist_validation` config keys, which never actually reached `cmdInitExecutePhase`/`cmdInitPlanPhase`/`cmdInitDebug`/`cmdInitNewMilestone` due to a dead `config.workflow` accessor. (#4273) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 72e6e12e1..7a2f99664 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -34,6 +34,7 @@ - [Health Validation](#19-health-validation) - [Cross-Phase Regression Gate](#20-cross-phase-regression-gate) - [Requirements Coverage Gate](#21-requirements-coverage-gate) + - [TDD-Applicability Predicate](#4273-tdd-applicability-predicate) - [Context Engineering Features](#context-engineering-features) - [Context Window Monitoring](#22-context-window-monitoring) - [Session Management](#23-session-management) @@ -734,6 +735,29 @@ **When:** Runs automatically at the end of `/gsd-plan-phase` after the plan checker loop. +--- + +### 4273. TDD-Applicability Predicate + +**Purpose:** Give the workflow engine one code-owned computation for whether TDD's RED/GREEN/REFACTOR +procedure applies to a given plan, instead of restating the same precedence logic as hand-written prose in +each dispatch backend — a restatement that had already drifted between two backends (#4264, #4265). This +is Phase 1 of epic #4272 (ADR-3473's fourth application of the single-owner-predicate pattern): it ships +the isolated `phase.tdd-applicable` query verb only. Wiring `execute-phase.md` and its +executor-isolation-dispatch step to consume the verb instead of their own inline predicates is a later +phase of the same epic. + +**Command:** `gsd-tools query phase.tdd-applicable [--cli-flag]` + +**Requirements:** +- REQ-TDDA-01: System MUST resolve applicability via a fixed precedence: `--cli-flag` (explicit override) > + plan frontmatter `type: tdd` > any task in the plan carrying `tdd="true"` > project config + `workflow.tdd_mode` +- REQ-TDDA-02: System MUST report which precedence tier decided the outcome (`cli_flag`, `plan_frontmatter`, + `task_attribute`, `config`, or `none`) alongside the boolean result +- REQ-TDDA-03: System MUST emit JSON (`applicable`, `source`, `plan_type`, `config_tdd_mode`, + `cli_flag_present`) so callers can consume the decision without re-deriving it + --- diff --git a/docs/features/tdd-applicability-predicate.md b/docs/features/tdd-applicability-predicate.md new file mode 100644 index 000000000..eaeadc46a --- /dev/null +++ b/docs/features/tdd-applicability-predicate.md @@ -0,0 +1,24 @@ +--- +id: 4273 +title: TDD-Applicability Predicate +group: Quality Assurance Features +--- + +**Purpose:** Give the workflow engine one code-owned computation for whether TDD's RED/GREEN/REFACTOR +procedure applies to a given plan, instead of restating the same precedence logic as hand-written prose in +each dispatch backend — a restatement that had already drifted between two backends (#4264, #4265). This +is Phase 1 of epic #4272 (ADR-3473's fourth application of the single-owner-predicate pattern): it ships +the isolated `phase.tdd-applicable` query verb only. Wiring `execute-phase.md` and its +executor-isolation-dispatch step to consume the verb instead of their own inline predicates is a later +phase of the same epic. + +**Command:** `gsd-tools query phase.tdd-applicable [--cli-flag]` + +**Requirements:** +- REQ-TDDA-01: System MUST resolve applicability via a fixed precedence: `--cli-flag` (explicit override) > + plan frontmatter `type: tdd` > any task in the plan carrying `tdd="true"` > project config + `workflow.tdd_mode` +- REQ-TDDA-02: System MUST report which precedence tier decided the outcome (`cli_flag`, `plan_frontmatter`, + `task_attribute`, `config`, or `none`) alongside the boolean result +- REQ-TDDA-03: System MUST emit JSON (`applicable`, `source`, `plan_type`, `config_tdd_mode`, + `cli_flag_present`) so callers can consume the decision without re-deriving it diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 94d335a41..50cdd0190 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -81,6 +81,7 @@ "phase-dependency-levels.test.cjs", "phase-resolution-parity.test.cjs", "phase-structure.test.cjs", + "phase-tdd-applicable.test.cjs", "phase.test.cjs" ], "issue": "3186" diff --git a/src/config-loader.cts b/src/config-loader.cts index 4a7b3b337..3684d8b7e 100644 --- a/src/config-loader.cts +++ b/src/config-loader.cts @@ -915,6 +915,7 @@ function loadConfigResolved(cwd: string, options: Record = {}): firecrawl: get('firecrawl') ?? defaults.firecrawl, exa_search: get('exa_search') ?? defaults.exa_search, mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false, + tdd_mode: getNested('workflow', 'tdd_mode') ?? false, text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode, auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false, _auto_chain_active: get('_auto_chain_active', { section: 'workflow', field: '_auto_chain_active' }) ?? false, diff --git a/src/init.cts b/src/init.cts index 3db51b9fd..8e547c69a 100644 --- a/src/init.cts +++ b/src/init.cts @@ -908,8 +908,6 @@ function cmdInitExecutePhase( : null; const phase_req_ids = reqExtracted && reqExtracted !== 'TBD' ? reqExtracted : null; - const wf = (config.workflow ?? {}) as Record; - // #3188: these paths are null when the file is absent, matching the contract // the conditional sibling fields (context_path, patterns_path, ...) already // honour and that ultraplan-phase.md / execute-phase.md gate on. Hoisted so @@ -922,7 +920,7 @@ function cmdInitExecutePhase( executor_model: resolveModelInternal(cwd, 'gsd-executor'), verifier_model: resolveModelInternal(cwd, 'gsd-verifier'), - tdd_mode: options['tdd'] || Boolean(wf['tdd_mode']) || false, + tdd_mode: options['tdd'] || Boolean(config.tdd_mode) || false, commit_docs: config.commit_docs, sub_repos: config.sub_repos, parallelization: config.parallelization, @@ -1088,8 +1086,6 @@ function cmdInitPlanPhase( assertValidGranularityOverride(granularityOverride, error); const granularity = resolveGranularityInternal(cwd, 'planning', granularityOverride || undefined); - const wf = (config.workflow ?? {}) as Record; - // #3188: see cmdInitExecutePhase — null when absent, parity with the // conditional sibling fields in this same result object. const statePath = path.join(planningDir(cwd), 'STATE.md'); @@ -1101,11 +1097,11 @@ function cmdInitPlanPhase( planner_model: resolveModelInternal(cwd, 'gsd-planner'), checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'), - tdd_mode: options['tdd'] || Boolean(wf['tdd_mode']) || false, + tdd_mode: options['tdd'] || Boolean(config.tdd_mode) || false, granularity, - research_enabled: wf['research'], + research_enabled: config.research, plan_checker_enabled: config.plan_checker, - nyquist_validation_enabled: wf['nyquist_validation'], + nyquist_validation_enabled: config.nyquist_validation, commit_docs: config.commit_docs, text_mode: config.text_mode, auto_advance: !!(config.auto_advance), @@ -1376,15 +1372,13 @@ function cmdInitNewMilestone(cwd: string, raw: boolean, options: Record; - const result: Record = { researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'), synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'), roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'), commit_docs: config.commit_docs, - research_enabled: wf['research'], + research_enabled: config.research, // #3216 review Finding 2: `?? null` so an unresolved milestone still emits // the key with an explicit `null` rather than letting JSON.stringify drop @@ -1610,12 +1604,11 @@ function cmdInitOnboard( options: Record = {}, ): void { const config = loadConfig(cwd); - const workflowConfig = (config.workflow ?? {}) as Record; const result = { ...buildOnboardProjection(cwd, { commitDocs: !!config.commit_docs, fast: options['fast'] === true, - textMode: options['text'] === true || !!config.text_mode || !!workflowConfig['text_mode'], + textMode: options['text'] === true || !!config.text_mode, }), ...getInitGitState(cwd), }; @@ -3034,7 +3027,7 @@ function cmdInitTransition(cwd: string, raw: boolean, options: Record = {}): void { const config = loadConfig(cwd); - const wf = (config.workflow ?? {}) as Record; const result: Record = { commit_docs: config.commit_docs, @@ -3061,7 +3053,7 @@ function cmdInitDebug(cwd: string, raw: boolean, options: Record void; + cmdPhaseTddApplicable: (cwd: string, args: string[], raw: boolean) => void; cmdPhaseNextDecimal: (cwd: string, arg: string | undefined, raw: boolean) => void; cmdPhaseAdd: (cwd: string, desc: string, raw: boolean, customId: string | null) => void; cmdPhaseAddBatch: (cwd: string, descriptions: string[], raw: boolean) => void; @@ -87,6 +88,14 @@ function routePhaseCommand({ phase, args, cwd, raw, error }: RoutePhaseCommandOp return; } + // `tdd-applicable` (#4273): same CJS-native dispatch shape as `mvp-mode` + // immediately above — a precedence cascade over plan/task/config sources + // with its own typed JSON result, not routed through the SDK query layer. + if (subcommand === 'tdd-applicable') { + phase.cmdPhaseTddApplicable(cwd, args.slice(2), raw); + return; + } + // ── Build the CJS registry ────────────────────────────────────────────────── // Each handler receives a ctx object from the hub and must return a HubResult. const cjsRegistry = { @@ -240,14 +249,14 @@ function routePhaseCommand({ phase, args, cwd, raw, error }: RoutePhaseCommandOp // ── Build manifest (available subcommands for UnknownCommand detection) ───── // `availableSubcommands` is what the error message shows. It excludes // unsupported commands (already handled above) but does NOT include 'mvp-mode' - // because it was absent from PHASE_SUBCOMMANDS in the original and was not - // shown in the "Available:" list there either. + // or 'tdd-applicable' (#4273) because both are absent from PHASE_SUBCOMMANDS + // and were not shown in the "Available:" list there either. // // `manifestSubcommands` is the full routing set for the hub — it includes - // 'mvp-mode' (which the original code routed via a handler even without a - // manifest entry) so the hub's UnknownCommand check passes for it. + // 'mvp-mode' and 'tdd-applicable' (both routed via a handler even without a + // manifest entry) so the hub's UnknownCommand check passes for them. const availableSubcommands = PHASE_SUBCOMMANDS.filter(s => !UNSUPPORTED[s]); - const manifestSubcommands = ['mvp-mode', ...availableSubcommands]; + const manifestSubcommands = ['mvp-mode', 'tdd-applicable', ...availableSubcommands]; const manifest = { phase: manifestSubcommands }; // ── Construct hub ────────────────────────────────────────────────────────── diff --git a/src/phase.cts b/src/phase.cts index c1df0821c..515785d92 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -448,6 +448,62 @@ function cmdPhaseMvpMode(cwd: string, args: string[], raw: boolean): void { ); } +/** + * `phase.tdd-applicable [--cli-flag]` (#4273, Phase 1 of epic + * #4272) — resolves whether the TDD RED/GREEN/REFACTOR gate applies to a + * given plan, in strict precedence order: an explicit `--cli-flag` wins over + * the plan's own `type: tdd` frontmatter, which wins over any task in the + * plan carrying `tdd="true"` (the #4265 mixed-mode shape), which wins over + * the project-wide `workflow.tdd_mode` config default. Mirrors + * `cmdPhaseMvpMode`'s precedence-cascade shape immediately above. + */ +function cmdPhaseTddApplicable(cwd: string, args: string[], raw: boolean): void { + const planPath = args[0]; + if (!planPath) { + error('Usage: phase.tdd-applicable [--cli-flag]', ERROR_REASON.USAGE); + } + + const resolvedPath = path.isAbsolute(planPath) ? planPath : path.join(cwd, planPath); + if (!fs.existsSync(resolvedPath)) { + error(`Plan file not found: ${planPath}`, ERROR_REASON.PHASE_NOT_FOUND); + } + + const cliFlagPresent = args.includes('--cli-flag'); + const content = fs.readFileSync(resolvedPath, 'utf-8'); + const doc = parsePlanDocument(content, resolvedPath); + const planType = doc.type; + const taskTddAttribute = doc.tasks.some((t) => t.tdd === 'true'); + const config = loadConfig(cwd); + const configTddMode = Boolean(config.tdd_mode); + + let applicable = false; + let source = 'none'; + if (cliFlagPresent) { + applicable = true; + source = 'cli_flag'; + } else if (planType === 'tdd') { + applicable = true; + source = 'plan_frontmatter'; + } else if (taskTddAttribute) { + applicable = true; + source = 'task_attribute'; + } else if (configTddMode) { + applicable = true; + source = 'config'; + } + + output( + { + applicable, + source, + plan_type: planType, + config_tdd_mode: configTddMode, + cli_flag_present: cliFlagPresent, + }, + raw, + ); +} + function cmdFindPhase(cwd: string, phase: string, raw: boolean): void { if (!phase) { error('phase identifier required'); @@ -4520,6 +4576,7 @@ export = { cmdPhaseAdd, cmdPhaseAddBatch, cmdPhaseMvpMode, + cmdPhaseTddApplicable, cmdPhaseInsert, cmdPhaseRemove, cmdPhaseComplete, diff --git a/src/plan-document.cts b/src/plan-document.cts index e39448899..664fe9db4 100644 --- a/src/plan-document.cts +++ b/src/plan-document.cts @@ -76,11 +76,20 @@ interface PlanTask { * normalises to null, same as every other optional attribute here). */ trackerId: string | null; + /** + * Verbatim `tdd` attribute value on a `` opening tag (e.g. `"true"`), + * or null. Never coerced to boolean — read exactly like `trackerId`. Null + * for a checkpoint task (never read), an absent attribute, or an + * empty-string value. #4273 (epic #4272, ADR-3473's 4th application). + */ + tdd: string | null; } interface PlanDocument { /** `` body, else frontmatter `objective`, else null. */ objective: string | null; + /** Frontmatter `type` value, read verbatim (e.g. `"tdd"`, `"standard"`), or null when absent. #4273. */ + type: string | null; /** Frontmatter `wave` as an integer, or null when absent/unparseable. */ declaredWave: number | null; dependsOn: string[]; @@ -206,6 +215,7 @@ function parseXmlTasks(content: string): PlanTask[] { acceptanceCriteria: [], done: null, trackerId: null, + tdd: null, }; } @@ -218,6 +228,7 @@ function parseXmlTasks(content: string): PlanTask[] { acceptanceCriteria: splitCriteria(elementBody(block, 'acceptance_criteria')), done: collapseWhitespace(elementBody(block, 'done')), trackerId: tagAttribute(openTag, 'tracker-id'), + tdd: tagAttribute(openTag, 'tdd'), }; }); } @@ -237,6 +248,7 @@ function parseMarkdownTasks(content: string): PlanTask[] { acceptanceCriteria: [], done: null, trackerId: null, + tdd: null, })); } @@ -324,8 +336,16 @@ function parsePlanDocument(content: string, planPath = ''): PlanDocument { agentHint = hintStr !== '' ? hintStr : null; } + let planType: string | null = null; + const fmType = fm['type']; + if (fmType !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string + planType = String(fmType); + } + return { objective: extractObjective(content) || (fm['objective'] as string | null) || null, + type: planType, declaredWave, dependsOn, autonomous, diff --git a/tests/init.test.cjs b/tests/init.test.cjs index 967ed1d75..dd55550d1 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -4612,6 +4612,117 @@ describe('init section manifest', () => { }); }); + describe('init tdd_mode: workflow.tdd_mode config flows through loadConfig (#4273 defect fix)', () => { + // Regression test for a pre-existing defect fixed alongside #4273's + // `phase.tdd-applicable` work: `loadConfig()` (src/config-loader.cts) + // had no flattened `tdd_mode` field, so every `(config.workflow ?? {})` + // call site in this file always read `{}` — `workflow.tdd_mode` set in + // `.planning/config.json` silently never reached `cmdInitExecutePhase`, + // `cmdInitPlanPhase`, or `cmdInitDebug`'s `tdd_mode` output field, despite + // `workflow.tdd_mode` being a documented config contract + // (gsd-core/references/tdd.md). `loadConfig()` now flattens + // `workflow.tdd_mode` onto `config.tdd_mode` — mirroring the existing + // `workflow.mvp_mode` -> `config.mvp_mode` pattern — and all three call + // sites read `config.tdd_mode` directly instead of the dead `wf['tdd_mode']` + // lookup. Before the fix, every assertion below would have failed + // (`body.tdd_mode` would read `false` regardless of the config value). + function writeWorkflowConfig(dir, workflowConfig) { + fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: workflowConfig })); + } + + test('executePhaseTddModeReflectsWorkflowConfig', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-tdd-exec-'); + writeWorkflowConfig(dir, { tdd_mode: true }); + const body = parseOkJson(runExecutePhase(['1'], dir), 'tdd-exec'); + assert.equal(body.tdd_mode, true, 'workflow.tdd_mode: true must flow through to the tdd_mode output field'); + }); + + test('executePhaseTddModeFalseWhenConfigAbsent', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-tdd-exec-absent-'); + const body = parseOkJson(runExecutePhase(['1'], dir), 'tdd-exec-absent'); + assert.equal(body.tdd_mode, false); + }); + + test('planPhaseTddModeReflectsWorkflowConfig', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-tdd-plan-'); + writeWorkflowConfig(dir, { tdd_mode: true }); + const body = parseOkJson(runSectionManifestCli(['init.plan-phase', '1'], dir), 'tdd-plan'); + assert.equal(body.tdd_mode, true, 'workflow.tdd_mode: true must flow through to the tdd_mode output field'); + }); + + test('planPhaseTddModeFalseWhenConfigAbsent', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-tdd-plan-absent-'); + const body = parseOkJson(runSectionManifestCli(['init.plan-phase', '1'], dir), 'tdd-plan-absent'); + assert.equal(body.tdd_mode, false); + }); + + test('debugTddModeReflectsWorkflowConfig', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-tdd-debug-'); + writeWorkflowConfig(dir, { tdd_mode: true }); + const body = parseOkJson(runSectionManifestCli(['init.debug'], dir), 'tdd-debug'); + assert.equal(body.tdd_mode, true, 'workflow.tdd_mode: true must flow through to the tdd_mode output field'); + }); + + test('debugTddModeFalseWhenConfigFalse', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-tdd-debug-false-'); + writeWorkflowConfig(dir, { tdd_mode: false }); + const body = parseOkJson(runSectionManifestCli(['init.debug'], dir), 'tdd-debug-false'); + assert.equal(body.tdd_mode, false); + }); + }); + + describe('init research_enabled/nyquist_validation_enabled: workflow.research + workflow.nyquist_validation config flow through loadConfig (#4273 defect fix)', () => { + // Regression test for the same dead-accessor class as the tdd_mode block + // above, found while fixing it: `cmdInitNewMilestone`'s `research_enabled` + // and `cmdInitPlanPhase`'s `research_enabled` / `nyquist_validation_enabled` + // all read through `(config.workflow ?? {})` (`wf`), which `loadConfig()` + // never populates — so all three fields were ALWAYS `undefined`, + // regardless of `.planning/config.json`. `loadConfig()` already flattens + // `workflow.research` -> `config.research` and `workflow.nyquist_validation` + // -> `config.nyquist_validation` (both default `true` when unset — see + // gsd-core/bin/shared/config-defaults.manifest.json); these call sites now + // read the flattened fields directly instead of the dead `wf[...]` lookup. + // Before the fix, every assertion below would have failed (`undefined` + // regardless of config, dropped entirely from the JSON output). + function writeWorkflowConfig(dir, workflowConfig) { + fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: workflowConfig })); + } + + test('newMilestoneResearchEnabledDefaultsTrue', (t) => { + const dir = fs.realpathSync(createFixture()); + t.after(() => cleanup(dir)); + const result = runGsdTools('init new-milestone', dir); + assert.ok(result.success, `Command failed: ${result.error}`); + const body = JSON.parse(result.output); + assert.strictEqual(body.research_enabled, true, 'workflow.research defaults to true and must flow through to research_enabled'); + }); + + test('newMilestoneResearchEnabledReflectsWorkflowConfigFalse', (t) => { + const dir = fs.realpathSync(createFixture()); + t.after(() => cleanup(dir)); + writeWorkflowConfig(dir, { research: false }); + const result = runGsdTools('init new-milestone', dir); + assert.ok(result.success, `Command failed: ${result.error}`); + const body = JSON.parse(result.output); + assert.strictEqual(body.research_enabled, false, 'workflow.research: false must flow through to research_enabled'); + }); + + test('planPhaseResearchAndNyquistEnabledReflectWorkflowConfig', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-research-plan-'); + writeWorkflowConfig(dir, { research: false, nyquist_validation: false }); + const body = parseOkJson(runSectionManifestCli(['init.plan-phase', '1'], dir), 'research-plan'); + assert.strictEqual(body.research_enabled, false, 'workflow.research: false must flow through to research_enabled'); + assert.strictEqual(body.nyquist_validation_enabled, false, 'workflow.nyquist_validation: false must flow through to nyquist_validation_enabled'); + }); + + test('planPhaseResearchAndNyquistEnabledDefaultTrueWhenConfigAbsent', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-research-plan-absent-'); + const body = parseOkJson(runSectionManifestCli(['init.plan-phase', '1'], dir), 'research-plan-absent'); + assert.strictEqual(body.research_enabled, true); + assert.strictEqual(body.nyquist_validation_enabled, true); + }); + }); + // ── Row 62: stub @-refs still resolve (ADR-0002) ──── describe('commands/gsd/execute-phase.md: @-refs resolve (#2932 row 62)', () => { diff --git a/tests/phase-tdd-applicable.test.cjs b/tests/phase-tdd-applicable.test.cjs new file mode 100644 index 000000000..c5995334f --- /dev/null +++ b/tests/phase-tdd-applicable.test.cjs @@ -0,0 +1,160 @@ +'use strict'; + +/** + * Unit tests for `gsd-tools phase tdd-applicable` (#4273, Phase 1 of epic #4272). + * + * Matrix rows referenced below are from + * .gsd/phase/feat-4273-tdd-applicable-query-verb/50-test-matrix.md + */ + +const path = require('node:path'); +const fs = require('node:fs'); +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +function writePlan(dir, relPath, content) { + const full = path.join(dir, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content, 'utf8'); + return full; +} + +describe('phase.tdd-applicable', () => { + test('row 9 — --cli-flag wins regardless of plan content', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const planPath = writePlan(tmpDir, '.planning/phases/01-x/01-PLAN.md', `--- +type: standard +--- +a +`); + const result = runGsdTools(['query', 'phase.tdd-applicable', planPath, '--cli-flag'], tmpDir); + assert.equal(result.success, true, result.error); + const parsed = JSON.parse(result.output); + assert.equal(parsed.applicable, true); + assert.equal(parsed.source, 'cli_flag'); + }); + + test('row 10 — plan frontmatter type: tdd, no flag', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const planPath = writePlan(tmpDir, '.planning/phases/01-x/01-PLAN.md', `--- +type: tdd +--- +a +`); + const result = runGsdTools(['query', 'phase.tdd-applicable', planPath], tmpDir); + assert.equal(result.success, true, result.error); + const parsed = JSON.parse(result.output); + assert.equal(parsed.applicable, true); + assert.equal(parsed.source, 'plan_frontmatter'); + }); + + test('row 11 — mixed-mode plan: standard plan, one tdd="true" task (the #4265 shape)', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const planPath = writePlan(tmpDir, '.planning/phases/01-x/01-PLAN.md', `--- +type: standard +--- +a +b +`); + const result = runGsdTools(['query', 'phase.tdd-applicable', planPath], tmpDir); + assert.equal(result.success, true, result.error); + const parsed = JSON.parse(result.output); + assert.equal(parsed.applicable, true); + assert.equal(parsed.source, 'task_attribute'); + }); + + test('row 12 — config.workflow.tdd_mode true, nothing else set', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const planPath = writePlan(tmpDir, '.planning/phases/01-x/01-PLAN.md', `a\n`); + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ workflow: { tdd_mode: true } }), 'utf8'); + const result = runGsdTools(['query', 'phase.tdd-applicable', planPath], tmpDir); + assert.equal(result.success, true, result.error); + const parsed = JSON.parse(result.output); + assert.equal(parsed.applicable, true); + assert.equal(parsed.source, 'config'); + }); + + test('row 13 — nothing set: applicable false, source none', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const planPath = writePlan(tmpDir, '.planning/phases/01-x/01-PLAN.md', `a\n`); + const result = runGsdTools(['query', 'phase.tdd-applicable', planPath], tmpDir); + assert.equal(result.success, true, result.error); + const parsed = JSON.parse(result.output); + assert.equal(parsed.applicable, false); + assert.equal(parsed.source, 'none'); + }); + + test('row 14 — missing plan file: non-zero exit, no silent false', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const missingPath = path.join(tmpDir, '.planning', 'phases', '01-x', 'does-not-exist-PLAN.md'); + const result = runGsdTools(['query', 'phase.tdd-applicable', missingPath], tmpDir); + assert.equal(result.success, false); + }); + + test('row 15 — missing plan-id argument: USAGE error, non-zero exit', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['query', 'phase.tdd-applicable'], tmpDir); + assert.equal(result.success, false); + }); + + test('row 16 — independence: --cli-flag wins over type: standard', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const planPath = writePlan(tmpDir, '.planning/phases/01-x/01-PLAN.md', `--- +type: standard +--- +a +`); + const result = runGsdTools(['query', 'phase.tdd-applicable', planPath, '--cli-flag'], tmpDir); + const parsed = JSON.parse(result.output); + assert.equal(parsed.source, 'cli_flag'); + }); + + test('row 17 — independence: type: tdd wins over a task with no tdd attribute (task tier never consulted)', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const planPath = writePlan(tmpDir, '.planning/phases/01-x/01-PLAN.md', `--- +type: tdd +--- +a +`); + const result = runGsdTools(['query', 'phase.tdd-applicable', planPath], tmpDir); + const parsed = JSON.parse(result.output); + assert.equal(parsed.source, 'plan_frontmatter'); + }); + + test('row 18 — JSON output shape names every source field', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const planPath = writePlan(tmpDir, '.planning/phases/01-x/01-PLAN.md', `a\n`); + const result = runGsdTools(['query', 'phase.tdd-applicable', planPath], tmpDir); + assert.equal(result.success, true, result.error); + const parsed = JSON.parse(result.output); + assert.ok('applicable' in parsed); + assert.ok('source' in parsed); + assert.ok('plan_type' in parsed); + assert.ok('config_tdd_mode' in parsed); + assert.ok('cli_flag_present' in parsed); + }); + + test('row 19 — empty plan file (no frontmatter, no tasks) falls through, not an error', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const planPath = writePlan(tmpDir, '.planning/phases/01-x/01-PLAN.md', ''); + const result = runGsdTools(['query', 'phase.tdd-applicable', planPath], tmpDir); + assert.equal(result.success, true, result.error); + const parsed = JSON.parse(result.output); + assert.equal(parsed.applicable, false); + assert.equal(parsed.source, 'none'); + }); +}); diff --git a/tests/plan-document.test.cjs b/tests/plan-document.test.cjs index 1ffff9853..fd74e14f6 100644 --- a/tests/plan-document.test.cjs +++ b/tests/plan-document.test.cjs @@ -61,6 +61,99 @@ describe('plan-document: tracker-id attribute', () => { }); }); +describe('plan-document: tdd attribute (#4273)', () => { + test('row 1 — tdd="true" is read verbatim', () => { + const doc = parsePlanDocument(` + +Do a thing + +`); + assert.equal(doc.tasks.length, 1); + assert.equal(doc.tasks[0].tdd, 'true'); + }); + + test('row 2 — no tdd attribute yields tdd: null', () => { + const doc = parsePlanDocument(` + +Do a thing + +`); + assert.equal(doc.tasks.length, 1); + assert.equal(doc.tasks[0].tdd, null); + }); + + test('row 3 — tdd="" (empty string) normalises to null', () => { + const doc = parsePlanDocument(` + +Do a thing + +`); + assert.equal(doc.tasks.length, 1); + assert.equal(doc.tasks[0].tdd, null); + }); + + test('row 4 — tdd="TRUE" and tdd="1" are read verbatim, never coerced to a boolean', () => { + const docUpper = parsePlanDocument(` + +Do a thing + +`); + assert.equal(docUpper.tasks[0].tdd, 'TRUE'); + assert.notEqual(docUpper.tasks[0].tdd, 'true'); + + const docNumeric = parsePlanDocument(` + +Do a thing + +`); + assert.equal(docNumeric.tasks[0].tdd, '1'); + }); + + test('row 5 — checkpoint tasks never read tdd, even when present', () => { + const doc = parsePlanDocument(` + +Ship it + +`); + assert.equal(doc.tasks.length, 1); + assert.equal(doc.tasks[0].kind, 'checkpoint'); + assert.equal(doc.tasks[0].tdd, null); + }); +}); + +describe('plan-document: frontmatter type (#4273)', () => { + test('row 6 — frontmatter type: tdd is read verbatim onto doc.type', () => { + const doc = parsePlanDocument(`--- +type: tdd +--- + +Do a thing + +`); + assert.equal(doc.type, 'tdd'); + }); + + test('row 7 — frontmatter type: standard is read verbatim, not coerced to a boolean', () => { + const doc = parsePlanDocument(`--- +type: standard +--- + +Do a thing + +`); + assert.equal(doc.type, 'standard'); + }); + + test('row 8 — no frontmatter type yields doc.type: null', () => { + const doc = parsePlanDocument(` + +Do a thing + +`); + assert.equal(doc.type, null); + }); +}); + describe('plan-document: regression — legacy behaviour unchanged', () => { test('legacy `## Task N` markdown fallback still parses with trackerId: null', () => { const doc = parsePlanDocument(`