fix(installer): address second-round code-review criticals

writeGlobalUserCore now line-surgery upserts only the [core] keys it owns
into the existing file content — no read-parse-merge-emit round-trip. Hand
edits in shapes the minimal parseSimpleToml dropped (arrays, single-quoted
strings, dotted/quoted keys, \uXXXX escapes, custom sections) survive
byte-for-byte. Dead emitTomlSections helper removed.

writeModuleTomls emits core's shipped defaults under top-level [core], not
nested [modules.core] — matches where writeCentralConfig writes core deltas
and where resolve_config.py consumers read core.* from.

tomlString auto-infers TOML type from --set values: true/false → bool,
integer pattern → int, decimal pattern → float, otherwise quoted string.
Explicit-string escape hatch: --set k='"true"' writes "true" as a string.
Previously every --set value was wrapped in quotes, so --set bmm.workers=4
wrote "4" and downstream numeric comparisons broke.

upsertTomlKey's inline-comment detector now tracks double-quoted string
state via findInlineCommentStart, so a # inside a basic-string value
(legal per the TOML spec, contrary to the previous defending comment)
is no longer truncated. tomlHasKey and upsertTomlKey both use a new
isSectionHeader matcher that tolerates trailing inline comments on the
header line and walks every section occurrence — fixes mis-routing on
hand-edited files with [core]  # comment headers or duplicate sections.

Python test harnesses (test_resolve_config.py, test_resolve_customization_cascade.py)
now force BMAD_HOME to the synthetic isolation path unconditionally
instead of env.get(BMAD_HOME, default), which was a no-op when the
developer had BMAD_HOME set in their shell.

Adds 21 JS assertions covering the new behaviors directly: tomlString
type inference, # inside string values, inline-comment headers, and
writeGlobalUserCore round-trip preservation of hand-edited shapes.

Tests: 405 JS + 21 Python pass. Lint, markdownlint, prettier green.
This commit is contained in:
Brian Madison 2026-05-26 00:52:16 -05:00
parent 579c78d2aa
commit 9799d10123
5 changed files with 189 additions and 69 deletions

View File

