* feat(#3028): responsive Markdown separators instead of fixed-width rules Stage banners, checkpoints, completion and error panels used fixed-width runs of box-drawing characters -- a 53-column heavy rule and a 62-column double-line box. Those runs are ordinary text to a Markdown-rendering host, so in a narrower pane they wrap and the border comes apart from the heading it framed. Shipped content now emits an ATX heading for a titled section and a blank-line-delimited --- for a break between sections, both of which adapt to the available width. The same convention is applied to the three code sites that built these strings at runtime: the UAT checkpoint renderer, the milestone-close audit report, and the TDD review checkpoint table. Removing the box also removes its only reason to exist -- the east-asian-width padding helpers that kept its right border aligned (checkpointBoxLine, displayWidth, isWideCodePoint, ZERO_WIDTH_MARK_RE, CHECKPOINT_BOX_WIDTH). RTL directional isolation is unchanged. The convention is specified in gsd-core/references/ui-brand.md and enforced across all shipped content by tests/responsive-separators.test.cjs. Refs #3028 * test(#3028): pin the heading form in checkpoint and audit-report assertions These suites asserted the exact box borders and the 62-column padded banner interior. With the box gone they assert the ### heading form, the --- break and the bolded instruction line, and each now carries a positive assertion that no box character remains -- which is what pins the fix rather than merely tolerating it. Language coverage is converted, not dropped: Japanese, Chinese, Korean, Hindi and Arabic all still assert their rendered banner, and the Arabic case still asserts the RTL directional isolates the box removal must not disturb. Adds a case for a banner longer than the old inner width, which previously produced a ragged border and now has none. Refs #3028 * chore(#3028): acknowledge execute-plan.md growth from the checkpoint display spec The checkpoint_protocol display spec described the drawn box; it now describes the heading, the --- break and the bolded action prompt, which costs 22 bytes (40111 -> 40133, 827 under the cap). Appended to the existing #3370 fragment rather than filed as a new one: a growth ack keys on the bare filename and #3370 already declares execute-plan.md, so a second source naming it would be a hard duplicate-key error. Same supersede-by-append route #3370 took for the spent #2652 fragment. Refs #3028 * docs(#3028): state the load-bearing half of the separator rule, and amend the zh-CN reference Review found three things. The rule as first written demanded a blank line above AND below every ---. Only the one above is load-bearing: it is what stops CommonMark reading the rule as a setext underline for the line above. The one below is cosmetic, because a thematic break is a leaf block. The rule now says that, with the reason, instead of asserting a stricter form the content does not keep. The zh-CN reference had received the mechanical box-to-heading swap but none of the prose behind it: it still claimed a 62-character checkpoint width and still listed --- among forbidden mixed banner styles, so it contradicted the convention it was translating. It now carries the separator section, the setext reasoning, the unconditional-vs-per-runtime rationale and a corrected anti-pattern list, in Chinese. The user guide asserted that a heading is not a degradation anywhere. That is an assertion, not a demonstration. It now says what was actually traded away in a plain terminal, points at the recorded rationale, and invites the report that would justify the capability flag instead. Refs #3028 * chore(#3028): backfill changeset PR number Refs #3028 --------- Co-authored-by: sim <sim@local>
298 lines
11 KiB
JavaScript
298 lines
11 KiB
JavaScript
// Guards the responsive-separator convention defined in
|
|
// gsd-core/references/ui-brand.md § "Separators and Banners".
|
|
//
|
|
// Shipped content (gsd-core/, agents/, commands/) is runtime-loaded text: an
|
|
// agent reads it and emits the banners it describes. A fixed-width run of
|
|
// box-drawing characters is ordinary text to a Markdown-rendering host, so in a
|
|
// narrower pane it wraps and leaves orphan glyphs on a second line, coming apart
|
|
// from the heading it was meant to frame. Markdown headings and thematic breaks
|
|
// adapt to the available width instead.
|
|
//
|
|
// Enhancement for https://github.com/open-gsd/gsd-core/issues/3028.
|
|
//
|
|
// SCOPE - deliberately narrow, see the negative-space cases below:
|
|
// * A "rule line" is a line whose trimmed content is composed ENTIRELY of
|
|
// U+2500, U+2501 or U+2550, three or more of them. Tree glyphs, boxed-table
|
|
// rows, progress bars and inline art all carry other characters and are
|
|
// never flagged.
|
|
// * A "panel character" is one of U+2554 U+2557 U+255A U+255D U+2551 anywhere
|
|
// on a line.
|
|
// * docs/ is NOT scanned: a documentation page may legitimately quote the old
|
|
// form while explaining the change. src/ is not scanned either - its
|
|
// section-divider comments are read in an editor, never emitted.
|
|
|
|
const { describe, test, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const fc = require('fast-check');
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
|
|
// Runtime-loaded text: an agent reads these and emits what they describe.
|
|
const SHIPPED_ROOTS = ['gsd-core', 'agents', 'commands'];
|
|
|
|
// A shorter run than MIN_RULE_RUN is a glyph fragment, not a rule.
|
|
const MIN_RULE_RUN = 3;
|
|
const RULE_LINE_RE = /^[─━═]{3,}$/;
|
|
|
|
// Double-line box vocabulary only. Single-line characters (└ ├ │ ...) are
|
|
// deliberately absent: they are legitimate tree glyphs, not panel borders.
|
|
const PANEL_CHAR_RE = /[╔╗╚╝║╠╣╦╩╬]/;
|
|
|
|
const HEAVY = '━';
|
|
const LIGHT = '─';
|
|
const DOUBLE = '═';
|
|
|
|
// `trim()` also strips a trailing \r, so CRLF files are handled without a
|
|
// separate newline split dialect.
|
|
function isFixedWidthRuleLine(line) {
|
|
return RULE_LINE_RE.test(line.trim());
|
|
}
|
|
|
|
function hasPanelCharacter(line) {
|
|
return PANEL_CHAR_RE.test(line);
|
|
}
|
|
|
|
function findMarkdownFiles(dir) {
|
|
const out = [];
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') return out;
|
|
throw err;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
out.push(...findMarkdownFiles(full));
|
|
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
out.push(full);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function shippedMarkdownFiles() {
|
|
const files = [];
|
|
for (const root of SHIPPED_ROOTS) {
|
|
files.push(...findMarkdownFiles(path.join(REPO_ROOT, root)));
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function scan(files, predicate) {
|
|
const violations = [];
|
|
for (const filePath of files) {
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const lines = content.split(/\r?\n/);
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (predicate(lines[i])) {
|
|
violations.push(
|
|
`${path.relative(REPO_ROOT, filePath)}:${i + 1}: ${lines[i].trim().slice(0, 40)}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
const REMEDY =
|
|
'\n\nPer gsd-core/references/ui-brand.md "Separators and Banners":\n' +
|
|
' - a stage banner is an ATX heading with no rule lines;\n' +
|
|
' - a panel becomes a heading followed by its rows as plain lines;\n' +
|
|
' - a divider between sections is --- with a blank line above and below.\n' +
|
|
'A fixed-width run wraps in a narrow pane and comes apart from its heading.\n' +
|
|
'See https://github.com/open-gsd/gsd-core/issues/3028';
|
|
|
|
describe('responsive-separators', () => {
|
|
const tmpDirs = [];
|
|
afterEach(() => {
|
|
while (tmpDirs.length) cleanup(tmpDirs.pop());
|
|
});
|
|
|
|
function tempFile(name, content) {
|
|
const dir = createTempDir('responsive-separators');
|
|
tmpDirs.push(dir);
|
|
const filePath = path.join(dir, name);
|
|
fs.writeFileSync(filePath, content, 'utf-8');
|
|
return filePath;
|
|
}
|
|
|
|
test('the shipped-content walk actually finds files', () => {
|
|
const files = shippedMarkdownFiles();
|
|
// Without this the two repo-wide scans below would pass vacuously if the
|
|
// roots were ever renamed out from under them.
|
|
assert.ok(
|
|
files.length > 100,
|
|
`expected the shipped roots (${SHIPPED_ROOTS.join(', ')}) to hold markdown, found ${files.length}`
|
|
);
|
|
for (const root of SHIPPED_ROOTS) {
|
|
assert.ok(
|
|
files.some((f) => f.startsWith(path.join(REPO_ROOT, root) + path.sep)),
|
|
`no markdown found under shipped root "${root}"`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('no shipped content emits a fixed-width rule line', () => {
|
|
const violations = scan(shippedMarkdownFiles(), isFixedWidthRuleLine);
|
|
assert.deepStrictEqual(
|
|
violations,
|
|
[],
|
|
'Fixed-width box-drawing rule lines found in shipped content:\n' +
|
|
violations.map((v) => ` - ${v}`).join('\n') +
|
|
REMEDY
|
|
);
|
|
});
|
|
|
|
test('no shipped content emits box-panel characters', () => {
|
|
const violations = scan(shippedMarkdownFiles(), hasPanelCharacter);
|
|
assert.deepStrictEqual(
|
|
violations,
|
|
[],
|
|
'Box-panel characters found in shipped content:\n' +
|
|
violations.map((v) => ` - ${v}`).join('\n') +
|
|
REMEDY
|
|
);
|
|
});
|
|
|
|
test(`run of ${MIN_RULE_RUN - 1} is not a rule (limit-1)`, () => {
|
|
assert.equal(isFixedWidthRuleLine(HEAVY.repeat(MIN_RULE_RUN - 1)), false);
|
|
assert.equal(isFixedWidthRuleLine(LIGHT.repeat(MIN_RULE_RUN - 1)), false);
|
|
assert.equal(isFixedWidthRuleLine(DOUBLE.repeat(MIN_RULE_RUN - 1)), false);
|
|
});
|
|
|
|
test(`run of ${MIN_RULE_RUN} is a rule (limit)`, () => {
|
|
assert.equal(isFixedWidthRuleLine(HEAVY.repeat(MIN_RULE_RUN)), true);
|
|
assert.equal(isFixedWidthRuleLine(LIGHT.repeat(MIN_RULE_RUN)), true);
|
|
assert.equal(isFixedWidthRuleLine(DOUBLE.repeat(MIN_RULE_RUN)), true);
|
|
});
|
|
|
|
test(`run of ${MIN_RULE_RUN + 1} is a rule (limit+1)`, () => {
|
|
assert.equal(isFixedWidthRuleLine(HEAVY.repeat(MIN_RULE_RUN + 1)), true);
|
|
});
|
|
|
|
test('run of 1 is not a rule', () => {
|
|
assert.equal(isFixedWidthRuleLine(HEAVY), false);
|
|
});
|
|
|
|
test('lines that merely contain box-drawing glyphs are not rules', () => {
|
|
const notRules = [
|
|
'└── file.md',
|
|
'├── dir/',
|
|
'├────┼────┤',
|
|
'Progress: ████░░ 80%',
|
|
'| --- | --- |',
|
|
'---',
|
|
'### GSD ► EXECUTING WAVE 8',
|
|
'',
|
|
' \t ',
|
|
`a${HEAVY.repeat(3)}`,
|
|
`${HEAVY.repeat(3)}a`,
|
|
];
|
|
for (const line of notRules) {
|
|
assert.equal(
|
|
isFixedWidthRuleLine(line),
|
|
false,
|
|
`should not be a rule: ${JSON.stringify(line)}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('an indented rule line is still a rule', () => {
|
|
assert.equal(isFixedWidthRuleLine(` ${HEAVY.repeat(4)} `), true);
|
|
assert.equal(isFixedWidthRuleLine(`\t${LIGHT.repeat(3)}\t`), true);
|
|
});
|
|
|
|
test('mixed rule characters still count as a rule', () => {
|
|
assert.equal(isFixedWidthRuleLine(`${LIGHT}${LIGHT}${LIGHT}${HEAVY}${HEAVY}${DOUBLE}`), true);
|
|
});
|
|
|
|
test('panel characters are detected anywhere on the line', () => {
|
|
assert.equal(hasPanelCharacter('╔══╗'), true);
|
|
assert.equal(hasPanelCharacter('║ CHECKPOINT: Verification Required ║'), true);
|
|
assert.equal(hasPanelCharacter('╚══╝'), true);
|
|
assert.equal(hasPanelCharacter('╠══╣'), true);
|
|
assert.equal(hasPanelCharacter('├──┤'), false);
|
|
assert.equal(hasPanelCharacter('│ tree'), false);
|
|
assert.equal(hasPanelCharacter('### CHECKPOINT: Verification Required'), false);
|
|
assert.equal(hasPanelCharacter('└── file.md'), false);
|
|
});
|
|
|
|
test('the scan flags a heavy fixed-width rule line, with path and line number', () => {
|
|
const filePath = tempFile(
|
|
'banner.md',
|
|
['Display banner:', '', HEAVY.repeat(53), ' GSD ► PLANNING', ''].join('\n')
|
|
);
|
|
const violations = scan([filePath], isFixedWidthRuleLine);
|
|
assert.equal(violations.length, 1);
|
|
assert.match(violations[0], /banner\.md:3: /);
|
|
});
|
|
|
|
test('the scan flags box-panel characters', () => {
|
|
const filePath = tempFile(
|
|
'panel.md',
|
|
['╔══╗', '║ ERROR ║', '╚══╝'].join('\n')
|
|
);
|
|
const violations = scan([filePath], hasPanelCharacter);
|
|
assert.equal(violations.length, 3);
|
|
});
|
|
|
|
test('CRLF files are scanned identically to LF files', () => {
|
|
const crlf = tempFile(
|
|
'crlf.md',
|
|
['Display:', HEAVY.repeat(53), '└── file.md', ''].join('\r\n')
|
|
);
|
|
const violations = scan([crlf], isFixedWidthRuleLine);
|
|
assert.equal(violations.length, 1, 'the \\r must not defeat the rule-line trim');
|
|
assert.match(violations[0], /crlf\.md:2: /);
|
|
});
|
|
|
|
test('a file with no separators produces no violations', () => {
|
|
const filePath = tempFile(
|
|
'clean.md',
|
|
['# Title', '', '### GSD ► PLANNING', '', '---', '', 'Body.'].join('\n')
|
|
);
|
|
assert.deepStrictEqual(scan([filePath], isFixedWidthRuleLine), []);
|
|
assert.deepStrictEqual(scan([filePath], hasPanelCharacter), []);
|
|
});
|
|
|
|
test('an empty shipped file produces no violations', () => {
|
|
const filePath = tempFile('empty.md', '');
|
|
assert.deepStrictEqual(scan([filePath], isFixedWidthRuleLine), []);
|
|
assert.deepStrictEqual(scan([filePath], hasPanelCharacter), []);
|
|
});
|
|
|
|
const RULE_CHAR = fc.constantFrom(LIGHT, HEAVY, DOUBLE);
|
|
|
|
test(`property: any pure run of ${MIN_RULE_RUN}+ rule characters is flagged`, () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.array(RULE_CHAR, { minLength: MIN_RULE_RUN, maxLength: 120 }),
|
|
fc.constantFrom('', ' ', ' ', '\t', ' \t '),
|
|
fc.constantFrom('', ' ', ' ', '\t', '\r'),
|
|
(chars, lead, trail) => isFixedWidthRuleLine(`${lead}${chars.join('')}${trail}`)
|
|
),
|
|
{ seed: 3028, numRuns: 300 }
|
|
);
|
|
});
|
|
|
|
test('property: a line carrying any non-rule, non-whitespace character is never flagged', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.array(RULE_CHAR, { minLength: 0, maxLength: 60 }),
|
|
fc
|
|
.string({ minLength: 1, maxLength: 12 })
|
|
.filter((s) => /\S/.test(s) && !/^[─━═\s]*$/.test(s)),
|
|
fc.array(RULE_CHAR, { minLength: 0, maxLength: 60 }),
|
|
(before, intruder, after) =>
|
|
isFixedWidthRuleLine(`${before.join('')}${intruder}${after.join('')}`) === false
|
|
),
|
|
{ seed: 3028, numRuns: 300 }
|
|
);
|
|
});
|
|
});
|