Files
bmad-code-org__bmad-method/test/test-quick-dev-renderer.js
Alex Verkhovsky 49069b8b52 feat(quick-dev): render templates via stdlib Python at skill entry (#2281)
* feat(quick-dev): render templates via stdlib Python at skill entry

Move compile-time variable substitution out of the LLM and into a
deterministic Python step. SKILL.md becomes a two-line stdout-dispatch
shim that runs render.py and follows the instruction it prints. The
renderer reads BMad configuration from the central four-layer TOML
surface introduced in #2285 (_bmad/config.toml plus config.user.toml
and the two _bmad/custom/ overrides), with a fallback to the legacy
per-module _bmad/bmm/config.yaml for pre-#2285 installs.

Compile-time refs ({{.var}}) get substituted at render time. LLM-runtime
refs ({var}) pass through untouched.

Renderer (render.py)
- Python 3 stdlib only (tomllib, already bundled since 3.11). UTF-8 I/O.
  Every invocation rebuilds from scratch — no hash, no cache.
- find_project_root walks up from cwd; HALT to stdout if no _bmad/
  is found anywhere on the path.
- load_central_config deep-merges the four TOML layers in priority
  order (base-team → base-user → custom-team → custom-user) so user
  overrides in _bmad/custom/config.user.toml win over installer-
  regenerated base values. flatten_central_config lifts scalar keys
  from [core] and [modules.bmm] into the renderer's flat namespace;
  module keys beat core on collision (matches the installer's own
  core-key-stripping behavior).
- When _bmad/config.toml is absent, falls through to the legacy
  flat-YAML parser for _bmad/bmm/config.yaml — the renderer keeps
  working across the #2285 transition.
- {{.var}} substitution; unresolved refs emit empty string (Go
  missingkey=zero semantics).
- Smart defaults for planning_artifacts / implementation_artifacts /
  communication_language applied after config load. Derives
  sprint_status / deferred_work_file from implementation_artifacts.
  {{.main_config}} points at whichever surface was actually read.
- Renders every .md in the skill dir except SKILL.md to
  {project-root}/_bmad/render/bmad-quick-dev/.
- On success, stderr summary plus a single stdout line:
  "read and follow {workflow_md}". On failure, stdout HALT directive —
  per the Anthropic skills spec, script stdout is the defined agent-
  communication channel.

Skill entry (SKILL.md)
- Two-line shim: run python render.py, follow stdout. No template
  tokens in SKILL.md itself.

Template conversions
- workflow.md, step-01..05, step-oneshot, sync-sprint-status: convert
  every compile-time {var} reference to {{.var}}. Runtime refs
  preserved.
- spec-template.md untouched (single-curly comment hint stays as
  documentation).

Skill-prose cleanups bundled in
- Remove dead step-file frontmatter: empty-string variable declarations
  (spec_file, story_key, diff_output, review_mode) in quick-dev step-01
  and code-review step-01; empty --- --- blocks in step-03 and step-05;
  the specLoopIteration counter init moved from step-04 frontmatter into
  the step body where first-entry vs loopback semantics are explicit.
- Unify the language rule across all six quick-dev step files plus
  workflow.md.

Tooling
- tools/validate-skills.js: add TPL-01 rule. Files whose name contains
  "template" must not contain compile-time {{.var}} substitutions.
  Template files seed durable, version-controlled artifacts that
  execute on other machines; baking a value at render time would
  freeze a machine-local path into every downstream artifact.
- tools/validate-file-refs.js: add render/ to INSTALL_ONLY_PATHS so
  the validator recognizes the runtime-generated buffer.
- tools/skill-validator.md: document TPL-01; deterministic rule count
  bumped from 14 to 15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(quick-dev): drop render.py YAML fallback and smart defaults

Single happy path: central _bmad/config.toml with four-layer merge,
Python 3.11+ required (no ImportError guard), HALT if config missing.
Deletes load_flat_yaml, the YAML fallback branch, the setdefault block
for planning_artifacts/implementation_artifacts/communication_language,
and the tomllib ImportError fallback.

