Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
441 lines
20 KiB
JavaScript
441 lines
20 KiB
JavaScript
/**
|
|
* MSD Tools Tests - --pick flag
|
|
*
|
|
* Regression tests for the --pick CLI flag that extracts a single field
|
|
* from JSON output, replacing the need for jq as an external dependency.
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { runMsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
const { seedPhase } = require('./fixtures/index.cjs');
|
|
const { runCli } = require('./helpers/cli-negative.cjs');
|
|
|
|
// ─── --pick flag ─────────────────────────────────────────────────────────────
|
|
|
|
describe('--pick flag', () => {
|
|
test('extracts a top-level field from JSON output', () => {
|
|
const result = runMsdTools('generate-slug "hello world" --pick slug');
|
|
assert.strictEqual(result.success, true);
|
|
assert.strictEqual(result.output, 'hello-world');
|
|
});
|
|
|
|
test('extracts a top-level field using array args', () => {
|
|
const result = runMsdTools(['generate-slug', 'hello world', '--pick', 'slug']);
|
|
assert.strictEqual(result.success, true);
|
|
assert.strictEqual(result.output, 'hello-world');
|
|
});
|
|
|
|
// #3365 / ADR-3473 §8.4 P6: an ABSENT field is a failure ("I could not
|
|
// answer"), never a demotion to the empty answer at exit 0. This inverts
|
|
// the old pinned assertion below (kept as a comment for the historical
|
|
// record — measured on this tree, 2026-08-26, exit 0 + empty stdout):
|
|
// const result = runMsdTools('generate-slug "test" --pick nonexistent');
|
|
// assert.strictEqual(result.success, true);
|
|
// assert.strictEqual(result.output, '');
|
|
test('absentFieldExitsNonZero_3365', () => {
|
|
const result = runMsdTools('generate-slug "test" --pick nonexistent');
|
|
assert.strictEqual(result.success, false, 'an absent --pick field must exit non-zero');
|
|
assert.strictEqual(result.output, '');
|
|
assert.match(result.error, /nonexistent/, 'stderr must name the requested field');
|
|
});
|
|
|
|
// P2 (test matrix): a count of zero is a real value, not absence — this
|
|
// must keep PASSING before and after the fix (the non-change half of #3365).
|
|
test('zeroCountPrintsZeroAtExitZero', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const result = runMsdTools('query phases.list --type summaries --pick count', tmpDir);
|
|
assert.strictEqual(result.success, true);
|
|
assert.strictEqual(result.output, '0');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
// P4 (test matrix, negative space N1): a field present with an explicit
|
|
// `null` value is an answer, not a failure — must keep PASSING before and
|
|
// after the fix. Measured: `phases.list --type plans --pick phase_dir` on
|
|
// the enumeration path (no --phase given) returns `phase_dir: null`.
|
|
test('presentButNullIsEmptyAtExitZero', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const result = runMsdTools('query phases.list --type plans --pick phase_dir', tmpDir);
|
|
assert.strictEqual(result.success, true);
|
|
assert.strictEqual(result.output, '');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
// P10 (test matrix): required boundary triple over an array field of
|
|
// known length N=3 (three seeded phase directories).
|
|
test('arrayIndexBoundaryAtLenMinus1_Len_LenPlus1', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
seedPhase(tmpDir, '01-alpha');
|
|
seedPhase(tmpDir, '02-beta');
|
|
seedPhase(tmpDir, '03-gamma');
|
|
|
|
const atLenMinus1 = runMsdTools('query phases.list --pick directories[2]', tmpDir);
|
|
assert.strictEqual(atLenMinus1.success, true);
|
|
assert.strictEqual(atLenMinus1.output, '03-gamma');
|
|
|
|
const atLen = runMsdTools('query phases.list --pick directories[3]', tmpDir);
|
|
assert.strictEqual(atLen.success, false, 'index == length is out of range and must exit non-zero');
|
|
|
|
const atLenPlus1 = runMsdTools('query phases.list --pick directories[4]', tmpDir);
|
|
assert.strictEqual(atLenPlus1.success, false, 'index == length + 1 is out of range and must exit non-zero');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
// P12 (test matrix): the measured B11 defect — a non-JSON command's
|
|
// `--pick` must never dump the whole document as a coincidental "success".
|
|
// TODAY (measured on this tree, 2026-08-26), against an empty temp project:
|
|
// $ msd-tools audit-open --pick nonexistent_field
|
|
// ### Milestone Close: Open Artifact Audit
|
|
//
|
|
// All artifact types clear. Safe to proceed.
|
|
//
|
|
// ---
|
|
// exit 0
|
|
test('nonJsonOutputDoesNotDumpWholeDocument', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const result = runMsdTools('audit-open --pick nonexistent_field', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.strictEqual(result.output, '');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
// P13 (test matrix): `--raw --pick <known field>` withdraws its
|
|
// coincidental "success" — measured today: exit 0, stdout `hello-world`,
|
|
// via the same non-JSON dump `--raw` produces.
|
|
test('rawPlusPickIsRejectedNotCoincidentallyRight', () => {
|
|
const result = runMsdTools(['generate-slug', 'Hello World', '--raw', '--pick', 'slug']);
|
|
assert.strictEqual(result.success, false);
|
|
});
|
|
|
|
// P14 (test matrix): the confidently-wrong case — measured today: exit 0,
|
|
// stdout `hello-world` (the SLUG field's value, not the bogus field asked
|
|
// for), via the same non-JSON dump.
|
|
test('rawPlusPickBogusDoesNotEmitAnotherFieldsValue', () => {
|
|
const result = runMsdTools(['generate-slug', 'Hello World', '--raw', '--pick', 'bogus']);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(
|
|
!result.output.includes('hello-world'),
|
|
`must not leak another field's value; got: ${JSON.stringify(result.output)}`,
|
|
);
|
|
});
|
|
|
|
test('errors when --pick has no value', () => {
|
|
const result = runMsdTools('generate-slug "test" --pick');
|
|
assert.strictEqual(result.success, false);
|
|
assert.match(result.error, /Missing value for --pick/);
|
|
});
|
|
|
|
test('errors when --pick value starts with --', () => {
|
|
const result = runMsdTools(['generate-slug', 'test', '--pick', '--raw']);
|
|
assert.strictEqual(result.success, false);
|
|
assert.match(result.error, /Missing value for --pick/);
|
|
});
|
|
|
|
test('does not collide with frontmatter --field flag', () => {
|
|
// frontmatter subcommand uses --field internally; --pick should not interfere
|
|
const result = runMsdTools('generate-slug "test-value" --pick slug');
|
|
assert.strictEqual(result.success, true);
|
|
assert.strictEqual(result.output, 'test-value');
|
|
});
|
|
|
|
test('works with current-timestamp command', () => {
|
|
const result = runMsdTools('current-timestamp --pick timestamp');
|
|
assert.strictEqual(result.success, true);
|
|
assert.ok(result.output.length > 0, 'timestamp should not be empty');
|
|
assert.match(result.output, /^\d{4}-\d{2}-\d{2}T/);
|
|
});
|
|
|
|
// B7 (design 40-design.md; test matrix P7): a dotted path that resolves
|
|
// partway then dies. `count` is a number on `phases.list --type summaries`
|
|
// (a real, always-present field); walking `.missing` off it is not a
|
|
// plain object, so the path dies partway through — the same failure class
|
|
// as B6 (field absent outright), not a crash.
|
|
test('absentDottedPathExitsNonZero', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const result = runCli(
|
|
['query', 'phases.list', '--type', 'summaries', '--pick', 'count.missing'],
|
|
{ cwd: tmpDir },
|
|
);
|
|
assert.notStrictEqual(result.status, 0);
|
|
assert.strictEqual(result.reason, 'pick_field_absent');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
// B9 (test matrix P11): bracket syntax applied to a non-array field.
|
|
test('bracketOnNonArrayExitsNonZero', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const result = runCli(
|
|
['query', 'phases.list', '--type', 'summaries', '--pick', 'count[0]'],
|
|
{ cwd: tmpDir },
|
|
);
|
|
assert.notStrictEqual(result.status, 0);
|
|
assert.strictEqual(result.reason, 'pick_field_absent');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
// B10 (test matrix): the existing boundary test above only exercises
|
|
// non-negative indices — negative-index normalization
|
|
// (`arr.length + index`) is a separate branch in extractField and was
|
|
// otherwise untested. N=3 seeded phase directories.
|
|
test('negativeArrayIndexInRangeResolves', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
seedPhase(tmpDir, '01-alpha');
|
|
seedPhase(tmpDir, '02-beta');
|
|
seedPhase(tmpDir, '03-gamma');
|
|
|
|
const last = runMsdTools('query phases.list --pick directories[-1]', tmpDir);
|
|
assert.strictEqual(last.success, true);
|
|
assert.strictEqual(last.output, '03-gamma');
|
|
|
|
const first = runMsdTools('query phases.list --pick directories[-3]', tmpDir);
|
|
assert.strictEqual(first.success, true);
|
|
assert.strictEqual(first.output, '01-alpha');
|
|
|
|
const outOfRange = runMsdTools('query phases.list --pick directories[-4]', tmpDir);
|
|
assert.strictEqual(outOfRange.success, false, 'index == -(N+1) is out of range and must exit non-zero');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
// B14 (test matrix P15): the JSON root itself is not an object. VERIFIED
|
|
// on this tree: config-get with --default on a missing key emits the bare
|
|
// JSON string "fallback" (not an object), so --pick must fail rather than
|
|
// walk a string as if it had named fields.
|
|
test('nonObjectJsonRootExitsNonZero', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const plain = runMsdTools('config-get nonexistent.key --default fallback', tmpDir);
|
|
assert.strictEqual(plain.success, true);
|
|
assert.strictEqual(plain.output, '"fallback"');
|
|
|
|
const result = runCli(
|
|
['config-get', 'nonexistent.key', '--default', 'fallback', '--pick', 'value'],
|
|
{ cwd: tmpDir },
|
|
);
|
|
assert.notStrictEqual(result.status, 0);
|
|
assert.strictEqual(result.reason, 'pick_field_absent');
|
|
assert.match(result.message, /JSON string, not an object/);
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
// B17 (test matrix P19/P20, negative space N8): io.cjs's output() writes
|
|
// `@file:<path>` instead of inline JSON once the serialized payload
|
|
// exceeds 50000 characters, and `--pick` MUST resolve that redirection
|
|
// BEFORE parsing — otherwise every large result becomes a false
|
|
// pick_output_not_json. The fixture below seeds exactly PHASE_COUNT real
|
|
// phase directories (skipping phase number 999, which phase.cjs's sentinel
|
|
// predicate — SENTINEL_RANGES [0,999] — excludes from the list regardless
|
|
// of padding width, confirmed empirically; skipping it keeps `count`
|
|
// exactly PHASE_COUNT so the assertions below are deterministic) with
|
|
// padded names long enough that the serialized JSON provably exceeds the
|
|
// threshold. The spill is MEASURED, not assumed: the plain (non --pick)
|
|
// path transparently resolves @file: back to inline JSON (#1891), so its
|
|
// stdout length IS the real serialized payload size.
|
|
test('largeAtFilePayloadStillResolves + largeAtFilePayloadAbsentFieldIsAbsentNotNonJson', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const PHASE_COUNT = 1200;
|
|
let made = 0;
|
|
for (let i = 1; made < PHASE_COUNT; i++) {
|
|
if (i === 999) continue; // sentinel phase id — excluded from the list, would skew `count`
|
|
seedPhase(tmpDir, `${String(i).padStart(5, '0')}-phase-name-padding-to-make-this-longer`);
|
|
made++;
|
|
}
|
|
|
|
const plain = runMsdTools('query phases.list', tmpDir);
|
|
assert.strictEqual(plain.success, true);
|
|
assert.ok(
|
|
plain.output.length > 50000,
|
|
`fixture must exceed the 50000-char @file: spill threshold; measured ${plain.output.length}`,
|
|
);
|
|
|
|
// Present field ("largeAtFilePayloadStillResolves"): resolves at exit
|
|
// 0 through the @file: payload.
|
|
const present = runMsdTools('query phases.list --pick count', tmpDir);
|
|
assert.strictEqual(present.success, true);
|
|
assert.strictEqual(present.output, String(PHASE_COUNT));
|
|
|
|
// Absent field ("largeAtFilePayloadAbsentFieldIsAbsentNotNonJson"):
|
|
// must be pick_field_absent, NOT pick_output_not_json — proving the
|
|
// @file: resolution ran before the JSON.parse/absence check.
|
|
const absent = runCli(
|
|
['query', 'phases.list', '--pick', 'nonexistent_field'],
|
|
{ cwd: tmpDir },
|
|
);
|
|
assert.notStrictEqual(absent.status, 0);
|
|
assert.strictEqual(absent.reason, 'pick_field_absent');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// formatDiagnosticToken (io.cjs) — untrusted --pick token escaping.
|
|
//
|
|
// Adversarial review finding (isolated review, verified live): `--pick`'s
|
|
// field value reaches the "field not found" / "output was not JSON"
|
|
// diagnostics verbatim. Repro on this tree BEFORE the fix:
|
|
// $ node msd-tools.cjs generate-slug x --pick $'a\nError: forged'
|
|
// Error: --pick a
|
|
// Error: forged: field not found; available top-level keys: slug
|
|
// Spawns the real CLI (the vulnerability is about the literal bytes on
|
|
// stderr) and asserts on the RAW stderr string — a trimmed assertion would
|
|
// hide a leading/trailing forged blank line.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('formatDiagnosticToken escapes untrusted --pick field values', () => {
|
|
const HOSTILE_TOKENS = [
|
|
['embedded newline forging a second Error: line', 'a\nError: forged'],
|
|
['embedded double quote', 'a"bogus'],
|
|
['embedded C0 control character', 'a\x07bogus'],
|
|
];
|
|
|
|
for (const [label, token] of HOSTILE_TOKENS) {
|
|
test(`--pick field-not-found diagnostic stays single-line for a token with ${label}`, () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const result = runCli(['generate-slug', 'x', '--pick', token], { cwd: tmpDir, jsonErrors: false });
|
|
assert.notStrictEqual(result.status, 0);
|
|
const rawStderr = result.stderr;
|
|
const nonEmptyLines = rawStderr.split('\n').filter((l) => l.length > 0);
|
|
assert.strictEqual(
|
|
nonEmptyLines.length, 1,
|
|
`expected exactly one non-empty stderr line, got raw stderr: ${JSON.stringify(rawStderr)}`,
|
|
);
|
|
assert.match(nonEmptyLines[0], /^Error: /);
|
|
const errorPrefixedLineCount = rawStderr.split('\n').filter((l) => l.startsWith('Error:')).length;
|
|
assert.strictEqual(errorPrefixedLineCount, 1, 'the hostile token must not forge a second "Error:" line');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// formatKeyForDiagnosticList (msd-tools.cjs) — untrusted frontmatter KEY
|
|
// escaping, as distinct from the untrusted --pick TOKEN escaping above.
|
|
//
|
|
// `frontmatter get <file>` (no --field) reads an arbitrary user-authored
|
|
// markdown file and, on a subsequent --pick miss, echoes that document's own
|
|
// top-level frontmatter keys straight into the "field not found; available
|
|
// top-level keys: ..." diagnostic. A key is therefore untrusted input from a
|
|
// user document in exactly the way a --pick argv token is untrusted input
|
|
// from the shell — formatKeyForDiagnosticList (msd-tools.cjs) exists to
|
|
// neutralize it the same way formatDiagnosticToken neutralizes the token.
|
|
//
|
|
// Reachable + verified live on this tree, e.g. for a frontmatter key
|
|
// containing a real embedded newline:
|
|
// $ printf '---\n"weird\\nkey": v\nplain: y\n---\n\nbody\n' > f.md
|
|
// $ msd-tools frontmatter get f.md --pick absent_field
|
|
// Error: --pick "absent_field": field not found; available top-level keys: weird\nkey, plain
|
|
// The `\n` in that stderr is the two-character ESCAPED sequence backslash-n,
|
|
// not a real newline — the raw/untrimmed stderr assertions below pin that.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('formatKeyForDiagnosticList escapes untrusted frontmatter keys', () => {
|
|
test('hostileFrontmatterKeyCannotForgeASecondErrorLine', () => {
|
|
const HOSTILE_KEY_FIXTURES = [
|
|
['embedded newline', '---\n"weird\\nkey": v\nplain: y\n---\n\nbody\n', 'weird\\nkey'],
|
|
['embedded double quote', '---\n"weird\\"quotekey": v\nplain: y\n---\n\nbody\n', 'weird\\"quotekey'],
|
|
['embedded C0 control character', '---\n"weird\\u0007ctrlkey": v\nplain: y\n---\n\nbody\n', 'weird\\u0007ctrlkey'],
|
|
];
|
|
|
|
for (const [label, frontmatterSource, expectedEscapedKey] of HOSTILE_KEY_FIXTURES) {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const f = path.join(tmpDir, 'weird.md');
|
|
fs.writeFileSync(f, frontmatterSource);
|
|
|
|
const result = runCli(
|
|
['frontmatter', 'get', f, '--pick', 'absent_field'],
|
|
{ cwd: tmpDir, jsonErrors: false },
|
|
);
|
|
assert.notStrictEqual(result.status, 0, `[${label}] must exit non-zero`);
|
|
|
|
const rawStderr = result.stderr;
|
|
// Split on the raw, UNTRIMMED stderr and drop exactly one trailing
|
|
// empty element (the newline error() always terminates its message
|
|
// with) — a hostile key that forged a second line would leave MORE
|
|
// than one element after that single drop.
|
|
const lines = rawStderr.split('\n');
|
|
assert.strictEqual(
|
|
lines[lines.length - 1], '',
|
|
`[${label}] expected a single trailing empty element from the terminating newline, got raw stderr: ${JSON.stringify(rawStderr)}`,
|
|
);
|
|
const linesWithoutTrailingEmpty = lines.slice(0, -1);
|
|
assert.strictEqual(
|
|
linesWithoutTrailingEmpty.length, 1,
|
|
`[${label}] expected exactly one line after dropping the trailing empty element, got raw stderr: ${JSON.stringify(rawStderr)}`,
|
|
);
|
|
const errorPrefixedLineCount = linesWithoutTrailingEmpty.filter((l) => l.startsWith('Error:')).length;
|
|
assert.strictEqual(errorPrefixedLineCount, 1, `[${label}] the hostile key must not forge a second "Error:" line`);
|
|
|
|
// The diagnostic must stay USEFUL, not merely safe: a "fix" that
|
|
// dropped the offending key entirely would also pass the one-line
|
|
// assertions above, so pin that the escaped key is still present.
|
|
assert.ok(
|
|
linesWithoutTrailingEmpty[0].includes(`available top-level keys: ${expectedEscapedKey}, plain`),
|
|
`[${label}] expected the escaped key to still name the offending key, got: ${JSON.stringify(linesWithoutTrailingEmpty[0])}`,
|
|
);
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Negative-space control: an ORDINARY frontmatter document (no hostile
|
|
// bytes in any key) must still produce a plain, readable, unquoted key
|
|
// list — proving the escape does not turn every normal diagnostic into
|
|
// JSON-quoted noise.
|
|
test('ordinaryFrontmatterKeysStayPlainAndUnquotedInDiagnostic', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const f = path.join(tmpDir, 'plain.md');
|
|
fs.writeFileSync(f, '---\nalpha: v\nbeta: y\n---\n\nbody\n');
|
|
|
|
const result = runCli(
|
|
['frontmatter', 'get', f, '--pick', 'absent_field'],
|
|
{ cwd: tmpDir, jsonErrors: false },
|
|
);
|
|
assert.notStrictEqual(result.status, 0);
|
|
assert.match(result.stderr, /available top-level keys: alpha, beta\n$/);
|
|
// Only the KEY LIST must stay unquoted — `--pick "absent_field"` earlier
|
|
// in the same message is legitimately JSON-quoted by formatDiagnosticToken
|
|
// (a separate escape, for the untrusted argv token, not the frontmatter
|
|
// key), so scope the "no JSON-quoting noise" assertion to the key-list
|
|
// segment rather than the whole stderr string.
|
|
const keyListSegment = result.stderr.slice(result.stderr.indexOf('available top-level keys:'));
|
|
assert.ok(!keyListSegment.includes('"'), `ordinary keys must not be JSON-quoted, got: ${JSON.stringify(keyListSegment)}`);
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
});
|