test(#1977): consolidate 22 misc + repo-invariant regression tests
Final epic-#1969 batch. Fold 22 issue-named files: the 4 genuine repo-wide invariant scans (551-eslint-bin-lib-coverage, bug-3054 stale /gsd-next, bug-3810 no-gsd-sdk-runtime-refs, feat-3593 cli-negative-universal) into a NEW shared repo-invariants.test.cjs; the other 18 as singletons into their nearest module suite (model-resolver, codex-config, runtime-converters, security, state-transition, worktree-safety, roadmap-parser, etc.). Verbatim block-scoped describe wrappers; 334 subtests conserved 1:1. Host-env pre-check (B2+B6): the 6 CLI folds into GSD_TEST_MODE-setting hosts (model-resolver/ codex-config/runtime-converters) are benign — each origin independently sets GSD_TEST_MODE=1 itself (idempotent), unlike the B6 real-install case. Regenerates regression-name allowlist (222->213), ratchets file-count allowlist (state 17->16), makes 7 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes stale ids). Repoints 2 tests/ refs in docs/TESTING-SUITES.md. lint:ci green. Part of epic #1969. Closes #1977. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1313,3 +1313,200 @@ describe('ADR-1769 #1796: applyStatePreservation — table-driven post-sync cons
|
||||
assert.deepEqual(r.postFm, { status: 'executing', progress: { percent: 10 } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-21-state-md-template-frontmatter.test.cjs — consolidation epic #1969 (B8 #1977)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-21-state-md-template-frontmatter (consolidation epic #1969 B8 #1977)", () => {
|
||||
/**
|
||||
* Regression guard — Bug #21
|
||||
*
|
||||
* Both STATE.md template files must include a YAML frontmatter block in their
|
||||
* "File Template" section so that an AI agent creating .planning/STATE.md from
|
||||
* the template produces a file that frontmatter consumers can read immediately
|
||||
* (before the first `state.*` mutation calls syncStateFrontmatter).
|
||||
*
|
||||
* Prior to the fix, the template's File Template section began with
|
||||
* `# Project State` (no frontmatter), leaving the init→first-write window
|
||||
* without `gsd_state_version`, `status`, or `progress` keys.
|
||||
*
|
||||
* Acceptance criteria:
|
||||
* 1. The template body extracted from each state.md file's File Template code
|
||||
* block must begin with `---`.
|
||||
* 2. The frontmatter must contain at minimum: `gsd_state_version` and `status`.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
|
||||
const TEMPLATE_PATHS = [
|
||||
path.join(REPO_ROOT, 'gsd-core', 'templates', 'state.md'),
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract the content of the first ```markdown ... ``` code block from a
|
||||
* template file. Returns the raw string (including any leading/trailing
|
||||
* whitespace within the block).
|
||||
*
|
||||
* @param {string} fileContent - Full text of the template file.
|
||||
* @returns {string} The extracted code block body.
|
||||
*/
|
||||
function extractFileTemplate(fileContent) {
|
||||
const match = fileContent.match(/```markdown\r?\n([\s\S]*?)```/);
|
||||
assert.ok(match, 'No ```markdown code block found in template file');
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal YAML frontmatter parser: returns the set of top-level keys present
|
||||
* in the first --- ... --- block at the start of `text`. Does not parse nested
|
||||
* keys — list-valued fields (e.g. `tags: [a, b]`) are recorded only by their
|
||||
* key name, not their value. Returns an empty Set when the text has no frontmatter.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function parseFrontmatterKeys(text) {
|
||||
const keys = new Set();
|
||||
if (!text.trimStart().startsWith('---')) return keys;
|
||||
const lines = text.split(/\r?\n/);
|
||||
let inBlock = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!inBlock) {
|
||||
if (trimmed === '---') { inBlock = true; continue; }
|
||||
break; // frontmatter must be at the very start
|
||||
}
|
||||
if (trimmed === '---') break; // end of block
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
keys.add(trimmed.slice(0, colonIdx).trim());
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal YAML frontmatter parser: returns a plain object of top-level keys
|
||||
* and their scalar or nested-object values from the first --- ... --- block.
|
||||
* Handles one level of indented nesting (e.g. progress.total_plans).
|
||||
* Does not handle YAML lists or multi-line values.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
function parseFrontmatter(text) {
|
||||
const result = {};
|
||||
if (!text.trimStart().startsWith('---')) return result;
|
||||
const lines = text.split(/\r?\n/);
|
||||
let inBlock = false;
|
||||
let currentKey = null;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!inBlock) {
|
||||
if (trimmed === '---') { inBlock = true; continue; }
|
||||
break;
|
||||
}
|
||||
if (trimmed === '---') break;
|
||||
// Detect indented (nested) line: starts with whitespace
|
||||
if (line.match(/^\s+\S/) && currentKey !== null) {
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const subKey = trimmed.slice(0, colonIdx).trim();
|
||||
const rawVal = trimmed.slice(colonIdx + 1).trim();
|
||||
const numVal = Number(rawVal);
|
||||
if (typeof result[currentKey] !== 'object') result[currentKey] = {};
|
||||
result[currentKey][subKey] = rawVal === '' ? null : (isNaN(numVal) ? rawVal : numVal);
|
||||
}
|
||||
} else {
|
||||
currentKey = null;
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
const rawVal = trimmed.slice(colonIdx + 1).trim();
|
||||
if (rawVal === '') {
|
||||
result[key] = {};
|
||||
currentKey = key;
|
||||
} else {
|
||||
const numVal = Number(rawVal);
|
||||
result[key] = isNaN(numVal) ? rawVal.replace(/^'|'$/g, '') : numVal;
|
||||
currentKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('bug #21 — STATE.md template must carry YAML frontmatter', () => {
|
||||
for (const templatePath of TEMPLATE_PATHS) {
|
||||
const label = path.relative(REPO_ROOT, templatePath);
|
||||
|
||||
test(`${label} — File Template block starts with frontmatter`, () => {
|
||||
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||
const body = extractFileTemplate(content);
|
||||
|
||||
// The template body must open with a YAML frontmatter delimiter.
|
||||
assert.ok(
|
||||
body.trimStart().startsWith('---'),
|
||||
`${label}: File Template must start with '---' (YAML frontmatter), ` +
|
||||
`but starts with: ${JSON.stringify(body.slice(0, 60))}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${label} — frontmatter contains gsd_state_version`, () => {
|
||||
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||
const body = extractFileTemplate(content);
|
||||
const keys = parseFrontmatterKeys(body.trimStart());
|
||||
|
||||
assert.ok(
|
||||
keys.has('gsd_state_version'),
|
||||
`${label}: frontmatter must include 'gsd_state_version', found keys: ${[...keys].join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${label} — frontmatter contains status`, () => {
|
||||
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||
const body = extractFileTemplate(content);
|
||||
const keys = parseFrontmatterKeys(body.trimStart());
|
||||
|
||||
assert.ok(
|
||||
keys.has('status'),
|
||||
`${label}: frontmatter must include 'status', found keys: ${[...keys].join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${label} — progress sub-schema has zeroed total_plans and completed_plans`, () => {
|
||||
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||
const body = extractFileTemplate(content);
|
||||
const fm = parseFrontmatter(body.trimStart());
|
||||
|
||||
assert.ok(
|
||||
fm.progress && typeof fm.progress === 'object',
|
||||
`${label}: frontmatter must include a 'progress' sub-object`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
fm.progress.total_plans,
|
||||
0,
|
||||
`${label}: progress.total_plans must be 0 in the template`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
fm.progress.completed_plans,
|
||||
0,
|
||||
`${label}: progress.completed_plans must be 0 in the template`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user