chore(bmad-module): frontmatter/CRLF, test hygiene, fixture corrections

- frontmatter: accept a block that ends at EOF (optional trailing newline).
- legacy-resolver: accept CRLF frontmatter delimiters.
- integration.test.sh: unique mktemp stderr capture, explicit if/then/else
  assertions (drop the SC2015 && ok || ko chains), plus unknown-flag and
  invalid-channel usage-error cases.
- test-installation-components: clear the resolution cache in a finally.
- acme-devlog fixtures: correct the uninstall note to the flat TOML layout,
  replace `ls -t | head` with a glob/-nt lookup, drop the always-on devlog
  config file: fact, "run" -> "invoke" wording, and reconcile the devlog-write
  template contract with the bundled template.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pbean 2026-06-20 16:00:09 -07:00
parent cbf87c414d
commit 08dc71f8cc
9 changed files with 78 additions and 47 deletions

View File

@ -3,7 +3,7 @@ import { parse as parseYaml } from './vendor/yaml.mjs';
// Parse YAML frontmatter from a markdown string. Returns the parsed object, // Parse YAML frontmatter from a markdown string. Returns the parsed object,
// or null if no frontmatter block is present / it failed to parse. // or null if no frontmatter block is present / it failed to parse.
export function parseFrontmatter(content) { export function parseFrontmatter(content) {
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!m) return null; if (!m) return null;
try { try {
return parseYaml(m[1]); return parseYaml(m[1]);

View File

@ -305,7 +305,7 @@ async function readModuleYaml(yamlAbs) {
async function parseSkillFrontmatter(skillDirAbs) { async function parseSkillFrontmatter(skillDirAbs) {
try { try {
const content = await fs.readFile(path.join(skillDirAbs, 'SKILL.md'), 'utf8'); const content = await fs.readFile(path.join(skillDirAbs, 'SKILL.md'), 'utf8');
const match = content.match(/^---\s*\n([\s\S]*?)\n---/); const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
if (!match) return { name: '', description: '' }; if (!match) return { name: '', description: '' };
const parsed = parseYaml(match[1]) || {}; const parsed = parseYaml(match[1]) || {};
return { name: parsed.name || '', description: parsed.description || '' }; return { name: parsed.name || '', description: parsed.description || '' };

View File

@ -32,7 +32,7 @@ See `docs/index.md` (in this repo) for the customization recipe.
## Uninstall ## Uninstall
``` ```
bmad-module remove devlog # leaves _bmad/custom/devlog/ intact bmad-module remove devlog # leaves _bmad/custom/bmad-agent-historian*.toml intact
bmad-module remove devlog --purge # removes user customizations too bmad-module remove devlog --purge # removes user customizations too
``` ```

View File

@ -22,8 +22,16 @@ if [ -f "$today_file" ]; then
exit 0 exit 0
fi fi
# Fall back to the most recent .md by mtime. # Fall back to the most recent .md by mtime. Glob + `-nt` instead of parsing
latest=$(ls -t "${devlog_path}"/*.md 2>/dev/null | head -n 1 || true) # `ls` so filenames with spaces/newlines are handled safely (and ShellCheck
# stays happy).
latest=""
for f in "${devlog_path}"/*.md; do
[ -e "$f" ] || continue
if [ -z "$latest" ] || [ "$f" -nt "$latest" ]; then
latest="$f"
fi
done
if [ -n "$latest" ]; then if [ -n "$latest" ]; then
echo "=== Most recent devlog ($(basename "$latest" .md)) ===" echo "=== Most recent devlog ($(basename "$latest" .md)) ==="
cat "$latest" cat "$latest"

View File

@ -22,8 +22,11 @@ activation_steps_append = []
# Persistent facts the agent keeps in mind for the whole session. # Persistent facts the agent keeps in mind for the whole session.
# `file:` entries are loaded as content; literal entries are facts verbatim. # `file:` entries are loaded as content; literal entries are facts verbatim.
# Note: the devlog config is intentionally NOT loaded here as an always-on
# `file:` fact — it may not exist on first run, which would fail activation
# before the `/bmad-devlog-setup` fallback can create it. Skills read the
# config after checking it exists.
persistent_facts = [ persistent_facts = [
"file:{project-root}/_bmad/devlog/config.yaml",
"The devlog is a primary source. Never invent context that isn't written down.", "The devlog is a primary source. Never invent context that isn't written down.",
] ]

View File

@ -11,7 +11,7 @@ Walks devlog entries in a date range and produces a structured summary: themes,
### Step 1: Resolve config ### Step 1: Resolve config
Read `{project-root}/_bmad/devlog/config.yaml`. If missing, run `/bmad-devlog-setup` first. Read `{project-root}/_bmad/devlog/config.yaml`. If missing, invoke `/bmad-devlog-setup` first.
### Step 2: Parse the range argument ### Step 2: Parse the range argument

View File

@ -16,7 +16,7 @@ Read `{project-root}/_bmad/devlog/config.yaml`. Expect:
- `devlog_path` (absolute path) - `devlog_path` (absolute path)
- `entry_format` (`iso` | `weekly` | `monthly`) - `entry_format` (`iso` | `weekly` | `monthly`)
If config is missing, run `/bmad-devlog-setup` first. If config is missing, invoke `/bmad-devlog-setup` first.
### Step 2: Determine the entry file ### Step 2: Determine the entry file
@ -26,7 +26,7 @@ If config is missing, run `/bmad-devlog-setup` first.
### Step 3: Initialize if absent ### Step 3: Initialize if absent
If the file doesn't exist, copy `./assets/template.md` to the target path. Substitute `{{date}}`, `{{author}}` (from `user_name`), and `{{week}}`/`{{month}}` placeholders. If the file doesn't exist, copy `./assets/template.md` to the target path. Substitute `{{date}}` and `{{author}}` (from `user_name`). For `weekly`/`monthly`, render the `{{date}}` heading from the period value (e.g. `2026-W21` or `2026-05`) instead of reusing the daily date.
### Step 4: Collect entry content ### Step 4: Collect entry content

View File

@ -34,11 +34,13 @@ ko() { printf ' \033[31m✗\033[0m %s\n' "$*"; fail=$((fail+1)); }
# Wrapper that captures stdout/stderr/exit code into globals. # Wrapper that captures stdout/stderr/exit code into globals.
run() { run() {
local stderr_file
stderr_file="$(mktemp)"
set +e set +e
STDOUT="$(node "${MODULE_JS}" "$@" 2>/tmp/bmad-module-stderr.$$)" STDOUT="$(node "${MODULE_JS}" "$@" 2>"${stderr_file}")"
EXIT=$? EXIT=$?
STDERR="$(cat /tmp/bmad-module-stderr.$$)" STDERR="$(cat "${stderr_file}")"
rm -f /tmp/bmad-module-stderr.$$ rm -f "${stderr_file}"
set -e set -e
} }
@ -115,15 +117,22 @@ ok "skeleton seeded at ${WORKDIR}/_bmad/"
note "list (no modules)" note "list (no modules)"
run list run list
assert_exit 0 "list empty" assert_exit 0 "list empty"
[[ "${STDOUT}" == *"no modules installed"* ]] && ok "stdout reports empty" \ if [[ "${STDOUT}" == *"no modules installed"* ]]; then ok "stdout reports empty"
|| ko "expected 'no modules installed' in stdout: ${STDOUT}" else ko "expected 'no modules installed' in stdout: ${STDOUT}"; fi
# ─── 1a. usage errors: unknown flag / invalid channel reject early ───────────
note "unknown flag and invalid channel → exit 2"
run install "${EXAMPLES}/minimal/acme-md-lint" --bogus
assert_exit 2 "unknown flag rejected"
run install "${EXAMPLES}/minimal/acme-md-lint" --channel stabl --dry-run
assert_exit 2 "invalid channel rejected"
# ─── 2. dry-run install of minimal module ──────────────────────────────────── # ─── 2. dry-run install of minimal module ────────────────────────────────────
note "install --dry-run examples/minimal/acme-md-lint" note "install --dry-run examples/minimal/acme-md-lint"
run install "${EXAMPLES}/minimal/acme-md-lint" --dry-run run install "${EXAMPLES}/minimal/acme-md-lint" --dry-run
assert_exit 0 "dry-run install" assert_exit 0 "dry-run install"
[[ "${STDOUT}" == *"dry-run"* ]] && ok "stdout mentions dry-run" \ if [[ "${STDOUT}" == *"dry-run"* ]]; then ok "stdout mentions dry-run"
|| ko "expected 'dry-run' in stdout: ${STDOUT}" else ko "expected 'dry-run' in stdout: ${STDOUT}"; fi
assert_path_absent "_bmad/mdlint" assert_path_absent "_bmad/mdlint"
# ─── 3. real install of minimal module ─────────────────────────────────────── # ─── 3. real install of minimal module ───────────────────────────────────────
@ -141,13 +150,13 @@ assert_grep ',"mdlint",' "_bmad/_config/files-manifest.csv"
note "list (after minimal install)" note "list (after minimal install)"
run list run list
assert_exit 0 "list one" assert_exit 0 "list one"
[[ "${STDOUT}" == *"mdlint"* ]] && ok "stdout includes mdlint" \ if [[ "${STDOUT}" == *"mdlint"* ]]; then ok "stdout includes mdlint"
|| ko "expected 'mdlint' in stdout: ${STDOUT}" else ko "expected 'mdlint' in stdout: ${STDOUT}"; fi
run list --json run list --json
assert_exit 0 "list --json" assert_exit 0 "list --json"
[[ "${STDOUT}" == *"\"name\": \"mdlint\""* ]] && ok "json includes mdlint name" \ if [[ "${STDOUT}" == *"\"name\": \"mdlint\""* ]]; then ok "json includes mdlint name"
|| ko "expected mdlint in JSON: ${STDOUT}" else ko "expected mdlint in JSON: ${STDOUT}"; fi
# ─── 5. idempotent re-install ──────────────────────────────────────────────── # ─── 5. idempotent re-install ────────────────────────────────────────────────
note "install acme-md-lint again (idempotent / collision)" note "install acme-md-lint again (idempotent / collision)"
@ -190,8 +199,8 @@ assert_path_exists "_bmad/devlog/module-help.csv"
assert_path_absent "_bmad/devlog/docs" assert_path_absent "_bmad/devlog/docs"
assert_path_absent "_bmad/devlog/README.md" assert_path_absent "_bmad/devlog/README.md"
assert_path_absent "_bmad/devlog/CHANGELOG.md" assert_path_absent "_bmad/devlog/CHANGELOG.md"
[[ "${STDOUT}" == *"hooks"* ]] && ok "warns about hooks not auto-activated" \ if [[ "${STDOUT}" == *"hooks"* ]]; then ok "warns about hooks not auto-activated"
|| ko "expected hooks warning in stdout: ${STDOUT}" else ko "expected hooks warning in stdout: ${STDOUT}"; fi
# ─── 9a. parity: central config + agent roster (gap #3) ────────────────────── # ─── 9a. parity: central config + agent roster (gap #3) ──────────────────────
note "config generation + agent roster" note "config generation + agent roster"
@ -213,8 +222,9 @@ assert_path_exists "_bmad-output/journal"
# ─── 9c. parity: merged help catalog (gap #1) ──────────────────────────────── # ─── 9c. parity: merged help catalog (gap #1) ────────────────────────────────
note "bmad-help.csv merge" note "bmad-help.csv merge"
assert_path_exists "_bmad/_config/bmad-help.csv" assert_path_exists "_bmad/_config/bmad-help.csv"
head -1 _bmad/_config/bmad-help.csv | grep -q '^module,skill,display-name,' \ if head -1 _bmad/_config/bmad-help.csv | grep -q '^module,skill,display-name,'; then
&& ok "bmad-help.csv has canonical header" || ko "bmad-help.csv header wrong" ok "bmad-help.csv has canonical header"
else ko "bmad-help.csv header wrong"; fi
assert_grep '^devlog,bmad-devlog-write,' "_bmad/_config/bmad-help.csv" assert_grep '^devlog,bmad-devlog-write,' "_bmad/_config/bmad-help.csv"
assert_grep '^devlog,bmad-agent-historian,' "_bmad/_config/bmad-help.csv" assert_grep '^devlog,bmad-agent-historian,' "_bmad/_config/bmad-help.csv"
# the core baseline row is still present # the core baseline row is still present
@ -224,8 +234,8 @@ assert_grep ',bmad-help,Help,' "_bmad/_config/bmad-help.csv"
note "install examples/legacy/bmad-mini-legacy (legacy marketplace.json)" note "install examples/legacy/bmad-mini-legacy (legacy marketplace.json)"
run install "${EXAMPLES}/legacy/bmad-mini-legacy" run install "${EXAMPLES}/legacy/bmad-mini-legacy"
assert_exit 0 "install legacy mini" assert_exit 0 "install legacy mini"
[[ "${STDOUT}" == *"resolved legacy module mlg"* ]] && ok "stdout reports legacy resolution" \ if [[ "${STDOUT}" == *"resolved legacy module mlg"* ]]; then ok "stdout reports legacy resolution"
|| ko "expected 'resolved legacy module mlg' in stdout: ${STDOUT}" else ko "expected 'resolved legacy module mlg' in stdout: ${STDOUT}"; fi
# Synthetic plugin.json is staged; marketplace.json is preserved verbatim. # Synthetic plugin.json is staged; marketplace.json is preserved verbatim.
assert_path_exists "_bmad/mlg/.claude-plugin/plugin.json" assert_path_exists "_bmad/mlg/.claude-plugin/plugin.json"
assert_path_exists "_bmad/mlg/.claude-plugin/marketplace.json" assert_path_exists "_bmad/mlg/.claude-plugin/marketplace.json"
@ -273,13 +283,15 @@ run remove mdlint
assert_exit 0 "remove mdlint" assert_exit 0 "remove mdlint"
assert_path_absent "_bmad/mdlint" assert_path_absent "_bmad/mdlint"
assert_path_exists "_bmad/custom/mdlint/override.md" assert_path_exists "_bmad/custom/mdlint/override.md"
[[ "${STDOUT}" == *"preserved"* ]] && ok "stdout mentions preserved customs" \ if [[ "${STDOUT}" == *"preserved"* ]]; then ok "stdout mentions preserved customs"
|| ko "expected 'preserved' in stdout: ${STDOUT}" else ko "expected 'preserved' in stdout: ${STDOUT}"; fi
# manifest rows for mdlint should be gone # manifest rows for mdlint should be gone
grep -q ',"mdlint",' _bmad/_config/files-manifest.csv && \ if grep -q ',"mdlint",' _bmad/_config/files-manifest.csv; then
ko "mdlint rows still in files-manifest.csv" || ok "files-manifest.csv pruned" ko "mdlint rows still in files-manifest.csv"
grep -q '"acme-md-lint"' _bmad/_config/skill-manifest.csv && \ else ok "files-manifest.csv pruned"; fi
ko "acme-md-lint row still in skill-manifest.csv" || ok "skill-manifest.csv pruned" if grep -q '"acme-md-lint"' _bmad/_config/skill-manifest.csv; then
ko "acme-md-lint row still in skill-manifest.csv"
else ok "skill-manifest.csv pruned"; fi
# ─── 11. remove --purge ────────────────────────────────────────────────────── # ─── 11. remove --purge ──────────────────────────────────────────────────────
note "remove devlog --purge" note "remove devlog --purge"
@ -290,14 +302,18 @@ assert_exit 0 "remove --purge"
assert_path_absent "_bmad/devlog" assert_path_absent "_bmad/devlog"
assert_path_absent "_bmad/custom/devlog" assert_path_absent "_bmad/custom/devlog"
# config blocks and help rows for devlog are stripped on removal # config blocks and help rows for devlog are stripped on removal
grep -q '\[modules\.devlog]' _bmad/config.toml \ if grep -q '\[modules\.devlog]' _bmad/config.toml; then
&& ko "[modules.devlog] still in config.toml" || ok "config.toml [modules.devlog] stripped" ko "[modules.devlog] still in config.toml"
grep -q '\[agents\.bmad-agent-historian]' _bmad/config.toml \ else ok "config.toml [modules.devlog] stripped"; fi
&& ko "[agents.bmad-agent-historian] still in config.toml" || ok "config.toml agent block stripped" if grep -q '\[agents\.bmad-agent-historian]' _bmad/config.toml; then
grep -q '\[modules\.devlog]' _bmad/config.user.toml \ ko "[agents.bmad-agent-historian] still in config.toml"
&& ko "[modules.devlog] still in config.user.toml" || ok "config.user.toml [modules.devlog] stripped" else ok "config.toml agent block stripped"; fi
grep -q '^devlog,' _bmad/_config/bmad-help.csv \ if grep -q '\[modules\.devlog]' _bmad/config.user.toml; then
&& ko "devlog rows still in bmad-help.csv" || ok "bmad-help.csv devlog rows removed" ko "[modules.devlog] still in config.user.toml"
else ok "config.user.toml [modules.devlog] stripped"; fi
if grep -q '^devlog,' _bmad/_config/bmad-help.csv; then
ko "devlog rows still in bmad-help.csv"
else ok "bmad-help.csv devlog rows removed"; fi
# [core] survives the removal # [core] survives the removal
assert_grep '^user_name = "Tester"' "_bmad/config.toml" assert_grep '^user_name = "Tester"' "_bmad/config.toml"
@ -330,8 +346,8 @@ run install "${EXAMPLES}/minimal/acme-md-lint" --project-dir "${IDEPROJ}"
assert_exit 0 "install into IDE project" assert_exit 0 "install into IDE project"
assert_path_exists "${IDEPROJ}/.claude/skills/acme-md-lint/SKILL.md" assert_path_exists "${IDEPROJ}/.claude/skills/acme-md-lint/SKILL.md"
assert_path_exists "${IDEPROJ}/.agents/skills/acme-md-lint/SKILL.md" assert_path_exists "${IDEPROJ}/.agents/skills/acme-md-lint/SKILL.md"
[[ "${STDOUT}" == *"claude-code"* ]] && ok "stdout reports claude-code distribution" \ if [[ "${STDOUT}" == *"claude-code"* ]]; then ok "stdout reports claude-code distribution"
|| ko "expected claude-code in stdout: ${STDOUT}" else ko "expected claude-code in stdout: ${STDOUT}"; fi
# Canonical end-state: skill source dirs removed from _bmad/ after distribution. # Canonical end-state: skill source dirs removed from _bmad/ after distribution.
if find "${IDEPROJ}/_bmad" -name SKILL.md | grep -q .; then if find "${IDEPROJ}/_bmad" -name SKILL.md | grep -q .; then
ko "SKILL.md still under _bmad after distribution" ko "SKILL.md still under _bmad after distribution"
@ -354,9 +370,9 @@ run install "${EXAMPLES}/minimal-npm/acme-npmtool"
assert_exit 0 "install npm fixture" assert_exit 0 "install npm fixture"
assert_path_exists "_bmad/npmtool/package.json" assert_path_exists "_bmad/npmtool/package.json"
if command -v npm >/dev/null 2>&1; then if command -v npm >/dev/null 2>&1; then
[[ "${STDOUT}" == *"installed npm dependencies for npmtool"* ]] \ if [[ "${STDOUT}" == *"installed npm dependencies for npmtool"* ]]; then
&& ok "npm dependencies installed" \ ok "npm dependencies installed"
|| ko "expected npm install confirmation in stdout: ${STDOUT}" else ko "expected npm install confirmation in stdout: ${STDOUT}"; fi
# The fixture has zero deps, so npm writes package-lock.json (not node_modules); # The fixture has zero deps, so npm writes package-lock.json (not node_modules);
# its presence proves npm actually ran inside the installed module dir. # its presence proves npm actually ran inside the installed module dir.
assert_path_exists "_bmad/npmtool/package-lock.json" assert_path_exists "_bmad/npmtool/package-lock.json"

View File

@ -3739,8 +3739,12 @@ async function runTests() {
const om = new OfficialModules(); const om = new OfficialModules();
const tracked = []; const tracked = [];
const result = await om.install('devlog', bmadDir, (p) => tracked.push(p), { skipModuleInstaller: true, moduleConfig: {} }); let result;
try {
result = await om.install('devlog', bmadDir, (p) => tracked.push(p), { skipModuleInstaller: true, moduleConfig: {} });
} finally {
CustomModuleManager._resolutionCache.delete('devlog'); CustomModuleManager._resolutionCache.delete('devlog');
}
assert(result.success === true && result.module === 'devlog', 'install() routes plugin-json resolution and succeeds'); assert(result.success === true && result.module === 'devlog', 'install() routes plugin-json resolution and succeeds');
assert( assert(