@ -14,7 +14,11 @@ def run_raw(args, env_overrides=None):
"""Run resolver, return CompletedProcess-like object with decoded streams.
Use this when the test expects a non-zero exit (e.g. fail-fast checks)."""
env = os.environ.copy()
env["BMAD_HOME"] = env.get("BMAD_HOME", "/nonexistent-bmad-home-default")
# Force BMAD_HOME to a guaranteed-missing path so any value the developer
# has set in their shell can't leak the real ~/.bmad into a test that
# expected an empty global. Tests that need a populated global pass it via
# env_overrides below.
env["BMAD_HOME"] = "/nonexistent-bmad-home-default"
if env_overrides:
env.update(env_overrides)
result = subprocess.run(

View File

@ -25,7 +25,10 @@ def make_skill(parent: Path, name: str, defaults_toml: str, module: str | None =
def run(skill_dir: Path, key=None, env_overrides=None):
env = os.environ.copy()
env.setdefault("BMAD_HOME", "/nonexistent-bmad-home-default")
# Force BMAD_HOME to a guaranteed-missing path so the developer's real
# ~/.bmad never leaks into a test expecting an empty global. Tests that
# need a populated global override via env_overrides below.
env["BMAD_HOME"] = "/nonexistent-bmad-home-default"
if env_overrides:
env.update(env_overrides)
args = [sys.executable, str(SCRIPT), "--skill", str(skill_dir)]

View File

@ -1836,6 +1836,37 @@ async function runTests() {
assert(teamContent.includes('Installer-managed. Regenerated on every install'), 'config.toml has installer-managed header');
assert(userContent.includes('Holds install answers scoped to YOU personally.'), 'config.user.toml header clarifies user scope');
assert(globalContent.includes('Global personal BMad config'), '~/.bmad/config.user.toml has global-personal header');
// writeGlobalUserCore must preserve hand-edits in shapes the old
// round-trip parser silently dropped — arrays, single-quoted strings,
// dotted/quoted keys, \uXXXX escapes, custom sections.
const handEdited = [
'# user-authored — should survive installer rewrites',
'[core]',
'user_name = "WillBeOverwritten"',
'nickname = "blank-on-purpose"',
'',
'[custom_section]',
"literal = 'single-quoted string'",
'tags = ["a", "b", "c"]',
String.raw`unicode = "Märy"`,
'"weird.key" = "quoted-dotted key"',
'',
].join('\n');
await fs.writeFile(globalCorePath, handEdited, 'utf8');
await generator35.writeGlobalUserCore({ core: { user_name: 'Updated', communication_language: 'Italian' } });
const afterReplay = await fs.readFile(globalCorePath, 'utf8');
assert(afterReplay.includes('user_name = "Updated"'), 'writeGlobalUserCore updates the key we own');
assert(afterReplay.includes('communication_language = "Italian"'), 'writeGlobalUserCore writes new scope:user key');
assert(afterReplay.includes("literal = 'single-quoted string'"), 'writeGlobalUserCore preserves single-quoted string values');
assert(afterReplay.includes('tags = ["a", "b", "c"]'), 'writeGlobalUserCore preserves array values');
assert(afterReplay.includes(String.raw`unicode = "Märy"`), String.raw`writeGlobalUserCore preserves \uXXXX escapes verbatim`);
assert(afterReplay.includes('"weird.key" = "quoted-dotted key"'), 'writeGlobalUserCore preserves quoted/dotted keys');
assert(afterReplay.includes('[custom_section]'), 'writeGlobalUserCore preserves user-authored sections');
assert(
afterReplay.includes('nickname = "blank-on-purpose"'),
'writeGlobalUserCore preserves hand-written keys outside the installer schema',
);
} finally {
await fs.remove(tempBmadDir35).catch(() => {});
await fs.remove(tempGlobalDir35).catch(() => {});
@ -3091,6 +3122,17 @@ async function runTests() {
assert(tomlString(String.raw`back\slash`) === String.raw`"back\\slash"`, 'tomlString escapes backslashes');
assert(tomlString('line1\nline2') === String.raw`"line1\nline2"`, 'tomlString escapes newlines');
// ---- tomlString: type inference (--set bmm.workers=4 must be int) ----
assert(tomlString('true') === 'true', 'tomlString emits bare bool for "true"');
assert(tomlString('false') === 'false', 'tomlString emits bare bool for "false"');
assert(tomlString('4') === '4', 'tomlString emits bare integer for digit string');
assert(tomlString('-17') === '-17', 'tomlString emits bare integer for negative');
assert(tomlString('3.14') === '3.14', 'tomlString emits bare float for decimal');
assert(tomlString('-0.5') === '-0.5', 'tomlString emits bare float for negative decimal');
assert(tomlString('1e10') === '"1e10"', 'tomlString quotes scientific notation (not in inferred set)');
assert(tomlString('4.') === '"4."', 'tomlString quotes incomplete float (preserves as string)');
assert(tomlString('"true"') === String.raw`"\"true\""`, 'tomlString preserves explicitly-quoted "true" as string');
// ---- upsertTomlKey: insert into existing section ---------------------
{
const before = `[core]\nuser_name = "Brian"\n\n[modules.bmm]\nproject_knowledge = "{project-root}/docs"\n`;
@ -3132,6 +3174,26 @@ async function runTests() {
assert(!withoutTrailing.endsWith('\n'), 'upsertTomlKey preserves absence of trailing newline');
}
// ---- upsertTomlKey: `#` inside string value is NOT a comment ---------
// Per TOML spec, basic strings may contain unescaped `#`. The previous
// /\s+#/ scanner truncated such values, producing malformed TOML.
{
const before = `[core]\nproject_name = "hello # world"\n`;
const after = upsertTomlKey(before, '[core]', 'project_name', '"updated # value"');
assert(after.includes('project_name = "updated # value"'), 'upsertTomlKey writes value containing # intact');
assert(!after.includes('# world'), 'upsertTomlKey does not preserve a fake comment that lived inside the old string');
}
// ---- upsertTomlKey: section header with inline comment is found -----
{
const before = `[core] # personal identity\nuser_name = "old"\n`;
const after = upsertTomlKey(before, '[core]', 'user_name', '"Brian"');
assert(after.includes('user_name = "Brian"'), 'upsertTomlKey updates key under header with inline comment');
// Should not have appended a duplicate [core] block at EOF.
const headerOccurrences = (after.match(/^\[core]/gm) || []).length;
assert(headerOccurrences === 1, `upsertTomlKey reuses existing [core] header (got ${headerOccurrences} headers)`);
}
// ---- applySetOverrides happy path ------------------------------------
{
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-'));
@ -3370,7 +3432,10 @@ async function runTests() {
assert(!coreContent.includes('[agents.bmad-agent-analyst]'), 'bmm-owned agents do NOT leak into core/module.toml');
// [core] questions (user_name, project_name, etc.) are core's defaults
assert(coreContent.includes('[modules.core]'), 'core/module.toml has [modules.core] section');
// and must live at top-level [core] — resolvers + consumers read core.*
// from that namespace, not from a nested [modules.core] subtree.
assert(coreContent.includes('[core]'), 'core/module.toml has top-level [core] section');
assert(!coreContent.includes('[modules.core]'), 'core defaults do NOT live under [modules.core]');
assert(coreContent.includes('user_name = "BMad"'), 'core/module.toml carries user_name default ("BMad" from module.yaml)');
assert(coreContent.includes('document_output_language = "English"'), 'core/module.toml carries document_output_language default');

View File

@ -3,7 +3,8 @@ const fs = require('../fs-native');
const yaml = require('yaml');
const crypto = require('node:crypto');
const { resolveInstalledModuleYaml } = require('../project-root');
const { globalUserConfigPath, loadGlobalConfig, parseSimpleToml } = require('../global-config');
const { globalUserConfigPath, loadGlobalConfig } = require('../global-config');
const { upsertTomlKey } = require('../set-overrides');
const prompts = require('../prompts');
// Load package.json for version info
@ -704,37 +705,32 @@ class ManifestGenerator {
const globalPath = globalUserConfigPath();
await fs.ensureDir(path.dirname(globalPath));
// Read-merge-write so we don't trample any pre-existing scopes / sections
// the user might have written to ~/.bmad/config.user.toml by hand or via
// another tool.
let existing = {};
// Line-surgery upsert into the existing file (or a fresh one with the
// installer header). We only touch the [core] keys we own. Every other
// section, comment, and value passes through byte-for-byte — including
// shapes the previous round-trip parser quietly dropped (arrays,
// single-quoted strings, dotted/quoted keys, \uXXXX escapes, etc.).
let content;
if (await fs.pathExists(globalPath)) {
try {
existing = parseSimpleToml(await fs.readFile(globalPath, 'utf8'));
} catch {
existing = {};
}
content = await fs.readFile(globalPath, 'utf8');
} else {
content =
[
'# ─────────────────────────────────────────────────────────────────',
'# Global personal BMad config — values tied to YOU as a user, not',
'# any specific project. Installer writes scope:user identity here',
'# (user_name, communication_language) so re-installs across projects',
"# don't re-ask the same questions.",
'#',
'# Location precedence: $BMAD_HOME if set, else ~/.bmad',
'# Resolver tier: lower than project-level _bmad/*.toml.',
'# ─────────────────────────────────────────────────────────────────',
'',
].join('\n') + '\n';
}
for (const [key, value] of Object.entries(userScopeValues)) {
content = upsertTomlKey(content, '[core]', key, formatTomlValue(value));
}
const mergedCore = { ...existing.core, ...userScopeValues };
const mergedConfig = { ...existing, core: mergedCore };
const lines = [
'# ─────────────────────────────────────────────────────────────────',
'# Global personal BMad config — values tied to YOU as a user, not',
'# any specific project. Installer writes scope:user identity here',
'# (user_name, communication_language) so re-installs across projects',
"# don't re-ask the same questions.",
'#',
'# Location precedence: $BMAD_HOME if set, else ~/.bmad',
'# Resolver tier: lower than project-level _bmad/*.toml.',
'# ─────────────────────────────────────────────────────────────────',
'',
];
// Emit sections recursively so nested tables (e.g. [modules.bmm] reachable
// via dotted parseSimpleToml output) round-trip as proper sub-tables
// instead of being stringified as "[object Object]" inside their parent.
emitTomlSections(mergedConfig, [], lines);
const content = lines.join('\n').replace(/\n+$/, '\n');
await fs.writeFile(globalPath, content);
return globalPath;
}
@ -829,7 +825,12 @@ class ManifestGenerator {
];
if (Object.keys(resolvedDefaults).length > 0) {
lines.push(`[modules.${moduleCode}]`);
// Core's defaults belong under top-level [core] — that's where
// writeCentralConfig emits core deltas and where resolve_config.py
// consumers read core.* from. Everything else gets the per-module
// [modules.<code>] namespace.
const sectionHeader = moduleCode === 'core' ? '[core]' : `[modules.${moduleCode}]`;
lines.push(sectionHeader);
for (const [key, value] of Object.entries(resolvedDefaults)) {
lines.push(`${key} = ${formatTomlValue(value)}`);
}
@ -1180,26 +1181,4 @@ function formatTomlValue(value) {
return `"${escaped}"`;
}
// Recursively serialize a parsed TOML object back to text. Scalar entries
// are written under the current section header; nested objects are emitted
// as dotted sub-tables. Skips null/undefined/'' values.
function emitTomlSections(node, sectionPath, lines) {
const scalars = [];
const nested = [];
for (const [key, value] of Object.entries(node)) {
if (value === null || value === undefined || value === '') continue;
if (value && typeof value === 'object' && !Array.isArray(value)) {
nested.push([key, value]);
} else {
scalars.push([key, value]);
}
}
if (scalars.length > 0) {
if (sectionPath.length > 0) lines.push(`[${sectionPath.join('.')}]`);
for (const [key, value] of scalars) lines.push(`${key} = ${formatTomlValue(value)}`);
lines.push('');
}
for (const [key, value] of nested) emitTomlSections(value, [...sectionPath, key], lines);
}
module.exports = { ManifestGenerator };

View File

@ -85,11 +85,25 @@ function parseSetEntries(entries) {
}
/**
* Encode a JS string as a TOML basic string (double-quoted with escapes).
* @param {string} value
* Encode a `--set` value as a TOML literal. Types are inferred from the value
* so `--set bmm.workers=4` writes `workers = 4` (integer), not `"4"` (string).
*
* Rules (mirror how TOML would interpret the literal hand-typed in a config):
* - `true` / `false` boolean
* - `-?\d+` integer
* - `-?\d+\.\d+` float
* - everything else quoted basic string
*
* To force a string that looks like a bool/number, wrap in literal quotes:
* --set foo.x='"true"' x = "true"
*
* @param {string} value raw value as received from the --set flag
*/
function tomlString(value) {
const s = String(value);
if (s === 'true' || s === 'false') return s;
if (/^-?\d+$/.test(s)) return s;
if (/^-?\d+\.\d+$/.test(s)) return s;
// Per the TOML spec, basic strings escape `\`, `"`, and control characters.
return (
'"' +
@ -144,8 +158,10 @@ function upsertTomlKey(content, section, key, valueToml) {
const hadTrailingNewline = lines.length > 0 && lines.at(-1) === '';
if (hadTrailingNewline) lines.pop();
// Locate the target section.
const sectionStart = lines.findIndex((line) => line.trim() === section);
// Locate the target section. Tolerates a trailing inline comment on the
// header (`[core] # personal`) and a header line with non-newline
// trailing whitespace — `line.trim() === section` would miss both.
const sectionStart = lines.findIndex((line) => isSectionHeader(line, section));
if (sectionStart === -1) {
// Section doesn't exist — append a new block. Pad with a blank line if
// the file is non-empty so sections stay visually separated.
@ -170,11 +186,12 @@ function upsertTomlKey(content, section, key, valueToml) {
const match = lines[i].match(keyPattern);
if (match) {
const indent = match[1];
// Preserve trailing comment if present. We split on the first `#` that
// is preceded by whitespace — TOML strings can't contain unescaped `#`
// in basic-string form so this is safe for the values we emit.
// Preserve trailing comment if present. `findInlineCommentStart` tracks
// double-quoted string state so a `#` inside a value like
// `"path with # hash"` isn't mistaken for a comment marker (per TOML
// spec, basic strings may contain unescaped `#`).
const tail = match[2];
const commentIdx = tail.search(/\s+#/);
const commentIdx = findInlineCommentStart(tail);
const commentSuffix = commentIdx === -1 ? '' : tail.slice(commentIdx);
lines[i] = `${indent}${key} = ${valueToml}${commentSuffix}`;
return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
@ -196,6 +213,51 @@ function escapeRegExp(s) {
return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
}
/**
* Match a TOML section header line against a target like `[core]`. Tolerates
* leading/trailing whitespace and a trailing inline comment, which the
* previous `line.trim() === section` check missed.
*/
function isSectionHeader(line, target) {
const trimmed = line.trimStart();
if (!trimmed.startsWith(target)) return false;
const after = trimmed.slice(target.length);
return /^\s*(?:#.*)?$/.test(after);
}
/**
* Find the start index of an inline `# comment` in a TOML value tail,
* tracking double-quoted string state so a `#` inside a string literal is
* not treated as a comment. Returns the index of the whitespace run that
* precedes the `#` (matching the contract of the old `/\s+#/` regex), or
* -1 if there's no inline comment.
*/
function findInlineCommentStart(text) {
let inString = false;
let escaped = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (ch === '#' && !inString) {
let j = i;
while (j > 0 && /\s/.test(text[j - 1])) j--;
if (j < i) return j;
}
}
return -1;
}
/**
* Look up `[section] key` in a TOML file. Returns true if the file exists,
* the section is present, and `key` is set within it. Used by
@ -207,12 +269,19 @@ async function tomlHasKey(filePath, section, key) {
if (!(await fs.pathExists(filePath))) return false;
const content = await fs.readFile(filePath, 'utf8');
const lines = content.split('\n');
const sectionStart = lines.findIndex((line) => line.trim() === section);
if (sectionStart === -1) return false;
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
for (let i = sectionStart + 1; i < lines.length; i++) {
if (/^\s*\[/.test(lines[i])) return false;
if (keyPattern.test(lines[i])) return true;
// Walk every line tracking whether we're inside the target section. This
// both tolerates inline-commented headers (`[core] # personal`) and
// handles the edge case where the same section appears more than once
// (legal in TOML — tomllib merges them — but the previous `findIndex`
// only checked the first block, misrouting `--set`).
let inTargetSection = false;
for (const line of lines) {
if (/^\s*\[/.test(line)) {
inTargetSection = isSectionHeader(line, section);
continue;
}
if (inTargetSection && keyPattern.test(line)) return true;
}
return false;
}