Part of plan-quick-dev-python-config-hardening.md (F0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(quick-dev): normalize render.py paths to forward slashes

On Windows, os.path.join returns backslash-separated paths that can
misrender as escape sequences when later concatenated into POSIX
shell strings or regexes. Normalize the project root to forward
slashes after find_project_root, and use posixpath.join for every
path that gets baked into rendered .md files or joined into config
values. os.makedirs and os.listdir accept forward-slash paths on
Windows, so their call sites stay as-is.

Part of plan-quick-dev-python-config-hardening.md (F3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(quick-dev): preserve source line endings in render.py

Python text-mode open() with the platform default performs universal-
newline translation: on Windows, LF source files get written as CRLF,
producing spurious diffs when rendered output is compared against
source. Pass newline="" on both the source read and the rendered
write so line endings pass through verbatim.

Part of plan-quick-dev-python-config-hardening.md (F4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(quick-dev): delete stale .md renders before rebuilding

render.py rebuilds from scratch per the docstring, but
makedirs(exist_ok=True) only overwrites files that still exist in
the source — stale outputs from renamed/deleted source files linger
in _bmad/render/bmad-quick-dev/ forever. Remove every .md in the
render dir before the render loop; keep the dir itself and any
non-.md files.

Part of plan-quick-dev-python-config-hardening.md (F5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(quick-dev): scope render/ whitelist to bmad-quick-dev

The previous INSTALL_ONLY_PATHS entry 'render/' was a blanket prefix
that let every {project-root}/_bmad/render/... reference in any skill
slip past validation. Narrow to 'render/bmad-quick-dev/' so only this
skill's render buffer is whitelisted. Future skills adopting the
stdout-dispatch renderer pattern add their own entries explicitly.

Part of plan-quick-dev-python-config-hardening.md (F6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(quick-dev): add renderer smoke test with TOML override

New test/test-quick-dev-renderer.js spins up a temp project with
base _bmad/config.toml and a _bmad/custom/config.user.toml override,
runs render.py, and asserts the override wins in rendered workflow.md
and that sprint_status is rooted at an absolute path in the temp
project. Registered as test:renderer in package.json and chained
into the npm test script.

Part of plan-quick-dev-python-config-hardening.md (F7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(quick-dev): HALT cleanly when base config.toml is unparseable

Load the four config layers through a load_toml helper that marks the
base _bmad/config.toml as required. A missing, unparseable, or unreadable
base now prints a HALT directive to stdout and exits, instead of being
silently skipped and then crashing downstream with a KeyError when a
derived value (e.g. implementation_artifacts) is absent. Optional layers
still warn on stderr and fall back to empty. Merge semantics are
unchanged (dict-aware deep merge, override wins for lists and scalars).

* fix(quick-dev): resolve render.py via {skill-root} in skill entry shim

The bare `python render.py` shim assumes the agent's working directory is
the skill directory, but agents run from the project root, so the script
is not found. Reference it as `{skill-root}/render.py` — BMAD's standard
token for a skill's installed directory, already used by every other
skill's resolve_customization.py invocation — and add the one-line
`{skill-root}` explainer so the model resolves it from an instruction
rather than guessing. Interpreter stays `python`; the python vs python3
choice is a separate cross-platform concern.

* refactor(quick-dev): resolve [workflow] customization in render.py

render.py now merges the three customize layers (customize.toml ->
custom/bmad-quick-dev.toml -> .user.toml) with the same structural rules as
resolve_customization.py and inlines the resolved [workflow] values, so no
{workflow.*} placeholder survives. workflow.md drops its Step 1 runtime
resolver + manual-merge fallback; step-05 and step-oneshot drop their runtime
workflow.on_complete calls. The shared resolve_customization.py and every
other skill are untouched. Smoke test extended with a [workflow] override
fixture covering inlining, array append, and no-leak assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(quick-dev): harden render.py invocation in the SKILL.md shim

The shim called bare `python`, which can resolve to Python 2 or be absent;
render.py needs 3.11+ for tomllib. Spell out python3 and the version
requirement. Also make the exit code authoritative: on a non-zero exit
(including an uncaught crash that writes only to stderr), do not proceed --
report what was printed and stop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(quick-dev): drop the render.py success stderr line

The "rendered N files" progress line was pure diagnostic noise. The shim
already tells the LLM to ignore stderr and follow the stdout instruction, so
on success render.py now prints only the "read and follow ..." line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(quick-dev): drop the activation gate sentence from the rendered workflow

The gate ported from #2398 defended against runtime customization
indirection: agents guessed resolver outputs instead of executing them,
silently skipping append steps. render.py inlines the prepend/append
entries into the rendered workflow.md, so there is nothing left to
short-circuit, and each inlined list already carries its own execute-
in-order imperative. In the default install both lists render as
_None._ and the gate is pure noise.

* feat(quick-dev): materialize review layers into invocation blocks

Reconcile #2550 with render-time [workflow] resolution. Main made review
layers configurable as [[workflow.review_layers]] arrays of tables and
had the LLM resolve them during activation; this branch resolves the
[workflow] block in render.py instead, so activation-time resolution no
longer exists and the layer refs must be materialized at render time.

Rather than inlining the layer tables as data plus interpretation rules,
render.py now knows this skill's customization schema outright and
renders review_layers/oneshot_review_layers as direct invocation blocks:
disabled layers (empty instruction) drop out, each active layer becomes
a #### section holding its instruction verbatim, zero active layers
renders the HALT instruction, and runtime placeholders like
{diff_output} pass through. The only judgment left to the LLM is the
optional `when` condition, which renders as a run-time guard line.
The step-04/step-oneshot review intros collapse to a single execute-in-
parallel imperative. Smoke test covers default rendering, replace-by-id,
disable-by-empty-instruction, when-guards, and the all-disabled HALT.

* fix(quick-dev): invoke render.py via uv run per house standard

The SKILL.md shim launched render.py with bare `python3`, which the rest
of BMAD is migrating away from: the customize-bmad docs and the
installer's uv-check standardize on `uv run` (uv provisions a suitable
3.11+ interpreter on demand). Bare `python3` is also fragile on Windows,
where python.org installs expose `python`/`py` rather than `python3`.

Make `uv run` the primary invocation and demote `python3` to the
documented fallback, spelling out `python`/`py -3` for Windows and the
3.11+ tomllib requirement. render.py itself is unchanged; the renderer
test drives it directly and is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(quick-dev): HALT cleanly on missing or malformed config

render.py derived sprint_status/deferred_work_file from an unconditional
vars_["implementation_artifacts"] subscript, so a config lacking that key
raised a raw KeyError instead of the stdout HALT the rest of the script
uses on bad input. flatten_central_config likewise called .get("bmm") on
merged["modules"] without checking it was a table, so a non-table
[modules] crashed with an AttributeError.

Guard both: HALT with a clear stdout directive when implementation_artifacts
is missing or blank, and coerce a non-dict modules to {} before indexing.
Add renderer regression tests asserting each path exits without a Python
traceback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(quick-dev): resolve config at compile time, drop the runtime re-read

The activation "Load Config" step told the LLM to open {{.main_config}}
and re-resolve project_name, communication_language, sprint_status, etc.
at run time -- but render.py already bakes those from the full four-layer
config merge. main_config pointed at only the base _bmad/config.toml, so
on installs with override layers (config.user.toml / custom/*) the runtime
re-read saw stale values that could contradict the baked {{.var}} in the
same rendered file. It also handed resolution back to the LLM: the drift
this skill's renderer exists to remove.

Delete the ceremony and wire each value where it is actually used:

- Every value the step resolved is already inlined at its point of use
  (planning/implementation_artifacts, sprint_status, communication_language)
  or loaded via persistent_facts (project-context.md), so the central
  block was pure redundancy.
- Fold document_output_language into the per-step language rule, adopting
  the house-canonical form ("Speak in X. Write any file output in Y.")
  already used by bmad-checkpoint-preview.
- Move the {date} = current-datetime definition to step-02, where the
  spec template's {date} field is filled.
- Drop the user greeting (user_name) and user_skill_level tailoring:
  quick-dev is not a conversational skill and neither was load-bearing.
- Remove main_config from render.py; it had no remaining consumer.

Renderer tests repointed at the files that now carry these values, plus
coverage for document_output_language baking and main_config removal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(quick-dev): reference variables by bare name, not placeholder curlies

Curlies mean "expand this to the value"; a bare backticked name means
"this is the variable/field I'm talking about". Several step files wrapped
a variable in curlies where they were only naming, assigning, passing, or
testing it -- so the notation implied an expansion that never happens:

- step-01: identify `epic_num`/`story_num`, set/leave `story_key` unset
- step-02: test `preserved_intent`; and resolve the template's `date` field
  (was `{date}`, which read as "expand date here" rather than naming it)
- step-03/step-05/step-oneshot: pass `target_status` to sync-sprint-status,
  set `title`
- sync-sprint-status: the `target_status` parameter, `story_key` precondition,
  and both `target_status` conditionals

Value tokens that are genuinely materialized in place -- `{spec_file}` paths,
`development_status[{story_key}]`, "set ... to `{target_status}`" -- stay
curly. Also reword step-02's frozen-block instruction from the ambiguous
"substitute it for the `<frozen-after-approval>` block" to "replace the
`<frozen-after-approval>` block in the spec you just filled out with
`preserved_intent`" so it's clear the replacement happens in the artifact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(quick-dev): require uv, drop the python3 interpreter fallback

The SKILL.md shim tried `uv run render.py` and, if uv was missing, retried
with a bare `python3`/`py -3` interpreter. Nothing else in the codebase
does that interpreter fallback: the uv-based skills (bmad-prd, bmad-ux,
bmad-architecture, bmad-product-brief) fall back to reading customize.toml
and using defaults -- graceful feature degradation, never a different
runner -- and the legacy skills just call python3 outright. uv is the
established house runner (memlog.py, resolve_customization.py, lint_spine.py
all invoke it).

That graceful-degrade path does not exist here: render.py is the entry
dispatch that produces the workflow.md the LLM then follows, so there is
nothing to fall back to. The only honest outcomes are "uv runs it" or
"HALT". Make uv the floor and drop the fallback.

Pin the interpreter the house way -- a PEP 723 `requires-python = ">=3.11"`
block, matching memlog.py/lint_spine.py -- so `uv run` provisions a 3.11+
interpreter and the tomllib requirement is guaranteed rather than hoped for.
This replaces the prose "needs 3.11+" hedge the shim used to carry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 19:31:55 -07:00

372 lines
16 KiB
JavaScript

/**
* Smoke test for bmad-quick-dev render.py
*
* Sets up a temp project with base + override config layers and a
* _bmad/custom/bmad-quick-dev.user.toml [workflow] override, runs render.py,
* and asserts:
* 1. The central-config override wins (step files' language line contains "Japanese").
* 2. sprint_status is an absolute path rooted at the temp project dir.
* 3. [workflow] customization is self-resolved and inlined: prepend bullet,
* persistent_facts append (base kept), empty list -> _None._, on_complete
* scalar baked into step-05/step-oneshot.
* 4. Review layers materialize as direct invocation blocks: default layers
* become #### sections in step-04, an override replacing a layer by id
* wins, an empty-instruction override drops its layer, a `when` renders
* as a run-time guard, runtime placeholders like {diff_output} survive,
* and disabling every layer renders the HALT instruction.
* 5. No {workflow.*} placeholder or resolve_customization.py call survives
* in any rendered file.
*
* Usage: node test/test-quick-dev-renderer.js
* Exit codes: 0 = all tests pass, 1 = test failures
*/
'use strict';
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
// ANSI color codes (same as other test files)
const colors = {
reset: '\u001B[0m',
green: '\u001B[32m',
red: '\u001B[31m',
cyan: '\u001B[36m',
};
let totalTests = 0;
let passedTests = 0;
const failures = [];
function test(name, fn) {
totalTests++;
try {
fn();
passedTests++;
console.log(` ${colors.green}\u2713${colors.reset} ${name}`);
} catch (error) {
console.log(` ${colors.red}\u2717${colors.reset} ${name} ${colors.red}${error.message}${colors.reset}`);
failures.push({ name, message: error.message });
}
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const SKILL_SRC = path.join(__dirname, '..', 'src', 'bmm-skills', '4-implementation', 'bmad-quick-dev');
/**
* Recursively copy a directory (stdlib only, no fs.cp to stay >=20 compat).
*/
function copyDirSync(src, dst) {
fs.mkdirSync(dst, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const dstPath = path.join(dst, entry.name);
if (entry.isDirectory()) {
copyDirSync(srcPath, dstPath);
} else {
fs.copyFileSync(srcPath, dstPath);
}
}
}
// Extra one-off temp projects created by makeProject(); cleaned up in finally.
const extraTmpDirs = [];
/**
* Spin up an isolated temp project with the given _bmad/config.toml body and a
* copy of the skill dir, so a single bad-config scenario can be rendered in
* isolation. Returns { dir, skillDst }; the caller runs render.py against it.
*/
function makeProject(configText) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-renderer-halt-'));
extraTmpDirs.push(dir);
fs.mkdirSync(path.join(dir, '_bmad'), { recursive: true });
fs.writeFileSync(path.join(dir, '_bmad', 'config.toml'), configText, 'utf-8');
const skillDst = path.join(dir, 'bmad-quick-dev');
copyDirSync(SKILL_SRC, skillDst);
return { dir, skillDst };
}
// ---------------------------------------------------------------------------
// Test fixture setup
// ---------------------------------------------------------------------------
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-renderer-test-'));
try {
// _bmad/config.toml — base layer
fs.mkdirSync(path.join(tmpDir, '_bmad'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '_bmad', 'config.toml'),
[
'[core]',
'communication_language = "French"',
'document_output_language = "Klingon"',
'',
'[modules.bmm]',
'planning_artifacts = "{project-root}/plan"',
'implementation_artifacts = "{project-root}/impl"',
].join('\n'),
'utf-8',
);
// _bmad/custom/config.user.toml — override layer (should win)
fs.mkdirSync(path.join(tmpDir, '_bmad', 'custom'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '_bmad', 'custom', 'config.user.toml'),
['[core]', 'communication_language = "Japanese"'].join('\n'),
'utf-8',
);
// _bmad/custom/bmad-quick-dev.user.toml — [workflow] customization override.
// Exercises render.py's self-resolution: array append (persistent_facts),
// list inlining (activation_steps_prepend), and scalar override (on_complete),
// all baked into the rendered output with no runtime resolve_customization.py.
fs.writeFileSync(
path.join(tmpDir, '_bmad', 'custom', 'bmad-quick-dev.user.toml'),
[
'[workflow]',
'activation_steps_prepend = ["TEST_PREPEND_STEP"]',
'persistent_facts = ["TEST_EXTRA_FACT"]',
'on_complete = "TEST_ON_COMPLETE_INSTRUCTION"',
'',
'[[workflow.review_layers]]',
'id = "edge-case-hunter"',
'name = "Replaced Layer"',
'when = "TEST_WHEN_CONDITION"',
'instruction = "TEST_REPLACED_LAYER_INSTRUCTION"',
'',
'[[workflow.review_layers]]',
'id = "verification-gap"',
'instruction = ""',
].join('\n'),
'utf-8',
);
// Copy skill dir into <tmpDir>/bmad-quick-dev/ so find_project_root() walks
// up and finds <tmpDir>/_bmad/, and os.path.basename(script_dir) resolves
// to the real skill name so the render output lands at
// _bmad/render/bmad-quick-dev/workflow.md.
const skillDst = path.join(tmpDir, 'bmad-quick-dev');
copyDirSync(SKILL_SRC, skillDst);
// ---------------------------------------------------------------------------
// Run render.py
// ---------------------------------------------------------------------------
console.log(`\n${colors.cyan}Quick-dev renderer smoke tests${colors.reset}\n`);
const result = spawnSync('python3', [path.join(skillDst, 'render.py')], {
cwd: skillDst,
encoding: 'utf-8',
});
const renderDir = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev');
const readRendered = (name) => fs.readFileSync(path.join(renderDir, name), 'utf-8');
const renderedMdFiles = () => fs.readdirSync(renderDir).filter((f) => f.endsWith('.md'));
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test('render.py exits with code 0', () => {
assert(result.status === 0, `exit code ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
test('workflow.md exists in render output', () => {
const rendered = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev', 'workflow.md');
assert(fs.existsSync(rendered), `workflow.md not found at ${rendered}`);
});
test('custom override wins — communication_language baked into step files', () => {
const content = readRendered('step-01-clarify-and-route.md');
assert(content.includes('Japanese'), 'communication_language override (Japanese) did not win in the step-01 language line');
});
test('document_output_language bakes into the per-step language line', () => {
const content = readRendered('step-01-clarify-and-route.md');
assert(content.includes('Klingon'), 'document_output_language not baked into the step-01 language line');
});
test('sprint_status is an absolute path rooted at temp project dir', () => {
const content = readRendered('sync-sprint-status.md');
// Normalize to forward slashes for cross-platform matching
const normalizedTmp = tmpDir.replaceAll('\\', '/');
// sprint_status should appear as <tmpDir>/impl/sprint-status.yaml
const expected = `${normalizedTmp}/impl/sprint-status.yaml`;
assert(
content.includes(expected),
`sprint_status path not found.\nExpected substring: ${expected}\n` +
`sync-sprint-status.md excerpt (first 2000 chars):\n${content.slice(0, 2000)}`,
);
});
test('workflow override — prepend step inlined as a bullet', () => {
const content = readRendered('workflow.md');
assert(content.includes('- TEST_PREPEND_STEP'), 'activation_steps_prepend not inlined as a bullet');
});
test('workflow override — persistent_facts append (base kept, override added)', () => {
const content = readRendered('workflow.md');
assert(content.includes('- TEST_EXTRA_FACT'), 'override persistent_fact not inlined');
assert(content.includes('project-context.md'), 'base persistent_fact dropped — append semantics broken');
});
test('empty activation_steps_append renders the _None._ sentinel', () => {
const content = readRendered('workflow.md');
assert(content.includes('_None._'), '_None._ sentinel missing for empty list');
});
test('on_complete scalar inlined into step-05 and step-oneshot', () => {
for (const file of ['step-05-present.md', 'step-oneshot.md']) {
assert(readRendered(file).includes('TEST_ON_COMPLETE_INSTRUCTION'), `on_complete not inlined into ${file}`);
}
});
test('review layers materialize as invocation blocks in step-04', () => {
const content = readRendered('step-04-review.md');
assert(content.includes('#### Blind Hunter'), 'default review layer not rendered as a #### invocation block');
assert(!content.includes('- id:'), 'layer table data leaked into the rendered output');
assert(content.includes('{diff_output}'), 'runtime {diff_output} placeholder did not survive rendering');
});
test('review layer override replaces the matching default by id', () => {
const content = readRendered('step-04-review.md');
assert(content.includes('#### Replaced Layer'), 'override layer name not used as block title');
assert(content.includes('TEST_REPLACED_LAYER_INSTRUCTION'), 'override layer instruction not inlined');
assert(!content.includes('bmad-review-edge-case-hunter'), 'replaced default layer instruction still present');
assert(content.includes('bmad-review-adversarial-general'), 'untouched default layer dropped by keyed merge');
});
test('empty-instruction override drops its layer entirely', () => {
const content = readRendered('step-04-review.md');
assert(!content.includes('verification-gap'), 'disabled layer id still present in rendered output');
assert(!content.includes('Verification Gap Reviewer'), 'disabled layer name still present in rendered output');
});
test('when condition renders as a run-time guard line', () => {
const content = readRendered('step-04-review.md');
assert(
content.includes('Run this layer only if the following holds in the current context: `TEST_WHEN_CONDITION`'),
'when condition not rendered as a guard line',
);
});
test('disabling every layer renders the HALT instruction', () => {
// Second render pass: replace the override file so every default layer
// (and the oneshot route's only layer) is disabled, then re-render.
fs.writeFileSync(
path.join(tmpDir, '_bmad', 'custom', 'bmad-quick-dev.user.toml'),
[
'[workflow]',
'',
'[[workflow.review_layers]]',
'id = "blind-hunter"',
'instruction = ""',
'',
'[[workflow.review_layers]]',
'id = "edge-case-hunter"',
'instruction = ""',
'',
'[[workflow.review_layers]]',
'id = "verification-gap"',
'instruction = ""',
'',
'[[workflow.oneshot_review_layers]]',
'id = "blind-hunter"',
'instruction = ""',
].join('\n'),
'utf-8',
);
const rerun = spawnSync('python3', [path.join(skillDst, 'render.py')], {
cwd: skillDst,
encoding: 'utf-8',
});
assert(rerun.status === 0, `re-render exit code ${rerun.status}\nstderr: ${rerun.stderr}`);
const halt = 'No review layers are active. HALT with status `blocked` and blocking condition `no active review layers`.';
for (const file of ['step-04-review.md', 'step-oneshot.md']) {
assert(readRendered(file).includes(halt), `HALT instruction missing from ${file}`);
}
});
test('no {workflow.*} placeholder survives in any rendered file', () => {
const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('{workflow.'));
assert(leaks.length === 0, `{workflow.*} leaked in: ${leaks.join(', ')}`);
});
test('no resolve_customization.py reference survives in any rendered file', () => {
const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('resolve_customization.py'));
assert(leaks.length === 0, `resolve_customization.py still referenced in: ${leaks.join(', ')}`);
});
test('no main_config reference survives in any rendered file', () => {
const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('main_config'));
assert(leaks.length === 0, `main_config still referenced in: ${leaks.join(', ')} (the runtime config re-read was removed)`);
});
// ---------------------------------------------------------------------------
// Bad-config HALTs cleanly (never a raw Python traceback)
// ---------------------------------------------------------------------------
test('missing implementation_artifacts HALTs cleanly (no traceback)', () => {
const { skillDst: dst } = makeProject(['[core]', 'communication_language = "French"'].join('\n'));
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('HALT and report to the user: config is missing `implementation_artifacts`'),
`stdout missing the implementation_artifacts HALT directive.\nstdout: ${res.stdout}`,
);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
test('non-table [modules] does not crash the renderer', () => {
const { dir, skillDst: dst } = makeProject(
['modules = "oops-not-a-table"', '', '[core]', 'implementation_artifacts = "{project-root}/impl"'].join('\n'),
);
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 0, `expected exit 0, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(!res.stderr.includes('Traceback'), `renderer crashed on non-table modules:\n${res.stderr}`);
assert(
fs.existsSync(path.join(dir, '_bmad', 'render', 'bmad-quick-dev', 'workflow.md')),
'workflow.md not rendered when [modules] was a non-table scalar',
);
});
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
for (const dir of extraTmpDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
console.log(`\n${colors.cyan}${'═'.repeat(55)}${colors.reset}`);
console.log(`${colors.cyan}Test Results:${colors.reset}`);
console.log(` Total: ${totalTests}`);
console.log(` Passed: ${colors.green}${passedTests}${colors.reset}`);
console.log(` Failed: ${passedTests === totalTests ? colors.green : colors.red}${totalTests - passedTests}${colors.reset}`);
console.log(`${colors.cyan}${'═'.repeat(55)}${colors.reset}\n`);
if (failures.length > 0) {
console.log(`${colors.red}FAILED TESTS:${colors.reset}\n`);
for (const failure of failures) {
console.log(`${colors.red}\u2717${colors.reset} ${failure.name}`);
console.log(` ${failure.message}\n`);
}
process.exit(1);
}
console.log(`${colors.green}All tests passed!${colors.reset}\n`);
process.exit(0);