* fix(#2402): honor response_language across orchestrator output + UAT checkpoint renderer Replays the in-flight bot branch fix/2402-response-language-orchestrator-coverage (seven commits, never pushed) onto current origin/next as a single squashed commit. The original work was substantial and correct; this commit preserves its full scope, trimmed where rebase conflicts + workflow size budgets required it. Three independent layers where response_language was being dropped are closed: Layer 1 — orchestrator-facing directives across workflows. Adds the strong "All user-facing output in this workflow MUST be presented in {response_language}; technical terms, code, paths, and subagent prompts stay in English" directive to ~40 workflows that previously either lacked it entirely (verify-work, new-project, new-milestone, quick, manager, and ~35 more) or carried only the weak subagent-prompt-only form (plan-phase, execute-phase). The directive covers narration between tool calls and banner output, not just the AskUserQuestion prompts. Layer 2 — UAT checkpoint renderer (src/uat.cts). buildCheckpoint now accepts an optional responseLanguage parameter and renders the frame strings ("CHECKPOINT: Verification Required", "Type `pass` or describe what's wrong.") in any of 9 languages (English/Spanish/French/German/Portuguese/Japanese/ Chinese/Korean/Italian) with an alias table covering ~30 input variants (en, es, español, ja, 日本語, etc.). cmdRenderCheckpoint reads config.response_language via loadConfig(cwd) and passes it through, so the byte-for-byte block verify-work.md reprints verbatim is already localized when written — preserving the anti-injection hygiene rule at verify-work.md (the model is forbidden to translate after the fact). CJK display width is computed by East Asian Width property ranges (W/F) so the right ║ border of the banner stays aligned for full-width characters. English fallback is byte-identical to the pre-fix behavior when response_language is unset or unrecognized. Layer 3 — literal English report templates in execute-phase. The top-of- workflow directive covers all template sites (templates are a structural source, not literal output). Inline render-language notes that previously sat at each template site were removed during the squash because they pushed execute-phase.md over its frozen pre-phase-6 byte ceiling (93600 — ADR-857 Phase 6 capstone). The single top directive covers the same surface with fewer bytes. Also extends src/docs.cts and src/init.cts to propagate response_language into the init JSON bundle of the additional workflows so the directive can read it. Tests added: - tests/uat.test.cjs: buildCheckpoint with unset/unrecognized language falls back to English default; recognized language swaps only the two frame strings while structural lines stay untouched; CJK display-width regression (independent recomputation of East Asian Width W/F ranges). - tests/workspace.test.cjs, tests/docs-update.test.cjs: response_language wiring through docs.cts/init.cts. References: #2402; reporter's three-layer triage + Layer-4 follow-up; the byte-for-byte anti-injection hygiene rule at verify-work.md (the reason Layer 2 must be renderer-side, not model-translated). This is a squash of the in-flight bot branch — seven commits representing the original implementation plus its subsequent fix/CJK-padding/test/ changeset/regen cycles, none of which were ever pushed or PR'd. The squash captures the final coherent state. * chore(#2402): backfill pr:2457 in .changeset/2402-response-language-orchestrator-coverage.md * chore(#2402): regen golden + size baseline after rebase against #2315 (PR #2451) Rebase conflicts were entirely in generated artifacts (golden-install-parity fixtures + workflow-size-baseline.json). After taking theirs during rebase, regenerated cleanly against the merged source tree.
454 lines
18 KiB
JavaScript
454 lines
18 KiB
JavaScript
/**
|
|
* GSD Tools Tests - docs-update
|
|
*
|
|
* Integration tests for the docs-init gsd-tools subcommand.
|
|
* Covers: JSON output shape, project type detection, existing doc scanning,
|
|
* GSD marker detection, and doc tooling detection.
|
|
*
|
|
* Requirements: VERF-03
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
// ─── JSON output shape ────────────────────────────────────────────────────────
|
|
|
|
describe('docs-init command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('returns expected JSON shape', () => {
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
|
|
// Top-level scalar fields
|
|
assert.strictEqual(typeof data.doc_writer_model, 'string');
|
|
assert.strictEqual(typeof data.commit_docs, 'boolean');
|
|
assert.strictEqual(typeof data.planning_exists, 'boolean');
|
|
assert.strictEqual(typeof data.project_root, 'string');
|
|
assert.strictEqual(typeof data.agents_installed, 'boolean');
|
|
|
|
// Array fields
|
|
assert.ok(Array.isArray(data.existing_docs), 'existing_docs should be an array');
|
|
assert.ok(Array.isArray(data.monorepo_workspaces), 'monorepo_workspaces should be an array');
|
|
assert.ok(Array.isArray(data.missing_agents), 'missing_agents should be an array');
|
|
|
|
// project_type object with 7 boolean fields
|
|
assert.ok(data.project_type && typeof data.project_type === 'object', 'project_type should be an object');
|
|
assert.strictEqual(typeof data.project_type.has_package_json, 'boolean');
|
|
assert.strictEqual(typeof data.project_type.has_api_routes, 'boolean');
|
|
assert.strictEqual(typeof data.project_type.has_cli_bin, 'boolean');
|
|
assert.strictEqual(typeof data.project_type.is_open_source, 'boolean');
|
|
assert.strictEqual(typeof data.project_type.has_deploy_config, 'boolean');
|
|
assert.strictEqual(typeof data.project_type.is_monorepo, 'boolean');
|
|
assert.strictEqual(typeof data.project_type.has_tests, 'boolean');
|
|
|
|
// doc_tooling object with 4 boolean fields
|
|
assert.ok(data.doc_tooling && typeof data.doc_tooling === 'object', 'doc_tooling should be an object');
|
|
assert.strictEqual(typeof data.doc_tooling.docusaurus, 'boolean');
|
|
assert.strictEqual(typeof data.doc_tooling.vitepress, 'boolean');
|
|
assert.strictEqual(typeof data.doc_tooling.mkdocs, 'boolean');
|
|
assert.strictEqual(typeof data.doc_tooling.storybook, 'boolean');
|
|
|
|
// planning_exists is true since createTempProject creates .planning/
|
|
assert.strictEqual(data.planning_exists, true);
|
|
});
|
|
|
|
test('bare project returns all false signals', () => {
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
|
|
// All project_type fields should be false for a bare project
|
|
assert.strictEqual(data.project_type.has_package_json, false);
|
|
assert.strictEqual(data.project_type.has_api_routes, false);
|
|
assert.strictEqual(data.project_type.has_cli_bin, false);
|
|
assert.strictEqual(data.project_type.is_open_source, false);
|
|
assert.strictEqual(data.project_type.has_deploy_config, false);
|
|
assert.strictEqual(data.project_type.is_monorepo, false);
|
|
assert.strictEqual(data.project_type.has_tests, false);
|
|
|
|
// No docs, no workspaces, no doc tooling
|
|
assert.deepEqual(data.existing_docs, []);
|
|
assert.deepEqual(data.monorepo_workspaces, []);
|
|
assert.strictEqual(data.doc_tooling.docusaurus, false);
|
|
assert.strictEqual(data.doc_tooling.vitepress, false);
|
|
assert.strictEqual(data.doc_tooling.mkdocs, false);
|
|
assert.strictEqual(data.doc_tooling.storybook, false);
|
|
});
|
|
});
|
|
|
|
// ─── response_language wiring (#2402) ─────────────────────────────────────────
|
|
//
|
|
// cmdDocsInit predates withProjectRoot (init.cts) and never picked up
|
|
// response_language, so docs-update's orchestrator-owned prompts silently
|
|
// stayed English even with response_language configured. Mirrors the
|
|
// established `init manager` response_language coverage pattern
|
|
// (tests/init-manager.test.cjs).
|
|
|
|
describe('docs-init response_language wiring (#2402)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('output includes response_language when configured', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ response_language: 'Japanese' })
|
|
);
|
|
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.response_language, 'Japanese');
|
|
});
|
|
|
|
test('output omits response_language when not configured', () => {
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.response_language, undefined);
|
|
});
|
|
});
|
|
|
|
// ─── project type detection ───────────────────────────────────────────────────
|
|
|
|
describe('project type detection', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('detects CLI tool from package.json bin field', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'package.json'),
|
|
JSON.stringify({ name: 'my-cli', bin: { mycli: 'bin/cli.js' } }),
|
|
'utf-8'
|
|
);
|
|
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.project_type.has_cli_bin, true);
|
|
assert.strictEqual(data.project_type.has_package_json, true);
|
|
});
|
|
|
|
test('detects open source from LICENSE file', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'LICENSE'), 'MIT License', 'utf-8');
|
|
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.project_type.is_open_source, true);
|
|
});
|
|
|
|
test('detects monorepo from package.json workspaces', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'package.json'),
|
|
JSON.stringify({ name: 'mono', workspaces: ['packages/*'] }),
|
|
'utf-8'
|
|
);
|
|
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.project_type.is_monorepo, true);
|
|
assert.ok(data.monorepo_workspaces.includes('packages/*'), 'monorepo_workspaces should contain packages/*');
|
|
});
|
|
|
|
test('detects tests from tests directory', () => {
|
|
fs.mkdirSync(path.join(tmpDir, 'tests'), { recursive: true });
|
|
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.project_type.has_tests, true);
|
|
});
|
|
|
|
test('detects deploy config from Dockerfile', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'Dockerfile'), 'FROM node:20', 'utf-8');
|
|
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.project_type.has_deploy_config, true);
|
|
});
|
|
|
|
test('detects API routes from src/app/api directory', () => {
|
|
fs.mkdirSync(path.join(tmpDir, 'src', 'app', 'api'), { recursive: true });
|
|
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.project_type.has_api_routes, true);
|
|
});
|
|
});
|
|
|
|
// ─── existing doc scanning ────────────────────────────────────────────────────
|
|
|
|
describe('existing doc scanning', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('scans .md files in project root', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# README\n', 'utf-8');
|
|
fs.writeFileSync(path.join(tmpDir, 'ARCHITECTURE.md'), '# Architecture\n', 'utf-8');
|
|
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
assert.ok(data.existing_docs.length >= 2, 'existing_docs should contain at least 2 entries');
|
|
|
|
const paths = data.existing_docs.map(d => d.path);
|
|
assert.ok(paths.includes('README.md'), 'existing_docs should contain README.md');
|
|
assert.ok(paths.includes('ARCHITECTURE.md'), 'existing_docs should contain ARCHITECTURE.md');
|
|
});
|
|
|
|
test('detects GSD marker in existing docs', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'README.md'),
|
|
'<!-- generated-by: gsd-doc-writer -->\n# README\n',
|
|
'utf-8'
|
|
);
|
|
fs.writeFileSync(path.join(tmpDir, 'NOTES.md'), '# Notes\n', 'utf-8');
|
|
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
|
|
const readmeEntry = data.existing_docs.find(d => d.path === 'README.md');
|
|
assert.ok(readmeEntry, 'README.md should appear in existing_docs');
|
|
assert.strictEqual(readmeEntry.has_gsd_marker, true, 'README.md should have GSD marker');
|
|
|
|
const notesEntry = data.existing_docs.find(d => d.path === 'NOTES.md');
|
|
assert.ok(notesEntry, 'NOTES.md should appear in existing_docs');
|
|
assert.strictEqual(notesEntry.has_gsd_marker, false, 'NOTES.md should not have GSD marker');
|
|
});
|
|
});
|
|
|
|
// ─── doc tooling detection ────────────────────────────────────────────────────
|
|
|
|
describe('doc tooling detection', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('detects Docusaurus config', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'docusaurus.config.js'), 'module.exports = {};', 'utf-8');
|
|
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.doc_tooling.docusaurus, true);
|
|
});
|
|
|
|
test('detects VitePress config', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.vitepress'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, '.vitepress', 'config.ts'), 'export default {};', 'utf-8');
|
|
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.doc_tooling.vitepress, true);
|
|
});
|
|
|
|
test('detects MkDocs config', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'mkdocs.yml'), 'site_name: test', 'utf-8');
|
|
|
|
const result = runGsdTools(['docs-init'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.doc_tooling.mkdocs, true);
|
|
});
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3135-capture-backlog-workflow.test.cjs — consolidation epic #1969 (B3 #1972)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3135-capture-backlog-workflow (consolidation epic #1969 B3 #1972)", () => {
|
|
// allow-test-rule: source-text-is-the-product — workflow and command .md files (see #3135)
|
|
// ARE what the runtime loads; asserting their existence and behavioral content
|
|
// tests the deployed skill surface contract, not implementation internals.
|
|
|
|
'use strict';
|
|
|
|
// Regression tests for bug #3135.
|
|
//
|
|
// PR #2824 consolidated add-backlog into `gsd-capture --backlog` by creating
|
|
// a routing wrapper in commands/gsd/capture.md that delegates to
|
|
// workflows/add-backlog.md via execution_context. The workflow file was never
|
|
// created. Same gap class as reapply-patches.md (found and fixed in the same PR).
|
|
//
|
|
// Fix: create gsd-core/workflows/add-backlog.md with the full process
|
|
// ported from the deleted commands/gsd/add-backlog.md (git ref 87917131^).
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'add-backlog.md');
|
|
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
|
|
|
// ─── #3135: add-backlog workflow ─────────────────────────────────────────────
|
|
|
|
describe('#3135: gsd-core/workflows/add-backlog.md', () => {
|
|
test('file exists', () => {
|
|
assert.ok(
|
|
fs.existsSync(WORKFLOW),
|
|
'gsd-core/workflows/add-backlog.md does not exist — capture --backlog has no implementation to load',
|
|
);
|
|
});
|
|
|
|
test('uses gsd-sdk query phase.next-decimal to find next 999.x slot', () => {
|
|
const src = fs.readFileSync(WORKFLOW, 'utf8');
|
|
assert.ok(
|
|
src.includes('phase.next-decimal'),
|
|
'add-backlog.md must use gsd-sdk query phase.next-decimal to find the next 999.x number',
|
|
);
|
|
});
|
|
|
|
test('writes to ROADMAP.md', () => {
|
|
const src = fs.readFileSync(WORKFLOW, 'utf8');
|
|
assert.ok(src.includes('ROADMAP.md'), 'add-backlog.md must write to ROADMAP.md');
|
|
});
|
|
|
|
test('creates a .planning/phases/ directory', () => {
|
|
const src = fs.readFileSync(WORKFLOW, 'utf8');
|
|
assert.ok(
|
|
src.includes('.planning/phases') || src.includes('planning/phases'),
|
|
'add-backlog.md must create a phase directory under .planning/phases/',
|
|
);
|
|
});
|
|
|
|
test('uses generate-slug for the directory name', () => {
|
|
const src = fs.readFileSync(WORKFLOW, 'utf8');
|
|
assert.ok(
|
|
src.includes('generate-slug'),
|
|
'add-backlog.md must use gsd-sdk query generate-slug to build the phase directory slug',
|
|
);
|
|
});
|
|
|
|
test('commits via gsd-sdk query commit', () => {
|
|
const src = fs.readFileSync(WORKFLOW, 'utf8');
|
|
assert.ok(
|
|
src.includes('gsd-sdk query commit') || src.includes('query commit'),
|
|
'add-backlog.md must commit via gsd-sdk query commit',
|
|
);
|
|
});
|
|
|
|
test('writes ROADMAP entry before creating directory (#2280 ordering invariant)', () => {
|
|
const src = fs.readFileSync(WORKFLOW, 'utf8');
|
|
const roadmapIdx = src.indexOf('ROADMAP.md');
|
|
const mkdirIdx = src.search(/mkdir|\.gitkeep/);
|
|
assert.ok(roadmapIdx !== -1, 'ROADMAP.md write step not found');
|
|
assert.ok(mkdirIdx !== -1, 'directory creation step not found');
|
|
assert.ok(
|
|
roadmapIdx < mkdirIdx,
|
|
'ROADMAP.md entry must be written BEFORE the phase directory is created (#2280 ordering invariant)',
|
|
);
|
|
});
|
|
|
|
test('uses 999.x numbering for backlog items', () => {
|
|
const src = fs.readFileSync(WORKFLOW, 'utf8');
|
|
assert.ok(
|
|
src.includes('999'),
|
|
'add-backlog.md must document 999.x numbering scheme for backlog items',
|
|
);
|
|
});
|
|
|
|
test('documents /gsd-review-backlog for promotion', () => {
|
|
const src = fs.readFileSync(WORKFLOW, 'utf8');
|
|
assert.ok(
|
|
src.includes('review-backlog') || src.includes('gsd-review-backlog'),
|
|
'add-backlog.md should mention /gsd-review-backlog for promoting items to active milestone',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── capture.md routing integrity ────────────────────────────────────────────
|
|
|
|
describe('#3135: capture.md correctly routes --backlog to add-backlog workflow', () => {
|
|
function executionContextIncludes(body) {
|
|
const blocks = [
|
|
...body.matchAll(/<execution_context(?:_extended)?>([\s\S]*?)<\/execution_context(?:_extended)?>/g),
|
|
].map((m) => m[1]);
|
|
const targets = [];
|
|
for (const blk of blocks) {
|
|
for (const line of blk.split('\n')) {
|
|
const t = line.trim();
|
|
if (!t.startsWith('@')) continue;
|
|
const rel = t.replace(/^@~?\/?(?:\.claude\/)?(?:gsd-core\/)?/, '');
|
|
targets.push(rel);
|
|
}
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
test('capture.md execution_context @-includes add-backlog.md', () => {
|
|
const body = fs.readFileSync(path.join(COMMANDS_DIR, 'capture.md'), 'utf8');
|
|
const targets = executionContextIncludes(body);
|
|
assert.ok(
|
|
targets.some((t) => /(^|\/)workflows\/add-backlog\.md$/.test(t)),
|
|
`capture.md execution_context must @-include workflows/add-backlog.md; got: ${JSON.stringify(targets)}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
});
|
|
}
|