* 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>
This commit is contained in:
@@ -238,8 +238,8 @@ describe('audit cutover: output shape equivalence', () => {
|
||||
`audit-open must succeed. stderr: ${result.error}`);
|
||||
const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
assert.ok(
|
||||
lines.includes('Milestone Close: Open Artifact Audit'),
|
||||
`report title must appear as a standalone line; got: ${JSON.stringify(lines.slice(0, 5))}`,
|
||||
lines.includes('### Milestone Close: Open Artifact Audit'),
|
||||
`report title must appear as a standalone Markdown heading line; got: ${JSON.stringify(lines.slice(0, 5))}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -481,7 +481,7 @@ describe('audit-open — does not crash with ReferenceError (#2659)', () => {
|
||||
* 2. Even after switching to `core.output(formatted, raw)`, the human-readable
|
||||
* branch JSON-stringifies the formatted string because `core.output` only
|
||||
* bypasses JSON encoding when called as `core.output(null, true, rawValue)`.
|
||||
* Result: stdout contains `"━━━…\n Milestone Close: …\n…"` (a JSON string
|
||||
* Result: stdout contains `"### Milestone Close: …\n…"` (a JSON string
|
||||
* literal) instead of the rendered report.
|
||||
*
|
||||
* The shape assertions below catch both regressions structurally — never via
|
||||
@@ -640,10 +640,10 @@ describe('audit-open — output shape (#2911)', () => {
|
||||
|
||||
const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
|
||||
// The first non-empty line must be the divider character row, *not* a
|
||||
// JSON-encoded string starting with a quote. If core.output JSON-stringified
|
||||
// The first non-empty line must be the rendered Markdown heading row, *not*
|
||||
// a JSON-encoded string starting with a quote. If core.output JSON-stringified
|
||||
// the formatted report, the entire payload sits on one line wrapped in
|
||||
// double quotes ("━━━…\n…").
|
||||
// double quotes ("### Milestone Close: …\n…").
|
||||
assert.ok(
|
||||
!result.output.startsWith('"'),
|
||||
'text-mode stdout must not begin with a JSON quote (would mean the report was JSON.stringified)'
|
||||
@@ -655,8 +655,8 @@ describe('audit-open — output shape (#2911)', () => {
|
||||
|
||||
// Section headers from formatAuditReport that must appear as standalone lines.
|
||||
assert.ok(
|
||||
lines.includes('Milestone Close: Open Artifact Audit'),
|
||||
`expected report title as a standalone line; got lines: ${JSON.stringify(lines.slice(0, 5))}`
|
||||
lines.includes('### Milestone Close: Open Artifact Audit'),
|
||||
`expected report title as a standalone Markdown heading line; got lines: ${JSON.stringify(lines.slice(0, 5))}`
|
||||
);
|
||||
assert.ok(
|
||||
lines.includes('All artifact types clear. Safe to proceed.'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"execute-plan.md": "#3370: the Pattern A dispatch prompt spec gained the gate-semantics clause (gate=\"blocking\" (the default) is auto-approvable in auto-mode per the executor's own checkpoint protocol, gate=\"blocking-human\" always surfaces to a human; add no instruction overriding that protocol), closing the identically-shaped dispatch-time gap on the single-plan path named in the issue. Growth ~248 bytes (38913 -> 39161, still under the DEFAULT 40 KiB ceiling). Supersedes the spent #2652 fragment (merged into next), which also named execute-plan.md and would otherwise double-ack the same path. #3659 appends --mode \"$ISOLATION\" to the #2649 pre-dispatch base-check and corrects the baseRef restore-advice to the orchestrator/harness split (+154B; the harness does not read baseRef, #48). Deliberate growth. — #3299 append: the tracer feedback gate's interactive branch, which read `Interactive: STOP -> return a checkpoint:human-verify` and keyed on auto-mode alone, now branches on HUMAN_VERIFY_MODE — under the documented `end-of-phase` default an automated-only tracer `<verify>` is re-run and continues to expansion with no checkpoint. Growth is 796 bytes (39315 -> 40111, still under the DEFAULT_CAP of 40960 LF bytes, 849 bytes of headroom; the earlier 39161 -> 39957 / 1003 figures were measured before the 2026-08-22 merge of next and are superseded): that branch plus a `HUMAN_VERIFY_MODE=` read alongside the existing RUNTIME/USE_WORKTREES reads in parse_segments. The read carries an explicit `--default end-of-phase` because `workflow.human_verify_mode` is absent from SCHEMA_DEFAULTS (src/config.cts): a bare config-get exits non-zero with `Key not found` on any project whose config.json predates #3309, which is every pre-existing project and the reporter's exact config. The three carve-outs (`<human-check>`, `gate=\"blocking-human\"`, `mid-flight`) are stated inline here rather than delegated to gsd-core/references/dispatch-isolation-gate.md, because this file is the inline dispatch path used for step-by-step / non-Claude-Code execution, where agents/gsd-executor.md is never loaded. This entry carries #3370's own reason forward verbatim above rather than replacing it — that growth is in the base and still needs its account. The blocking-human STOP is evaluated BEFORE the auto-mode branch here, matching golden rule 6 in checkpoints.md — the earlier ordering let an autonomous run continue past a blocking-human tracer."
|
||||
"execute-plan.md": "#3370: the Pattern A dispatch prompt spec gained the gate-semantics clause (gate=\"blocking\" (the default) is auto-approvable in auto-mode per the executor's own checkpoint protocol, gate=\"blocking-human\" always surfaces to a human; add no instruction overriding that protocol), closing the identically-shaped dispatch-time gap on the single-plan path named in the issue. Growth ~248 bytes (38913 -> 39161, still under the DEFAULT 40 KiB ceiling). Supersedes the spent #2652 fragment (merged into next), which also named execute-plan.md and would otherwise double-ack the same path. #3659 appends --mode \"$ISOLATION\" to the #2649 pre-dispatch base-check and corrects the baseRef restore-advice to the orchestrator/harness split (+154B; the harness does not read baseRef, #48). Deliberate growth. — #3299 append: the tracer feedback gate's interactive branch, which read `Interactive: STOP -> return a checkpoint:human-verify` and keyed on auto-mode alone, now branches on HUMAN_VERIFY_MODE — under the documented `end-of-phase` default an automated-only tracer `<verify>` is re-run and continues to expansion with no checkpoint. Growth is 796 bytes (39315 -> 40111, still under the DEFAULT_CAP of 40960 LF bytes, 849 bytes of headroom; the earlier 39161 -> 39957 / 1003 figures were measured before the 2026-08-22 merge of next and are superseded): that branch plus a `HUMAN_VERIFY_MODE=` read alongside the existing RUNTIME/USE_WORKTREES reads in parse_segments. The read carries an explicit `--default end-of-phase` because `workflow.human_verify_mode` is absent from SCHEMA_DEFAULTS (src/config.cts): a bare config-get exits non-zero with `Key not found` on any project whose config.json predates #3309, which is every pre-existing project and the reporter's exact config. The three carve-outs (`<human-check>`, `gate=\"blocking-human\"`, `mid-flight`) are stated inline here rather than delegated to gsd-core/references/dispatch-isolation-gate.md, because this file is the inline dispatch path used for step-by-step / non-Claude-Code execution, where agents/gsd-executor.md is never loaded. This entry carries #3370's own reason forward verbatim above rather than replacing it — that growth is in the base and still needs its account. The blocking-human STOP is evaluated BEFORE the auto-mode branch here, matching golden rule 6 in checkpoints.md — the earlier ordering let an autonomous run continue past a blocking-human tracer. — #3028 append: the checkpoint_protocol step's one-line display spec described the old drawn box (`CHECKPOINT: [Type]` box … `YOUR ACTION: [signal]`). Under #3028 a checkpoint is a `### CHECKPOINT: [Type]` heading, a `---` break and a bolded action prompt, so the spec now reads `### CHECKPOINT: [Type]` heading → Progress {X}/{Y} → Task name → type-specific content → `---` → `**YOUR ACTION: [signal]**`. Growth is 22 bytes (40111 -> 40133, still under the DEFAULT_CAP of 40960 LF bytes, 827 bytes of headroom) — the naming of the two new Markdown tokens and the heading marker. Appended here rather than filed as a new fragment because a growth ack keys on the BARE FILENAME and #3370 already declares `execute-plan.md`; two sources naming one path is a hard duplicate-key error, so this follows the same supersede-by-append route #3370 itself took for the spent #2652 fragment. Every other file this change touches SHRANK, which needs no acknowledgment."
|
||||
}
|
||||
}
|
||||
|
||||
297
tests/responsive-separators.test.cjs
Normal file
297
tests/responsive-separators.test.cjs
Normal file
@@ -0,0 +1,297 @@
|
||||
// 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,6 @@ const {
|
||||
CHECKPOINT_FRAMES,
|
||||
CHECKPOINT_LANGUAGE_ALIASES,
|
||||
resolveCheckpointFrame,
|
||||
checkpointBoxLine,
|
||||
parseDeferredItems,
|
||||
} = require('../gsd-core/bin/lib/uat.cjs');
|
||||
|
||||
@@ -932,12 +931,12 @@ describe('uat render-checkpoint', () => {
|
||||
|
||||
assert.ok(japanese.includes('チェックポイント'));
|
||||
assert.ok(japanese.includes('`pass`'));
|
||||
// Structural lines (borders, separators, Test N heading, expected content) are untouched.
|
||||
assert.ok(japanese.includes('╔══════════════════════════════════════════════════════════════╗'));
|
||||
assert.ok(japanese.includes('╚══════════════════════════════════════════════════════════════╝'));
|
||||
assert.ok(japanese.includes('──────────────────────────────────────────────────────────────'));
|
||||
// Structural lines (heading marker, separator, Test N heading, expected content) are untouched.
|
||||
assert.ok(japanese.includes('### チェックポイント: 検証が必要です'));
|
||||
assert.ok(japanese.includes('---'));
|
||||
assert.ok(japanese.includes('**Test 1: Sample**'));
|
||||
assert.ok(japanese.includes('Something happens.'));
|
||||
assert.ok(!/[╔╗╚╝║]/.test(japanese), 'the box border must be gone (#3028)');
|
||||
assert.notStrictEqual(japanese, english);
|
||||
});
|
||||
|
||||
@@ -1116,91 +1115,73 @@ describe('uat render-checkpoint', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Regression: #2402 review medium finding — checkpointBoxLine() padded using
|
||||
// JS string `.length` (UTF-16 code units), not display width. The property
|
||||
// below supplies an independent, category-labelled cell-width oracle rather
|
||||
// than copying the implementation's Unicode range logic.
|
||||
describe('checkpoint banner padding uses terminal display width (#2402, #2530)', () => {
|
||||
test('property: category-labelled strings are padded to a 62-cell interior', () => {
|
||||
const oneCell = fc.constantFrom(
|
||||
{ text: 'a', width: 1 },
|
||||
{ text: '7', width: 1 },
|
||||
{ text: ' ', width: 1 },
|
||||
{ text: '\u093e', width: 1 }, // Mc: DEVANAGARI VOWEL SIGN AA
|
||||
{ text: '\u093f', width: 1 }, // Mc: DEVANAGARI VOWEL SIGN I
|
||||
{ text: '\u0949', width: 1 }, // Mc: DEVANAGARI VOWEL SIGN CANDRA O
|
||||
);
|
||||
const zeroCell = fc.constantFrom(
|
||||
{ text: '\u0301', width: 0 }, // Mn: COMBINING ACUTE ACCENT
|
||||
{ text: '\u093c', width: 0 }, // Mn: DEVANAGARI SIGN NUKTA
|
||||
{ text: '\u20dd', width: 0 }, // Me: COMBINING ENCLOSING CIRCLE
|
||||
{ text: '\u200d', width: 0 }, // Cf: ZERO WIDTH JOINER
|
||||
{ text: '\u2066', width: 0 }, // Cf: LEFT-TO-RIGHT ISOLATE
|
||||
{ text: '\u2069', width: 0 }, // Cf: POP DIRECTIONAL ISOLATE
|
||||
);
|
||||
const twoCell = fc.constantFrom(
|
||||
{ text: '界', width: 2 },
|
||||
{ text: '語', width: 2 },
|
||||
{ text: '한', width: 2 },
|
||||
);
|
||||
// Regression: #3028 — the checkpoint renderer no longer draws a 64-column
|
||||
// double-line box (checkpointBoxLine/displayWidth/isWideCodePoint/
|
||||
// ZERO_WIDTH_MARK_RE/CHECKPOINT_BOX_WIDTH were removed from src/uat.cts).
|
||||
// These cases now pin the heading form (`### {banner}`) directly instead of
|
||||
// a padded box interior; the localized-language coverage that used to prove
|
||||
// display-width-correct padding now proves the banner text is emitted
|
||||
// intact, unpadded, and box-free.
|
||||
describe('checkpoint banner renders as a heading, not a box (#2402, #2530, #3028)', () => {
|
||||
test('exact rendered banner heading for Japanese/Chinese/Korean (regression pin)', () => {
|
||||
const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' };
|
||||
const japanese = buildCheckpoint(currentTest, 'Japanese');
|
||||
const chinese = buildCheckpoint(currentTest, 'Chinese');
|
||||
const korean = buildCheckpoint(currentTest, 'Korean');
|
||||
|
||||
fc.assert(fc.property(
|
||||
fc.array(fc.oneof(oneCell, zeroCell, twoCell), { maxLength: 35 }),
|
||||
(cells) => {
|
||||
const text = cells.map((cell) => cell.text).join('');
|
||||
const textWidth = cells.reduce((sum, cell) => sum + cell.width, 0);
|
||||
const padding = ' '.repeat(Math.max(0, 60 - textWidth));
|
||||
assert.strictEqual(
|
||||
checkpointBoxLine(text),
|
||||
`║ ${text}${padding}║`,
|
||||
);
|
||||
},
|
||||
));
|
||||
});
|
||||
assert.strictEqual(japanese.split('\n')[0], '### チェックポイント: 検証が必要です');
|
||||
assert.strictEqual(chinese.split('\n')[0], '### 检查点:需要验证');
|
||||
assert.strictEqual(korean.split('\n')[0], '### 체크포인트: 검증 필요');
|
||||
|
||||
test('padding boundary: width limit-1, limit, and limit+1', () => {
|
||||
for (const width of [59, 60, 61]) {
|
||||
const text = 'a'.repeat(width);
|
||||
assert.strictEqual(
|
||||
checkpointBoxLine(text),
|
||||
`║ ${text}${' '.repeat(Math.max(0, 60 - width))}║`,
|
||||
`unexpected rendering at text width ${width}`,
|
||||
);
|
||||
for (const output of [japanese, chinese, korean]) {
|
||||
assert.ok(!/[╔╗╚╝║]/.test(output), 'the box border must be gone (#3028)');
|
||||
}
|
||||
});
|
||||
|
||||
test('exact rendered banner lines for Japanese/Chinese/Korean (regression pin)', () => {
|
||||
test('exact rendered Hindi banner heading ignores combining-mark cell width (regression pin)', () => {
|
||||
const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' };
|
||||
assert.strictEqual(
|
||||
buildCheckpoint(currentTest, 'Japanese').split('\n')[1],
|
||||
'║ チェックポイント: 検証が必要です ║',
|
||||
);
|
||||
assert.strictEqual(
|
||||
buildCheckpoint(currentTest, 'Chinese').split('\n')[1],
|
||||
'║ 检查点:需要验证 ║',
|
||||
);
|
||||
assert.strictEqual(
|
||||
buildCheckpoint(currentTest, 'Korean').split('\n')[1],
|
||||
'║ 체크포인트: 검증 필요 ║',
|
||||
);
|
||||
});
|
||||
|
||||
test('exact rendered Hindi banner line ignores combining-mark cell width (regression pin)', () => {
|
||||
const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' };
|
||||
assert.strictEqual(
|
||||
buildCheckpoint(currentTest, 'Hindi').split('\n')[1],
|
||||
`║ चेकपॉइंट: सत्यापन आवश्यक${' '.repeat(40)}║`,
|
||||
);
|
||||
const hindi = buildCheckpoint(currentTest, 'Hindi');
|
||||
assert.strictEqual(hindi.split('\n')[0], '### चेकपॉइंट: सत्यापन आवश्यक');
|
||||
assert.ok(!/[╔╗╚╝║]/.test(hindi), 'the box border must be gone (#3028)');
|
||||
});
|
||||
|
||||
test('exact rendered Arabic frame is isolated inside the LTR checkpoint layout', () => {
|
||||
const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' };
|
||||
const arabic = buildCheckpoint(currentTest, 'Arabic');
|
||||
// The one behavior the box removal must not disturb: the RTL banner and
|
||||
// instruction text stay wrapped in directional isolates.
|
||||
assert.strictEqual(
|
||||
arabic.split('\n')[1],
|
||||
`║ \u2067نقطة تحقق: المراجعة مطلوبة\u2069${' '.repeat(34)}║`,
|
||||
arabic.split('\n')[0],
|
||||
`### نقطة تحقق: المراجعة مطلوبة`,
|
||||
);
|
||||
assert.ok(arabic.includes('\u2067اكتب `pass` أو صف المشكلة.\u2069'));
|
||||
assert.ok(arabic.includes('اكتب `pass` أو صف المشكلة.'));
|
||||
assert.ok(!/[╔╗╚╝║]/.test(arabic), 'the box border must be gone (#3028)');
|
||||
});
|
||||
|
||||
test('emits an over-long banner intact (no box to overflow)', (t) => {
|
||||
// Previously a banner exceeding the 64-column inner width produced a
|
||||
// ragged, unpadded border. Now there is no border to overflow — the
|
||||
// full heading text is emitted intact regardless of length. None of the
|
||||
// shipped frames are long enough to exercise this, so a synthetic frame
|
||||
// is registered on the exported (mutable) lookup tables for the
|
||||
// duration of the test.
|
||||
const longBanner = `${'X'.repeat(80)}: Verification required well beyond the old 64-column box width`;
|
||||
const frameKey = '__test_overlong_frame__3028__';
|
||||
const aliasKey = '__test_overlong_alias__3028__';
|
||||
CHECKPOINT_FRAMES[frameKey] = {
|
||||
banner: longBanner,
|
||||
instruction: 'Type `pass` or describe what\'s wrong.',
|
||||
};
|
||||
CHECKPOINT_LANGUAGE_ALIASES[aliasKey] = frameKey;
|
||||
t.after(() => {
|
||||
delete CHECKPOINT_FRAMES[frameKey];
|
||||
delete CHECKPOINT_LANGUAGE_ALIASES[aliasKey];
|
||||
});
|
||||
const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' };
|
||||
const output = buildCheckpoint(currentTest, aliasKey);
|
||||
assert.strictEqual(output.split('\n')[0], `### ${longBanner}`,
|
||||
'an over-long banner must be emitted in full, not truncated or wrapped');
|
||||
assert.ok(!/[╔╗╚╝║]/.test(output), 'no box characters should appear regardless of banner length');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1224,7 +1205,9 @@ awaiting: user response
|
||||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||||
assert.ok(result.output.includes('**Test 2: Submit form validation**'));
|
||||
assert.ok(result.output.includes('Empty submit keeps controls visible.'));
|
||||
assert.ok(result.output.includes("Type `pass` or describe what's wrong."));
|
||||
// The instruction line renders as a bold line preceded by a `---` thematic
|
||||
// break, not inside a box border (#3028).
|
||||
assert.ok(result.output.includes("---\n\n**Type `pass` or describe what's wrong.**"));
|
||||
});
|
||||
|
||||
test('strips protocol leak lines from current test copy', () => {
|
||||
@@ -1440,14 +1423,14 @@ awaiting: user response
|
||||
assert.ok(result.output.includes('Verificación requerida'), 'banner should be in Spanish');
|
||||
assert.ok(result.output.includes('Escribe `pass`'), 'instruction line should be in Spanish');
|
||||
|
||||
// Structure/IDs stay untranslated: box borders, the Test N: name line, and the
|
||||
// expected content are preserved verbatim.
|
||||
assert.ok(result.output.includes('╔══════════════════════════════════════════════════════════════╗'));
|
||||
assert.ok(result.output.includes('╚══════════════════════════════════════════════════════════════╝'));
|
||||
assert.ok(result.output.includes('──────────────────────────────────────────────────────────────'));
|
||||
// Structure/IDs stay untranslated: the heading marker, the `---` separator,
|
||||
// the Test N: name line, and the expected content are preserved verbatim.
|
||||
assert.ok(result.output.includes('### PUNTO DE CONTROL: Verificación requerida'));
|
||||
assert.ok(result.output.includes('---'));
|
||||
assert.ok(result.output.includes('**Test 2: Submit form validation**'));
|
||||
assert.ok(result.output.includes('Empty submit keeps controls visible.'));
|
||||
assert.ok(result.output.includes('Validation error copy is shown.'));
|
||||
assert.ok(!/[╔╗╚╝║]/.test(result.output), 'the box border must be gone (#3028)');
|
||||
});
|
||||
|
||||
// Regression guard for the "unset ⇒ byte-identical English" acceptance criterion.
|
||||
@@ -1471,17 +1454,15 @@ awaiting: user response
|
||||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||||
|
||||
const expected = [
|
||||
'╔══════════════════════════════════════════════════════════════╗',
|
||||
'║ CHECKPOINT: Verification Required ║',
|
||||
'╚══════════════════════════════════════════════════════════════╝',
|
||||
'### CHECKPOINT: Verification Required',
|
||||
'',
|
||||
'**Test 2: Submit form validation**',
|
||||
'',
|
||||
'Empty submit keeps controls visible.\nValidation error copy is shown.',
|
||||
'',
|
||||
'──────────────────────────────────────────────────────────────',
|
||||
'Type `pass` or describe what\'s wrong.',
|
||||
'──────────────────────────────────────────────────────────────',
|
||||
'---',
|
||||
'',
|
||||
'**Type `pass` or describe what\'s wrong.**',
|
||||
].join('\n');
|
||||
|
||||
assert.strictEqual(result.output, expected);
|
||||
|
||||
Reference in New Issue
Block a user