Files
msd-core/tests/docs-update.test.cjs
Daniel Einspanjer f0ff23635e fix(#2602): discover project-local Codex agents (#2623)
* fix(#2602): discover project-local Codex agents

- Select an existing local Codex agents directory before global fallback
- Prove init reports the canonical local installation through compiled CJS

* test(#2602): lock Codex agent precedence

- Cover override, local authority, global fallback, and runtime compatibility
- Exercise installed state through the compiled resolver

* fix(#2602): resolve local Codex agent skills

- Pass the canonical project root to the non-Claude persona fallback
- Cover nested-Codex fallback and Claude compatibility through the CLI

* test(#2602): cover local Codex validation status

- Assert emitted validate and health commands use the project-local install
- Preserve empty local-directory authority beside complete global agents

* fix(#2602): align validation with local Codex discovery

- Pass the resolved runtime and project root to health W010
- Resolve the validate-agents runtime before checking installation status

* test(#2602): cover local Codex docs status

- Assert docs-init reports an authoritative empty local install as unhealthy

* fix(#2602): align docs with local Codex discovery

- Pass the resolved runtime and canonical project root to the shared agent checker

* fix(#2602): honor agent-skills runtime override

- Resolve agent-skills fallback runtime through the canonical project resolver
- Cover conflicting config and GSD_RUNTIME values through the emitted CLI

* fix(#2602): ignore non-directory local agents paths

- Treat only a local Codex agents directory as authoritative
- Cover regular-file fallback through the emitted install checker

* chore(#2602): add changelog fragment

- record the user-visible local Codex agent discovery fix for PR #2623

* fix(#2602): align local agent discovery with runtime policy

- Resolve Codex's local config directory through the canonical runtime policy
- Use test-managed cleanup for local-agent discovery coverage

* fix(#2602): discover local agents across runtimes

- Prefer manifest-backed project-local installs for non-Claude runtimes
- Respect runtime-specific local install roots and preserve global fallback behavior
- Cover native, partial, cross-runtime, and project-root local discovery

* fix(#2602): preserve agent discovery fallback

- Fall back globally when local-install probes fail
- Document and test symlink rejection
- Align the changeset with repository format

* fix(#2602): reuse local directory policy

- Resolve runtimes without local config through the canonical sentinel
- Document the manifest gate and refresh the context index

---------

Co-authored-by: Daniel E. <daniel.e@teachingstrategies.com>
Co-authored-by: Rezolv <dave@sienkowski.com>
2026-07-31 21:20:46 -04:00

513 lines
20 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');
const MODEL_PROFILES = require('../gsd-core/bin/lib/model-profiles.cjs').MODEL_PROFILES;
const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES);
function createCompleteCodexAgents(projectRoot) {
const agentsDir = path.join(projectRoot, '.codex', 'agents');
const files = {};
fs.mkdirSync(agentsDir, { recursive: true });
for (const name of EXPECTED_AGENTS) {
fs.writeFileSync(path.join(agentsDir, `${name}.md`), `# ${name}\n`);
fs.writeFileSync(path.join(agentsDir, `${name}.toml`), `name = "${name}"\n`);
files[`agents/${name}.md`] = {};
files[`agents/${name}.toml`] = {};
}
fs.writeFileSync(path.join(projectRoot, '.codex', 'gsd-file-manifest.json'), JSON.stringify({ files }));
}
// ─── 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);
});
});
describe('docs-init Codex project-local agent status', () => {
let tmpDir;
let globalHome;
beforeEach(() => {
tmpDir = createTempProject();
globalHome = path.join(tmpDir, 'global-codex');
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ runtime: 'codex' }));
fs.mkdirSync(path.join(globalHome, 'agents'), { recursive: true });
for (const name of EXPECTED_AGENTS) {
fs.writeFileSync(path.join(globalHome, 'agents', `${name}.md`), `# global-${name}\n`);
}
});
afterEach(() => {
cleanup(tmpDir);
});
test('reports a complete local Codex installation instead of a global fixture', () => {
createCompleteCodexAgents(tmpDir);
const result = runGsdTools('docs-init --raw', tmpDir, { CODEX_HOME: globalHome });
assert.ok(result.success, `docs-init failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.project_root, fs.realpathSync(tmpDir));
assert.strictEqual(output.agents_installed, true);
assert.deepStrictEqual(output.missing_agents, []);
});
test('reports an empty local Codex directory as unhealthy even with global agents', () => {
fs.mkdirSync(path.join(tmpDir, '.codex', 'agents'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.codex', 'gsd-file-manifest.json'), JSON.stringify({ files: {} }));
const result = runGsdTools('docs-init --raw', tmpDir, { CODEX_HOME: globalHome });
assert.ok(result.success, `docs-init failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.agents_installed, false);
assert.deepStrictEqual(output.missing_agents, EXPECTED_AGENTS);
});
});
// ─── 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)}`,
);
});
});
});
}