test(#1973): consolidate 48 workflow regression tests into workflow-aspect suites
Fold 48 issue-named workflow-markdown regression files into the canonical test that owns each workflow aspect (execute-phase-*, plan-phase-*, quick-*, discuss-*, worktree-cleanup, secure-phase, verify, update, settings, etc.), across 32 existing suites. Verbatim block-scoped describe wrappers; 281 subtests conserved 1:1. No new test files. Host-env pre-check: only gsd-settings-advanced spawns CLI and it sets no GSD_WORKSTREAM/GSD_PROJECT value — no leak risk. Regenerates regression-name allowlist (222->181), ratchets file-count allowlist (verify 11->10), makes 30 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes 30 stale ids). lint:ci green. Part of epic #1969. Closes #1973. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,27 +15,15 @@
|
||||
"tests/autonomous-interactive.test.cjs :: source-text-is-the-product",
|
||||
"tests/autonomous-to-flag.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-131-release-tarball-smoke-explicit-home.test.cjs :: integration-test-input",
|
||||
"tests/bug-17-askuserquestion-option-cap.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-170-workflow-fallback-install-hint.test.cjs :: workflow markdown is shipped product text; this test validates fallback hint literals across all workflow files",
|
||||
"tests/bug-1891-file-resolution.test.cjs :: structural-implementation-guard",
|
||||
"tests/bug-211-launcher-home-fallback.test.cjs :: structural/behavioral regression for the ~/.claude fallback arm in",
|
||||
"tests/bug-2136-sh-hook-version.test.cjs :: structural-regression-guard",
|
||||
"tests/bug-214-phase-researcher-write-truncation-contract.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-214-writer-agents-write-truncation-contract.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-222-research-synthesizer-write-contract.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2346-agent-read-loop-guards.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2388-plan-phase-no-branch-rename.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2396-makefile-test-priority.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2399-commit-docs-plan-phase.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2410-stream-checkpoint-heartbeats.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2419-project-researcher-agent.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2421-planner-grep-gate-hygiene.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2470-update-md-claude-path.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2492-context-coverage-gate.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2502-insert-phase-state-update.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2516-inherit-model-execute-phase.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2543-gsd-slash-namespace.test.cjs :: structural-regression-guard",
|
||||
"tests/bug-2549-2550-2552-discuss-phase-context.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2559-stale-search-year.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2686-review-fix-worktree.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2772-gitmodules-path-intersection.test.cjs :: source-text-is-the-product",
|
||||
@@ -44,21 +32,14 @@
|
||||
"tests/bug-2839-review-fix-transactional-cleanup.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2973-profile-user-skills-path.test.cjs :: source-text-is-the-product. profile-user.md IS the",
|
||||
"tests/bug-2990-code-fixer-worktree-branch.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3096-ai-integration-phase-parallel-race.test.cjs :: reads product workflow markdown (ai-integration-phase.md) to verify structural ordering contract — not a source-grep test",
|
||||
"tests/bug-3097-3099-executor-worktree-path-safety.test.cjs :: reads markdown product files (gsd-executor.md, worktree-path-safety.md) to verify structural protocol — not source-grep",
|
||||
"tests/bug-3120-secure-phase-empty-register.test.cjs :: reads product workflow markdown (secure-phase.md) to verify structural guard contract — not a source-grep test",
|
||||
"tests/bug-3129-validate-commit-git-bypass.test.cjs :: reads hook shell script to verify delegation pattern — structural contract test, not source-grep",
|
||||
"tests/bug-3130-update-npx-robust-invocation.test.cjs :: reads product workflow markdown (update.md) to verify structural invocation contract — not a source-grep test",
|
||||
"tests/bug-3236-capture-seed-one-shot.test.cjs :: source-text-is-the-product — workflow and command .md files",
|
||||
"tests/bug-3290-intel-updater-layout-block.test.cjs :: source-text-is-the-product — agents/gsd-intel-updater.md IS",
|
||||
"tests/bug-33-settings-model-profile-adaptive.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3381-verify-work-workstream.test.cjs :: source-text-is-the-product — verify-work.md is a runtime workflow contract.",
|
||||
"tests/bug-3384-secondary-defects.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3430-planner-phase-contract.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3446-resume-continue-here-discovery.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3491-nested-git-worktree.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3516-reapply-patches-gsd-update-filter.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3521-quick-cleanup-cwd-pin.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs :: validates runtime CLI stdout/stderr warning behavior, not source grep",
|
||||
"tests/bug-3542-executor-git-stash-prohibition.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs :: source-text-is-the-product",
|
||||
@@ -71,20 +52,14 @@
|
||||
"tests/bug-3683-workflow-colon-namespace-leak.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3689-resume-glob-nomatch.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-378-update-check-scoped-name.test.cjs :: structural assertion on hook delegation; the behavior being",
|
||||
"tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3805-fast-md-log-to-state-schema.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-444-resolver-local-claude-install.test.cjs :: structural/behavioral regression for the repo-local .claude/ install",
|
||||
"tests/bug-571-doc-writer-fix-mode-edit-only.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-619-codebase-drift-gate-shim.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-621-plan-phase-gap-analysis-gsd-run.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-622-graphify-optional-graph-html.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-630-wave-cleanup-orchestrator-root.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-685-windowshide-spawn.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-687-agy-timeout.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-891-non-claude-runtime-home-fallback.test.cjs :: structural/behavioral regression for non-Claude runtime-home",
|
||||
"tests/bug-936-no-nested-spawner-wrap.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-947-hermes-gsd-prefix.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-patterns-reference.test.cjs :: source-text-is-the-product",
|
||||
"tests/chain-flag-plan-phase.test.cjs :: source-text-is-the-product",
|
||||
@@ -125,8 +100,6 @@
|
||||
"tests/edge-probe-spec-phase-contract.test.cjs :: runtime-contract-is-the-product — spec-phase.md Step 5.5 is the deployed workflow runtime contract under assertion",
|
||||
"tests/edit-phase.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-2380-sync-skills.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-2430-learnings-consumption.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-2433-todo-phase-linking.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-2500-codebase-mapper-arch-rich-format.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-2789-description-budget.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-2790-skill-consolidation.test.cjs :: source-text-is-the-product",
|
||||
@@ -136,7 +109,6 @@
|
||||
"tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — the hookEventName is",
|
||||
"tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — the stamp template token",
|
||||
"tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — the stdin-read and",
|
||||
"tests/enh-773-codex-exec-automation-flags.test.cjs :: source-text-is-the-product",
|
||||
"tests/eslint-rules.test.cjs :: <source-grep reason> must still error",
|
||||
"tests/eslint-rules.test.cjs :: pending migration",
|
||||
"tests/eslint-rules.test.cjs :: source-text-is-the-product",
|
||||
@@ -149,10 +121,8 @@
|
||||
"tests/execute-phase-worktree-artifacts.test.cjs :: source-text-is-the-product",
|
||||
"tests/explore-command.test.cjs :: source-text-is-the-product",
|
||||
"tests/extract-learnings.test.cjs :: source-text-is-the-product",
|
||||
"tests/feat-22-surfacing-docs.test.cjs :: docs-parity",
|
||||
"tests/feat-2840-issue-driven-orchestration-guide.test.cjs :: structural-IR parser for a docs guide. The .includes()",
|
||||
"tests/feat-3039-help-tiered.test.cjs :: source-text-is-the-product",
|
||||
"tests/fix-3722-execute-phase-human-needed-checkpoint.test.cjs :: source-text-is-the-product",
|
||||
"tests/forensics.test.cjs :: source-text-is-the-product",
|
||||
"tests/frontmatter-cli.test.cjs :: source-text-is-the-product",
|
||||
"tests/gates-taxonomy.test.cjs :: source-text-is-the-product",
|
||||
|
||||
@@ -1,42 +1,22 @@
|
||||
[
|
||||
"bug-131-release-tarball-smoke-explicit-home.test.cjs",
|
||||
"bug-1367-claude-local-flat-command-layout.test.cjs",
|
||||
"bug-17-askuserquestion-option-cap.test.cjs",
|
||||
"bug-170-workflow-fallback-install-hint.test.cjs",
|
||||
"bug-1834-sh-hooks-installed.test.cjs",
|
||||
"bug-1891-file-resolution.test.cjs",
|
||||
"bug-1974-context-exhaustion-record.test.cjs",
|
||||
"bug-2002-offer-next-context.test.cjs",
|
||||
"bug-2004-pr-branch-milestone.test.cjs",
|
||||
"bug-21-state-md-template-frontmatter.test.cjs",
|
||||
"bug-211-launcher-home-fallback.test.cjs",
|
||||
"bug-2136-sh-hook-version.test.cjs",
|
||||
"bug-214-phase-researcher-write-truncation-contract.test.cjs",
|
||||
"bug-214-writer-agents-write-truncation-contract.test.cjs",
|
||||
"bug-222-research-synthesizer-write-contract.test.cjs",
|
||||
"bug-2344-read-guard-claudecode-env.test.cjs",
|
||||
"bug-2346-agent-read-loop-guards.test.cjs",
|
||||
"bug-2351-intel-kilo-layout.test.cjs",
|
||||
"bug-2384-post-merge-deletion-audit.test.cjs",
|
||||
"bug-2388-plan-phase-no-branch-rename.test.cjs",
|
||||
"bug-2396-makefile-test-priority.test.cjs",
|
||||
"bug-2399-commit-docs-plan-phase.test.cjs",
|
||||
"bug-2410-stream-checkpoint-heartbeats.test.cjs",
|
||||
"bug-2419-project-researcher-agent.test.cjs",
|
||||
"bug-2421-planner-grep-gate-hygiene.test.cjs",
|
||||
"bug-2432-quick-plan-predispatch-commit.test.cjs",
|
||||
"bug-2451-context-monitor-over-report.test.cjs",
|
||||
"bug-2470-update-md-claude-path.test.cjs",
|
||||
"bug-2492-context-coverage-gate.test.cjs",
|
||||
"bug-2501-resurrection-detection.test.cjs",
|
||||
"bug-2502-insert-phase-state-update.test.cjs",
|
||||
"bug-2504-uat-foundation-phases.test.cjs",
|
||||
"bug-2506-settings-profile-nonclaude-warning.test.cjs",
|
||||
"bug-2516-inherit-model-execute-phase.test.cjs",
|
||||
"bug-2520-read-guard-hook-subprocess-env.test.cjs",
|
||||
"bug-2523-quick-deferred-items.test.cjs",
|
||||
"bug-2543-gsd-slash-namespace.test.cjs",
|
||||
"bug-2549-2550-2552-discuss-phase-context.test.cjs",
|
||||
"bug-2559-stale-search-year.test.cjs",
|
||||
"bug-260-worktree-path-guard.test.cjs",
|
||||
"bug-261-worktree-force-add-guard.test.cjs",
|
||||
@@ -44,13 +24,10 @@
|
||||
"bug-2772-gitmodules-path-intersection.test.cjs",
|
||||
"bug-2784-update-cache-clear-path.test.cjs",
|
||||
"bug-2808-skill-hyphen-name.test.cjs",
|
||||
"bug-2838-summary-rescue-gitignored-planning.test.cjs",
|
||||
"bug-2839-review-fix-transactional-cleanup.test.cjs",
|
||||
"bug-2866-codex-strip-no-trailing-newline.test.cjs",
|
||||
"bug-2876-skill-frontmatter-quote.test.cjs",
|
||||
"bug-2912-progress-context-authority.test.cjs",
|
||||
"bug-2916-handle-branching-default-base.test.cjs",
|
||||
"bug-2950-stale-command-refs.test.cjs",
|
||||
"bug-2969-verify-reapply-patches.test.cjs",
|
||||
"bug-2973-profile-user-skills-path.test.cjs",
|
||||
"bug-2990-code-fixer-worktree-branch.test.cjs",
|
||||
@@ -58,24 +35,14 @@
|
||||
"bug-2994-verify-reapply-patches-installed-path.test.cjs",
|
||||
"bug-2995-post-install-script-paths.test.cjs",
|
||||
"bug-3019-help-passthrough.test.cjs",
|
||||
"bug-3050-update-backup-eacces-nonfatal.test.cjs",
|
||||
"bug-3054-stale-gsd-next-references.test.cjs",
|
||||
"bug-3072-optional-sketch-findings-guard.test.cjs",
|
||||
"bug-3083-resume-route-clear.test.cjs",
|
||||
"bug-3087-planner-directive-language.test.cjs",
|
||||
"bug-3096-ai-integration-phase-parallel-race.test.cjs",
|
||||
"bug-3097-3099-executor-worktree-path-safety.test.cjs",
|
||||
"bug-3120-secure-phase-empty-register.test.cjs",
|
||||
"bug-3129-validate-commit-git-bypass.test.cjs",
|
||||
"bug-3130-update-npx-robust-invocation.test.cjs",
|
||||
"bug-3195-quick-resurrection-guard.test.cjs",
|
||||
"bug-3236-capture-seed-one-shot.test.cjs",
|
||||
"bug-3290-intel-updater-layout-block.test.cjs",
|
||||
"bug-33-settings-model-profile-adaptive.test.cjs",
|
||||
"bug-3320-planner-deep-work-rules.test.cjs",
|
||||
"bug-3321-verifier-runs-probes.test.cjs",
|
||||
"bug-3357-codex-legacy-hooks-json-migration.test.cjs",
|
||||
"bug-3381-verify-work-workstream.test.cjs",
|
||||
"bug-3384-secondary-defects.test.cjs",
|
||||
"bug-3430-planner-phase-contract.test.cjs",
|
||||
"bug-3442-codex-legacy-hooks-json-migration.test.cjs",
|
||||
@@ -83,8 +50,6 @@
|
||||
"bug-3446-resume-continue-here-discovery.test.cjs",
|
||||
"bug-3491-nested-git-worktree.test.cjs",
|
||||
"bug-3509-path-spaces.test.cjs",
|
||||
"bug-3516-reapply-patches-gsd-update-filter.test.cjs",
|
||||
"bug-3521-quick-cleanup-cwd-pin.test.cjs",
|
||||
"bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs",
|
||||
"bug-3541-installer-migration-prompt-user-resolution.test.cjs",
|
||||
"bug-3542-executor-git-stash-prohibition.test.cjs",
|
||||
@@ -102,22 +67,16 @@
|
||||
"bug-3689-resume-glob-nomatch.test.cjs",
|
||||
"bug-3735-profiles-core-includes-surface.test.cjs",
|
||||
"bug-378-update-check-scoped-name.test.cjs",
|
||||
"bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs",
|
||||
"bug-3805-fast-md-log-to-state-schema.test.cjs",
|
||||
"bug-3810-no-gsd-sdk-runtime-refs.test.cjs",
|
||||
"bug-444-resolver-local-claude-install.test.cjs",
|
||||
"bug-571-doc-writer-fix-mode-edit-only.test.cjs",
|
||||
"bug-619-codebase-drift-gate-shim.test.cjs",
|
||||
"bug-621-plan-phase-gap-analysis-gsd-run.test.cjs",
|
||||
"bug-622-graphify-optional-graph-html.test.cjs",
|
||||
"bug-630-wave-cleanup-orchestrator-root.test.cjs",
|
||||
"bug-637-workflow-no-hardcoded-home-tool.test.cjs",
|
||||
"bug-641-files-from-suite-token.test.cjs",
|
||||
"bug-685-windowshide-spawn.test.cjs",
|
||||
"bug-687-agy-timeout.test.cjs",
|
||||
"bug-891-non-claude-runtime-home-fallback.test.cjs",
|
||||
"bug-925-context-monitor-hook-event-name.test.cjs",
|
||||
"bug-936-no-nested-spawner-wrap.test.cjs",
|
||||
"bug-941-managed-hooks-registry-manifest.test.cjs",
|
||||
"bug-947-hermes-gsd-prefix.test.cjs",
|
||||
"bug-969-test-infra-flake-hardening.test.cjs"
|
||||
|
||||
@@ -88,7 +88,6 @@
|
||||
"files": [
|
||||
"bug-2969-verify-reapply-patches.test.cjs",
|
||||
"bug-2994-verify-reapply-patches-installed-path.test.cjs",
|
||||
"bug-3381-verify-work-workstream.test.cjs",
|
||||
"bug-3657-verify-reapply-patches-pristine-drift.test.cjs",
|
||||
"verify-health.test.cjs",
|
||||
"verify-mvp-uat.test.cjs",
|
||||
|
||||
@@ -86,3 +86,116 @@ describe('AskUserQuestion text-mode fallback (#2012)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-17-askuserquestion-option-cap.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-17-askuserquestion-option-cap (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (see #17)
|
||||
// Workflow markdown is the shipped runtime contract; validating its AskUserQuestion
|
||||
// option limits is a behavioral guard, not an implementation-detail assertion.
|
||||
|
||||
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, '..', 'gsd-core', 'workflows');
|
||||
const ASK_USER_QUESTION_OPTION_CAP = 4;
|
||||
|
||||
function walkMarkdownFiles(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkMarkdownFiles(full, out);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && full.endsWith('.md')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findBalancedClose(text, openIndex, openCh, closeCh) {
|
||||
let depth = 0;
|
||||
for (let i = openIndex; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === openCh) depth++;
|
||||
else if (ch === closeCh) {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function getLineNumber(text, index) {
|
||||
return text.slice(0, index).split('\n').length;
|
||||
}
|
||||
|
||||
function collectOptionCapViolations(file, text) {
|
||||
const violations = [];
|
||||
const askRe = /\bAskUserQuestion\s*\(\s*\[/g;
|
||||
let askMatch;
|
||||
|
||||
while ((askMatch = askRe.exec(text)) !== null) {
|
||||
const askStart = askMatch.index;
|
||||
const arrayOpen = text.indexOf('[', askStart);
|
||||
if (arrayOpen === -1) continue;
|
||||
const arrayClose = findBalancedClose(text, arrayOpen, '[', ']');
|
||||
if (arrayClose === -1) continue;
|
||||
const askBlock = text.slice(arrayOpen, arrayClose + 1);
|
||||
const blockOffset = arrayOpen;
|
||||
|
||||
const optionsRe = /\boptions\s*:\s*\[/g;
|
||||
let optionsMatch;
|
||||
while ((optionsMatch = optionsRe.exec(askBlock)) !== null) {
|
||||
const openInBlock = optionsMatch.index + optionsMatch[0].length - 1;
|
||||
const closeInBlock = findBalancedClose(askBlock, openInBlock, '[', ']');
|
||||
if (closeInBlock === -1) continue;
|
||||
const optionsBody = askBlock.slice(openInBlock, closeInBlock + 1);
|
||||
const labelCount = (optionsBody.match(/\blabel\s*:\s*"[^"]+"/g) || []).length;
|
||||
if (labelCount > ASK_USER_QUESTION_OPTION_CAP) {
|
||||
const globalIdx = blockOffset + optionsMatch.index;
|
||||
violations.push({
|
||||
file,
|
||||
line: getLineNumber(text, globalIdx),
|
||||
count: labelCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
describe('bug #17: AskUserQuestion options arrays respect runtime cap', () => {
|
||||
test('every AskUserQuestion options array in workflows has at most 4 options', () => {
|
||||
const files = walkMarkdownFiles(ROOT);
|
||||
const violations = [];
|
||||
|
||||
for (const file of files) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
violations.push(...collectOptionCapViolations(file, text));
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
violations.length,
|
||||
0,
|
||||
[
|
||||
`Found ${violations.length} AskUserQuestion options-array cap violation(s).`,
|
||||
`Runtime cap is ${ASK_USER_QUESTION_OPTION_CAP} options per question.`,
|
||||
...violations.map((v) => {
|
||||
const rel = path.relative(path.join(__dirname, '..'), v.file);
|
||||
return ` ${rel}:${v.line} -> ${v.count} options`;
|
||||
}),
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow markdown is the shipped runtime contract; validating its AskUserQuestion
|
||||
// option limits is a behavioral guard, not an implementation-detail assertion.
|
||||
|
||||
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, '..', 'gsd-core', 'workflows');
|
||||
const ASK_USER_QUESTION_OPTION_CAP = 4;
|
||||
|
||||
function walkMarkdownFiles(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkMarkdownFiles(full, out);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && full.endsWith('.md')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findBalancedClose(text, openIndex, openCh, closeCh) {
|
||||
let depth = 0;
|
||||
for (let i = openIndex; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === openCh) depth++;
|
||||
else if (ch === closeCh) {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function getLineNumber(text, index) {
|
||||
return text.slice(0, index).split('\n').length;
|
||||
}
|
||||
|
||||
function collectOptionCapViolations(file, text) {
|
||||
const violations = [];
|
||||
const askRe = /\bAskUserQuestion\s*\(\s*\[/g;
|
||||
let askMatch;
|
||||
|
||||
while ((askMatch = askRe.exec(text)) !== null) {
|
||||
const askStart = askMatch.index;
|
||||
const arrayOpen = text.indexOf('[', askStart);
|
||||
if (arrayOpen === -1) continue;
|
||||
const arrayClose = findBalancedClose(text, arrayOpen, '[', ']');
|
||||
if (arrayClose === -1) continue;
|
||||
const askBlock = text.slice(arrayOpen, arrayClose + 1);
|
||||
const blockOffset = arrayOpen;
|
||||
|
||||
const optionsRe = /\boptions\s*:\s*\[/g;
|
||||
let optionsMatch;
|
||||
while ((optionsMatch = optionsRe.exec(askBlock)) !== null) {
|
||||
const openInBlock = optionsMatch.index + optionsMatch[0].length - 1;
|
||||
const closeInBlock = findBalancedClose(askBlock, openInBlock, '[', ']');
|
||||
if (closeInBlock === -1) continue;
|
||||
const optionsBody = askBlock.slice(openInBlock, closeInBlock + 1);
|
||||
const labelCount = (optionsBody.match(/\blabel\s*:\s*"[^"]+"/g) || []).length;
|
||||
if (labelCount > ASK_USER_QUESTION_OPTION_CAP) {
|
||||
const globalIdx = blockOffset + optionsMatch.index;
|
||||
violations.push({
|
||||
file,
|
||||
line: getLineNumber(text, globalIdx),
|
||||
count: labelCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
describe('bug #17: AskUserQuestion options arrays respect runtime cap', () => {
|
||||
test('every AskUserQuestion options array in workflows has at most 4 options', () => {
|
||||
const files = walkMarkdownFiles(ROOT);
|
||||
const violations = [];
|
||||
|
||||
for (const file of files) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
violations.push(...collectOptionCapViolations(file, text));
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
violations.length,
|
||||
0,
|
||||
[
|
||||
`Found ${violations.length} AskUserQuestion options-array cap violation(s).`,
|
||||
`Runtime cap is ${ASK_USER_QUESTION_OPTION_CAP} options per question.`,
|
||||
...violations.map((v) => {
|
||||
const rel = path.relative(path.join(__dirname, '..'), v.file);
|
||||
return ` ${rel}:${v.line} -> ${v.count} options`;
|
||||
}),
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
'use strict';
|
||||
// allow-test-rule: workflow markdown is shipped product text; this test validates fallback hint literals across all workflow files
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
const LEGACY_HINT = 'npx get-shit-done-cc@latest --claude --local';
|
||||
const CURRENT_HINT = 'npx -y @opengsd/gsd-core@latest --claude --local';
|
||||
|
||||
function findMarkdownFiles(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...findMarkdownFiles(full));
|
||||
else if (entry.isFile() && full.endsWith('.md')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('bug #170: workflow fallback hints do not reference get-shit-done-cc', () => {
|
||||
const files = findMarkdownFiles(WORKFLOWS_DIR);
|
||||
let legacyCount = 0;
|
||||
let currentCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
if (src.includes(LEGACY_HINT)) legacyCount += 1;
|
||||
if (src.includes(CURRENT_HINT)) currentCount += 1;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
legacyCount,
|
||||
0,
|
||||
`workflow fallback hints must not reference legacy package (${LEGACY_HINT})`
|
||||
);
|
||||
assert.ok(
|
||||
currentCount > 0,
|
||||
`expected at least one workflow fallback hint to use current package (${CURRENT_HINT})`
|
||||
);
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #2002
|
||||
*
|
||||
* offer_next in execute-phase.md must present conditional next steps
|
||||
* based on whether CONTEXT.md already exists for the next phase.
|
||||
* The previous flat list offered all options equally with no primary
|
||||
* recommendation, leaving agents without guidance on the correct first step.
|
||||
*
|
||||
* Fixed: offer_next now checks for {next}-CONTEXT.md in the phase directory.
|
||||
* - If CONTEXT.md is missing: primary suggestion is /gsd-discuss-phase
|
||||
* - If CONTEXT.md exists: primary suggestion is /gsd-plan-phase
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const workflowPath = path.resolve(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'
|
||||
);
|
||||
|
||||
describe('bug #2002: offer_next checks CONTEXT.md before suggesting next step', () => {
|
||||
let content;
|
||||
|
||||
// Read once — all tests share the same file content
|
||||
test('setup: workflow file is readable', () => {
|
||||
content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(content.length > 0, 'execute-phase.md must not be empty');
|
||||
});
|
||||
|
||||
test('offer_next section checks for CONTEXT.md existence', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// The workflow must check for CONTEXT.md in the next phase directory
|
||||
assert.ok(
|
||||
content.includes('CONTEXT.md'),
|
||||
'offer_next must reference CONTEXT.md to determine primary next step'
|
||||
);
|
||||
});
|
||||
|
||||
test('offer_next presents /gsd-discuss-phase when CONTEXT.md does not exist', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// Must have a conditional path where discuss-phase is the primary step
|
||||
// when CONTEXT.md is missing — look for proximity of "not exist"/"missing"/
|
||||
// "does not exist" and "gsd-discuss-phase" in the offer_next step
|
||||
const offerNextIdx = content.indexOf('offer_next');
|
||||
assert.ok(offerNextIdx !== -1, 'offer_next step must exist');
|
||||
|
||||
// Use 5000-char window — the step is ~60 lines of prose before the conditionals
|
||||
const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000);
|
||||
assert.ok(
|
||||
/CONTEXT\.md.*does not exist|CONTEXT\.md.*not.*exist|If CONTEXT\.md does/i.test(offerNextSection) ||
|
||||
/gsd-discuss-phase.*recommended|recommended.*gsd-discuss-phase/i.test(offerNextSection),
|
||||
'offer_next must present /gsd-discuss-phase as primary when CONTEXT.md does not exist'
|
||||
);
|
||||
});
|
||||
|
||||
test('offer_next presents /gsd-plan-phase when CONTEXT.md exists', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
const offerNextIdx = content.indexOf('offer_next');
|
||||
assert.ok(offerNextIdx !== -1, 'offer_next step must exist');
|
||||
|
||||
const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000);
|
||||
assert.ok(
|
||||
/CONTEXT\.md.*exists|exists.*CONTEXT\.md|If CONTEXT\.md/i.test(offerNextSection),
|
||||
'offer_next must present /gsd-plan-phase as primary when CONTEXT.md exists'
|
||||
);
|
||||
});
|
||||
|
||||
test('offer_next section contains at least one conditional guard before listing commands', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
const offerNextIdx = content.indexOf('offer_next');
|
||||
assert.ok(offerNextIdx !== -1, 'offer_next step must exist');
|
||||
|
||||
const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000);
|
||||
|
||||
// The fixed version must contain at least one "If CONTEXT.md" conditional
|
||||
// guard before presenting command options. The old flat list had no guard.
|
||||
assert.ok(
|
||||
/If CONTEXT\.md/i.test(offerNextSection),
|
||||
'offer_next must contain at least one "If CONTEXT.md" conditional guard'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #2004
|
||||
*
|
||||
* /gsd-pr-branch must not exclude milestone archive and structural planning
|
||||
* commits. The previous implementation filtered ALL .planning/-only commits,
|
||||
* including STATE.md, ROADMAP.md, MILESTONES.md, and milestones/** updates
|
||||
* that are needed to preserve repository planning state after a merge.
|
||||
*
|
||||
* Fixed: pr-branch.md now distinguishes:
|
||||
* - Transient planning commits (phase plans, summaries, research, context) → EXCLUDE
|
||||
* - Structural planning commits (STATE.md, ROADMAP.md, MILESTONES.md,
|
||||
* PROJECT.md, milestones/**) → INCLUDE
|
||||
* - Code commits (any non-.planning/ file) → INCLUDE
|
||||
* - Mixed commits (code + planning) → INCLUDE
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const workflowPath = path.resolve(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'pr-branch.md'
|
||||
);
|
||||
|
||||
describe('bug #2004: pr-branch preserves structural planning commits', () => {
|
||||
let content;
|
||||
|
||||
test('setup: pr-branch workflow is readable', () => {
|
||||
content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(content.length > 0, 'pr-branch.md must not be empty');
|
||||
});
|
||||
|
||||
test('workflow distinguishes structural vs transient planning commits', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// Must contain language distinguishing structural from transient/phase planning files
|
||||
assert.ok(
|
||||
/structural|milestone.*archive|STATE\.md.*INCLUDE|preserve.*milestone|milestone.*preserve/i.test(content),
|
||||
'pr-branch.md must distinguish structural planning commits from transient ones'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow lists STATE.md and ROADMAP.md as structural files to preserve', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('STATE.md'),
|
||||
'pr-branch.md must reference STATE.md as a structural file to preserve'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('ROADMAP.md'),
|
||||
'pr-branch.md must reference ROADMAP.md as a structural file to preserve'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow lists MILESTONES.md or milestones/ as structural files to preserve', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('MILESTONES.md') || content.includes('milestones/'),
|
||||
'pr-branch.md must reference MILESTONES.md or milestones/ as structural files to preserve'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow has four commit categories (code, planning-only, mixed, structural)', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// Must have at least a "structural" or "milestone" category beyond the original three
|
||||
assert.ok(
|
||||
/structural.*commit|milestone.*commit|commit.*structural|commit.*milestone/i.test(content) ||
|
||||
/INCLUDE.*STATE\.md|STATE\.md.*INCLUDE/i.test(content),
|
||||
'pr-branch.md must classify structural planning commits as INCLUDE'
|
||||
);
|
||||
});
|
||||
|
||||
test('create_pr_branch step does not rm -r --cached all of .planning/', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// The original bug: `git rm -r --cached .planning/` nuked structural files.
|
||||
// The fix must either remove this wholesale rm or scope it to transient dirs.
|
||||
// Acceptable: narrowed rm targeting only phase/, quick/, research/, etc.
|
||||
// Not acceptable: `git rm -r --cached .planning/` with no scoping.
|
||||
const hasUnscoped = /git rm -r --cached \.planning\/(?!\*)?(?!phases|quick|research|threads|todos|debug|seeds|ui-reviews|codebase)/
|
||||
.test(content);
|
||||
assert.ok(
|
||||
!hasUnscoped,
|
||||
'create_pr_branch must not use unscoped "git rm -r --cached .planning/" — scope to transient subdirectories only'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SYNTHESIZER_PATH = path.join(REPO_ROOT, 'agents', 'gsd-research-synthesizer.md');
|
||||
|
||||
function readSynthesizerPrompt() {
|
||||
return fs.readFileSync(SYNTHESIZER_PATH, 'utf8');
|
||||
}
|
||||
|
||||
describe('bug #222: research synthesizer must write SUMMARY.md via Write tool', () => {
|
||||
test('step 6 has explicit hard-rule block forbidding return-message content fallback', () => {
|
||||
const prompt = readSynthesizerPrompt();
|
||||
|
||||
assert.match(
|
||||
prompt,
|
||||
/canonical output of this agent[\s\S]*existing on disk after you return/i,
|
||||
'Step 6 must define SUMMARY.md-on-disk as canonical output.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Hard rules \(must follow\):/i,
|
||||
'Step 6 must contain explicit hard rules block.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Use the `Write` tool[\s\S]*there are no restrictions/i,
|
||||
'Rule 1 must force Write tool usage and reject hallucinated restrictions.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Do NOT return the SUMMARY\.md content in your response/i,
|
||||
'Rule 2 must forbid returning SUMMARY content in the response body.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Do NOT ask permission to write/i,
|
||||
'Rule 3 must forbid write-permission asks for this agent.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Do NOT use `Bash\(cat << 'EOF'\)` or heredoc/i,
|
||||
'Rule 4 must forbid heredoc/Bash file creation fallback.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/If the Write tool errors[\s\S]*Do not silently fall back to returning content/i,
|
||||
'Rule 5 must force explicit error reporting for Write failures.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #222 recurrence: orchestrator self-heals when synthesizer returns SUMMARY.md inline', () => {
|
||||
const WORKFLOWS = [
|
||||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'new-project.md'),
|
||||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'new-milestone.md'),
|
||||
];
|
||||
|
||||
for (const wf of WORKFLOWS) {
|
||||
const name = path.basename(wf);
|
||||
|
||||
test(`${name} has the #222 synthesizer SUMMARY.md self-heal guard`, () => {
|
||||
const text = fs.readFileSync(wf, 'utf8');
|
||||
|
||||
// Marker tying the guard to the issue
|
||||
assert.match(text, /#222[^\n]*self-heal|self-heal[^\n]*#222/i,
|
||||
`${name} must contain a #222-tagged self-heal guard after the synthesizer returns.`);
|
||||
|
||||
// Verifies the file exists AND is substantive/non-empty
|
||||
assert.match(text, /SUMMARY\.md[\s\S]{0,120}?(non-empty|substantive|exists)/i,
|
||||
`${name} must verify .planning/research/SUMMARY.md exists AND is substantive — non-empty.`);
|
||||
|
||||
// Truncation/validator guard: references the continuation sentinel OR the verify-summary CLI
|
||||
assert.match(text, /gsd:write-continue|verify-summary/i,
|
||||
`${name} must guard against truncated/invalid SUMMARY.md (sentinel or verify-summary).`);
|
||||
|
||||
// Self-heal must commit ALL research artifacts, not just SUMMARY.md
|
||||
assert.match(text, /--files \.planning\/research\//,
|
||||
`${name} self-heal must commit ALL research artifacts, not just SUMMARY.md.`);
|
||||
|
||||
// Persists inline-returned document via Write rather than trusting the agent
|
||||
assert.match(text, /returned[\s\S]{0,200}?document[\s\S]{0,200}?Write tool/i,
|
||||
`${name} must instruct the orchestrator to persist inline-returned document with the Write tool.`);
|
||||
|
||||
// Must not proceed to roadmapper against a missing or incomplete SUMMARY.md
|
||||
assert.match(text, /gsd-roadmapper[\s\S]{0,200}?(missing|incomplete|do NOT)/i,
|
||||
`${name} must block spawning gsd-roadmapper when SUMMARY.md is missing or incomplete.`);
|
||||
|
||||
// Must name the FULL SUMMARY template markers so the orchestrator persists the real
|
||||
// document, not the brief structured return (resolves the HIGH finding).
|
||||
assert.match(text, /# Project Research Summary[\s\S]{0,260}?## Sources/,
|
||||
`${name}: self-heal must name the full SUMMARY.md template markers (# Project Research Summary … ## Sources).`);
|
||||
// Must reference the brief confirmation marker it must NOT mistake for file content.
|
||||
assert.match(text, /## SYNTHESIS COMPLETE/,
|
||||
`${name}: self-heal must distinguish the brief ## SYNTHESIS COMPLETE confirmation from the real document.`);
|
||||
});
|
||||
|
||||
test(`${name} runs the #222 self-heal AFTER the synthesizer and BEFORE gsd-roadmapper`, () => {
|
||||
const text = fs.readFileSync(wf, 'utf8');
|
||||
const synthIdx = text.indexOf('subagent_type="gsd-research-synthesizer"');
|
||||
const healIdx = text.indexOf('Synthesizer output self-heal (#222)');
|
||||
const roadIdx = text.indexOf('subagent_type="gsd-roadmapper"');
|
||||
assert.ok(synthIdx >= 0, `${name}: synthesizer dispatch not found`);
|
||||
assert.ok(healIdx >= 0, `${name}: #222 self-heal block not found`);
|
||||
assert.ok(roadIdx >= 0, `${name}: gsd-roadmapper dispatch not found`);
|
||||
assert.ok(healIdx > synthIdx, `${name}: self-heal must come AFTER the synthesizer dispatch`);
|
||||
assert.ok(roadIdx > healIdx, `${name}: self-heal must come BEFORE the gsd-roadmapper dispatch`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for #2384.
|
||||
*
|
||||
* During execute-phase, the orchestrator merges per-plan worktree branches into
|
||||
* main. The pre-merge deletion check (git diff --diff-filter=D HEAD...WT_BRANCH)
|
||||
* only catches files deleted on the worktree branch. A post-merge audit is also
|
||||
* required to catch deletions that made it into the merge commit (e.g., files
|
||||
* that were in the common ancestor but deleted by the merged worktree) and to
|
||||
* provide a revert safety net.
|
||||
*
|
||||
* After #3797: execute-phase.md delegates worktree cleanup to the SDK's
|
||||
* worktree.cleanup-wave command, which implements pre-merge deletion checks
|
||||
* (diff --diff-filter=D) internally via executeWorktreeWaveCleanupPlan.
|
||||
* The manual post-merge shell audit (MERGE_DEL_COUNT, git reset --hard) has
|
||||
* been removed from the workflow — it was part of the SDK-absence fallback.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const EXECUTE_PHASE = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'
|
||||
);
|
||||
|
||||
/**
|
||||
* Parse execute-phase.md into a structured contract object.
|
||||
* Returns typed boolean fields so tests can assert on structure
|
||||
* rather than raw text.
|
||||
*/
|
||||
function parseExecutePhaseContract(filePath) {
|
||||
const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/);
|
||||
return {
|
||||
// Does the workflow call the worktree.cleanup-wave SDK command?
|
||||
delegatesToCleanupWave: lines.some(l => l.includes('worktree.cleanup-wave')),
|
||||
// Does the cleanup-wave invocation use || exit 1 (fail-closed)?
|
||||
cleanupWaveFailClosed: lines.some(
|
||||
l => /gsd_run query worktree\.cleanup-wave.*\|\| exit 1/.test(l),
|
||||
),
|
||||
// Does the workflow export/reference WAVE_WORKTREE_MANIFEST for the SDK?
|
||||
passesWaveManifest: lines.some(l => l.includes('WAVE_WORKTREE_MANIFEST')),
|
||||
};
|
||||
}
|
||||
|
||||
describe('execute-phase.md — post-merge deletion audit (#2384)', () => {
|
||||
const contract = parseExecutePhaseContract(EXECUTE_PHASE);
|
||||
|
||||
test('execute-phase delegates to worktree.cleanup-wave (which handles deletion audit)', () => {
|
||||
// After #3797: worktree.cleanup-wave in worktree-safety.cjs performs
|
||||
// diff --diff-filter=D checks (blocks branches with deletions) before merge.
|
||||
// The workflow delegates to the SDK rather than duplicating the check inline.
|
||||
assert.ok(
|
||||
contract.delegatesToCleanupWave,
|
||||
'execute-phase.md must delegate to gsd_run query worktree.cleanup-wave (#2384/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase cleanup-wave uses || exit 1 (fail-closed for blocked deletions)', () => {
|
||||
// If worktree.cleanup-wave detects deletions, it exits 1 (blocked).
|
||||
// The || exit 1 in the workflow propagates that refusal rather than swallowing it.
|
||||
assert.ok(
|
||||
contract.cleanupWaveFailClosed,
|
||||
'execute-phase.md must use || exit 1 so deletion-blocked cleanups surface to the orchestrator',
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase still has pre-merge deletion check (via guard before worktree.cleanup-wave)', () => {
|
||||
// The primary deletion guard is now in worktree-safety.cjs (SDK).
|
||||
// The workflow must still enforce WAVE_WORKTREE_MANIFEST so the SDK
|
||||
// has the info it needs to validate branches.
|
||||
assert.ok(
|
||||
contract.passesWaveManifest,
|
||||
'execute-phase.md must pass WAVE_WORKTREE_MANIFEST to worktree.cleanup-wave',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2388: plan-phase silently renames feature branch
|
||||
* when phase slug has changed since the branch was created.
|
||||
*
|
||||
* Fix: plan-phase.md must include an explicit instruction not to create,
|
||||
* rename, or switch git branches during the planning workflow.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md');
|
||||
|
||||
describe('bug-2388: plan-phase must not rename or create git branches', () => {
|
||||
test('plan-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(PLAN_PHASE_PATH), 'plan-phase.md should exist');
|
||||
});
|
||||
|
||||
test('plan-phase.md contains explicit no-branch-rename instruction', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Must say "do not" and mention branch in the context of phase slug/rename
|
||||
const hasBranchGuard = (
|
||||
/do not.{0,80}branch/i.test(content) ||
|
||||
/branch.{0,80}do not/i.test(content) ||
|
||||
/NEVER.{0,80}branch/i.test(content) ||
|
||||
/branch.{0,80}NEVER/i.test(content)
|
||||
);
|
||||
assert.ok(
|
||||
hasBranchGuard,
|
||||
'plan-phase.md must include an explicit instruction not to create or rename git branches'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md mentions phase rename does not affect branch name', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Should explain that a phase rename in ROADMAP.md is plan-level, not git-level
|
||||
const hasPlanLevelExplanation = (
|
||||
content.includes('phase rename') ||
|
||||
content.includes('phase_slug') ||
|
||||
content.includes('branch identity') ||
|
||||
content.includes('branch name')
|
||||
);
|
||||
assert.ok(
|
||||
hasPlanLevelExplanation,
|
||||
'plan-phase.md should clarify that phase slug changes do not change the git branch'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md does not contain git checkout -b instruction', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// The workflow should not instruct the LLM to run git checkout -b
|
||||
assert.ok(
|
||||
!content.includes('git checkout -b'),
|
||||
'plan-phase.md must not instruct LLM to create a new branch via git checkout -b'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2396: hardcoded host-level test commands bypass
|
||||
* container-only project Makefiles.
|
||||
*
|
||||
* Fix: execute-phase.md, verify-phase.md, and audit-fix.md must check for
|
||||
* Makefile with a test target (and other wrappers) before falling through
|
||||
* to hardcoded language-sniffed commands.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const EXECUTE_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
const VERIFY_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md');
|
||||
const AUDIT_FIX_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'audit-fix.md');
|
||||
|
||||
function assertMakefileCheckBeforeNpmTest(filePath, label) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// Must check for Makefile with test target
|
||||
const hasMakefileCheck = /Makefile.*grep.*test:|grep.*test:.*Makefile/s.test(content) ||
|
||||
(content.includes('Makefile') && content.includes('"^test:"'));
|
||||
assert.ok(
|
||||
hasMakefileCheck,
|
||||
`${label}: must check for Makefile with test: target before falling through to hardcoded commands`
|
||||
);
|
||||
|
||||
// make test must appear before npm test in the file
|
||||
const makeTestIdx = content.indexOf('make test');
|
||||
const npmTestIdx = content.indexOf('npm test');
|
||||
assert.ok(makeTestIdx !== -1, `${label}: must contain "make test"`);
|
||||
assert.ok(npmTestIdx !== -1, `${label}: must still contain "npm test" as fallback`);
|
||||
assert.ok(
|
||||
makeTestIdx < npmTestIdx,
|
||||
`${label}: "make test" must appear before "npm test" (Makefile takes priority)`
|
||||
);
|
||||
}
|
||||
|
||||
function assertConfigGetBeforeMakefile(filePath, label) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
// Must check workflow.test_command config before Makefile sniff.
|
||||
// Verify within each bash code block: the workflow.test_command lookup
|
||||
// appears before the Makefile grep in the same block.
|
||||
assert.ok(
|
||||
content.includes('workflow.test_command'),
|
||||
`${label}: must check workflow.test_command config before Makefile/language sniff`
|
||||
);
|
||||
|
||||
// Extract bash blocks to check ordering within each block.
|
||||
// Use the actual Makefile test ([ -f "Makefile" ]) not just the word "Makefile"
|
||||
// (which appears in comments before the config-get call).
|
||||
const bashBlockRe = /```bash([\s\S]*?)```/g;
|
||||
let match;
|
||||
let anyBlockCorrectlyOrdered = false;
|
||||
while ((match = bashBlockRe.exec(content)) !== null) {
|
||||
const block = match[1];
|
||||
if (block.includes('workflow.test_command') && block.includes('[ -f "Makefile"')) {
|
||||
const configIdx = block.indexOf('workflow.test_command');
|
||||
const makefileIdx = block.indexOf('[ -f "Makefile"');
|
||||
if (configIdx < makefileIdx) {
|
||||
anyBlockCorrectlyOrdered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
anyBlockCorrectlyOrdered,
|
||||
`${label}: within a bash block, workflow.test_command config check must appear before Makefile test ([ -f "Makefile" ])`
|
||||
);
|
||||
}
|
||||
|
||||
describe('bug-2396: Makefile test target must take priority over hardcoded commands', () => {
|
||||
test('execute-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(EXECUTE_PHASE_PATH), 'execute-phase.md should exist');
|
||||
});
|
||||
|
||||
test('verify-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(VERIFY_PHASE_PATH), 'verify-phase.md should exist');
|
||||
});
|
||||
|
||||
test('audit-fix.md exists', () => {
|
||||
assert.ok(fs.existsSync(AUDIT_FIX_PATH), 'audit-fix.md should exist');
|
||||
});
|
||||
|
||||
test('execute-phase.md: Makefile check precedes npm test (post-merge gate)', () => {
|
||||
assertMakefileCheckBeforeNpmTest(EXECUTE_PHASE_PATH, 'execute-phase.md');
|
||||
});
|
||||
|
||||
test('verify-phase.md: Makefile check precedes npm test', () => {
|
||||
assertMakefileCheckBeforeNpmTest(VERIFY_PHASE_PATH, 'verify-phase.md');
|
||||
});
|
||||
|
||||
test('audit-fix.md: Makefile check precedes npm test', () => {
|
||||
assertMakefileCheckBeforeNpmTest(AUDIT_FIX_PATH, 'audit-fix.md');
|
||||
});
|
||||
|
||||
test('execute-phase.md: workflow.test_command config checked first (within bash block)', () => {
|
||||
assertConfigGetBeforeMakefile(EXECUTE_PHASE_PATH, 'execute-phase.md');
|
||||
});
|
||||
|
||||
test('verify-phase.md: workflow.test_command config checked first (within bash block)', () => {
|
||||
assertConfigGetBeforeMakefile(VERIFY_PHASE_PATH, 'verify-phase.md');
|
||||
});
|
||||
|
||||
test('audit-fix.md: workflow.test_command config checked first (within bash block)', () => {
|
||||
assertConfigGetBeforeMakefile(AUDIT_FIX_PATH, 'audit-fix.md');
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Bug #2399: commit_docs:true is ignored in plan-phase
|
||||
*
|
||||
* The plan-phase workflow generates plan artifacts but never commits them even
|
||||
* when commit_docs is true. A step between 13b and 14 must commit the PLAN.md
|
||||
* files and updated STATE.md when commit_docs is set.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md');
|
||||
|
||||
describe('plan-phase commit_docs support (#2399)', () => {
|
||||
test('plan-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(PLAN_PHASE_PATH), 'gsd-core/workflows/plan-phase.md must exist');
|
||||
});
|
||||
|
||||
test('plan-phase.md has a commit step for plan artifacts', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Must contain a commit call that references PLAN.md files
|
||||
assert.ok(
|
||||
content.includes('PLAN.md') && content.includes('commit'),
|
||||
'plan-phase.md must include a commit step that references PLAN.md files'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md commit step is gated on commit_docs', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// The commit step must be conditional on commit_docs
|
||||
assert.ok(
|
||||
content.includes('commit_docs'),
|
||||
'plan-phase.md must reference commit_docs to gate the plan commit step'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md commit step references STATE.md', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Should commit STATE.md alongside PLAN.md files
|
||||
assert.ok(
|
||||
content.includes('STATE.md'),
|
||||
'plan-phase.md commit step should include STATE.md to capture planning completion state'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md has a step 13c that commits plan artifacts', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
const step13b = content.indexOf('## 13b.');
|
||||
const step14 = content.indexOf('## 14.');
|
||||
// Look for the step 13c section (or any commit step between 13b and 14)
|
||||
const step13c = content.indexOf('## 13c.');
|
||||
|
||||
assert.ok(step13b !== -1, '## 13b. section must exist');
|
||||
assert.ok(step14 !== -1, '## 14. section must exist');
|
||||
assert.ok(step13c !== -1, '## 13c. step must exist (commit plans step)');
|
||||
assert.ok(
|
||||
step13c > step13b && step13c < step14,
|
||||
`Step 13c (at ${step13c}) must appear between step 13b (at ${step13b}) and step 14 (at ${step14})`
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md uses gsd-sdk query commit for the plan commit', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Must use gsd-sdk query commit (not raw git) so commit_docs guard in gsd-tools is respected
|
||||
assert.ok(
|
||||
content.includes('gsd-sdk query commit') || content.includes('gsd-tools') || content.includes('gsd-sdk'),
|
||||
'plan-phase.md plan commit step must use gsd-sdk query commit (not raw git commit)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Bug #2410 — /gsd:manager background execute-phase Task fails with
|
||||
* "Stream idle timeout" on multi-plan phases.
|
||||
*
|
||||
* Fix: execute-phase.md instructs the orchestrator to emit `[checkpoint]`
|
||||
* heartbeat lines at every wave boundary AND every plan boundary so the
|
||||
* Claude API SSE stream never idles long enough to trigger the platform
|
||||
* timeout. This test validates the workflow contract that backs that fix.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase.md'
|
||||
);
|
||||
const COMMANDS_DOC_PATH = path.join(__dirname, '..', 'docs', 'COMMANDS.md');
|
||||
|
||||
describe('bug #2410: execute-phase emits checkpoint heartbeats', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
|
||||
test('workflow references the stream idle timeout symptom by name', () => {
|
||||
assert.ok(
|
||||
/Stream idle timeout/.test(workflow),
|
||||
'workflow should name the API error it is preventing'
|
||||
);
|
||||
assert.ok(
|
||||
workflow.includes('#2410'),
|
||||
'workflow should cite the tracking issue for future maintainers'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow defines a [checkpoint] heartbeat line format', () => {
|
||||
assert.ok(
|
||||
workflow.includes('[checkpoint]'),
|
||||
'workflow should document the [checkpoint] marker prefix'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a wave-start heartbeat (A: wave-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} starting/.test(workflow),
|
||||
'workflow should emit a wave-start [checkpoint] marker before spawning agents'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a wave-complete heartbeat (A: wave-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(workflow),
|
||||
'workflow should emit a wave-complete [checkpoint] marker after spot-checks'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a plan-start heartbeat (B: plan-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(workflow),
|
||||
'workflow should emit a plan-start [checkpoint] marker before each Task() dispatch'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a plan-complete heartbeat (B: plan-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(workflow),
|
||||
'workflow should emit a plan-complete [checkpoint] marker after executor returns'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow handles plan failure and checkpoint-gate heartbeats too', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} failed/.test(workflow),
|
||||
'workflow should emit a plan-failed [checkpoint] marker on executor error'
|
||||
);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} checkpoint/.test(workflow),
|
||||
'workflow should emit a heartbeat when a plan returns a human-gate checkpoint'
|
||||
);
|
||||
});
|
||||
|
||||
test('heartbeats include a monotonic plans-done counter', () => {
|
||||
// The {P}/{Q} counter lets grep-based recovery tools reconstruct progress
|
||||
// from a truncated transcript if the agent dies mid-phase.
|
||||
assert.ok(
|
||||
/\{P\}\/\{Q\} plans done/.test(workflow),
|
||||
'heartbeats should include a {P}/{Q} phase-wide completed-plan counter'
|
||||
);
|
||||
});
|
||||
|
||||
test('wave-start heartbeat precedes the "Describe what\'s being built" text', () => {
|
||||
const describeIdx = workflow.indexOf("Describe what's being built");
|
||||
const heartbeatIdx = workflow.indexOf(
|
||||
'[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} starting'
|
||||
);
|
||||
assert.ok(describeIdx !== -1, 'workflow should still have the describe step');
|
||||
assert.ok(heartbeatIdx !== -1, 'wave-start heartbeat template should be present');
|
||||
// The instruction to emit the heartbeat appears in step 2, which is the
|
||||
// step titled "Describe what's being built". The actual sentinel text we
|
||||
// look for is the inline literal template — it must be emitted BEFORE any
|
||||
// tool calls in that step.
|
||||
const step2 = workflow.slice(
|
||||
describeIdx,
|
||||
workflow.indexOf('3. **Spawn executor agents', describeIdx)
|
||||
);
|
||||
assert.ok(
|
||||
step2.includes('[checkpoint]'),
|
||||
'step 2 should instruct the orchestrator to emit a [checkpoint] heartbeat'
|
||||
);
|
||||
assert.ok(
|
||||
/before any further reasoning or spawning/i.test(step2) ||
|
||||
/before any tool call/i.test(step2) ||
|
||||
/no tool call/i.test(step2),
|
||||
'step 2 should make clear the heartbeat is an assistant-text line, not a tool call'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-start heartbeat is inside the spawn step', () => {
|
||||
const spawnIdx = workflow.indexOf('3. **Spawn executor agents');
|
||||
const waitIdx = workflow.indexOf('4. **Wait for all agents', spawnIdx);
|
||||
assert.ok(spawnIdx !== -1 && waitIdx !== -1, 'spawn and wait steps must exist');
|
||||
const step3 = workflow.slice(spawnIdx, waitIdx);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(step3),
|
||||
'plan-start heartbeat should be emitted inside step 3 (spawn executor agents)'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-complete and wave-complete heartbeats are inside the wait/report steps', () => {
|
||||
const waitIdx = workflow.indexOf('4. **Wait for all agents');
|
||||
const hookIdx = workflow.indexOf('5. **Post-wave hook validation', waitIdx);
|
||||
assert.ok(waitIdx !== -1 && hookIdx !== -1, 'wait + hook steps must exist');
|
||||
const step4 = workflow.slice(waitIdx, hookIdx);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(step4),
|
||||
'plan-complete heartbeat should be emitted in step 4 (wait for agents)'
|
||||
);
|
||||
|
||||
const reportIdx = workflow.indexOf('6. **Report completion');
|
||||
const failureIdx = workflow.indexOf('7. **Handle failures', reportIdx);
|
||||
assert.ok(reportIdx !== -1 && failureIdx !== -1, 'report + failure steps must exist');
|
||||
const step6 = workflow.slice(reportIdx, failureIdx);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(step6),
|
||||
'wave-complete heartbeat should be emitted in step 6 (report completion)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2410: checkpoint heartbeat format is user-documented', () => {
|
||||
const commandsDoc = fs.readFileSync(COMMANDS_DOC_PATH, 'utf-8');
|
||||
|
||||
test('COMMANDS.md documents the [checkpoint] format under /gsd-manager', () => {
|
||||
const managerIdx = commandsDoc.indexOf('### `/gsd-manager`');
|
||||
assert.ok(managerIdx !== -1, '/gsd-manager section should exist');
|
||||
const section = commandsDoc.slice(managerIdx, managerIdx + 4000);
|
||||
assert.ok(
|
||||
/\[checkpoint\]/.test(section),
|
||||
'COMMANDS.md /gsd-manager section should document [checkpoint] heartbeat markers'
|
||||
);
|
||||
assert.ok(
|
||||
/Stream idle timeout/i.test(section),
|
||||
'COMMANDS.md should explain what the heartbeats prevent'
|
||||
);
|
||||
assert.ok(
|
||||
/#2410/.test(section),
|
||||
'COMMANDS.md should reference the tracking issue'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Bug #2432: quick.md PLAN.md timing — worktree executor can't read PLAN.md
|
||||
*
|
||||
* The orchestrator must commit PLAN.md to the base branch BEFORE spawning the
|
||||
* worktree-isolated executor. Without this, the executor's first Read resolves
|
||||
* to a main-repo absolute path (not a worktree path), which primes CC's path
|
||||
* cache and causes subsequent Edit/Write calls to silently target the main repo
|
||||
* instead of the worktree (CC issue #36182 amplifier).
|
||||
*
|
||||
* Fix: Step 5.6 commits PLAN.md pre-dispatch when USE_WORKTREES is active.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const QUICK_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
describe('quick.md pre-dispatch PLAN.md commit (#2432)', () => {
|
||||
let content;
|
||||
|
||||
test('quick.md exists', () => {
|
||||
assert.ok(fs.existsSync(QUICK_MD), 'gsd-core/workflows/quick.md must exist');
|
||||
content = fs.readFileSync(QUICK_MD, 'utf-8');
|
||||
});
|
||||
|
||||
test('Step 5.6 exists between Step 5.5 and Step 6', () => {
|
||||
const step55 = content.indexOf('Step 5.5');
|
||||
const step56 = content.indexOf('Step 5.6');
|
||||
const step6 = content.indexOf('Step 6:');
|
||||
assert.ok(step55 !== -1, 'Step 5.5 must exist');
|
||||
assert.ok(step56 !== -1, 'Step 5.6 must exist');
|
||||
assert.ok(step6 !== -1, 'Step 6 must exist');
|
||||
assert.ok(step56 > step55, 'Step 5.6 must appear after Step 5.5');
|
||||
assert.ok(step56 < step6, 'Step 5.6 must appear before Step 6');
|
||||
});
|
||||
|
||||
test('Step 5.6 is gated on USE_WORKTREES', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
assert.ok(
|
||||
step56Block.includes('USE_WORKTREES'),
|
||||
'Step 5.6 must be gated on USE_WORKTREES — only commit pre-dispatch in worktree mode'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 5.6 is gated on commit_docs', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
assert.ok(
|
||||
step56Block.includes('commit_docs'),
|
||||
'Step 5.6 must respect commit_docs config — skip pre-dispatch commit when commit_docs is false'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 5.6 stages and commits PLAN.md', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
assert.ok(
|
||||
step56Block.includes('PLAN.md'),
|
||||
'Step 5.6 must reference PLAN.md in the pre-dispatch commit'
|
||||
);
|
||||
assert.ok(
|
||||
step56Block.includes('git add') || step56Block.includes('git commit'),
|
||||
'Step 5.6 must include git add/commit to stage and commit PLAN.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 5.6 uses --no-verify to avoid hook interference', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
assert.ok(
|
||||
step56Block.includes('--no-verify'),
|
||||
'Step 5.6 pre-dispatch commit must use --no-verify to avoid hook interference'
|
||||
);
|
||||
});
|
||||
|
||||
test('executor prompt references PLAN.md via relative (worktree-rooted) path', () => {
|
||||
// QUICK_DIR is always set to ".planning/quick/..." (relative) so ${QUICK_DIR}/...PLAN.md
|
||||
// resolves relative to the worktree root, not the main repo absolute path.
|
||||
// Verify the executor prompt uses QUICK_DIR variable (not a hardcoded absolute path).
|
||||
const executorTask = content.indexOf('subagent_type="gsd-executor"');
|
||||
assert.ok(executorTask !== -1, 'executor Task() spawn must exist');
|
||||
// Find the files_to_read block near the executor spawn
|
||||
const filesBlock = content.lastIndexOf('<files_to_read>', executorTask);
|
||||
const filesBlockEnd = content.indexOf('</files_to_read>', filesBlock);
|
||||
const filesContent = content.slice(filesBlock, filesBlockEnd);
|
||||
assert.ok(
|
||||
filesContent.includes('QUICK_DIR') || filesContent.includes('.planning/quick'),
|
||||
'executor files_to_read must reference PLAN.md via relative QUICK_DIR path'
|
||||
);
|
||||
assert.ok(
|
||||
!filesContent.match(/\/home\/|\/Users\/|\/root\//),
|
||||
'executor files_to_read must NOT contain hardcoded absolute paths'
|
||||
);
|
||||
});
|
||||
|
||||
test('#1265 records both parent base and plan commit for worktree dispatch', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
const step6Block = content.slice(step6Start, content.indexOf('After executor returns:', step6Start));
|
||||
|
||||
assert.ok(
|
||||
step56Block.includes('QUICK_PLAN_PARENT'),
|
||||
'quick worktree mode must record the pre-dispatch parent SHA before committing PLAN.md (#1265)'
|
||||
);
|
||||
assert.ok(
|
||||
step56Block.includes('QUICK_PLAN_COMMIT'),
|
||||
'quick worktree mode must record the PLAN.md commit SHA after the pre-dispatch commit (#1265)'
|
||||
);
|
||||
assert.ok(
|
||||
step6Block.includes('EXPECTED_BASE_ALTERNATE'),
|
||||
'executor guard embed must carry the alternate accepted base for parent-vs-plan worktree forks (#1265)'
|
||||
);
|
||||
});
|
||||
|
||||
test('#1265 executor materializes PLAN.md from the plan commit when worktree starts at parent', () => {
|
||||
const executorTask = content.indexOf('subagent_type="gsd-executor"');
|
||||
assert.ok(executorTask !== -1, 'executor Task() spawn must exist');
|
||||
const promptStart = content.lastIndexOf('prompt="', executorTask);
|
||||
const promptEnd = content.indexOf('subagent_type="gsd-executor"', promptStart);
|
||||
const executorPrompt = content.slice(promptStart, promptEnd);
|
||||
|
||||
assert.ok(
|
||||
executorPrompt.includes('git show') && executorPrompt.includes('QUICK_PLAN_COMMIT'),
|
||||
'executor prompt must materialize PLAN.md from git show <plan-commit>:<plan-path> when absent (#1265)'
|
||||
);
|
||||
assert.ok(
|
||||
executorPrompt.indexOf('git show') < executorPrompt.indexOf('<files_to_read>'),
|
||||
'PLAN.md materialization must be a first action before files_to_read can prime paths (#1265)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
'use strict';
|
||||
|
||||
|
||||
/**
|
||||
* Regression test for #2470.
|
||||
*
|
||||
* update.md is installed into every runtime directory including .gemini, .codex,
|
||||
* .opencode, etc. The installer's scanForLeakedPaths() uses the regex
|
||||
* /(?:~|\$HOME)\/\.claude\b/g to detect unresolved .claude path references after
|
||||
* copyWithPathReplacement() runs. The replacer handles "~/.claude/" (trailing slash)
|
||||
* but not "~/.claude" (bare, no trailing slash) — so any bare reference in
|
||||
* update.md would slip through and trigger the installer warning for non-Claude runtimes.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const UPDATE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md');
|
||||
|
||||
describe('update.md — no bare ~.claude path references (#2470)', () => {
|
||||
const content = fs.readFileSync(UPDATE_MD, 'utf-8');
|
||||
|
||||
test('update.md does not contain bare ~/\\.claude (without trailing slash)', () => {
|
||||
// This is the exact pattern from the installer's scanForLeakedPaths():
|
||||
// /(?:~|\$HOME)\/\.claude\b/g
|
||||
// The replacer handles ~/\.claude\/ (with trailing slash) but misses bare ~/\.claude
|
||||
// so we must not have bare references in the source file.
|
||||
const matches = content.match(/(?:~|\$HOME)\/\.claude(?!\/)/g);
|
||||
assert.strictEqual(
|
||||
matches,
|
||||
null,
|
||||
`update.md must not contain bare ~/.claude (without trailing slash) — installer scanner flags these as unresolved path refs: ${JSON.stringify(matches)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Bug #2492: Add gates to ensure discuss-phase decisions are translated to
|
||||
* plans (plan-phase, BLOCKING) and verified against shipped artifacts
|
||||
* (verify-phase, NON-BLOCKING).
|
||||
*
|
||||
* These workflow files are loaded as prompts by the corresponding subagents.
|
||||
* The tests below verify that the prompt text contains the gate steps and
|
||||
* the config-toggle skip clauses — losing them silently would regress the
|
||||
* fix.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PLAN_PHASE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md');
|
||||
const VERIFY_PHASE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md');
|
||||
const SCHEMA_MANIFEST_JSON = path.join(__dirname, '..', 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json');
|
||||
|
||||
describe('plan-phase decision-coverage gate (#2492)', () => {
|
||||
const md = fs.readFileSync(PLAN_PHASE, 'utf-8');
|
||||
|
||||
test('contains a Decision Coverage Gate step', () => {
|
||||
assert.ok(
|
||||
/Decision Coverage Gate/i.test(md),
|
||||
'plan-phase.md must define a Decision Coverage Gate step',
|
||||
);
|
||||
});
|
||||
|
||||
test('invokes the check.decision-coverage-plan handler', () => {
|
||||
assert.ok(
|
||||
md.includes('check.decision-coverage-plan'),
|
||||
'plan-phase.md must call gsd-sdk query check.decision-coverage-plan',
|
||||
);
|
||||
});
|
||||
|
||||
test('mentions workflow.context_coverage_gate skip clause', () => {
|
||||
assert.ok(
|
||||
md.includes('workflow.context_coverage_gate'),
|
||||
'plan-phase.md must reference workflow.context_coverage_gate to allow skipping',
|
||||
);
|
||||
});
|
||||
|
||||
test('decision gate appears AFTER the existing Requirements Coverage Gate', () => {
|
||||
// Anchored heading regexes — avoid prose-substring traps (review F8/F9).
|
||||
const reqIdx = md.search(/^## 13[a-z]?\.\s+Requirements Coverage Gate/m);
|
||||
const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m);
|
||||
assert.ok(reqIdx !== -1, 'Requirements Coverage Gate heading must exist as ## 13[a-z]?.');
|
||||
assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.');
|
||||
assert.ok(decIdx > reqIdx, 'Decision gate must run after Requirements gate');
|
||||
});
|
||||
|
||||
test('decision gate appears BEFORE plans are committed', () => {
|
||||
const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m);
|
||||
const commitIdx = md.search(/^## 13[a-z]?\.\s+Commit Plans/m);
|
||||
assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.');
|
||||
assert.ok(commitIdx !== -1, 'Commit Plans heading must exist as ## 13[a-z]?.');
|
||||
assert.ok(decIdx < commitIdx, 'Decision gate must run before commit so failures block the commit');
|
||||
});
|
||||
|
||||
test('plan-phase Decision Coverage Gate uses CONTEXT_PATH variable defined in INIT extraction (review F1)', () => {
|
||||
// The CONTEXT_PATH bash variable is defined at Step 4 (`CONTEXT_PATH=$(_gsd_field "$INIT" context_path)`).
|
||||
// The plan-phase gate snippet must reference the same casing — `${CONTEXT_PATH}` — not `${context_path}`,
|
||||
// otherwise the BLOCKING gate is invoked with an empty path and silently skips.
|
||||
const defIdx = md.indexOf('CONTEXT_PATH=$(_gsd_field "$INIT" context_path)');
|
||||
assert.ok(defIdx !== -1, 'CONTEXT_PATH must be defined from INIT JSON');
|
||||
|
||||
const gateIdx = md.indexOf('check.decision-coverage-plan');
|
||||
assert.ok(gateIdx !== -1, 'check.decision-coverage-plan invocation must exist');
|
||||
|
||||
// Slice the surrounding gate snippet (~600 chars) and verify variable casing matches the definition.
|
||||
const snippet = md.slice(Math.max(0, gateIdx - 200), gateIdx + 400);
|
||||
assert.ok(
|
||||
snippet.includes('${CONTEXT_PATH}'),
|
||||
'Gate snippet must reference ${CONTEXT_PATH} (uppercase) to match the variable defined in Step 4',
|
||||
);
|
||||
assert.ok(
|
||||
!snippet.includes('${context_path}'),
|
||||
'Gate snippet must NOT reference ${context_path} (lowercase) — that name is undefined in shell scope',
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase blocking gate exits non-zero on failure (review F15)', () => {
|
||||
// The gate is documented as BLOCKING. To actually block, the shell snippet must
|
||||
// exit with non-zero status when `passed` is false. Without exit-1 the workflow
|
||||
// continues silently past the failure.
|
||||
const gateIdx = md.indexOf('check.decision-coverage-plan');
|
||||
assert.ok(gateIdx !== -1);
|
||||
const snippet = md.slice(gateIdx, gateIdx + 800);
|
||||
// Accept either an inline `|| exit 1` or a `|| { ...; exit 1; }` group.
|
||||
const hasJqGuard =
|
||||
/jq[^\r\n]*\.data\.passed\s*==\s*true/.test(snippet) ||
|
||||
/jq[^\r\n]*\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet);
|
||||
const hasExitOne = /\|\|\s*(?:exit\s+1|\{[\s\S]{0,200}?exit\s+1)/.test(snippet);
|
||||
assert.ok(
|
||||
hasJqGuard && hasExitOne,
|
||||
'plan-phase gate must guard with `jq -e .passed == true || exit 1` (or `|| { ...; exit 1; }`) to actually block',
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase gate accepts top-level .passed field from CLI output (#275)', () => {
|
||||
const gateIdx = md.indexOf('check.decision-coverage-plan');
|
||||
assert.ok(gateIdx !== -1, 'check.decision-coverage-plan invocation must exist');
|
||||
const snippet = md.slice(gateIdx, gateIdx + 800);
|
||||
assert.ok(
|
||||
/\.(?:passed)\s*==\s*true\s*\|\|\s*\.data\.passed\s*==\s*true/.test(snippet) ||
|
||||
/\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet),
|
||||
'plan-phase gate must explicitly check top-level .passed with a compatibility fallback to .data.passed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify-phase decision-coverage gate (#2492)', () => {
|
||||
const md = fs.readFileSync(VERIFY_PHASE, 'utf-8');
|
||||
|
||||
test('contains a verify_decisions step', () => {
|
||||
assert.ok(
|
||||
/verify_decisions/.test(md),
|
||||
'verify-phase.md must define a verify_decisions step',
|
||||
);
|
||||
});
|
||||
|
||||
test('invokes the check.decision-coverage-verify handler', () => {
|
||||
assert.ok(
|
||||
md.includes('check.decision-coverage-verify'),
|
||||
'verify-phase.md must call gsd-sdk query check.decision-coverage-verify',
|
||||
);
|
||||
});
|
||||
|
||||
test('declares the decision gate as non-blocking / warning only', () => {
|
||||
const lower = md.toLowerCase();
|
||||
assert.ok(
|
||||
lower.includes('non-blocking') || lower.includes('warning only') || lower.includes('not block'),
|
||||
'verify-phase.md must declare the decision gate is non-blocking',
|
||||
);
|
||||
});
|
||||
|
||||
test('mentions workflow.context_coverage_gate skip clause', () => {
|
||||
assert.ok(
|
||||
md.includes('workflow.context_coverage_gate'),
|
||||
'verify-phase.md must reference workflow.context_coverage_gate to allow skipping',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime wiring for #2492 gates', () => {
|
||||
test('schema manifest includes context_coverage_gate', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(SCHEMA_MANIFEST_JSON, 'utf-8'));
|
||||
assert.ok(
|
||||
manifest.validKeys.includes('workflow.context_coverage_gate'),
|
||||
'workflow.context_coverage_gate must be present in config-schema manifest',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Tests for bug #2501: resurrection-detection block in execute-phase.md must
|
||||
* check git history before deleting new .planning/ files.
|
||||
*
|
||||
* Root cause: the original logic deleted ANY .planning/ file that was absent
|
||||
* from PRE_MERGE_FILES, which includes brand-new files (e.g. SUMMARY.md)
|
||||
* that the executor just created. A true "resurrection" is a file that was
|
||||
* previously tracked on main, deliberately deleted, and then re-introduced by
|
||||
* a worktree merge. Detecting that requires a git history check, not just a
|
||||
* pre-merge tree membership check.
|
||||
*
|
||||
* After #3797: execute-phase.md delegates worktree cleanup to the SDK's
|
||||
* worktree.cleanup-wave command. Resurrection detection is handled internally
|
||||
* by the SDK. The inline WAS_DELETED shell check has been removed from the
|
||||
* workflow — it was part of the SDK-absence fallback which is no longer needed
|
||||
* since the preflight block exits if neither local nor global SDK is available.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const EXECUTE_PHASE = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'
|
||||
);
|
||||
|
||||
describe('execute-phase.md — resurrection-detection guard (#2501)', () => {
|
||||
let content;
|
||||
|
||||
// Load once; each test reads from the cached string.
|
||||
test('file is readable', () => {
|
||||
content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
assert.ok(content.length > 0, 'execute-phase.md must not be empty');
|
||||
});
|
||||
|
||||
test('cleanup delegates to SDK (handles resurrection detection internally)', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
// After #3797: execute-phase.md delegates to worktree.cleanup-wave, which
|
||||
// handles pre-merge deletion checks internally. The SDK checks diff --diff-filter=D
|
||||
// before merging, blocking branches that contain file deletions (#2384/#2501).
|
||||
assert.ok(
|
||||
content.includes('worktree.cleanup-wave'),
|
||||
'execute-phase.md must delegate to worktree.cleanup-wave (#2501/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase does not use the buggy PRE_MERGE_FILES form', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
// The buggy pattern from before #2501 — deletion conditioned on absence
|
||||
// from PRE_MERGE_FILES snapshot. Must remain absent.
|
||||
const hasBuggyGuard =
|
||||
content.includes('PRE_MERGE_FILES') &&
|
||||
/if\s*!\s*echo\s*"\$PRE_MERGE_FILES"\s*\|\s*grep\s+-qxF\s*"\$RESURRECTED"/.test(content);
|
||||
assert.ok(
|
||||
!hasBuggyGuard,
|
||||
'execute-phase.md must NOT delete files based on the PRE_MERGE_FILES snapshot grep (inverted guard bug #2501)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2502: insert-phase does not update STATE.md's
|
||||
* next-phase recommendation after inserting a decimal phase.
|
||||
*
|
||||
* Root cause: insert-phase.md's update_project_state step only added a
|
||||
* "Roadmap Evolution" note to STATE.md, but never updated the "Current Phase"
|
||||
* / next-run recommendation to point at the newly inserted phase.
|
||||
*
|
||||
* Fix: insert-phase.md must include a step that updates STATE.md's next-phase
|
||||
* pointer (current_phase / next recommended run) to the newly inserted phase.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INSERT_PHASE_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'insert-phase.md'
|
||||
);
|
||||
|
||||
describe('bug-2502: insert-phase must update STATE.md next-phase recommendation', () => {
|
||||
test('insert-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(INSERT_PHASE_PATH), 'insert-phase.md should exist');
|
||||
});
|
||||
|
||||
test('insert-phase.md contains a STATE.md next-phase update instruction', () => {
|
||||
const content = fs.readFileSync(INSERT_PHASE_PATH, 'utf-8');
|
||||
|
||||
// Must reference STATE.md and the concept of updating the next/current phase pointer
|
||||
const mentionsStateUpdate = (
|
||||
/STATE\.md.{0,200}(next.phase|current.phase|next.run|recommendation)/is.test(content) ||
|
||||
/(next.phase|current.phase|next.run|recommendation).{0,200}STATE\.md/is.test(content)
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
mentionsStateUpdate,
|
||||
'insert-phase.md must instruct updating STATE.md\'s next-phase recommendation to point to the newly inserted phase'
|
||||
);
|
||||
});
|
||||
|
||||
test('insert-phase.md update_project_state step covers next-phase pointer', () => {
|
||||
const content = fs.readFileSync(INSERT_PHASE_PATH, 'utf-8');
|
||||
|
||||
const stepMatch = content.match(/<step name="update_project_state">([\s\S]*?)<\/step>/i);
|
||||
assert.ok(stepMatch, 'insert-phase.md must contain update_project_state step');
|
||||
const stepContent = stepMatch[1];
|
||||
|
||||
const hasNextPhasePointerUpdate = (
|
||||
/\bcurrent[_ -]?phase\b/i.test(stepContent) ||
|
||||
/\bnext[_ -]?phase\b/i.test(stepContent) ||
|
||||
/\bnext recommended run\b/i.test(stepContent)
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
hasNextPhasePointerUpdate,
|
||||
'insert-phase.md update_project_state step must update STATE.md\'s next-phase pointer (current_phase) to the inserted decimal phase'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2504
|
||||
*
|
||||
* When UAT testing is mandated and a phase has no user-facing elements
|
||||
* (e.g., code foundations, database schema, internal APIs), the agent
|
||||
* invented artificial UAT steps — things like "manually run git commits",
|
||||
* "manually invoke methods", "manually check database state" — and left
|
||||
* work half-finished specifically to create things for a human to do.
|
||||
*
|
||||
* Fix: The verify-phase workflow's identify_human_verification step must
|
||||
* explicitly handle phases with no user-facing elements by auto-passing UAT
|
||||
* with a logged rationale instead of inventing manual steps.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const VERIFY_PHASE_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md'
|
||||
);
|
||||
|
||||
/**
|
||||
* Extract a named section from a markdown/workflow document.
|
||||
* Returns the text from `heading` up to (but not including) the next `## ` heading,
|
||||
* or to end-of-file if no subsequent heading exists.
|
||||
*/
|
||||
function extractSection(content, heading) {
|
||||
const start = content.indexOf(heading);
|
||||
if (start === -1) return '';
|
||||
const nextHeading = content.indexOf('\n## ', start + 1);
|
||||
return nextHeading === -1 ? content.slice(start) : content.slice(start, nextHeading);
|
||||
}
|
||||
|
||||
describe('bug #2504: UAT auto-pass for foundation/infrastructure phases', () => {
|
||||
test('verify-phase workflow file exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(VERIFY_PHASE_PATH),
|
||||
'gsd-core/workflows/verify-phase.md should exist'
|
||||
);
|
||||
});
|
||||
|
||||
test('identify_human_verification step handles phases with no user-facing elements', () => {
|
||||
const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8');
|
||||
const section = extractSection(content, 'identify_human_verification');
|
||||
// The step must explicitly call out the infrastructure/foundation case
|
||||
const hasInfrastructureHandling =
|
||||
section.includes('infrastructure') ||
|
||||
section.includes('foundation') ||
|
||||
section.includes('no user-facing') ||
|
||||
section.includes('no user facing') ||
|
||||
section.includes('internal API') ||
|
||||
section.includes('internal APIs') ||
|
||||
section.includes('database schema') ||
|
||||
section.includes('code foundation');
|
||||
|
||||
assert.ok(
|
||||
hasInfrastructureHandling,
|
||||
'verify-phase.md identify_human_verification step must explicitly handle ' +
|
||||
'infrastructure/foundation phases that have no user-facing elements. Without ' +
|
||||
'this, agents invent artificial manual steps to satisfy UAT requirements ' +
|
||||
'(root cause of #2504).'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow includes auto-pass or skip UAT language for non-user-facing phases', () => {
|
||||
const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8');
|
||||
const section = extractSection(content, 'identify_human_verification');
|
||||
const hasAutoPass =
|
||||
section.includes('auto-pass') ||
|
||||
section.includes('auto pass') ||
|
||||
section.includes('automatically pass') ||
|
||||
section.includes('skip UAT') ||
|
||||
section.includes('skip the UAT') ||
|
||||
section.includes('UAT does not apply') ||
|
||||
section.includes('UAT not applicable') ||
|
||||
section.includes('no UAT required');
|
||||
|
||||
assert.ok(
|
||||
hasAutoPass,
|
||||
'verify-phase.md identify_human_verification step must contain language about ' +
|
||||
'auto-passing or skipping UAT for phases without user-facing elements. Agents ' +
|
||||
'must not invent manual steps when there is nothing user-facing to test ' +
|
||||
'(root cause of #2504).'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow prohibits inventing artificial manual steps for infrastructure phases', () => {
|
||||
const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8');
|
||||
const section = extractSection(content, 'identify_human_verification');
|
||||
// The workflow must tell the agent NOT to invent steps when there's nothing to test.
|
||||
// Look for explicit prohibition or the inverse: "do not invent" or "must not create"
|
||||
// or equivalent framing like "only require human testing when..."
|
||||
const hasProhibition =
|
||||
section.includes('do not invent') ||
|
||||
section.includes('must not invent') ||
|
||||
section.includes('never invent') ||
|
||||
section.includes('Do not invent') ||
|
||||
section.includes('Must not invent') ||
|
||||
section.includes('Never invent') ||
|
||||
section.includes('only require human') ||
|
||||
section.includes('only add human') ||
|
||||
(section.includes('only flag') && section.includes('user-facing')) ||
|
||||
// Or via "N/A" framing
|
||||
(section.includes('N/A') && (
|
||||
section.includes('infrastructure') ||
|
||||
section.includes('foundation') ||
|
||||
section.includes('no user-facing')
|
||||
));
|
||||
|
||||
assert.ok(
|
||||
hasProhibition,
|
||||
'verify-phase.md identify_human_verification step must explicitly prohibit ' +
|
||||
'inventing artificial manual UAT steps for infrastructure phases. The current ' +
|
||||
'wording causes agents to create fake "manually run git commits" steps to ' +
|
||||
'satisfy UAT mandates (root cause of #2504).'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow includes a concept of N/A or not-applicable UAT state', () => {
|
||||
const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8');
|
||||
const section = extractSection(content, 'identify_human_verification');
|
||||
const hasNaState =
|
||||
section.includes('N/A') ||
|
||||
section.includes('not applicable') ||
|
||||
section.includes('not_applicable') ||
|
||||
section.includes('no_uat') ||
|
||||
section.includes('uat_not_applicable') ||
|
||||
section.includes('infrastructure phase') ||
|
||||
section.includes('foundation phase');
|
||||
|
||||
assert.ok(
|
||||
hasNaState,
|
||||
'verify-phase.md identify_human_verification step must include some concept of ' +
|
||||
'a "not applicable" or N/A UAT state for phases with no user-facing elements. ' +
|
||||
'This prevents agents from blocking phase completion on invented manual steps ' +
|
||||
'(root cause of #2504).'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2506
|
||||
*
|
||||
* /gsd-settings presents Quality/Balanced/Budget model profiles without any
|
||||
* warning that on non-Claude runtimes (Codex, Gemini CLI, etc.) these profiles
|
||||
* select Claude model tiers and have no effect on actual agent model selection.
|
||||
*
|
||||
* Fix: settings.md must include a non-Claude runtime note instructing users to
|
||||
* use "Inherit" or configure model_overrides manually, and the Inherit option
|
||||
* description must explicitly call out non-Claude runtimes.
|
||||
*
|
||||
* Closes: #2506
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SETTINGS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'settings.md');
|
||||
|
||||
describe('bug #2506: settings.md non-Claude runtime warning for model profiles', () => {
|
||||
let content;
|
||||
|
||||
before(() => {
|
||||
content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
});
|
||||
|
||||
test('settings.md contains a non-Claude runtime note for model profiles', () => {
|
||||
assert.ok(
|
||||
content.includes('non-Claude runtime') || content.includes('non-Claude runtimes'),
|
||||
'settings.md must include a note about non-Claude runtimes and model profiles'
|
||||
);
|
||||
});
|
||||
|
||||
test('non-Claude note explains profiles are no-ops without model_overrides', () => {
|
||||
assert.ok(
|
||||
content.includes('model_overrides') || content.includes('no effect'),
|
||||
'note must explain profiles have no effect on non-Claude runtimes without model_overrides'
|
||||
);
|
||||
});
|
||||
|
||||
test('Inherit option description explicitly mentions non-Claude runtimes', () => {
|
||||
// The Inherit option in AskUserQuestion must call out non-Claude runtimes
|
||||
const inheritOptionMatch = content.match(/label:\s*"Inherit"[^}]*description:\s*"([^"]+)"/s);
|
||||
assert.ok(inheritOptionMatch, 'Inherit option with label/description must exist in settings.md');
|
||||
const desc = inheritOptionMatch[1];
|
||||
assert.ok(
|
||||
desc.includes('non-Claude') || desc.includes('Codex') || desc.includes('Gemini'),
|
||||
`Inherit option description must mention non-Claude runtimes; got: "${desc}"`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for bug #2516
|
||||
*
|
||||
* When `.planning/config.json` has `model_profile: "inherit"`, the
|
||||
* `init.execute-phase` query returns `executor_model: "inherit"`. The
|
||||
* execute-phase workflow was passing this literal string directly to the
|
||||
* Task tool via `model="{executor_model}"`, causing Task to fall back to
|
||||
* its default model instead of inheriting the orchestrator model.
|
||||
*
|
||||
* Fix: the workflow must document that when `executor_model` is `"inherit"`,
|
||||
* the `model=` parameter must be OMITTED from Task() calls entirely.
|
||||
* Omitting `model=` causes Claude Code to inherit the current orchestrator
|
||||
* model automatically.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase.md'
|
||||
);
|
||||
|
||||
describe('bug #2516: executor_model "inherit" must not be passed literally to Task()', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist');
|
||||
});
|
||||
|
||||
test('workflow contains instructions for handling the "inherit" case', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist');
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const hasInheritInstruction =
|
||||
content.includes('"inherit"') &&
|
||||
(content.includes('omit') || content.includes('Omit') || content.includes('omitting') || content.includes('Omitting'));
|
||||
assert.ok(
|
||||
hasInheritInstruction,
|
||||
'execute-phase.md must document that when executor_model is "inherit", ' +
|
||||
'the model= parameter must be omitted from Task() calls. ' +
|
||||
'Found "inherit" mention: ' + content.includes('"inherit"') + '. ' +
|
||||
'Found omit mention: ' + (content.includes('omit') || content.includes('Omit'))
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow does not instruct passing model="inherit" literally to Task', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist');
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
// The workflow must not have an unconditional model="{executor_model}" template
|
||||
// that would pass "inherit" through. It should document conditional logic.
|
||||
const hasConditionalModelParam =
|
||||
content.includes('inherit') &&
|
||||
(
|
||||
content.includes('Only set `model=`') ||
|
||||
content.includes('only set `model=`') ||
|
||||
content.includes('Only set model=') ||
|
||||
content.includes('omit the `model=`') ||
|
||||
content.includes('omit the model=') ||
|
||||
content.includes('omit `model=`') ||
|
||||
content.includes('omit model=')
|
||||
);
|
||||
const lines = content.split(/\r?\n/);
|
||||
const hasLiteralInheritInTask = lines.some(line => {
|
||||
if (!/model\s*=\s*["']inherit["']/.test(line)) return false;
|
||||
// Exclude instructional/explanatory lines that document what NOT to do
|
||||
return !/\b(not|NOT|don'?t|do not|DO NOT|never|NEVER)\b/.test(line);
|
||||
});
|
||||
assert.ok(
|
||||
!hasLiteralInheritInTask,
|
||||
'execute-phase workflow must not pass literal "inherit" string to Task() model parameter'
|
||||
);
|
||||
assert.ok(
|
||||
hasConditionalModelParam && !hasLiteralInheritInTask,
|
||||
'execute-phase.md must conditionally omit model= when executor_model is "inherit", never pass it literally. ' +
|
||||
'The unconditional model="{executor_model}" template would pass the literal ' +
|
||||
'string "inherit" to Task(), which falls back to the default model instead ' +
|
||||
'of the orchestrator model (root cause of #2516).'
|
||||
);
|
||||
// Guard against a future contributor adding an unconditional model="{executor_model}"
|
||||
// template alongside the conditional docs — that would pass "inherit" literally to Task().
|
||||
const hasUnsafeTemplate = lines.some(line => {
|
||||
if (!/model\s*=\s*['"]\{executor_model\}['"]/.test(line)) return false;
|
||||
return !/\b(not|NOT|do not|DO NOT|don'?t|never|NEVER|omit)\b/i.test(line);
|
||||
});
|
||||
assert.ok(!hasUnsafeTemplate,
|
||||
'execute-phase.md must not contain an unconditional model="{executor_model}" template — ' +
|
||||
'it would pass "inherit" literally to Task() when executor_model is "inherit"'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow documents that omitting model= causes inheritance from orchestrator', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist');
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const hasInheritanceExplanation =
|
||||
content.includes('inherit') &&
|
||||
(
|
||||
content.includes('orchestrator model') ||
|
||||
content.includes('orchestrator\'s model') ||
|
||||
content.includes('inherits the') ||
|
||||
content.includes('inherit the current')
|
||||
);
|
||||
assert.ok(
|
||||
hasInheritanceExplanation,
|
||||
'execute-phase.md must explain that omitting model= causes Claude Code to ' +
|
||||
'inherit the current orchestrator model — this is the mechanism that makes ' +
|
||||
'"inherit" work correctly.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2523
|
||||
*
|
||||
* workflows/quick.md Step 8 ("Build file list") listed PLAN.md, SUMMARY.md,
|
||||
* STATE.md, and mode-conditional CONTEXT.md / RESEARCH.md / VERIFICATION.md —
|
||||
* but omitted deferred-items.md. When an executor logs out-of-scope findings
|
||||
* to ${QUICK_DIR}/${quick_id}-deferred-items.md during task execution, that
|
||||
* file was left untracked after the final commit even with commit_docs: true.
|
||||
*
|
||||
* Fix: add a file-existence-gated entry for deferred-items.md to Step 8.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
describe('bug #2523: quick-task final commit includes deferred-items.md', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/quick.md must exist');
|
||||
});
|
||||
|
||||
test('Step 8 file list references deferred-items.md', () => {
|
||||
const step8Idx = content.indexOf('Step 8: Final commit');
|
||||
assert.notEqual(step8Idx, -1, 'Step 8 section must exist in quick.md');
|
||||
|
||||
const step8Section = content.slice(step8Idx, step8Idx + 2000);
|
||||
assert.ok(
|
||||
step8Section.includes('deferred-items.md'),
|
||||
'Step 8 file list must include deferred-items.md. ' +
|
||||
'Without this, any out-of-scope findings logged by the executor to ' +
|
||||
'${QUICK_DIR}/${quick_id}-deferred-items.md are left untracked after commit (bug #2523).'
|
||||
);
|
||||
});
|
||||
|
||||
test('deferred-items.md entry is conditional on file existence', () => {
|
||||
const deferredIdx = content.indexOf('deferred-items.md');
|
||||
assert.notEqual(deferredIdx, -1, 'deferred-items.md must be mentioned in the workflow');
|
||||
|
||||
const surroundingContext = content.slice(Math.max(0, deferredIdx - 200), deferredIdx + 200);
|
||||
const isConditional =
|
||||
surroundingContext.toLowerCase().includes('exist') ||
|
||||
surroundingContext.toLowerCase().includes('if ');
|
||||
assert.ok(
|
||||
isConditional,
|
||||
'The deferred-items.md entry must be gated on file existence (not a mode flag). ' +
|
||||
'deferred-items.md can be created in any quick-task run, regardless of mode.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
'use strict';
|
||||
|
||||
|
||||
/**
|
||||
* Bugs #2549, #2550, #2552: discuss-phase context bloat and cache invalidation.
|
||||
*
|
||||
* #2549: load_prior_context must cap prior CONTEXT.md reads (was O(phases))
|
||||
* #2550: scout_codebase must select maps by phase type (was always all 7)
|
||||
* #2552: scout_codebase must not instruct split reads of the same file
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const DISCUSS_PHASE = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase.md',
|
||||
);
|
||||
// After the discuss-phase progressive-disclosure split (#717), the scout_codebase phase-type
|
||||
// table and split-reads warning live in references/scout-codebase.md.
|
||||
const SCOUT_REF = path.join(
|
||||
__dirname, '..', 'gsd-core', 'references', 'scout-codebase.md',
|
||||
);
|
||||
|
||||
function readDiscussContext() {
|
||||
// Both files are required after the discuss-phase/modes split — fail loudly if either is missing
|
||||
// rather than silently weakening the regression coverage.
|
||||
for (const p of [DISCUSS_PHASE, SCOUT_REF]) {
|
||||
assert.ok(fs.existsSync(p), `Required discuss-phase context source missing: ${p}`);
|
||||
}
|
||||
return [DISCUSS_PHASE, SCOUT_REF].map(p => fs.readFileSync(p, 'utf-8')).join('\n');
|
||||
}
|
||||
|
||||
describe('discuss-phase context fixes (#2549, #2550, #2552)', () => {
|
||||
let src;
|
||||
test('discuss-phase.md source exists', () => {
|
||||
assert.ok(fs.existsSync(DISCUSS_PHASE), 'discuss-phase.md must exist');
|
||||
assert.ok(
|
||||
fs.existsSync(SCOUT_REF),
|
||||
'references/scout-codebase.md must exist after the discuss-phase/modes progressive-disclosure split',
|
||||
);
|
||||
src = readDiscussContext();
|
||||
});
|
||||
|
||||
// ─── #2549: load_prior_context cap ──────────────────────────────────────
|
||||
test('#2549: load_prior_context must NOT instruct reading ALL prior CONTEXT.md files', () => {
|
||||
if (!src) src = readDiscussContext();
|
||||
assert.ok(
|
||||
!src.includes('For each CONTEXT.md where phase number < current phase'),
|
||||
'load_prior_context must not unboundedly read all prior CONTEXT.md files',
|
||||
);
|
||||
});
|
||||
|
||||
test('#2549: load_prior_context must reference a bounded read (3 phases or DECISIONS-INDEX)', () => {
|
||||
// Read ONLY the parent file — `src.includes('3')` against the
|
||||
// concatenated source can be satisfied by unrelated occurrences of "3"
|
||||
// in scout-codebase.md (e.g., "3-5 most relevant files"), masking a
|
||||
// regression where the parent drops the bounded-read instruction.
|
||||
const parent = fs.readFileSync(DISCUSS_PHASE, 'utf-8');
|
||||
const hasBound = /\b(?:most recent|latest|last|up to)\s+3\b[\s\S]{0,160}\bprior CONTEXT\.md\b/i.test(parent);
|
||||
const hasIndex = parent.includes('DECISIONS-INDEX.md');
|
||||
assert.ok(
|
||||
hasBound || hasIndex,
|
||||
'load_prior_context must reference a bounded read (e.g., most recent 3 phases) or DECISIONS-INDEX.md',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── #2550: scout_codebase phase-type selection ──────────────────────────
|
||||
test('#2550: scout_codebase must not instruct reading all 7 codebase maps', () => {
|
||||
if (!src) src = readDiscussContext();
|
||||
assert.ok(
|
||||
!src.includes('Read the most relevant ones (CONVENTIONS.md, STRUCTURE.md, STACK.md based on phase type)'),
|
||||
'scout_codebase must not use the old vague "most relevant" instruction without a selection table',
|
||||
);
|
||||
});
|
||||
|
||||
test('#2550: scout_codebase must include a phase-type-to-maps selection table', () => {
|
||||
if (!src) src = readDiscussContext();
|
||||
// The table maps phase types to specific map selections
|
||||
assert.ok(
|
||||
src.includes('Phase type') && src.includes('Read these maps'),
|
||||
'scout_codebase must include a phase-type to map-selection table',
|
||||
);
|
||||
// Key phase types must be covered
|
||||
assert.ok(src.includes('UI') || src.includes('frontend'), 'Table must cover UI/frontend phases');
|
||||
assert.ok(src.includes('Backend') || src.includes('API'), 'Table must cover backend phases');
|
||||
assert.ok(src.includes('Testing'), 'Table must cover testing phases');
|
||||
assert.ok(src.includes('Mixed'), 'Table must have a fallback for mixed/unclear phases');
|
||||
});
|
||||
|
||||
// ─── #2552: no split reads ───────────────────────────────────────────────
|
||||
test('#2552: scout_codebase must explicitly prohibit split reads of the same file', () => {
|
||||
if (!src) src = readDiscussContext();
|
||||
const prohibitsSplit = src.includes('split reads') || src.includes('split read');
|
||||
assert.ok(
|
||||
prohibitsSplit,
|
||||
'scout_codebase must explicitly warn against split reads (same file, two offsets) that break prompt cache',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Regression tests for #2838: SUMMARY rescue silently fails when .planning/
|
||||
* is gitignored.
|
||||
*
|
||||
* After #3797: execute-phase.md and quick.md delegate worktree cleanup to the
|
||||
* SDK's worktree.cleanup-wave command. The SDK's executeWorktreeWaveCleanupPlan
|
||||
* handles SUMMARY rescue internally using a filesystem-level find+cp approach
|
||||
* (bypassing gitignore) rather than the old git ls-files --exclude-standard
|
||||
* form that silently dropped gitignored files.
|
||||
*
|
||||
* The inline "Safety net" shell rescue block that was previously in both
|
||||
* workflow files has been removed — it was part of the SDK-absence fallback
|
||||
* which is now dead code since preflight exits if neither local nor global SDK
|
||||
* is available.
|
||||
*
|
||||
* This test file verifies that both workflows correctly delegate to the SDK
|
||||
* for SUMMARY rescue, and that neither workflow retains the broken inline form.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
const QUICK_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
/**
|
||||
* Parse a workflow markdown file into a structured contract object.
|
||||
* Returns typed boolean fields so tests assert on structure, not raw text.
|
||||
*/
|
||||
function parseWorkflowContract(filePath) {
|
||||
const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/);
|
||||
return {
|
||||
// Non-empty check
|
||||
nonEmpty: lines.length > 0 && lines.some(l => l.length > 0),
|
||||
// Does the workflow delegate SUMMARY rescue to worktree.cleanup-wave?
|
||||
delegatesToCleanupWave: lines.some(l => l.includes('worktree.cleanup-wave')),
|
||||
// Does the cleanup-wave invocation use || exit 1 (fail-closed)?
|
||||
cleanupWaveFailClosed: lines.some(
|
||||
l => /gsd_run query worktree\.cleanup-wave.*\|\| exit 1/.test(l),
|
||||
),
|
||||
// Does the workflow still contain the broken ls-files --exclude-standard rescue form?
|
||||
hasBrokenLsFilesForm: lines.some(
|
||||
l => l.includes('ls-files --modified --others --exclude-standard'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const executePhaseContract = parseWorkflowContract(EXECUTE_PHASE_PATH);
|
||||
const quickContract = parseWorkflowContract(QUICK_PATH);
|
||||
|
||||
describe('bug-2838: SUMMARY rescue delegates to SDK (worktree.cleanup-wave)', () => {
|
||||
|
||||
test('execute-phase.md is readable', () => {
|
||||
assert.ok(executePhaseContract.nonEmpty, 'execute-phase.md must not be empty');
|
||||
});
|
||||
|
||||
test('quick.md is readable', () => {
|
||||
assert.ok(quickContract.nonEmpty, 'quick.md must not be empty');
|
||||
});
|
||||
|
||||
test('execute-phase.md delegates SUMMARY rescue to SDK (worktree.cleanup-wave)', () => {
|
||||
// After #3797: worktree.cleanup-wave handles SUMMARY rescue via find+cp
|
||||
// (bypasses gitignore, fixing the #2838 bug). The workflow delegates to the
|
||||
// SDK rather than implementing rescue inline.
|
||||
assert.ok(
|
||||
executePhaseContract.delegatesToCleanupWave,
|
||||
'execute-phase.md must delegate to worktree.cleanup-wave for SUMMARY rescue (#2838/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md delegates SUMMARY rescue to SDK (worktree.cleanup-wave)', () => {
|
||||
// After #3797: worktree.cleanup-wave handles SUMMARY rescue via find+cp
|
||||
// (bypasses gitignore, fixing the #2838 bug).
|
||||
assert.ok(
|
||||
quickContract.delegatesToCleanupWave,
|
||||
'quick.md must delegate to worktree.cleanup-wave for SUMMARY rescue (#2838/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md does not retain broken git ls-files --exclude-standard rescue form (#2838)', () => {
|
||||
// The broken form used --exclude-standard which silently filtered out
|
||||
// gitignored .planning/ files — the root cause of #2838.
|
||||
assert.ok(
|
||||
!executePhaseContract.hasBrokenLsFilesForm,
|
||||
'execute-phase.md must not use ls-files --exclude-standard for SUMMARY rescue (broken for gitignored .planning/)',
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md does not retain broken git ls-files --exclude-standard rescue form (#2838)', () => {
|
||||
assert.ok(
|
||||
!quickContract.hasBrokenLsFilesForm,
|
||||
'quick.md must not use ls-files --exclude-standard for SUMMARY rescue (broken for gitignored .planning/)',
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md cleanup-wave uses || exit 1 (fail-closed so rescue errors surface)', () => {
|
||||
// If the SDK's rescue fails (e.g. filesystem error), || exit 1 surfaces
|
||||
// the failure to the orchestrator rather than silently continuing and
|
||||
// losing the SUMMARY.
|
||||
assert.ok(
|
||||
executePhaseContract.cleanupWaveFailClosed,
|
||||
'execute-phase.md cleanup-wave must use || exit 1 so SUMMARY rescue failures surface (#2838/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md cleanup-wave uses || exit 1 (fail-closed so rescue errors surface)', () => {
|
||||
assert.ok(
|
||||
quickContract.cleanupWaveFailClosed,
|
||||
'quick.md cleanup-wave must use || exit 1 so SUMMARY rescue failures surface (#2838/#3797)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* Tests for issue #2912 — /gsd-progress can use stale CLAUDE.md project block
|
||||
* instead of GSD tracking files as authoritative source.
|
||||
*
|
||||
* Fix: the `report` step in gsd-core/workflows/progress.md must contain
|
||||
* an explicit "context authority" directive establishing PROJECT.md, STATE.md,
|
||||
* and ROADMAP.md as the authoritative sources for the progress report, and
|
||||
* forbidding the use of CLAUDE.md `## Project` blocks as a source for any
|
||||
* report field.
|
||||
*
|
||||
* These tests parse the workflow markdown structurally (locate the
|
||||
* <step name="report"> ... </step> block, then locate the blockquote-style
|
||||
* directive inside it). They do NOT use `.includes()` over the whole file.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'progress.md'
|
||||
);
|
||||
|
||||
/** Extract the body of a <step name="..."> ... </step> block by parsing tags. */
|
||||
function extractStep(workflow, stepName) {
|
||||
const openTag = `<step name="${stepName}">`;
|
||||
const start = workflow.indexOf(openTag);
|
||||
if (start === -1) return null;
|
||||
const bodyStart = start + openTag.length;
|
||||
// Find the matching </step> — workflow steps in this file do not nest.
|
||||
const end = workflow.indexOf('</step>', bodyStart);
|
||||
if (end === -1) return null;
|
||||
return workflow.slice(bodyStart, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract contiguous markdown blockquote blocks from a chunk of markdown.
|
||||
* A blockquote is a run of consecutive lines starting with '>' (after any
|
||||
* leading whitespace). Returns the joined text of each blockquote with the
|
||||
* leading '>' markers stripped.
|
||||
*/
|
||||
function extractBlockquotes(md) {
|
||||
const lines = md.split(/\r?\n/);
|
||||
const blocks = [];
|
||||
let current = null;
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^\s*>\s?(.*)$/);
|
||||
if (m) {
|
||||
if (current === null) current = [];
|
||||
current.push(m[1]);
|
||||
} else {
|
||||
if (current !== null) {
|
||||
blocks.push(current.join('\n'));
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current !== null) blocks.push(current.join('\n'));
|
||||
return blocks;
|
||||
}
|
||||
|
||||
describe('#2912: progress report step has explicit context-authority directive', () => {
|
||||
test('progress.md workflow file exists and is readable', () => {
|
||||
const stat = fs.statSync(WORKFLOW_PATH);
|
||||
assert.ok(stat.isFile(), 'workflow file should exist');
|
||||
});
|
||||
|
||||
test('progress.md has a <step name="report"> section', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const reportStep = extractStep(workflow, 'report');
|
||||
assert.ok(reportStep, 'workflow should contain a report step');
|
||||
assert.ok(reportStep.length > 0, 'report step body should not be empty');
|
||||
});
|
||||
|
||||
test('report step contains a blockquote directive about context authority', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const reportStep = extractStep(workflow, 'report');
|
||||
assert.ok(reportStep, 'report step must be present');
|
||||
|
||||
const blockquotes = extractBlockquotes(reportStep);
|
||||
assert.ok(
|
||||
blockquotes.length > 0,
|
||||
'report step should contain at least one blockquote (the context-authority directive)'
|
||||
);
|
||||
|
||||
const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b));
|
||||
assert.ok(
|
||||
authorityBlock,
|
||||
'report step should contain a blockquote whose text includes "Context authority"'
|
||||
);
|
||||
});
|
||||
|
||||
test('context-authority directive names PROJECT.md, STATE.md, and ROADMAP.md as authoritative', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const reportStep = extractStep(workflow, 'report');
|
||||
assert.ok(reportStep, 'report step must exist');
|
||||
const blockquotes = extractBlockquotes(reportStep);
|
||||
const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b));
|
||||
assert.ok(authorityBlock, 'authority blockquote must exist');
|
||||
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/PROJECT\.md/,
|
||||
'directive should name PROJECT.md as authoritative'
|
||||
);
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/STATE\.md/,
|
||||
'directive should name STATE.md as authoritative'
|
||||
);
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/ROADMAP\.md/,
|
||||
'directive should name ROADMAP.md as authoritative'
|
||||
);
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/authoritative/i,
|
||||
'directive should describe these files as authoritative'
|
||||
);
|
||||
});
|
||||
|
||||
test('context-authority directive forbids using CLAUDE.md project block as a source', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const reportStep = extractStep(workflow, 'report');
|
||||
assert.ok(reportStep, 'report step must exist');
|
||||
const blockquotes = extractBlockquotes(reportStep);
|
||||
const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b));
|
||||
assert.ok(authorityBlock, 'authority blockquote must exist');
|
||||
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/CLAUDE\.md/,
|
||||
'directive should explicitly mention CLAUDE.md'
|
||||
);
|
||||
// Must explicitly forbid CLAUDE.md as a source — look for a NOT/do not directive
|
||||
// co-located with the CLAUDE.md mention.
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/(do\s+NOT|do\s+not|must\s+NOT|must\s+not|never)/i,
|
||||
'directive should contain an explicit prohibition (do NOT / must not / never)'
|
||||
);
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/## Project/,
|
||||
'directive should call out the CLAUDE.md "## Project" block specifically'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Bug #2950: Stale deleted command references in workflow files
|
||||
*
|
||||
* Multiple workflow files referenced command names removed in #2790
|
||||
* (gsd-add-phase, gsd-insert-phase, gsd-remove-phase, gsd-add-todo,
|
||||
* gsd-set-profile, gsd-settings-integrations, gsd-settings-advanced,
|
||||
* gsd-spike-wrap-up, gsd-sketch-wrap-up, gsd-code-review-fix).
|
||||
*
|
||||
* Fix: Update every occurrence to the new consolidated forms:
|
||||
* /gsd:phase (no flag | --insert | --remove)
|
||||
* /gsd:capture
|
||||
* /gsd:config (--profile | --integrations | --advanced)
|
||||
* /gsd:spike --wrap-up
|
||||
* /gsd:sketch --wrap-up
|
||||
* /gsd:code-review --fix
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
|
||||
function read(filename) {
|
||||
return fs.readFileSync(path.join(WORKFLOWS_DIR, filename), 'utf-8');
|
||||
}
|
||||
|
||||
// Deleted command names that must not appear anywhere in the fixed files.
|
||||
const DELETED_COMMANDS = [
|
||||
'/gsd-add-phase',
|
||||
'/gsd-insert-phase',
|
||||
'/gsd-remove-phase',
|
||||
'/gsd-add-todo',
|
||||
'/gsd-set-profile',
|
||||
'/gsd-settings-integrations',
|
||||
'/gsd-settings-advanced',
|
||||
'/gsd-spike-wrap-up',
|
||||
'/gsd-sketch-wrap-up',
|
||||
'/gsd-code-review-fix',
|
||||
];
|
||||
|
||||
// Per-file assertions: [file, deletedCmd, newForm]
|
||||
const FILE_ASSERTIONS = [
|
||||
// help.md → moved to help/modes/full.md in #3039 tiered-help refactor
|
||||
['help/modes/full.md', '/gsd-add-phase', '/gsd:phase "Add admin dashboard"'],
|
||||
['help/modes/full.md', '/gsd-insert-phase', '/gsd:phase --insert 7 "Fix critical auth bug"'],
|
||||
['help/modes/full.md', '/gsd-remove-phase', '/gsd:phase --remove 17'],
|
||||
['help/modes/full.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'],
|
||||
['help/modes/full.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'],
|
||||
['help/modes/full.md', '/gsd-add-todo', '/gsd:capture'],
|
||||
['help/modes/full.md', '/gsd-set-profile', '/gsd:config --profile budget'],
|
||||
|
||||
// do.md
|
||||
['do.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'],
|
||||
['do.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'],
|
||||
['do.md', '/gsd-add-phase', '/gsd:phase'],
|
||||
['do.md', '/gsd-add-todo', '/gsd:capture'],
|
||||
|
||||
// settings.md
|
||||
['settings.md', '/gsd-code-review-fix', '/gsd:code-review --fix'],
|
||||
['settings.md', '/gsd-settings-integrations', '/gsd:config --integrations'],
|
||||
['settings.md', '/gsd-set-profile', '/gsd:config --profile'],
|
||||
['settings.md', '/gsd-settings-advanced', '/gsd:config --advanced'],
|
||||
|
||||
// discuss-phase.md
|
||||
['discuss-phase.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'],
|
||||
['discuss-phase.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'],
|
||||
|
||||
// new-project.md
|
||||
['new-project.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'],
|
||||
['new-project.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'],
|
||||
|
||||
// plan-phase.md
|
||||
['plan-phase.md', '/gsd-insert-phase', '/gsd:phase --insert'],
|
||||
|
||||
// spike.md
|
||||
['spike.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'],
|
||||
|
||||
// sketch.md
|
||||
['sketch.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'],
|
||||
];
|
||||
|
||||
describe('bug #2950: stale deleted-command references removed from workflow files', () => {
|
||||
// Build a map of file → content to avoid re-reading
|
||||
const files = [...new Set(FILE_ASSERTIONS.map(([f]) => f))];
|
||||
const contentMap = {};
|
||||
for (const f of files) {
|
||||
contentMap[f] = read(f);
|
||||
}
|
||||
|
||||
// For each (file, deletedCmd) pair, assert the old name is absent
|
||||
for (const [file, deletedCmd] of FILE_ASSERTIONS) {
|
||||
test(`${file}: does not contain deleted command "${deletedCmd}"`, () => {
|
||||
const content = contentMap[file];
|
||||
assert.ok(
|
||||
!content.includes(deletedCmd),
|
||||
`${file} still contains deleted command "${deletedCmd}" — update to new form`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// For each (file, deletedCmd, newForm) triple, assert the new form is present
|
||||
for (const [file, , newForm] of FILE_ASSERTIONS) {
|
||||
test(`${file}: contains new form "${newForm}"`, () => {
|
||||
const content = contentMap[file];
|
||||
assert.ok(
|
||||
content.includes(newForm),
|
||||
`${file} is missing expected new form "${newForm}"`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Blanket check: no affected workflow file contains any of the deleted command names
|
||||
// (catches any we might have missed in per-file assertions above)
|
||||
const affectedFiles = [
|
||||
'help.md',
|
||||
'help/modes/full.md',
|
||||
'help/modes/default.md',
|
||||
'help/modes/brief.md',
|
||||
'help/modes/topic.md',
|
||||
'do.md',
|
||||
'settings.md',
|
||||
'discuss-phase.md',
|
||||
'new-project.md',
|
||||
'plan-phase.md',
|
||||
'spike.md',
|
||||
'sketch.md',
|
||||
];
|
||||
|
||||
for (const file of affectedFiles) {
|
||||
const content = read(file);
|
||||
for (const deleted of DELETED_COMMANDS) {
|
||||
test(`${file}: blanket check — "${deleted}" not present`, () => {
|
||||
assert.ok(
|
||||
!content.includes(deleted),
|
||||
`${file} contains deleted command "${deleted}"`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
describe('bug #3050: update backup skips unreadable files non-fatally', () => {
|
||||
test('update workflow backup loop wraps copyFileSync in try/catch and logs non-fatal skip', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const hasTryCatch = /try\s*\{[\s\S]*copyFileSync\([\s\S]*\}[\s\S]*catch\s*\(err\)/.test(content);
|
||||
assert.ok(hasTryCatch, 'backup copy loop must catch per-file copy errors');
|
||||
|
||||
const hasNonFatalSkipMessage = /Skipped \(non-fatal\):/.test(content);
|
||||
assert.ok(
|
||||
hasNonFatalSkipMessage,
|
||||
'workflow must log a non-fatal skip message for unreadable custom files',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
|
||||
function read(rel) {
|
||||
return fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
}
|
||||
|
||||
function extractFindingsProbesFromBashBlocks(markdown) {
|
||||
const probes = [];
|
||||
const fenceRe = /```bash\r?\n([\s\S]*?)```/g;
|
||||
let fenceMatch;
|
||||
|
||||
while ((fenceMatch = fenceRe.exec(markdown)) !== null) {
|
||||
const block = fenceMatch[1];
|
||||
const baseLine = markdown.slice(0, fenceMatch.index).split(/\r?\n/).length;
|
||||
const lines = block.split(/\r?\n/);
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
if (!line.includes('.claude/skills/')) return;
|
||||
const kind = line.includes('sketch-findings-*/SKILL.md')
|
||||
? 'sketch'
|
||||
: line.includes('spike-findings-*/SKILL.md')
|
||||
? 'spike'
|
||||
: null;
|
||||
if (!kind) return;
|
||||
|
||||
probes.push({
|
||||
lineNumber: baseLine + idx,
|
||||
commandText: line.trim(),
|
||||
kind,
|
||||
hasNonFatalGuard: /\|\|\s*true/.test(line),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return probes;
|
||||
}
|
||||
|
||||
describe('bug #3072: optional sketch/spike findings probes are non-fatal', () => {
|
||||
test('all sketch/spike findings SKILL.md ls probes include || true', () => {
|
||||
const files = ['ui-phase.md', 'plan-phase.md', 'discuss-phase.md', 'new-project.md'];
|
||||
const offenders = [];
|
||||
|
||||
for (const file of files) {
|
||||
const content = read(file);
|
||||
const probes = extractFindingsProbesFromBashBlocks(content);
|
||||
for (const probe of probes) {
|
||||
if (!probe.hasNonFatalGuard) {
|
||||
offenders.push(`${file}:${probe.lineNumber} ${probe.commandText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(offenders, [], `missing non-fatal guard on optional findings probe:\n${offenders.join('\n')}`);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
describe('bug #3083: resume-project next-step routing should not include /clear then:', () => {
|
||||
test('route_to_workflow block omits /clear then: in resume templates', () => {
|
||||
const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'resume-project.md');
|
||||
const content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
const routeStart = content.indexOf('<step name="route_to_workflow">');
|
||||
const routeEnd = content.indexOf('</step>', routeStart);
|
||||
const routeBlock = content.slice(routeStart, routeEnd);
|
||||
|
||||
assert.equal(routeBlock.includes('/clear` then:'), false, 'resume route templates must not include `/clear` then:');
|
||||
});
|
||||
|
||||
test('route_to_workflow block includes exception note explaining resume behavior', () => {
|
||||
const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'resume-project.md');
|
||||
const content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
const routeStart = content.indexOf('<step name="route_to_workflow">');
|
||||
const routeEnd = content.indexOf('</step>', routeStart);
|
||||
const routeBlock = content.slice(routeStart, routeEnd);
|
||||
|
||||
assert.match(routeBlock, /resume.*exception|exception.*resume/i);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
'use strict';
|
||||
// allow-test-rule: reads product workflow markdown (ai-integration-phase.md) to verify structural ordering contract — not a source-grep test
|
||||
|
||||
// Regression guard for bug #3096.
|
||||
//
|
||||
// ai-integration-phase.md listed Steps 7+8 (gsd-ai-researcher +
|
||||
// gsd-domain-researcher) without an explicit sequential ordering constraint.
|
||||
// An orchestrator optimizing for speed could reasonably parallelize them
|
||||
// since the sections appeared disjoint. When parallelized, gsd-domain-researcher's
|
||||
// Write call at finalization replaced the whole AI-SPEC.md file with its
|
||||
// in-memory copy (pre-researcher state), silently overwriting Sections 3/4.
|
||||
//
|
||||
// Confirmed at 40% incidence rate on a real run (2 of 5 worktree agents hit it).
|
||||
// Recovery cost: one extra ai-researcher dispatch (~18 min wall).
|
||||
//
|
||||
// Fix:
|
||||
// 1. Explicit "MUST run sequentially" note on Steps 7 and 8
|
||||
// 2. Edit-only tool discipline injected into both agent prompts
|
||||
|
||||
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 src = fs.readFileSync(
|
||||
path.join(ROOT, 'gsd-core', 'workflows', 'ai-integration-phase.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('bug #3096: ai-integration-phase sequential ordering and Edit-only discipline', () => {
|
||||
test('Step 7 documents sequential ordering requirement', () => {
|
||||
assert.ok(
|
||||
src.includes('sequentially') || src.includes('sequential'),
|
||||
'Steps 7+8 ordering note is missing — parallel dispatch race can recur',
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 7 gsd-ai-researcher prompt includes Edit-only tool discipline', () => {
|
||||
// The discipline block must appear before </objective> for gsd-ai-researcher
|
||||
const step7Idx = src.indexOf('## 7. Spawn gsd-ai-researcher');
|
||||
const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher');
|
||||
assert.ok(step7Idx !== -1, 'Step 7 not found');
|
||||
assert.ok(step8Idx !== -1, 'Step 8 not found');
|
||||
const step7Block = src.slice(step7Idx, step8Idx);
|
||||
assert.ok(
|
||||
step7Block.includes('Edit tool') && step7Block.includes('NEVER use Write'),
|
||||
'Step 7 agent prompt missing Edit-only tool discipline',
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 8 gsd-domain-researcher prompt includes Edit-only tool discipline', () => {
|
||||
const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher');
|
||||
const step9Idx = src.indexOf('## 9. Spawn gsd-eval-planner');
|
||||
assert.ok(step8Idx !== -1, 'Step 8 not found');
|
||||
assert.ok(step9Idx !== -1, 'Step 9 not found');
|
||||
const step8Block = src.slice(step8Idx, step9Idx);
|
||||
assert.ok(
|
||||
step8Block.includes('Edit tool') && step8Block.includes('NEVER use Write'),
|
||||
'Step 8 agent prompt missing Edit-only tool discipline',
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 8 references the wait instruction', () => {
|
||||
const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher');
|
||||
const step9Idx = src.indexOf('## 9. Spawn gsd-eval-planner');
|
||||
const step8Block = src.slice(step8Idx, step9Idx);
|
||||
assert.ok(
|
||||
step8Block.includes('Wait') || step8Block.includes('wait') || step8Block.includes('complete'),
|
||||
'Step 8 does not instruct orchestrator to wait for Step 7',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
'use strict';
|
||||
// allow-test-rule: reads product workflow markdown (secure-phase.md) to verify structural guard contract — not a source-grep test
|
||||
|
||||
// Regression guard for bug #3120.
|
||||
//
|
||||
// secure-phase.md Step 3 short-circuited to Step 6 (write SECURITY.md)
|
||||
// whenever threats_open: 0, without distinguishing between:
|
||||
// Case A: All plan-time threat_model threats are CLOSED (legitimate skip)
|
||||
// Case B: No threat_model blocks were written at plan time (legacy phases)
|
||||
// → rubber-stamps a clean SECURITY.md with zero audit performed
|
||||
//
|
||||
// Fix: Step 2c tracks `register_authored_at_plan_time` (true iff ≥1 PLAN
|
||||
// file contained a parseable <threat_model> block). Step 3 now requires BOTH
|
||||
// threats_open: 0 AND register_authored_at_plan_time to skip. If only
|
||||
// threats_open: 0 and NOT register_authored_at_plan_time, Step 5 runs in
|
||||
// retroactive-STRIDE mode.
|
||||
|
||||
const { test, describe } = 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 src = fs.readFileSync(
|
||||
path.join(ROOT, 'gsd-core', 'workflows', 'secure-phase.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('bug #3120: secure-phase short-circuit guards', () => {
|
||||
test('Step 2c tracks register_authored_at_plan_time', () => {
|
||||
assert.ok(
|
||||
src.includes('register_authored_at_plan_time'),
|
||||
'secure-phase.md does not track register_authored_at_plan_time in Step 2c',
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 3 short-circuit requires both conditions', () => {
|
||||
assert.ok(
|
||||
src.includes('threats_open: 0 AND register_authored_at_plan_time'),
|
||||
'Step 3 short-circuit does not gate on both threats_open:0 AND register_authored_at_plan_time',
|
||||
);
|
||||
});
|
||||
|
||||
test('retroactive-STRIDE mode is documented for legacy phases', () => {
|
||||
assert.ok(
|
||||
src.includes('retroactive') || src.includes('Retroactive'),
|
||||
'secure-phase.md does not document retroactive-STRIDE mode for legacy phases (no <threat_model> blocks)',
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 5 auditor constraint varies by mode', () => {
|
||||
assert.ok(
|
||||
(src.includes('Verify mitigations') || src.includes('verify mitigations')) &&
|
||||
(src.includes('Retroactive') || src.includes('retroactive')),
|
||||
'Step 5 does not distinguish planned vs retroactive-STRIDE auditor constraint',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
'use strict';
|
||||
// allow-test-rule: reads product workflow markdown (update.md) to verify structural invocation contract — not a source-grep test
|
||||
|
||||
// Regression guard for bug #3130.
|
||||
//
|
||||
// Two failure modes were observed with the pre-fix npx invocation form:
|
||||
// 1. Cache-stale: bare `npx -y @opengsd/gsd-core@<tag>` hits npx's local
|
||||
// cache and may pull an older version instead of the target tag.
|
||||
// 2. Token-routing: Bash-tool wrappers misroute the `@` token in
|
||||
// `@opengsd/gsd-core@<tag>`, causing npm to error with
|
||||
// "Unknown command: @opengsd/gsd-core@<tag>".
|
||||
//
|
||||
// The robust form is:
|
||||
// npx -y --package=@opengsd/gsd-core@"$TAG" -- gsd-core $ARGS
|
||||
//
|
||||
// `--package=` forces a fresh registry fetch, bypassing the npx cache.
|
||||
// `--` clearly delineates npx flags from the run-command, preventing
|
||||
// Bash-tool @-token misrouting.
|
||||
// `$TAG` is a shell variable (latest by default, next under --next/--rc),
|
||||
// set by the parse_update_channel step (#815).
|
||||
|
||||
const { 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 UPDATE_WF = path.join(ROOT, 'gsd-core', 'workflows', 'update.md');
|
||||
|
||||
const src = fs.readFileSync(UPDATE_WF, 'utf8');
|
||||
|
||||
test('bug #3130: update.md contains no bare npx invocations (cache-stale form)', () => {
|
||||
// Any occurrence of `npx -y @opengsd/gsd-core@<something>` without `--package=`
|
||||
// is the stale form that triggers the two failure modes.
|
||||
const stale = (src.match(/npx -y @opengsd\/gsd-core@\S+[^\r\n]*/g) || []);
|
||||
assert.deepEqual(
|
||||
stale,
|
||||
[],
|
||||
`Stale npx forms found in update.md (must use --package= form): ${stale.join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('bug #3130: update.md has >=3 robust npx invocations (--package= + -- separator)', () => {
|
||||
// Three sibling invocations: local, global, and unknown/fallback.
|
||||
// The tag is now a $TAG variable (latest by default, next under --next/--rc).
|
||||
const robust = (src.match(/npx -y --package=@opengsd\/gsd-core@\S+ -- gsd-core/g) || []);
|
||||
assert.ok(
|
||||
robust.length >= 3,
|
||||
`Expected >=3 robust npx invocations in update.md, found ${robust.length}`,
|
||||
);
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Drift-guard for bug #3195: quick.md and execute-phase.md must both use
|
||||
* the same resurrection-detection approach so they stay in sync.
|
||||
*
|
||||
* After #3797: both workflows delegate worktree cleanup to the SDK's
|
||||
* worktree.cleanup-wave command, which implements resurrection detection
|
||||
* (diff --diff-filter=D history checks) internally. The inline WAS_DELETED
|
||||
* shell variable form has been removed from both workflows — it was part of
|
||||
* the SDK-absence fallback which is now dead code since preflight exits if
|
||||
* neither local nor global SDK is available.
|
||||
*
|
||||
* This test ensures both workflows continue to use the same cleanup
|
||||
* mechanism (SDK delegation), not one inline and one delegated.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const QUICK_MD = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'quick.md'
|
||||
);
|
||||
const EXECUTE_PHASE_MD = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'
|
||||
);
|
||||
|
||||
describe('resurrection guard drift check — quick.md vs execute-phase.md (#3195)', () => {
|
||||
let quickContent;
|
||||
let executePhaseContent;
|
||||
|
||||
test('both workflow files are readable', () => {
|
||||
quickContent = fs.readFileSync(QUICK_MD, 'utf-8');
|
||||
executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8');
|
||||
assert.ok(quickContent.length > 0, 'quick.md must not be empty');
|
||||
assert.ok(executePhaseContent.length > 0, 'execute-phase.md must not be empty');
|
||||
});
|
||||
|
||||
test('quick.md delegates resurrection detection to SDK (worktree.cleanup-wave)', () => {
|
||||
if (!quickContent) quickContent = fs.readFileSync(QUICK_MD, 'utf-8');
|
||||
// After #3797: quick.md delegates to worktree.cleanup-wave, which handles
|
||||
// resurrection detection (diff --diff-filter=D) internally. The inline
|
||||
// WAS_DELETED form has been removed — it was part of the SDK-absence fallback.
|
||||
assert.ok(
|
||||
quickContent.includes('worktree.cleanup-wave'),
|
||||
'quick.md must delegate to worktree.cleanup-wave for resurrection detection (#3195/#3797)'
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md delegates resurrection detection to SDK (worktree.cleanup-wave)', () => {
|
||||
if (!executePhaseContent) executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8');
|
||||
// After #3797: execute-phase.md delegates to worktree.cleanup-wave, which handles
|
||||
// resurrection detection (diff --diff-filter=D) internally.
|
||||
assert.ok(
|
||||
executePhaseContent.includes('worktree.cleanup-wave'),
|
||||
'execute-phase.md must delegate to worktree.cleanup-wave for resurrection detection (#3195/#3797)'
|
||||
);
|
||||
});
|
||||
|
||||
test('both workflows use the same cleanup mechanism (SDK delegation parity)', () => {
|
||||
if (!quickContent) quickContent = fs.readFileSync(QUICK_MD, 'utf-8');
|
||||
if (!executePhaseContent) executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8');
|
||||
const quickDelegates = quickContent.includes('worktree.cleanup-wave');
|
||||
const executeDelegates = executePhaseContent.includes('worktree.cleanup-wave');
|
||||
assert.strictEqual(
|
||||
quickDelegates,
|
||||
executeDelegates,
|
||||
'quick.md and execute-phase.md must both use the same cleanup mechanism (SDK delegation parity, #3195)'
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md does not use the buggy PRE_MERGE_FILES grep form', () => {
|
||||
if (!quickContent) quickContent = fs.readFileSync(QUICK_MD, 'utf-8');
|
||||
// The buggy pattern: deletion conditioned on absence from PRE_MERGE_FILES snapshot
|
||||
const hasBuggyGuard =
|
||||
quickContent.includes('PRE_MERGE_FILES') &&
|
||||
/if\s*!\s*echo\s*"\$PRE_MERGE_FILES"\s*\|\s*grep\s+-qxF\s*"\$RESURRECTED"/.test(quickContent);
|
||||
assert.ok(
|
||||
!hasBuggyGuard,
|
||||
'quick.md must NOT delete files based on the PRE_MERGE_FILES snapshot grep (inverted guard bug #3195)'
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md does not use the buggy PRE_MERGE_FILES grep form', () => {
|
||||
if (!executePhaseContent) executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8');
|
||||
const hasBuggyGuard =
|
||||
executePhaseContent.includes('PRE_MERGE_FILES') &&
|
||||
/if\s*!\s*echo\s*"\$PRE_MERGE_FILES"\s*\|\s*grep\s+-qxF\s*"\$RESURRECTED"/.test(executePhaseContent);
|
||||
assert.ok(
|
||||
!hasBuggyGuard,
|
||||
'execute-phase.md must NOT delete files based on the PRE_MERGE_FILES snapshot grep (inverted guard bug)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product — workflow and command .md files
|
||||
// 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 #3236.
|
||||
//
|
||||
// The `plant-seed.md` workflow gained a mandatory Trigger / Why / Scope
|
||||
// questionnaire (gather_context step) that blocks before the seed file is
|
||||
// written. Users capturing a stream of ideas lose flow because the AI must
|
||||
// receive three answers before a single write happens.
|
||||
//
|
||||
// Fix: the seed file must be written FIRST (one-shot), with sensible defaults
|
||||
// for Trigger / Why / Scope. The enrichment questions must be optional and must
|
||||
// come AFTER the file is written, not before.
|
||||
//
|
||||
// Behavioral contract tested here:
|
||||
// 1. The `write-seed` step exists and comes BEFORE any AskUserQuestion for
|
||||
// Trigger / Why / Scope enrichment.
|
||||
// 2. The workflow provides sensible defaults for trigger_when and scope when
|
||||
// the user supplies only the idea summary.
|
||||
// 3. The AskUserQuestion calls for Trigger / Why / Scope still exist (optional
|
||||
// enrichment path preserved) but are gated after the file is written.
|
||||
|
||||
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 PLANT_SEED = path.join(ROOT, 'gsd-core', 'workflows', 'plant-seed.md');
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function readPlantSeed() {
|
||||
try {
|
||||
return fs.readFileSync(PLANT_SEED, 'utf8');
|
||||
} catch (err) {
|
||||
throw new Error('gsd-core/workflows/plant-seed.md not found: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract step names in document order from workflow XML.
|
||||
*/
|
||||
function extractStepNames(src) {
|
||||
const names = [];
|
||||
const re = /<step name="([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null) {
|
||||
names.push(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return byte offset of the step opening tag, or -1 if absent.
|
||||
*/
|
||||
function stepOffset(src, stepName) {
|
||||
return src.indexOf('<step name="' + stepName + '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return byte offset of the first AskUserQuestion with the given header label.
|
||||
*/
|
||||
function askQuestionOffset(src, header) {
|
||||
return src.indexOf('header: "' + header + '"');
|
||||
}
|
||||
|
||||
// ── #3236: plant-seed one-shot contract ──────────────────────────────────────
|
||||
|
||||
describe('#3236: plant-seed.md one-shot capture contract', () => {
|
||||
test('plant-seed.md exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(PLANT_SEED),
|
||||
'gsd-core/workflows/plant-seed.md does not exist',
|
||||
);
|
||||
});
|
||||
|
||||
test('write-seed step exists (hyphenated name per CONTEXT.md rule)', () => {
|
||||
const src = readPlantSeed();
|
||||
const steps = extractStepNames(src);
|
||||
assert.ok(
|
||||
steps.includes('write-seed'),
|
||||
'plant-seed.md must contain a <step name="write-seed"> (hyphens, not underscores); found: ' + JSON.stringify(steps),
|
||||
);
|
||||
});
|
||||
|
||||
test('write-seed step appears before Trigger AskUserQuestion', () => {
|
||||
const src = readPlantSeed();
|
||||
const writeOff = stepOffset(src, 'write-seed');
|
||||
assert.ok(writeOff !== -1, 'write-seed step must exist');
|
||||
const triggerOff = askQuestionOffset(src, 'Trigger');
|
||||
if (triggerOff === -1) return; // fully optional — no question at all is fine
|
||||
assert.ok(
|
||||
writeOff < triggerOff,
|
||||
'write-seed (offset ' + writeOff + ') must precede Trigger question (offset ' + triggerOff + ')',
|
||||
);
|
||||
});
|
||||
|
||||
test('write-seed step appears before Why AskUserQuestion', () => {
|
||||
const src = readPlantSeed();
|
||||
const writeOff = stepOffset(src, 'write-seed');
|
||||
assert.ok(writeOff !== -1, 'write-seed step must exist');
|
||||
const whyOff = askQuestionOffset(src, 'Why');
|
||||
if (whyOff === -1) return;
|
||||
assert.ok(
|
||||
writeOff < whyOff,
|
||||
'write-seed (offset ' + writeOff + ') must precede Why question (offset ' + whyOff + ')',
|
||||
);
|
||||
});
|
||||
|
||||
test('write-seed step appears before Scope AskUserQuestion', () => {
|
||||
const src = readPlantSeed();
|
||||
const writeOff = stepOffset(src, 'write-seed');
|
||||
assert.ok(writeOff !== -1, 'write-seed step must exist');
|
||||
const scopeOff = askQuestionOffset(src, 'Scope');
|
||||
if (scopeOff === -1) return;
|
||||
assert.ok(
|
||||
writeOff < scopeOff,
|
||||
'write-seed (offset ' + writeOff + ') must precede Scope question (offset ' + scopeOff + ')',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow documents a default value for trigger_when', () => {
|
||||
const src = readPlantSeed();
|
||||
assert.ok(
|
||||
/trigger_when.*default|default.*trigger|when relevant|when scope matches|unspecified/i.test(src),
|
||||
'plant-seed.md must document a default for trigger_when when the user provides no Trigger',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow documents a default value for scope', () => {
|
||||
const src = readPlantSeed();
|
||||
assert.ok(
|
||||
/scope.*default|default.*scope|scope.*unknown|unknown|scope.*unspecified|unspecified/i.test(src),
|
||||
'plant-seed.md must document a default for scope when the user provides no Scope',
|
||||
);
|
||||
});
|
||||
|
||||
test('write-seed step precedes enrich/gather step in step order', () => {
|
||||
const src = readPlantSeed();
|
||||
const steps = extractStepNames(src);
|
||||
const writeIdx = steps.findIndex((s) => s === 'write-seed' || s === 'write_seed');
|
||||
const gatherIdx = steps.findIndex(
|
||||
(s) => s === 'gather-context' || s === 'gather_context' || s === 'enrich-seed' || s === 'enrich_seed',
|
||||
);
|
||||
if (gatherIdx === -1 || writeIdx === -1) return; // no gather step — one-shot only path, fine
|
||||
assert.ok(
|
||||
writeIdx < gatherIdx,
|
||||
'write-seed (step index ' + writeIdx + ') must appear before gather/enrich (index ' + gatherIdx + ') — capture first, enrich later',
|
||||
);
|
||||
});
|
||||
|
||||
test('optional enrichment path is preserved', () => {
|
||||
const src = readPlantSeed();
|
||||
const hasEnrichStep = /enrich|gather.context|gather_context/i.test(src);
|
||||
const hasTriggerQuestion = src.includes('header: "Trigger"');
|
||||
assert.ok(
|
||||
hasEnrichStep || hasTriggerQuestion,
|
||||
'plant-seed.md must preserve an optional enrichment path (enrich step or Trigger/Why/Scope AskUserQuestion)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-product [#3320]
|
||||
// The bug is a contradiction in prompt/workflow source text. These assertions
|
||||
// intentionally pin the contract words that planner agents consume.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const PLANNER_AGENT = path.join(ROOT, 'agents', 'gsd-planner.md');
|
||||
const PLAN_PHASE_WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'plan-phase.md');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function extractDeepWorkRules() {
|
||||
const workflow = fs.readFileSync(PLAN_PHASE_WORKFLOW, 'utf8');
|
||||
const match = workflow.match(/<deep_work_rules>[\s\S]*?<\/deep_work_rules>/);
|
||||
assert.ok(match, 'plan-phase.md must contain a deep_work_rules block');
|
||||
return match[0];
|
||||
}
|
||||
|
||||
describe('bug #3320 planner action contract', () => {
|
||||
test('planner agent explicitly keeps implementation code out of action blocks', () => {
|
||||
const planner = fs.readFileSync(PLANNER_AGENT, 'utf8');
|
||||
|
||||
assert.match(
|
||||
planner,
|
||||
/NEVER place fenced code blocks \(```\) inside `<action>`/,
|
||||
'gsd-planner.md must explicitly forbid fenced implementation code in <action>'
|
||||
);
|
||||
assert.match(
|
||||
planner,
|
||||
/Code excerpts belong in `<read_first>` source files or referenced context/,
|
||||
'gsd-planner.md must route code excerpts to context/read-first material'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase deep_work_rules no longer requires self-sufficient code dumps', () => {
|
||||
const deepWorkRules = extractDeepWorkRules();
|
||||
|
||||
assert.doesNotMatch(
|
||||
deepWorkRules,
|
||||
/copy them into the action verbatim/,
|
||||
'deep_work_rules must not tell planners to copy source material verbatim into <action>'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
deepWorkRules,
|
||||
/complete the task from the action text alone/,
|
||||
'deep_work_rules must not make <action> self-sufficient without read_first/context'
|
||||
);
|
||||
assert.match(
|
||||
deepWorkRules,
|
||||
/Do not include full file contents, fenced code blocks, or complete implementations in `<action>`/,
|
||||
'deep_work_rules must explicitly bound concrete values to avoid code dumping'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase acceptance criteria allow behavior and test assertions', () => {
|
||||
const deepWorkRules = extractDeepWorkRules();
|
||||
|
||||
assert.match(
|
||||
deepWorkRules,
|
||||
/behavior assertion/,
|
||||
'acceptance criteria must allow behavior assertions, not just grep checks'
|
||||
);
|
||||
assert.match(
|
||||
deepWorkRules,
|
||||
/test command/,
|
||||
'acceptance criteria must allow test-command assertions'
|
||||
);
|
||||
});
|
||||
|
||||
test('quality gate matches the reconciled planner contract', () => {
|
||||
const workflow = read('gsd-core/workflows/plan-phase.md');
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/Every task has `<acceptance_criteria>` with behavior, test-command, CLI, or source assertions/,
|
||||
'quality gate must not narrow acceptance criteria back to grep-only checks'
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/Every `<action>` contains concrete identifiers without fenced code blocks or full implementations/,
|
||||
'quality gate must enforce concrete prose without implementation dumps'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product — verify-work.md is a runtime workflow contract.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
describe('bug #1716: resume_from_file routes to complete_session when no [pending] tests remain', () => {
|
||||
test('resume_from_file step contains guard clause for zero-pending (all-blocked) case', () => {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-work.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const stepStart = workflow.indexOf('<step name="resume_from_file">');
|
||||
assert.ok(stepStart !== -1, 'resume_from_file step must exist');
|
||||
|
||||
const stepEnd = workflow.indexOf('</step>', stepStart);
|
||||
const stepBody = workflow.slice(stepStart, stepEnd);
|
||||
|
||||
// Guard must appear immediately after the find-pending instruction.
|
||||
// Without it, all-blocked sessions (pending_count==0, blocked_count>0)
|
||||
// silently terminate and never reach complete_session (#1716).
|
||||
const findIdx = stepBody.indexOf("Find first test with `result: [pending]`.");
|
||||
const guardIdx = stepBody.indexOf("If no `[pending]` test found → go to `complete_session`.");
|
||||
|
||||
assert.ok(findIdx !== -1, 'find-pending instruction must be present');
|
||||
assert.ok(guardIdx !== -1, 'guard clause for zero-pending case must be present');
|
||||
assert.ok(guardIdx > findIdx, 'guard must appear after find-pending instruction');
|
||||
|
||||
const between = stepBody
|
||||
.slice(findIdx + "Find first test with `result: [pending]`.".length, guardIdx)
|
||||
.trim();
|
||||
assert.strictEqual(between, '', 'guard must be the next non-whitespace line after find-pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #3381: verify-work forwards workstream context', () => {
|
||||
test('workflow forwards ${GSD_WS} to workstream-sensitive SDK queries', () => {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-work.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.match(workflow, /GSD_WS=""/, 'verify-work must initialize GSD_WS');
|
||||
assert.match(
|
||||
workflow,
|
||||
/grep -qE -- '--ws\[\[:space:\]\]\+\[\^\[:space:\]\]\+'/,
|
||||
'verify-work must detect --ws in $ARGUMENTS',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/grep -oE -- '--ws\[\[:space:\]\]\+\[\^\[:space:\]\]\+'/,
|
||||
'verify-work must extract the --ws flag pair from $ARGUMENTS',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/PHASE_ARG=\$\(echo "\$ARGUMENTS" \| sed -E 's\/--ws\[\[:space:\]\]\+\[\^\[:space:\]\]\+\/\/g' \| xargs\)/,
|
||||
'verify-work must derive PHASE_ARG after removing --ws',
|
||||
);
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
assert.match(
|
||||
workflow,
|
||||
/gsd_run query init\.verify-work "\$\{PHASE_ARG\}" \$\{GSD_WS\}/,
|
||||
'init.verify-work must receive GSD_WS so phase_dir resolves in workstreams',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/gsd_run query phase\.mvp-mode "\$\{phase_number\}" \$\{GSD_WS\} --pick active/,
|
||||
'phase.mvp-mode must receive GSD_WS so roadmap mode is workstream-scoped',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/gsd_run query roadmap\.get-phase "\$\{phase_number\}" \$\{GSD_WS\} --pick goal/,
|
||||
'roadmap.get-phase must receive GSD_WS so goals are workstream-scoped',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// gsd-core/workflows/reapply-patches.md is the installed runtime workflow —
|
||||
// its text IS the deployed behavioral contract for the --reapply flag.
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Bug #3516: reapply-patches.md git-enhanced two-way merge filter misses
|
||||
* commits authored by the renamed `/gsd-update` flow.
|
||||
*
|
||||
* The `grep -v` alternation on line 231 only included the legacy `gsd:update`
|
||||
* marker. After the slash-command rename `/gsd:update` → `/gsd-update`, commits
|
||||
* authored by the current flow fall through the filter and are misclassified as
|
||||
* user customizations, prompting spurious merge conflicts during `--reapply`.
|
||||
*
|
||||
* Fix: add `gsd-update` arm to the alternation so both the legacy and current
|
||||
* commit-message prefixes are excluded. `GSD update` and `gsd-install`
|
||||
* exclusions are preserved.
|
||||
*
|
||||
* Per the repo's source-text-is-the-product exception: the workflow file's text
|
||||
* IS the deployed behavioral contract. Structural assertion against the parsed
|
||||
* shell command string is the correct test form here.
|
||||
*/
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'reapply-patches.md',
|
||||
);
|
||||
|
||||
/**
|
||||
* Extract the git log filter command from the workflow.
|
||||
*
|
||||
* Looks for the `grep -v "..."` shell snippet inside the Git-enhanced two-way
|
||||
* merge section and returns the alternation string between the quotes.
|
||||
* Returns null if the snippet is absent (signals a structural regression).
|
||||
*/
|
||||
function extractFilterAlternation(content) {
|
||||
// Match the grep -v "..." line in the bash block
|
||||
const match = content.match(/grep\s+-v\s+"([^"]+)"/);
|
||||
if (!match) return null;
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the alternation string (pipe-delimited) into individual arms.
|
||||
* Handles escaped pipes produced by shell regex syntax (`\|`).
|
||||
*/
|
||||
function parseAlternationArms(alternation) {
|
||||
// Shell grep alternation uses \| (escaped pipe); split on that
|
||||
return alternation.split(/\\\|/).map((arm) => arm.trim());
|
||||
}
|
||||
|
||||
describe('Bug #3516: git-enhanced two-way merge filter includes gsd-update arm', () => {
|
||||
let content;
|
||||
let alternation;
|
||||
let arms;
|
||||
|
||||
before(() => {
|
||||
content = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
alternation = extractFilterAlternation(content);
|
||||
arms = alternation ? parseAlternationArms(alternation) : [];
|
||||
});
|
||||
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(WORKFLOW_PATH),
|
||||
'gsd-core/workflows/reapply-patches.md must exist',
|
||||
);
|
||||
});
|
||||
|
||||
test('git-enhanced two-way merge section contains a grep -v filter', () => {
|
||||
assert.ok(
|
||||
alternation !== null,
|
||||
'reapply-patches.md must contain a `grep -v "..."` filter in the git-enhanced two-way merge section',
|
||||
);
|
||||
});
|
||||
|
||||
test('filter excludes legacy gsd:update commits (back-compat)', () => {
|
||||
assert.ok(
|
||||
arms.some((arm) => arm === 'gsd:update'),
|
||||
`filter must include 'gsd:update' arm for back-compat; got arms: ${JSON.stringify(arms)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('filter excludes renamed gsd-update commits (primary fix)', () => {
|
||||
assert.ok(
|
||||
arms.some((arm) => arm === 'gsd-update'),
|
||||
`filter must include 'gsd-update' arm (renamed flow); got arms: ${JSON.stringify(arms)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('filter excludes GSD update commits (no regression)', () => {
|
||||
assert.ok(
|
||||
arms.some((arm) => arm === 'GSD update'),
|
||||
`filter must include 'GSD update' arm; got arms: ${JSON.stringify(arms)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('filter excludes gsd-install commits (no regression)', () => {
|
||||
assert.ok(
|
||||
arms.some((arm) => arm === 'gsd-install'),
|
||||
`filter must include 'gsd-install' arm; got arms: ${JSON.stringify(arms)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('all four expected exclusion patterns are present in the filter', () => {
|
||||
const required = ['gsd:update', 'gsd-update', 'GSD update', 'gsd-install'];
|
||||
const missing = required.filter((p) => !arms.some((arm) => arm === p));
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`filter is missing required exclusion patterns: ${JSON.stringify(missing)}; got arms: ${JSON.stringify(arms)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// quick.md is the shipped orchestration contract for /gsd-quick; this
|
||||
// regression test previously locked the CWD-safety guard in the manual shell
|
||||
// cleanup loop. After #3797, quick.md delegates cleanup entirely to the SDK's
|
||||
// worktree.cleanup-wave command, which encapsulates CWD-pinning, STATE.md/
|
||||
// ROADMAP.md backup/restore, and deletion guards internally.
|
||||
//
|
||||
// This test file now verifies the delegation contract: quick.md calls
|
||||
// worktree.cleanup-wave with || exit 1 (fail-closed), which enforces the
|
||||
// safety semantics that were previously implemented inline in the shell loop.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const QUICK_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
function readQuickMd() {
|
||||
return fs.readFileSync(QUICK_MD, 'utf8');
|
||||
}
|
||||
|
||||
describe('bug #3521 — quick.md post-merge cleanup CWD safety (via SDK delegation, #3797)', () => {
|
||||
|
||||
test('quick.md is readable', () => {
|
||||
const content = readQuickMd();
|
||||
assert.ok(content.length > 0, 'quick.md must not be empty');
|
||||
});
|
||||
|
||||
test('quick.md cleanup delegates CWD-safe worktree cleanup to SDK (worktree.cleanup-wave)', () => {
|
||||
const content = readQuickMd();
|
||||
// After #3797: quick.md delegates to gsd_run query worktree.cleanup-wave
|
||||
// which handles CWD pinning, STATE.md backup, deletion guards, and branch
|
||||
// cleanup internally. The manual shell loop has been removed.
|
||||
assert.ok(
|
||||
content.includes('worktree.cleanup-wave'),
|
||||
'quick.md must delegate cleanup to gsd_run query worktree.cleanup-wave (#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md cleanup-wave call uses || exit 1 to enforce fail-closed safety (#3521 contract)', () => {
|
||||
const content = readQuickMd();
|
||||
// The || exit 1 enforces fail-closed: SDK safety refusals (e.g. branch
|
||||
// drift detection from #3174) surface immediately rather than being swallowed.
|
||||
// This is the equivalent of the pre-#3797 `gsd_run query ... || exit 1` in the
|
||||
// `if command -v gsd-sdk` branch.
|
||||
assert.match(
|
||||
content,
|
||||
/gsd_run query worktree\.cleanup-wave.*\|\| exit 1/,
|
||||
'quick.md cleanup-wave must use || exit 1 — fail-closed for safety refusals (#3521/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md manifest guard still blocks broad cleanup when manifest is missing (#3384)', () => {
|
||||
const content = readQuickMd();
|
||||
// The manifest guard must still be present before the cleanup-wave call
|
||||
// to prevent broad worktree cleanup when the manifest file is absent.
|
||||
assert.ok(
|
||||
content.includes('QUICK_WORKTREE_MANIFEST') || content.includes('WAVE_WORKTREE_MANIFEST'),
|
||||
'quick.md must still guard cleanup behind QUICK_WORKTREE_MANIFEST (#3384)',
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('refusing broad worktree cleanup') || content.includes('missing QUICK_WORKTREE_MANIFEST'),
|
||||
'quick.md must emit a blocked message when the manifest is missing (#3384)',
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// The deployed settings.md IS the product — testing its text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* Regression test for bug #3784
|
||||
*
|
||||
* /gsd-settings model profile UI omits `adaptive`. The AskUserQuestion block
|
||||
* for model_profile in settings.md lists only four options (Quality, Balanced,
|
||||
* Budget, Inherit) but the settings schema registers five valid profiles:
|
||||
* quality, balanced, budget, adaptive, inherit. The `adaptive` profile is
|
||||
* reachable by name via `gsd:config --profile adaptive` but cannot be selected
|
||||
* interactively through `/gsd:settings`.
|
||||
*
|
||||
* Root cause: the options array in the model-profile AskUserQuestion block was
|
||||
* written before the `adaptive` profile was introduced and was never updated.
|
||||
* Because AskUserQuestion enforces a hard 4-option cap, the fix uses a two-
|
||||
* question split: Q1 asks "Standard tier or Adaptive?" (2 options); if the
|
||||
* user picks Standard, Q2 asks which of the three standard profiles to use
|
||||
* (Quality, Balanced, Budget). This keeps every call within the 4-option cap
|
||||
* while making all five profiles reachable.
|
||||
*
|
||||
* Fixes: #3784
|
||||
*/
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SETTINGS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'settings.md');
|
||||
|
||||
/**
|
||||
* Extract all AskUserQuestion option labels from a text block.
|
||||
* Returns them lowercased for case-insensitive comparison.
|
||||
*/
|
||||
function extractOptionLabels(block) {
|
||||
const labelPattern = /label:\s*"([^"]+)"/g;
|
||||
const labels = [];
|
||||
let match;
|
||||
while ((match = labelPattern.exec(block)) !== null) {
|
||||
labels.push(match[1].toLowerCase());
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
describe('bug #3784: settings.md model profile UI exposes all 5 profiles', () => {
|
||||
let content;
|
||||
let presentBlock;
|
||||
|
||||
before(() => {
|
||||
content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
const presentMatch = content.match(/<step name="present_settings">[\s\S]*?<\/step>/);
|
||||
assert.ok(presentMatch, 'settings.md must have a present_settings step');
|
||||
presentBlock = presentMatch[0];
|
||||
});
|
||||
|
||||
// ── Core contract: all five valid profiles reachable via the settings UI ──
|
||||
|
||||
test('present_settings step includes Adaptive as a selectable option (#3784)', () => {
|
||||
// This is the primary assertion for bug #3784 — adaptive was missing.
|
||||
const labels = extractOptionLabels(presentBlock);
|
||||
assert.ok(
|
||||
labels.some(l => l === 'adaptive' || l.startsWith('adaptive')),
|
||||
[
|
||||
'Bug #3784: present_settings step must include an "Adaptive" label in',
|
||||
'its model profile AskUserQuestion options so users can select it',
|
||||
`interactively. Got labels: [${labels.join(', ')}]`,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
|
||||
test('present_settings step includes Quality as a selectable option', () => {
|
||||
const labels = extractOptionLabels(presentBlock);
|
||||
assert.ok(
|
||||
labels.some(l => l === 'quality' || l.startsWith('quality')),
|
||||
`present_settings step must include a "Quality" option. Got: [${labels.join(', ')}]`
|
||||
);
|
||||
});
|
||||
|
||||
test('present_settings step includes Balanced as a selectable option', () => {
|
||||
const labels = extractOptionLabels(presentBlock);
|
||||
assert.ok(
|
||||
labels.some(l => l === 'balanced' || l.startsWith('balanced')),
|
||||
`present_settings step must include a "Balanced" option. Got: [${labels.join(', ')}]`
|
||||
);
|
||||
});
|
||||
|
||||
test('present_settings step includes Budget as a selectable option', () => {
|
||||
const labels = extractOptionLabels(presentBlock);
|
||||
assert.ok(
|
||||
labels.some(l => l === 'budget' || l.startsWith('budget')),
|
||||
`present_settings step must include a "Budget" option. Got: [${labels.join(', ')}]`
|
||||
);
|
||||
});
|
||||
|
||||
test('present_settings step includes Inherit as a selectable option', () => {
|
||||
const labels = extractOptionLabels(presentBlock);
|
||||
assert.ok(
|
||||
labels.some(l => l === 'inherit' || l.startsWith('inherit')),
|
||||
`present_settings step must include an "Inherit" option. Got: [${labels.join(', ')}]`
|
||||
);
|
||||
});
|
||||
|
||||
// ── update_config step writes adaptive as a valid value ──
|
||||
|
||||
test('update_config step lists adaptive as a valid model_profile value', () => {
|
||||
const updateMatch = content.match(/<step name="update_config">[\s\S]*?<\/step>/);
|
||||
assert.ok(updateMatch, 'settings.md must have an update_config step');
|
||||
const block = updateMatch[0];
|
||||
assert.ok(
|
||||
block.includes('adaptive'),
|
||||
'update_config step must list "adaptive" as a valid model_profile value'
|
||||
);
|
||||
});
|
||||
|
||||
// ── confirm step displays adaptive as a possible profile value ──
|
||||
|
||||
test('confirm step table shows adaptive as a possible model profile value', () => {
|
||||
const confirmMatch = content.match(/<step name="confirm">[\s\S]*?<\/step>/);
|
||||
assert.ok(confirmMatch, 'settings.md must have a confirm step');
|
||||
const block = confirmMatch[0];
|
||||
assert.ok(
|
||||
block.includes('adaptive'),
|
||||
'confirm step must include "adaptive" in the Model Profile row placeholder'
|
||||
);
|
||||
});
|
||||
|
||||
// ── adaptive described with role-based routing semantics ──
|
||||
|
||||
test('settings.md describes adaptive profile with role-based routing semantics', () => {
|
||||
// Adaptive uses heavy/light role tiers per routingTier.
|
||||
// The UI description must convey role-based cost optimization and the heavy/light tier
|
||||
// split — not just mention "Adaptive" somewhere (that word appears 6+ times in the file).
|
||||
const lower = content.toLowerCase();
|
||||
assert.ok(
|
||||
lower.includes('role-based cost optimization') && lower.includes('heavy roles'),
|
||||
'settings.md must describe the adaptive profile with "role-based cost optimization" and "heavy roles" wording so the description is meaningful across all supported runtimes'
|
||||
);
|
||||
});
|
||||
|
||||
// ── 4-option cap enforcement ──
|
||||
|
||||
test('each question object in present_settings AskUserQuestion blocks has at most 4 options (AskUserQuestion runtime cap)', () => {
|
||||
// The AskUserQuestion runtime enforces a hard 4-option cap per individual question object
|
||||
// (each { question:..., options:[...] } entry). This test guards against a naïve revert
|
||||
// that puts all 5 profiles into a single question object instead of using the Q1/Q2 split.
|
||||
const ASK_USER_QUESTION_OPTION_CAP = 4; // hard limit enforced by the AskUserQuestion runtime
|
||||
|
||||
// Extract each individual options array by finding 'options: [' and walking to the
|
||||
// matching balanced ']', then count label: entries within that span.
|
||||
const optionsKeyRe = /\boptions\s*:\s*\[/g;
|
||||
let match;
|
||||
let questionIndex = 0;
|
||||
while ((match = optionsKeyRe.exec(presentBlock)) !== null) {
|
||||
questionIndex++;
|
||||
// Walk forward from the opening '[' to find the balanced close ']'.
|
||||
let depth = 0;
|
||||
const start = match.index + match[0].length - 1; // points at '['
|
||||
let end = start;
|
||||
for (let k = start; k < presentBlock.length; k++) {
|
||||
if (presentBlock[k] === '[') { depth++; }
|
||||
else if (presentBlock[k] === ']') {
|
||||
depth--;
|
||||
if (depth === 0) { end = k; break; }
|
||||
}
|
||||
}
|
||||
const optionsBody = presentBlock.slice(start, end + 1);
|
||||
const labelMatches = optionsBody.match(/label:\s*"[^"]+"/g) || [];
|
||||
const optionCount = labelMatches.length;
|
||||
assert.ok(
|
||||
optionCount <= ASK_USER_QUESTION_OPTION_CAP,
|
||||
`Question object ${questionIndex} in present_settings has ${optionCount} options — exceeds the runtime cap of ${ASK_USER_QUESTION_OPTION_CAP}. Split into multiple questions (as #3784 did for model_profile).`
|
||||
);
|
||||
}
|
||||
// Sanity check: there must be at least one options array found.
|
||||
assert.ok(questionIndex > 0, 'present_settings must contain at least one AskUserQuestion options array');
|
||||
});
|
||||
|
||||
// ── Brace-balance regression (bd53925f fixed duplicate '{' from 35fc1d21) ──
|
||||
|
||||
test('present_settings step has balanced braces — regression: brace-balance after #3784 split', () => {
|
||||
// commit bd53925f fixed a duplicate '{' introduced by 35fc1d21 when the model-profile
|
||||
// AskUserQuestion was split into Q1+Q2. This test guards against a recurrence.
|
||||
let depth = 0;
|
||||
for (const ch of presentBlock) {
|
||||
if (ch === '{') { depth++; }
|
||||
if (ch === '}') { depth--; }
|
||||
}
|
||||
assert.strictEqual(
|
||||
depth,
|
||||
0,
|
||||
`present_settings step has unbalanced braces: net depth after full scan is ${depth} (positive = extra '{', negative = extra '}'). Regression guard for bd53925f / #3784.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Reads gsd-core/workflows/fast.md whose deployed text IS the product —
|
||||
// the workflow markdown is executed verbatim by LLM runtimes.
|
||||
|
||||
/**
|
||||
* #3805 — fast.md log_to_state appends a schema-blind 4-column row to the
|
||||
* 5-column "Quick Tasks Completed" table created by quick.md Step 7.
|
||||
*
|
||||
* quick.md Step 7 creates the table with 5 columns:
|
||||
* | # | Description | Date | Commit | Directory |
|
||||
*
|
||||
* Before this fix, fast.md's log_to_state step appended a hardcoded 4-cell
|
||||
* row unconditionally:
|
||||
* echo "| $(date +%Y-%m-%d) | fast | $TASK | ✅ |" >> .planning/STATE.md
|
||||
*
|
||||
* This produces malformed Markdown when the existing table has a different
|
||||
* column count.
|
||||
*
|
||||
* Covers:
|
||||
* - fast.md does NOT contain the hardcoded 4-cell echo template
|
||||
* - fast.md log_to_state step reads/introspects the existing table header
|
||||
* before appending (schema-aware insertion)
|
||||
* - fast.md log_to_state step matches the 5-column schema from quick.md
|
||||
* Step 7 when that table is present
|
||||
* - fast.md log_to_state step skips (does not corrupt) the STATE.md write
|
||||
* when the table schema is unrecognized
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const FAST_MD_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'fast.md');
|
||||
|
||||
// The 5-column schema defined in quick.md Step 7 (non-validate mode).
|
||||
// Column count is 5: # | Description | Date | Commit | Directory
|
||||
// Named constant for traceability — mirrors quick.md Step 7's table header.
|
||||
const QUICK_MD_STEP7_COL_COUNT = 5;
|
||||
const QUICK_MD_STEP7_COLUMNS = ['#', 'Description', 'Date', 'Commit', 'Directory'];
|
||||
|
||||
describe('bug #3805: fast.md log_to_state must be schema-aware', () => {
|
||||
let fastMdContent;
|
||||
|
||||
test('fast.md workflow file exists and is readable', () => {
|
||||
assert.ok(fs.existsSync(FAST_MD_PATH), `fast.md not found at ${FAST_MD_PATH}`);
|
||||
fastMdContent = fs.readFileSync(FAST_MD_PATH, 'utf-8');
|
||||
});
|
||||
|
||||
test('fast.md log_to_state step does NOT hardcode a 4-cell row template', () => {
|
||||
// The old broken template: | date | fast | task | ✅ |
|
||||
// This regex matches the exact hardcoded pattern that ignores table schema.
|
||||
// A 4-cell row has exactly 4 pipe-delimited fields plus the surrounding pipes.
|
||||
const hardcoded4CellPattern = /echo\s+["'][|][^|]*[|][^|]*[|][^|]*[|][^|]*[|]\s*["']/;
|
||||
const match = fastMdContent.match(hardcoded4CellPattern);
|
||||
assert.ok(
|
||||
!match,
|
||||
[
|
||||
'fast.md log_to_state still contains the hardcoded 4-cell row template:',
|
||||
` "${match?.[0]}"`,
|
||||
'This appends a malformed row to the 5-column Quick Tasks Completed table',
|
||||
'created by quick.md Step 7.',
|
||||
`Expected table schema (${QUICK_MD_STEP7_COL_COUNT} cols): | ${QUICK_MD_STEP7_COLUMNS.join(' | ')} |`,
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
test('fast.md log_to_state step reads the existing table header (schema introspection)', () => {
|
||||
// The fix must inspect the existing STATE.md table header before appending.
|
||||
// Acceptable signals: reading STATE.md content, grepping for the header line,
|
||||
// or parsing columns from the header row.
|
||||
const hasHeaderRead =
|
||||
// Reads STATE.md to inspect it (awk/sed/grep on the file for header detection)
|
||||
/grep.*Quick Tasks Completed.*STATE\.md/.test(fastMdContent) ||
|
||||
/awk.*Quick Tasks Completed/.test(fastMdContent) ||
|
||||
/sed.*Quick Tasks Completed/.test(fastMdContent) ||
|
||||
// Reads the header line explicitly (head -n, sed -n, awk NR==)
|
||||
/head\s+-n/.test(fastMdContent) && /STATE\.md/.test(fastMdContent) ||
|
||||
// Counts pipe separators / columns from existing header
|
||||
/col.*count|column.*count|count.*col|NF|awk.*\|/.test(fastMdContent) ||
|
||||
// References schema detection in prose
|
||||
/schema|header|column\s+count|existing.*table/.test(fastMdContent);
|
||||
|
||||
assert.ok(
|
||||
hasHeaderRead,
|
||||
[
|
||||
'fast.md log_to_state step does not appear to introspect the existing table schema.',
|
||||
'The step must read the STATE.md table header to detect column count before appending.',
|
||||
'quick.md Step 7 uses schema-aware matching — fast.md must follow the same discipline.',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
test('fast.md log_to_state step references the 5-column quick.md schema', () => {
|
||||
// The fix must handle the 5-col schema: | # | Description | Date | Commit | Directory |
|
||||
// Test that all 5 column names appear in the log_to_state step's context.
|
||||
// Extract the log_to_state step content to scope the check.
|
||||
const logToStateMatch = fastMdContent.match(/<step name="log_to_state">([\s\S]*?)<\/step>/);
|
||||
assert.ok(logToStateMatch, 'fast.md must contain a <step name="log_to_state"> element');
|
||||
|
||||
const stepContent = logToStateMatch[1];
|
||||
|
||||
// All 5 column names from quick.md Step 7 must be referenced in the step.
|
||||
for (const col of QUICK_MD_STEP7_COLUMNS) {
|
||||
assert.ok(
|
||||
stepContent.toLowerCase().includes(col.toLowerCase()),
|
||||
[
|
||||
`fast.md log_to_state step does not reference column "${col}"`,
|
||||
`Expected all 5 columns from quick.md Step 7: ${QUICK_MD_STEP7_COLUMNS.join(', ')}`,
|
||||
`(${QUICK_MD_STEP7_COL_COUNT}-column schema)`,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('fast.md log_to_state step skips STATE.md write on unrecognized schema', () => {
|
||||
// The fix must not blindly append when the table schema is unknown.
|
||||
// Check for a guard that skips or logs rather than corrupting the file.
|
||||
const logToStateMatch = fastMdContent.match(/<step name="log_to_state">([\s\S]*?)<\/step>/);
|
||||
assert.ok(logToStateMatch, 'fast.md must contain a <step name="log_to_state"> element');
|
||||
|
||||
const stepContent = logToStateMatch[1];
|
||||
|
||||
// Must have a skip/guard path for unrecognized schemas
|
||||
const hasSkipGuard =
|
||||
/skip/i.test(stepContent) ||
|
||||
/unrecognized|unknown|mismatch/i.test(stepContent) ||
|
||||
/else\b/.test(stepContent) ||
|
||||
/warn|log/i.test(stepContent);
|
||||
|
||||
assert.ok(
|
||||
hasSkipGuard,
|
||||
[
|
||||
'fast.md log_to_state step does not appear to guard against unrecognized table schemas.',
|
||||
'When the existing STATE.md table does not match an expected schema,',
|
||||
'the step must skip the write (with a brief log) rather than append a malformed row.',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// The post-planning-gaps gap-analysis invocation is deployed workflow text the
|
||||
// runtime executes; the contract is that it routes through the gsd_run launcher,
|
||||
// not a hardcoded $HOME path (#621).
|
||||
|
||||
/**
|
||||
* Regression test for #621: plan-phase gap-analysis must route through gsd_run
|
||||
*
|
||||
* Prior to the fix, line 1631 of plan-phase.md hardcoded:
|
||||
* node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" gap-analysis ...
|
||||
* twice on the same line, breaking non-default install layouts.
|
||||
*
|
||||
* After the fix, both invocations route through gsd_run (the launcher defined
|
||||
* at line ~34 of the same file that resolves gsd-tools.cjs against
|
||||
* RUNTIME_DIR / git-toplevel / PATH / $HOME in order).
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'plan-phase.md'
|
||||
);
|
||||
|
||||
// ─── Fixture ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
|
||||
// ─── #621 regression: gap-analysis routes through gsd_run ────────────────────
|
||||
|
||||
describe('plan-phase workflow: post-planning-gaps gap-analysis uses gsd_run launcher (#621)', () => {
|
||||
test('gap-analysis dispatches via gsd_run loop render-hooks plan:post (ADR-857 capability gate)', () => {
|
||||
assert.ok(
|
||||
workflow.includes('gsd_run loop render-hooks plan:post'),
|
||||
'workflow must dispatch gap-analysis via gsd_run loop render-hooks plan:post, not a hardcoded node path or direct gsd_run gap-analysis call'
|
||||
);
|
||||
});
|
||||
|
||||
test('inner phase_req_ids query also routes through gsd_run', () => {
|
||||
assert.ok(
|
||||
workflow.includes('gsd_run query init.plan-phase'),
|
||||
'workflow must invoke the inner phase_req_ids query via gsd_run launcher'
|
||||
);
|
||||
});
|
||||
|
||||
test('no hardcoded node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" invocations remain (#621)', () => {
|
||||
const hardcodedCount = (
|
||||
workflow.match(/node "\$HOME\/\.claude\/gsd-core\/bin\/gsd-tools\.cjs"/g) || []
|
||||
).length;
|
||||
assert.strictEqual(
|
||||
hardcodedCount,
|
||||
0,
|
||||
[
|
||||
'#621 regression: workflow must not contain any hardcoded',
|
||||
'node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" invocations;',
|
||||
`found ${hardcodedCount}`,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
|
||||
test('post-planning-gaps block still gates on workflow.post_planning_gaps and preserves required args', () => {
|
||||
const hasGate = workflow.includes('workflow.post_planning_gaps');
|
||||
const hasPhaseDir = workflow.includes('gsd_run check ${hook.check.query} "${PHASE_DIR}" "${PHASE_REQ_IDS}"');
|
||||
const hasPickArg = workflow.includes('--pick phase_req_ids');
|
||||
assert.ok(
|
||||
hasGate,
|
||||
'workflow must still gate the gap-analysis step on workflow.post_planning_gaps config key'
|
||||
);
|
||||
assert.ok(
|
||||
hasPhaseDir,
|
||||
'gap-analysis check dispatch must pass "${PHASE_DIR}" (and "${PHASE_REQ_IDS}") positionally to gsd_run check'
|
||||
);
|
||||
assert.ok(
|
||||
hasPickArg,
|
||||
'inner query must still pass --pick phase_req_ids to extract phase requirement IDs'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md text IS what the runtime loads and the agent executes, so
|
||||
// asserting on its shell invocations tests the deployed contract directly.
|
||||
//
|
||||
// Repo-wide regression guard for #637 (generalizes the plan-phase-only guard
|
||||
// from #621): NO workflow .md may invoke gsd-tools via a hardcoded
|
||||
// `node "$HOME/.../gsd-tools.cjs"` path. On a global/shim-only install with no
|
||||
// project-local runtime, that path can miss a working install, so the step
|
||||
// reports the tool "not found" instead of resolving it. Every invocation must
|
||||
// go through the `gsd_run` launcher (defined once per file in the canonical
|
||||
// preamble, which resolves RUNTIME_DIR → .claude → PATH → $HOME in order).
|
||||
//
|
||||
// The parity test (runtime-launcher-parity) guards the retired $GSD_SDK and
|
||||
// bare /gsd-tools tokens but NOT this hardcoded-node form — which is exactly
|
||||
// how it survived across plan-phase.md (#621) and three more files (#637).
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
|
||||
// Hardcoded direct invocation form. Distinct from the canonical preamble, which
|
||||
// references $HOME only inside a `[ -f "$HOME/..." ]` probe / `GSD_TOOLS=`
|
||||
// assignment and always invokes `node "$GSD_TOOLS"` — never `node "$HOME/..."`.
|
||||
const HARDCODED_HOME_INVOCATION = /node\s+"\$HOME\/[^"]*gsd-tools\.cjs"/;
|
||||
|
||||
function collectWorkflowMarkdown(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...collectWorkflowMarkdown(full));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('bug #637: no workflow .md hardcodes a $HOME gsd-tools invocation', () => {
|
||||
test('every gsd-core/workflows/**/*.md resolves gsd-tools via gsd_run, not a hardcoded $HOME path', () => {
|
||||
const files = collectWorkflowMarkdown(WORKFLOWS_DIR);
|
||||
assert.ok(files.length > 0, 'expected workflow markdown files to exist');
|
||||
|
||||
const offenders = [];
|
||||
for (const file of files) {
|
||||
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
||||
lines.forEach((line, i) => {
|
||||
if (HARDCODED_HOME_INVOCATION.test(line)) {
|
||||
offenders.push(`${path.relative(WORKFLOWS_DIR, file)}:${i + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
offenders,
|
||||
[],
|
||||
'Workflow files must invoke gsd-tools via the resolved `gsd_run` launcher, ' +
|
||||
'not a hardcoded `node "$HOME/.../gsd-tools.cjs"` path. Offenders:\n' +
|
||||
offenders.join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// review.md is a workflow file whose deployed text IS the runtime contract; the
|
||||
// agy -p invocation cannot be run in CI, so we assert on its content (issue #687).
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const reviewPath = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'review.md');
|
||||
const read = () => fs.readFileSync(reviewPath, 'utf-8');
|
||||
|
||||
describe('bug #687: agy print mode must be bounded via its native --print-timeout', () => {
|
||||
test('invokes agy with its own --print-timeout flag (not an external killer)', () => {
|
||||
assert.match(read(), /agy --print-timeout \d+s? -p "\$\(cat/,
|
||||
'review.md must cap agy through `agy --print-timeout <N> -p …` (the tool\'s own mechanism)');
|
||||
});
|
||||
|
||||
test('discards partial output on non-zero exit so the fallback fires', () => {
|
||||
const c = read();
|
||||
assert.match(c, /_AGY_RC.*-ne 0/, 'review.md must check the agy exit code');
|
||||
assert.match(c, /: > \/tmp\/gsd-review-antigravity-/,
|
||||
'review.md must truncate the output file when agy timed out / failed');
|
||||
});
|
||||
|
||||
test('agy is bounded only by its own --print-timeout, not an external process killer', () => {
|
||||
const c = read();
|
||||
// Print-mode reviewers invoke the tool directly; agy must self-terminate via
|
||||
// --print-timeout, never via an external SIGKILL/timeout binary wrapped around it.
|
||||
assert.doesNotMatch(c, /-s KILL/, 'must not SIGKILL agy from the outside');
|
||||
// Any external timeout binary wrapping agy — `timeout 300s agy …`,
|
||||
// `gtimeout 300 agy …`, `timeout -s KILL 300 agy …`. The lookbehind keeps
|
||||
// agy's own `--print-timeout` flag from tripping it.
|
||||
assert.doesNotMatch(c, /(?<!print-)\bg?timeout[ \t]+[^\n]*\bagy\b/,
|
||||
'must not wrap agy in an external timeout binary — use its --print-timeout flag');
|
||||
assert.doesNotMatch(c, /kill -9 "\$_AGY/, 'must not use a kill -9 watchdog on agy');
|
||||
});
|
||||
|
||||
test('no unguarded bare "agy -p" invocation remains at line start', () => {
|
||||
// A bare `agy -p "$(cat …)"` with no cap was the original hang.
|
||||
assert.doesNotMatch(read(), /^agy -p "\$\(cat/m,
|
||||
'review.md must not invoke agy -p without --print-timeout');
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Structural guard — bug(#936): plan-review-convergence wrapped gsd-plan-phase
|
||||
* in Agent() at TWO sites (initial planning + replan). On Claude Code, a depth-1
|
||||
* Agent has no Agent tool, so plan-phase cannot spawn gsd-planner / gsd-plan-checker
|
||||
* → the replan loop never works when HIGHs are found.
|
||||
*
|
||||
* Fix: run plan-phase INLINE (bare Skill()) from the convergence orchestrator,
|
||||
* which runs at depth 0 and has Agent available — exactly how autonomous.md,
|
||||
* manager.md, and discuss-phase-assumptions.md already chain plan-phase.
|
||||
*
|
||||
* This guard dynamically derives the set of "spawner" workflows (those containing
|
||||
* `subagent_type=`) and asserts that NO workflow wraps a spawner inside Agent()
|
||||
* UNLESS the wrapping block includes a RUNTIME != claude carve-out (the #853
|
||||
* pattern already applied to autonomous.md / manager.md).
|
||||
*/
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// The workflow markdown IS the runtime instruction — static guards over
|
||||
// workflow text are the canonical regression-test mechanism (per CONTRIBUTING
|
||||
// exception matrix and tests/bug-853-bg-dispatch-runtime-gating.test.cjs).
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
|
||||
// ── 1. Derive spawner skill names dynamically ──────────────────────────────
|
||||
// A "spawner" workflow is one that contains `subagent_type=` — it NEEDS the
|
||||
// Agent tool to run and therefore cannot safely be wrapped in another Agent()
|
||||
// on Claude Code (where depth-1 agents have no Agent tool).
|
||||
|
||||
// Recursively collect all *.md files under WORKFLOWS_DIR (covers nested fragments
|
||||
// like discuss-phase/modes/*.md and execute-phase/steps/*.md).
|
||||
function collectWorkflowFiles(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const results = [];
|
||||
for (const e of entries) {
|
||||
const fullPath = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
results.push(...collectWorkflowFiles(fullPath));
|
||||
} else if (e.name.endsWith('.md')) {
|
||||
results.push({
|
||||
name: path.relative(WORKFLOWS_DIR, fullPath),
|
||||
path: fullPath,
|
||||
content: fs.readFileSync(fullPath, 'utf8'),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const allWorkflowFiles = collectWorkflowFiles(WORKFLOWS_DIR);
|
||||
|
||||
// Map: base-slug → workflow filename (e.g. "plan-phase" → "plan-phase.md")
|
||||
// Skill() calls use the "gsd-<slug>" convention in all workflow files.
|
||||
// We build BOTH the bare slug set and the gsd-prefixed skill-name set.
|
||||
const SPAWNER_BASE_SLUGS = new Set(
|
||||
allWorkflowFiles
|
||||
.filter((w) => w.content.includes('subagent_type='))
|
||||
.map((w) => w.name.replace(/\.md$/, ''))
|
||||
);
|
||||
|
||||
// Skill invocations use "gsd-<slug>" (e.g. gsd-plan-phase, gsd-execute-phase).
|
||||
// Build the regex from the prefixed names so it actually matches what workflows write.
|
||||
const SPAWNER_GSD_NAMES = new Set([...SPAWNER_BASE_SLUGS].map((s) => `gsd-${s}`));
|
||||
|
||||
// Build a regex that matches Skill(skill='gsd-<spawner>') or Skill(skill="gsd-<spawner>")
|
||||
const spawnerPattern = new RegExp(
|
||||
`Skill\\(\\s*skill=['"](?:${[...SPAWNER_GSD_NAMES].join('|')})['"]`,
|
||||
's'
|
||||
);
|
||||
|
||||
// ── 2. Helper: extract Agent() blocks from a workflow ─────────────────────
|
||||
// Each block starts at "Agent(" and ends at the balancing ")". We collect
|
||||
// the text of each such block together with the surrounding context (a 400
|
||||
// char window before the block) so we can check for RUNTIME carve-outs.
|
||||
|
||||
function extractAgentBlocks(content) {
|
||||
const blocks = [];
|
||||
let pos = 0;
|
||||
while (pos < content.length) {
|
||||
const start = content.indexOf('Agent(', pos);
|
||||
if (start === -1) break;
|
||||
// Walk forward to find the balancing closing paren
|
||||
let depth = 0;
|
||||
let i = start + 'Agent('.length - 1; // at the '('
|
||||
for (; i < content.length; i++) {
|
||||
if (content[i] === '(') depth++;
|
||||
else if (content[i] === ')') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
const end = i + 1;
|
||||
const blockText = content.slice(start, end);
|
||||
// Capture context: 400 chars before the block (for RUNTIME gate detection)
|
||||
const contextBefore = content.slice(Math.max(0, start - 400), start);
|
||||
blocks.push({ start, end, blockText, contextBefore });
|
||||
pos = end;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ── 3. Helper: does a block have a RUNTIME != claude carve-out nearby? ────
|
||||
// The #853 pattern looks like: "RUNTIME is `claude`" in a preceding condition
|
||||
// that switches to inline Skill() instead of the Agent() block. A block is
|
||||
// considered guarded when the 400-char context window before it (or the block
|
||||
// body itself for block-internal guards) contains any of these markers.
|
||||
|
||||
function hasRuntimeCarveout(block) {
|
||||
const haystack = block.contextBefore + block.blockText;
|
||||
return (
|
||||
/RUNTIME[^`\n]{0,30}(?:!=|≠|is not|!==)\s*[`'"]?claude/i.test(haystack) ||
|
||||
/RUNTIME[^`\n]{0,30}claude[^`\n]{0,30}(?:inline|not.*Agent|do NOT)/i.test(haystack) ||
|
||||
/If `RUNTIME` is `claude`/i.test(haystack) ||
|
||||
/On Claude Code.*inline/is.test(haystack)
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. The guard: scan every workflow for unguarded Agent→spawner wraps ───
|
||||
|
||||
describe('bug-936 — no workflow wraps a spawner skill inside Agent() without a RUNTIME carve-out', () => {
|
||||
test('spawner set is non-empty (self-check: subagent_type= grep must find files)', () => {
|
||||
assert.ok(SPAWNER_BASE_SLUGS.size > 0, `No spawner workflows found in ${WORKFLOWS_DIR} — SPAWNER_BASE_SLUGS derivation is broken`);
|
||||
// plan-phase must be a spawner (base slug)
|
||||
assert.ok(SPAWNER_BASE_SLUGS.has('plan-phase'), 'plan-phase.md must be in the spawner set (contains subagent_type=)');
|
||||
// gsd-plan-phase must be in the prefixed set used by the regex
|
||||
assert.ok(SPAWNER_GSD_NAMES.has('gsd-plan-phase'), 'gsd-plan-phase must be in SPAWNER_GSD_NAMES — the prefixed form used in Skill() calls');
|
||||
});
|
||||
|
||||
for (const wf of allWorkflowFiles) {
|
||||
// Only scan files that have at least one Agent( call
|
||||
if (!wf.content.includes('Agent(')) continue;
|
||||
|
||||
test(`${wf.name}: no Agent() block wraps a spawner Skill without a RUNTIME carve-out`, () => {
|
||||
const blocks = extractAgentBlocks(wf.content);
|
||||
const violations = blocks.filter((b) => {
|
||||
const wrapsSpawner = spawnerPattern.test(b.blockText);
|
||||
if (!wrapsSpawner) return false;
|
||||
return !hasRuntimeCarveout(b);
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(
|
||||
violations.map((v) => v.blockText.slice(0, 120).replace(/\n/g, '\\n')),
|
||||
[],
|
||||
`${wf.name} wraps a spawner Skill inside Agent() without a RUNTIME != claude carve-out.\n` +
|
||||
`Fix: run the spawner Skill inline (bare Skill() call at depth 0) OR add a RUNTIME gate.\n` +
|
||||
`See: bug #936, tests/bug-853-bg-dispatch-runtime-gating.test.cjs for the guarded pattern.`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── 5. Focused regression: plan-review-convergence never wraps plan-phase ─
|
||||
|
||||
describe('bug-936 — plan-review-convergence runs plan-phase inline, not inside Agent()', () => {
|
||||
const CONVERGENCE = fs.readFileSync(
|
||||
path.join(WORKFLOWS_DIR, 'plan-review-convergence.md'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
test('plan-review-convergence does NOT wrap gsd-plan-phase inside Agent()', () => {
|
||||
// The anti-pattern: Agent( block whose body contains Skill(skill='gsd-plan-phase')
|
||||
const blocks = extractAgentBlocks(CONVERGENCE);
|
||||
const wrapping = blocks.filter((b) =>
|
||||
/Skill\(\s*skill=['"]gsd-plan-phase['"]/.test(b.blockText) &&
|
||||
!hasRuntimeCarveout(b)
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
wrapping.map((v) => v.blockText.slice(0, 120).replace(/\n/g, '\\n')),
|
||||
[],
|
||||
'plan-review-convergence must NOT wrap gsd-plan-phase inside Agent(). ' +
|
||||
'Run it inline (bare Skill() at depth 0) so it can spawn gsd-planner/gsd-plan-checker. ' +
|
||||
'See: bug #936'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-review-convergence calls gsd-plan-phase inline (bare Skill call outside Agent block)', () => {
|
||||
// After the fix: at least one bare Skill(skill="gsd-plan-phase") must appear
|
||||
// outside any Agent( block — that is the inline call from the depth-0 orchestrator.
|
||||
const blocks = extractAgentBlocks(CONVERGENCE);
|
||||
// Remove all Agent block ranges from the text
|
||||
let masked = CONVERGENCE;
|
||||
// Work from end to start so offsets stay valid
|
||||
const sorted = [...blocks].sort((a, b) => b.start - a.start);
|
||||
for (const b of sorted) {
|
||||
masked = masked.slice(0, b.start) + ' '.repeat(b.end - b.start) + masked.slice(b.end);
|
||||
}
|
||||
const hasInlineCall = /Skill\(\s*skill=["']gsd-plan-phase["']/.test(masked);
|
||||
assert.ok(
|
||||
hasInlineCall,
|
||||
'plan-review-convergence must contain at least one bare Skill(skill="gsd-plan-phase") ' +
|
||||
'outside any Agent() block — this is the inline call that lets plan-phase spawn its sub-agents. ' +
|
||||
'See: bug #936'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-review-convergence still wraps gsd-review inside Agent() (leaf — isolation is correct)', () => {
|
||||
// gsd-review is a leaf (shells out via Bash, no subagent_type) so the Agent wrap is fine and intentional.
|
||||
const blocks = extractAgentBlocks(CONVERGENCE);
|
||||
const reviewWrap = blocks.some((b) => /Skill\(\s*skill=['"]gsd-review['"]/.test(b.blockText));
|
||||
assert.ok(reviewWrap, 'gsd-review must still be wrapped in Agent() — it is a Bash leaf and isolation is intentional');
|
||||
});
|
||||
});
|
||||
@@ -82,3 +82,79 @@ describe('plan-phase chain flag preservation (#1620)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2388-plan-phase-no-branch-rename.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2388-plan-phase-no-branch-rename (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #2388)
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2388: plan-phase silently renames feature branch
|
||||
* when phase slug has changed since the branch was created.
|
||||
*
|
||||
* Fix: plan-phase.md must include an explicit instruction not to create,
|
||||
* rename, or switch git branches during the planning workflow.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md');
|
||||
|
||||
describe('bug-2388: plan-phase must not rename or create git branches', () => {
|
||||
test('plan-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(PLAN_PHASE_PATH), 'plan-phase.md should exist');
|
||||
});
|
||||
|
||||
test('plan-phase.md contains explicit no-branch-rename instruction', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Must say "do not" and mention branch in the context of phase slug/rename
|
||||
const hasBranchGuard = (
|
||||
/do not.{0,80}branch/i.test(content) ||
|
||||
/branch.{0,80}do not/i.test(content) ||
|
||||
/NEVER.{0,80}branch/i.test(content) ||
|
||||
/branch.{0,80}NEVER/i.test(content)
|
||||
);
|
||||
assert.ok(
|
||||
hasBranchGuard,
|
||||
'plan-phase.md must include an explicit instruction not to create or rename git branches'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md mentions phase rename does not affect branch name', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Should explain that a phase rename in ROADMAP.md is plan-level, not git-level
|
||||
const hasPlanLevelExplanation = (
|
||||
content.includes('phase rename') ||
|
||||
content.includes('phase_slug') ||
|
||||
content.includes('branch identity') ||
|
||||
content.includes('branch name')
|
||||
);
|
||||
assert.ok(
|
||||
hasPlanLevelExplanation,
|
||||
'plan-phase.md should clarify that phase slug changes do not change the git branch'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md does not contain git checkout -b instruction', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// The workflow should not instruct the LLM to run git checkout -b
|
||||
assert.ok(
|
||||
!content.includes('git checkout -b'),
|
||||
'plan-phase.md must not instruct LLM to create a new branch via git checkout -b'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,3 +96,90 @@ describe('commit_docs bypass guard (#1783)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2399-commit-docs-plan-phase.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2399-commit-docs-plan-phase (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #2399)
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Bug #2399: commit_docs:true is ignored in plan-phase
|
||||
*
|
||||
* The plan-phase workflow generates plan artifacts but never commits them even
|
||||
* when commit_docs is true. A step between 13b and 14 must commit the PLAN.md
|
||||
* files and updated STATE.md when commit_docs is set.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md');
|
||||
|
||||
describe('plan-phase commit_docs support (#2399)', () => {
|
||||
test('plan-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(PLAN_PHASE_PATH), 'gsd-core/workflows/plan-phase.md must exist');
|
||||
});
|
||||
|
||||
test('plan-phase.md has a commit step for plan artifacts', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Must contain a commit call that references PLAN.md files
|
||||
assert.ok(
|
||||
content.includes('PLAN.md') && content.includes('commit'),
|
||||
'plan-phase.md must include a commit step that references PLAN.md files'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md commit step is gated on commit_docs', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// The commit step must be conditional on commit_docs
|
||||
assert.ok(
|
||||
content.includes('commit_docs'),
|
||||
'plan-phase.md must reference commit_docs to gate the plan commit step'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md commit step references STATE.md', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Should commit STATE.md alongside PLAN.md files
|
||||
assert.ok(
|
||||
content.includes('STATE.md'),
|
||||
'plan-phase.md commit step should include STATE.md to capture planning completion state'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md has a step 13c that commits plan artifacts', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
const step13b = content.indexOf('## 13b.');
|
||||
const step14 = content.indexOf('## 14.');
|
||||
// Look for the step 13c section (or any commit step between 13b and 14)
|
||||
const step13c = content.indexOf('## 13c.');
|
||||
|
||||
assert.ok(step13b !== -1, '## 13b. section must exist');
|
||||
assert.ok(step14 !== -1, '## 14. section must exist');
|
||||
assert.ok(step13c !== -1, '## 13c. step must exist (commit plans step)');
|
||||
assert.ok(
|
||||
step13c > step13b && step13c < step14,
|
||||
`Step 13c (at ${step13c}) must appear between step 13b (at ${step13b}) and step 14 (at ${step14})`
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md uses gsd-sdk query commit for the plan commit', () => {
|
||||
const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
|
||||
// Must use gsd-sdk query commit (not raw git) so commit_docs guard in gsd-tools is respected
|
||||
assert.ok(
|
||||
content.includes('gsd-sdk query commit') || content.includes('gsd-tools') || content.includes('gsd-sdk'),
|
||||
'plan-phase.md plan commit step must use gsd-sdk query commit (not raw git commit)'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,3 +186,118 @@ describe('workflow.discuss_mode config', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2549-2550-2552-discuss-phase-context.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2549-2550-2552-discuss-phase-context (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #2549)
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
'use strict';
|
||||
|
||||
|
||||
/**
|
||||
* Bugs #2549, #2550, #2552: discuss-phase context bloat and cache invalidation.
|
||||
*
|
||||
* #2549: load_prior_context must cap prior CONTEXT.md reads (was O(phases))
|
||||
* #2550: scout_codebase must select maps by phase type (was always all 7)
|
||||
* #2552: scout_codebase must not instruct split reads of the same file
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const DISCUSS_PHASE = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase.md',
|
||||
);
|
||||
// After the discuss-phase progressive-disclosure split (#717), the scout_codebase phase-type
|
||||
// table and split-reads warning live in references/scout-codebase.md.
|
||||
const SCOUT_REF = path.join(
|
||||
__dirname, '..', 'gsd-core', 'references', 'scout-codebase.md',
|
||||
);
|
||||
|
||||
function readDiscussContext() {
|
||||
// Both files are required after the discuss-phase/modes split — fail loudly if either is missing
|
||||
// rather than silently weakening the regression coverage.
|
||||
for (const p of [DISCUSS_PHASE, SCOUT_REF]) {
|
||||
assert.ok(fs.existsSync(p), `Required discuss-phase context source missing: ${p}`);
|
||||
}
|
||||
return [DISCUSS_PHASE, SCOUT_REF].map(p => fs.readFileSync(p, 'utf-8')).join('\n');
|
||||
}
|
||||
|
||||
describe('discuss-phase context fixes (#2549, #2550, #2552)', () => {
|
||||
let src;
|
||||
test('discuss-phase.md source exists', () => {
|
||||
assert.ok(fs.existsSync(DISCUSS_PHASE), 'discuss-phase.md must exist');
|
||||
assert.ok(
|
||||
fs.existsSync(SCOUT_REF),
|
||||
'references/scout-codebase.md must exist after the discuss-phase/modes progressive-disclosure split',
|
||||
);
|
||||
src = readDiscussContext();
|
||||
});
|
||||
|
||||
// ─── #2549: load_prior_context cap ──────────────────────────────────────
|
||||
test('#2549: load_prior_context must NOT instruct reading ALL prior CONTEXT.md files', () => {
|
||||
if (!src) src = readDiscussContext();
|
||||
assert.ok(
|
||||
!src.includes('For each CONTEXT.md where phase number < current phase'),
|
||||
'load_prior_context must not unboundedly read all prior CONTEXT.md files',
|
||||
);
|
||||
});
|
||||
|
||||
test('#2549: load_prior_context must reference a bounded read (3 phases or DECISIONS-INDEX)', () => {
|
||||
// Read ONLY the parent file — `src.includes('3')` against the
|
||||
// concatenated source can be satisfied by unrelated occurrences of "3"
|
||||
// in scout-codebase.md (e.g., "3-5 most relevant files"), masking a
|
||||
// regression where the parent drops the bounded-read instruction.
|
||||
const parent = fs.readFileSync(DISCUSS_PHASE, 'utf-8');
|
||||
const hasBound = /\b(?:most recent|latest|last|up to)\s+3\b[\s\S]{0,160}\bprior CONTEXT\.md\b/i.test(parent);
|
||||
const hasIndex = parent.includes('DECISIONS-INDEX.md');
|
||||
assert.ok(
|
||||
hasBound || hasIndex,
|
||||
'load_prior_context must reference a bounded read (e.g., most recent 3 phases) or DECISIONS-INDEX.md',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── #2550: scout_codebase phase-type selection ──────────────────────────
|
||||
test('#2550: scout_codebase must not instruct reading all 7 codebase maps', () => {
|
||||
if (!src) src = readDiscussContext();
|
||||
assert.ok(
|
||||
!src.includes('Read the most relevant ones (CONVENTIONS.md, STRUCTURE.md, STACK.md based on phase type)'),
|
||||
'scout_codebase must not use the old vague "most relevant" instruction without a selection table',
|
||||
);
|
||||
});
|
||||
|
||||
test('#2550: scout_codebase must include a phase-type-to-maps selection table', () => {
|
||||
if (!src) src = readDiscussContext();
|
||||
// The table maps phase types to specific map selections
|
||||
assert.ok(
|
||||
src.includes('Phase type') && src.includes('Read these maps'),
|
||||
'scout_codebase must include a phase-type to map-selection table',
|
||||
);
|
||||
// Key phase types must be covered
|
||||
assert.ok(src.includes('UI') || src.includes('frontend'), 'Table must cover UI/frontend phases');
|
||||
assert.ok(src.includes('Backend') || src.includes('API'), 'Table must cover backend phases');
|
||||
assert.ok(src.includes('Testing'), 'Table must cover testing phases');
|
||||
assert.ok(src.includes('Mixed'), 'Table must have a fallback for mixed/unclear phases');
|
||||
});
|
||||
|
||||
// ─── #2552: no split reads ───────────────────────────────────────────────
|
||||
test('#2552: scout_codebase must explicitly prohibit split reads of the same file', () => {
|
||||
if (!src) src = readDiscussContext();
|
||||
const prohibitsSplit = src.includes('split reads') || src.includes('split read');
|
||||
assert.ok(
|
||||
prohibitsSplit,
|
||||
'scout_codebase must explicitly warn against split reads (same file, two offsets) that break prompt cache',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -735,3 +735,156 @@ describe('Bug #2954: help.md ↔ commands/gsd/ bidirectional parity', () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2950-stale-command-refs.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2950-stale-command-refs (consolidation epic #1969 B4 #1973)", () => {
|
||||
/**
|
||||
* Bug #2950: Stale deleted command references in workflow files
|
||||
*
|
||||
* Multiple workflow files referenced command names removed in #2790
|
||||
* (gsd-add-phase, gsd-insert-phase, gsd-remove-phase, gsd-add-todo,
|
||||
* gsd-set-profile, gsd-settings-integrations, gsd-settings-advanced,
|
||||
* gsd-spike-wrap-up, gsd-sketch-wrap-up, gsd-code-review-fix).
|
||||
*
|
||||
* Fix: Update every occurrence to the new consolidated forms:
|
||||
* /gsd:phase (no flag | --insert | --remove)
|
||||
* /gsd:capture
|
||||
* /gsd:config (--profile | --integrations | --advanced)
|
||||
* /gsd:spike --wrap-up
|
||||
* /gsd:sketch --wrap-up
|
||||
* /gsd:code-review --fix
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
|
||||
function read(filename) {
|
||||
return fs.readFileSync(path.join(WORKFLOWS_DIR, filename), 'utf-8');
|
||||
}
|
||||
|
||||
// Deleted command names that must not appear anywhere in the fixed files.
|
||||
const DELETED_COMMANDS = [
|
||||
'/gsd-add-phase',
|
||||
'/gsd-insert-phase',
|
||||
'/gsd-remove-phase',
|
||||
'/gsd-add-todo',
|
||||
'/gsd-set-profile',
|
||||
'/gsd-settings-integrations',
|
||||
'/gsd-settings-advanced',
|
||||
'/gsd-spike-wrap-up',
|
||||
'/gsd-sketch-wrap-up',
|
||||
'/gsd-code-review-fix',
|
||||
];
|
||||
|
||||
// Per-file assertions: [file, deletedCmd, newForm]
|
||||
const FILE_ASSERTIONS = [
|
||||
// help.md → moved to help/modes/full.md in #3039 tiered-help refactor
|
||||
['help/modes/full.md', '/gsd-add-phase', '/gsd:phase "Add admin dashboard"'],
|
||||
['help/modes/full.md', '/gsd-insert-phase', '/gsd:phase --insert 7 "Fix critical auth bug"'],
|
||||
['help/modes/full.md', '/gsd-remove-phase', '/gsd:phase --remove 17'],
|
||||
['help/modes/full.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'],
|
||||
['help/modes/full.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'],
|
||||
['help/modes/full.md', '/gsd-add-todo', '/gsd:capture'],
|
||||
['help/modes/full.md', '/gsd-set-profile', '/gsd:config --profile budget'],
|
||||
|
||||
// do.md
|
||||
['do.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'],
|
||||
['do.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'],
|
||||
['do.md', '/gsd-add-phase', '/gsd:phase'],
|
||||
['do.md', '/gsd-add-todo', '/gsd:capture'],
|
||||
|
||||
// settings.md
|
||||
['settings.md', '/gsd-code-review-fix', '/gsd:code-review --fix'],
|
||||
['settings.md', '/gsd-settings-integrations', '/gsd:config --integrations'],
|
||||
['settings.md', '/gsd-set-profile', '/gsd:config --profile'],
|
||||
['settings.md', '/gsd-settings-advanced', '/gsd:config --advanced'],
|
||||
|
||||
// discuss-phase.md
|
||||
['discuss-phase.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'],
|
||||
['discuss-phase.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'],
|
||||
|
||||
// new-project.md
|
||||
['new-project.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'],
|
||||
['new-project.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'],
|
||||
|
||||
// plan-phase.md
|
||||
['plan-phase.md', '/gsd-insert-phase', '/gsd:phase --insert'],
|
||||
|
||||
// spike.md
|
||||
['spike.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'],
|
||||
|
||||
// sketch.md
|
||||
['sketch.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'],
|
||||
];
|
||||
|
||||
describe('bug #2950: stale deleted-command references removed from workflow files', () => {
|
||||
// Build a map of file → content to avoid re-reading
|
||||
const files = [...new Set(FILE_ASSERTIONS.map(([f]) => f))];
|
||||
const contentMap = {};
|
||||
for (const f of files) {
|
||||
contentMap[f] = read(f);
|
||||
}
|
||||
|
||||
// For each (file, deletedCmd) pair, assert the old name is absent
|
||||
for (const [file, deletedCmd] of FILE_ASSERTIONS) {
|
||||
test(`${file}: does not contain deleted command "${deletedCmd}"`, () => {
|
||||
const content = contentMap[file];
|
||||
assert.ok(
|
||||
!content.includes(deletedCmd),
|
||||
`${file} still contains deleted command "${deletedCmd}" — update to new form`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// For each (file, deletedCmd, newForm) triple, assert the new form is present
|
||||
for (const [file, , newForm] of FILE_ASSERTIONS) {
|
||||
test(`${file}: contains new form "${newForm}"`, () => {
|
||||
const content = contentMap[file];
|
||||
assert.ok(
|
||||
content.includes(newForm),
|
||||
`${file} is missing expected new form "${newForm}"`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Blanket check: no affected workflow file contains any of the deleted command names
|
||||
// (catches any we might have missed in per-file assertions above)
|
||||
const affectedFiles = [
|
||||
'help.md',
|
||||
'help/modes/full.md',
|
||||
'help/modes/default.md',
|
||||
'help/modes/brief.md',
|
||||
'help/modes/topic.md',
|
||||
'do.md',
|
||||
'settings.md',
|
||||
'discuss-phase.md',
|
||||
'new-project.md',
|
||||
'plan-phase.md',
|
||||
'spike.md',
|
||||
'sketch.md',
|
||||
];
|
||||
|
||||
for (const file of affectedFiles) {
|
||||
const content = read(file);
|
||||
for (const deleted of DELETED_COMMANDS) {
|
||||
test(`${file}: blanket check — "${deleted}" not present`, () => {
|
||||
assert.ok(
|
||||
!content.includes(deleted),
|
||||
`${file} contains deleted command "${deleted}"`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||||
// runtime loads — testing text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* Tests for #2430 — LEARNINGS.md consumption loop.
|
||||
*
|
||||
* Part A: plan-phase.md cross-phase context load includes LEARNINGS.md
|
||||
* Part B: transition.md graduation_scan step + graduation.md helper
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '../gsd-core/workflows');
|
||||
|
||||
function readWorkflow(name) {
|
||||
return fs.readFileSync(path.join(WORKFLOWS_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
describe('enh-2430 Part A — plan-phase LEARNINGS.md context load', () => {
|
||||
let content;
|
||||
|
||||
test('plan-phase.md includes LEARNINGS.md in cross-phase context load', () => {
|
||||
content = readWorkflow('plan-phase.md');
|
||||
assert.ok(
|
||||
content.includes('LEARNINGS.md files from the 3 most recent completed phases'),
|
||||
'plan-phase.md must mention LEARNINGS.md in cross-phase context block'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md LEARNINGS load is inside the 1M context-window gate', () => {
|
||||
content = content || readWorkflow('plan-phase.md');
|
||||
const windowBlock = content.match(/\$\{CONTEXT_WINDOW >= 500000[\s\S]*?` : ''\}/);
|
||||
assert.ok(windowBlock, 'CONTEXT_WINDOW gate block must exist');
|
||||
assert.ok(
|
||||
windowBlock[0].includes('LEARNINGS.md'),
|
||||
'LEARNINGS.md load must be inside the CONTEXT_WINDOW >= 500000 gate'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md source attribution mentioned for LEARNINGS load', () => {
|
||||
content = content || readWorkflow('plan-phase.md');
|
||||
assert.ok(
|
||||
content.includes('[from Phase N LEARNINGS]') || content.includes('source attribution'),
|
||||
'plan-phase.md must document source attribution for loaded LEARNINGS.md content'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md handles missing LEARNINGS.md gracefully (silent skip)', () => {
|
||||
content = content || readWorkflow('plan-phase.md');
|
||||
assert.ok(
|
||||
content.includes('skip silently if a phase has no LEARNINGS.md') ||
|
||||
content.includes('skip silently'),
|
||||
'plan-phase.md must document silent skip when LEARNINGS.md is absent'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md LEARNINGS load includes Depends-on chain', () => {
|
||||
content = content || readWorkflow('plan-phase.md');
|
||||
content.match(/Depends on.*?(\n.*?)+/);
|
||||
assert.ok(
|
||||
content.includes('LEARNINGS.md from any phases listed in'),
|
||||
'plan-phase.md must load LEARNINGS.md for Depends on chain phases'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md specifies context budget limit for LEARNINGS', () => {
|
||||
content = content || readWorkflow('plan-phase.md');
|
||||
assert.ok(
|
||||
content.includes('15%') || content.includes('drop oldest'),
|
||||
'plan-phase.md must specify budget limit and truncation strategy for LEARNINGS'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-2430 Part B — graduation_scan in transition.md', () => {
|
||||
let content;
|
||||
|
||||
test('transition.md contains graduation_scan step', () => {
|
||||
content = readWorkflow('transition.md');
|
||||
assert.ok(
|
||||
content.includes('graduation_scan'),
|
||||
'transition.md must contain graduation_scan step'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation_scan is placed after evolve_project step', () => {
|
||||
content = content || readWorkflow('transition.md');
|
||||
const evolvePos = content.indexOf('name="evolve_project"');
|
||||
const graduationPos = content.indexOf('name="graduation_scan"');
|
||||
assert.ok(evolvePos >= 0, 'evolve_project step must exist');
|
||||
assert.ok(graduationPos >= 0, 'graduation_scan step must exist');
|
||||
assert.ok(
|
||||
graduationPos > evolvePos,
|
||||
'graduation_scan must appear after evolve_project in transition.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation_scan is non-blocking (transition continues regardless)', () => {
|
||||
content = content || readWorkflow('transition.md');
|
||||
const scanBlock = content.match(/name="graduation_scan"[\s\S]*?<\/step>/);
|
||||
assert.ok(scanBlock, 'graduation_scan step must be parseable');
|
||||
assert.ok(
|
||||
scanBlock[0].includes('non-blocking') || scanBlock[0].includes('always non-blocking'),
|
||||
'graduation_scan must be documented as non-blocking'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation_scan delegates to graduation.md helper', () => {
|
||||
content = content || readWorkflow('transition.md');
|
||||
assert.ok(
|
||||
content.includes('graduation.md'),
|
||||
'graduation_scan must reference graduation.md helper workflow'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-2430 Part B — graduation.md helper workflow', () => {
|
||||
let content;
|
||||
|
||||
test('graduation.md exists', () => {
|
||||
content = readWorkflow('graduation.md');
|
||||
assert.ok(content.length > 0, 'graduation.md must exist and be non-empty');
|
||||
});
|
||||
|
||||
test('graduation.md documents features.graduation config flag', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('features.graduation'),
|
||||
'graduation.md must document features.graduation config flag'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md documents graduation_window config', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('graduation_window'),
|
||||
'graduation.md must document features.graduation_window config'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md documents graduation_threshold config', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('graduation_threshold'),
|
||||
'graduation.md must document features.graduation_threshold config'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md specifies HITL: Promote / Defer / Dismiss', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(content.includes('Promote'), 'graduation.md must document Promote action');
|
||||
assert.ok(content.includes('Defer'), 'graduation.md must document Defer action');
|
||||
assert.ok(content.includes('Dismiss'), 'graduation.md must document Dismiss action');
|
||||
});
|
||||
|
||||
test('graduation.md specifies category→target routing', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('PROJECT.md') && content.includes('PATTERNS.md'),
|
||||
'graduation.md must route categories to appropriate target files'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md specifies graduation_backlog in STATE.md', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('graduation_backlog'),
|
||||
'graduation.md must document STATE.md graduation_backlog for Defer/Dismiss'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md skips items with graduated: annotation', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('graduated:') || content.includes('Graduated:'),
|
||||
'graduation.md must skip already-graduated items'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md has silent no-op for first phase / insufficient data', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('no-op') || content.includes('silent'),
|
||||
'graduation.md must silently no-op when there is insufficient data'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md specifies Defer-all shorthand (A key)', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('Defer all') || content.includes('[Defer all]'),
|
||||
'graduation.md must document the Defer all shorthand for first-run batches'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-2430 — extract-learnings.md graduated: field', () => {
|
||||
test('extract-learnings.md documents optional graduated: annotation', () => {
|
||||
const content = readWorkflow('extract-learnings.md');
|
||||
assert.ok(
|
||||
content.includes('graduated:') || content.includes('Graduated:'),
|
||||
'extract-learnings.md must document optional graduated: field'
|
||||
);
|
||||
});
|
||||
|
||||
test('extract-learnings.md clarifies graduated: is written only by graduation workflow', () => {
|
||||
const content = readWorkflow('extract-learnings.md');
|
||||
assert.ok(
|
||||
content.includes('graduation workflow') || content.includes('graduation.md'),
|
||||
'extract-learnings.md must clarify that graduated: is written only by graduation.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-2430 — INVENTORY sync', () => {
|
||||
test('INVENTORY.md lists graduation.md', () => {
|
||||
const inventory = fs.readFileSync(
|
||||
path.join(__dirname, '../docs/INVENTORY.md'), 'utf-8'
|
||||
);
|
||||
assert.ok(inventory.includes('graduation.md'), 'INVENTORY.md must list graduation.md');
|
||||
});
|
||||
|
||||
test('INVENTORY-MANIFEST.json includes graduation.md', () => {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '../docs/INVENTORY-MANIFEST.json'), 'utf-8')
|
||||
);
|
||||
assert.ok(
|
||||
manifest.families.workflows.includes('graduation.md'),
|
||||
'INVENTORY-MANIFEST.json must include graduation.md in workflows array'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||||
// runtime loads — testing text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* Tests for gsd-new-milestone todo-to-phase linking (#2433).
|
||||
* Verifies the workflow text contains the correct linking and auto-close steps.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const NEW_MILESTONE = fs.readFileSync(
|
||||
path.join(ROOT, 'gsd-core/workflows/new-milestone.md'), 'utf-8'
|
||||
);
|
||||
const EXECUTE_PHASE = fs.readFileSync(
|
||||
path.join(ROOT, 'gsd-core/workflows/execute-phase.md'), 'utf-8'
|
||||
);
|
||||
|
||||
test('new-milestone.md: step 10.5 links pending todos to roadmap phases', () => {
|
||||
assert.ok(NEW_MILESTONE.includes('10.5'), 'step 10.5 should exist');
|
||||
assert.ok(NEW_MILESTONE.includes('resolves_phase'), 'should reference resolves_phase field');
|
||||
assert.ok(NEW_MILESTONE.includes('.planning/todos/pending'), 'should scan pending todos directory');
|
||||
});
|
||||
|
||||
test('new-milestone.md: todo linking runs after roadmap commit', () => {
|
||||
const roadmapCommitIdx = NEW_MILESTONE.indexOf('docs: create milestone v[X.Y] roadmap');
|
||||
const step105Idx = NEW_MILESTONE.indexOf('10.5. Link Pending Todos');
|
||||
const step11Idx = NEW_MILESTONE.indexOf('## 11. Done');
|
||||
assert.ok(roadmapCommitIdx < step105Idx, 'step 10.5 should come after roadmap commit');
|
||||
assert.ok(step105Idx < step11Idx, 'step 10.5 should come before step 11');
|
||||
});
|
||||
|
||||
test('new-milestone.md: todo linking is best-effort and leaves unmatched todos unmodified', () => {
|
||||
assert.ok(NEW_MILESTONE.includes('best-effort'), 'should describe best-effort matching');
|
||||
assert.ok(NEW_MILESTONE.includes('unmatched'), 'should mention leaving unmatched todos alone');
|
||||
assert.ok(NEW_MILESTONE.includes('confident match'), 'should gate on confident match');
|
||||
});
|
||||
|
||||
test('new-milestone.md: step 10.5 commits tagged todos', () => {
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
assert.ok(NEW_MILESTONE.includes('gsd_run query commit'), 'should commit tagged todos');
|
||||
assert.ok(NEW_MILESTONE.includes('resolves_phase after milestone'), 'commit message should mention resolves_phase');
|
||||
});
|
||||
|
||||
test('new-milestone.md: success_criteria includes todo linking', () => {
|
||||
assert.ok(NEW_MILESTONE.includes('resolves_phase: N'), 'success_criteria should mention resolves_phase tagging');
|
||||
});
|
||||
|
||||
test('execute-phase.md: close_phase_todos step exists', () => {
|
||||
assert.ok(EXECUTE_PHASE.includes('close_phase_todos'), 'close_phase_todos step should exist');
|
||||
assert.ok(EXECUTE_PHASE.includes('resolves_phase'), 'should check resolves_phase in todos');
|
||||
});
|
||||
|
||||
test('execute-phase.md: auto-close moves todos to completed directory', () => {
|
||||
assert.ok(EXECUTE_PHASE.includes('.planning/todos/completed'), 'should move to completed dir');
|
||||
assert.ok(EXECUTE_PHASE.includes('.planning/todos/pending'), 'should scan pending dir');
|
||||
assert.ok(EXECUTE_PHASE.includes('mv "$TODO_FILE" "$COMPLETED_DIR/"'), 'should use mv to move files');
|
||||
});
|
||||
|
||||
test('execute-phase.md: close_phase_todos runs after update_roadmap', () => {
|
||||
const updateRoadmapIdx = EXECUTE_PHASE.indexOf('name="update_roadmap"');
|
||||
const closeTodosIdx = EXECUTE_PHASE.indexOf('name="close_phase_todos"');
|
||||
assert.ok(updateRoadmapIdx < closeTodosIdx, 'close_phase_todos should run after update_roadmap');
|
||||
});
|
||||
|
||||
test('execute-phase.md: auto-close never blocks phase completion', () => {
|
||||
const closeTodosSection = EXECUTE_PHASE.slice(
|
||||
EXECUTE_PHASE.indexOf('name="close_phase_todos"'),
|
||||
EXECUTE_PHASE.indexOf('name="update_project_md"')
|
||||
);
|
||||
assert.ok(
|
||||
closeTodosSection.includes('never blocks') || closeTodosSection.includes('additive'),
|
||||
'close_phase_todos should be non-blocking'
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md: awk extracts resolves_phase from YAML frontmatter', () => {
|
||||
assert.ok(EXECUTE_PHASE.includes('awk'), 'should use awk for frontmatter extraction');
|
||||
assert.ok(EXECUTE_PHASE.includes('resolves_phase:'), 'awk pattern should match resolves_phase key');
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow markdown is runtime contract; these assertions verify that
|
||||
// automated codex exec invocations carry the correct automation flags.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
describe('enh-773: automated codex exec invocations include --ephemeral and --dangerously-bypass-hook-trust', () => {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(process.cwd(), 'gsd-core', 'workflows', 'review.md'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Extract codex exec INVOCATION lines from code fences. The #1115 capability
|
||||
// probe (`codex exec --help | grep …`) is not an automation invocation, so it
|
||||
// is excluded from the per-invocation flag assertions below.
|
||||
const codexExecLines = workflow
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes('codex exec') && !line.includes('codex exec --help'));
|
||||
|
||||
test('review.md contains at least one codex exec invocation', () => {
|
||||
assert.ok(
|
||||
codexExecLines.length > 0,
|
||||
'review.md must contain at least one codex exec invocation'
|
||||
);
|
||||
});
|
||||
|
||||
test('every codex exec invocation includes --ephemeral', () => {
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
line.includes('--ephemeral'),
|
||||
`codex exec invocation is missing --ephemeral:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('#1115: the hook-trust bypass is capability-gated, not passed unconditionally', () => {
|
||||
// --dangerously-bypass-hook-trust only exists on codex-cli >= 0.137.0. It must
|
||||
// be probed (`codex exec --help | grep`) and applied via $CODEX_BYPASS_FLAG so
|
||||
// older installs do not fail with "unexpected argument" (a silent empty review).
|
||||
assert.ok(
|
||||
/codex exec --help[^\r\n]*grep[^\r\n]*--dangerously-bypass-hook-trust/.test(workflow),
|
||||
'review.md must capability-probe --dangerously-bypass-hook-trust via `codex exec --help | grep`'
|
||||
);
|
||||
assert.ok(
|
||||
workflow.includes('CODEX_BYPASS_FLAG="--dangerously-bypass-hook-trust"'),
|
||||
'the probe must set CODEX_BYPASS_FLAG to the flag when the CLI supports it'
|
||||
);
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
line.includes('$CODEX_BYPASS_FLAG'),
|
||||
`codex exec invocation must apply the capability-gated $CODEX_BYPASS_FLAG, not an unconditional flag:\n ${line.trim()}`
|
||||
);
|
||||
// …and must NOT also pass the literal flag (that would reintroduce #1115).
|
||||
assert.ok(
|
||||
!line.includes('--dangerously-bypass-hook-trust'),
|
||||
`codex exec invocation must not pass the literal --dangerously-bypass-hook-trust (use the gated $CODEX_BYPASS_FLAG):\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('#1115: codex review failures are surfaced, not silently swallowed', () => {
|
||||
// stderr must be captured (not discarded to /dev/null) and an empty output
|
||||
// must be replaced with a diagnostic, so a broken reviewer is reported.
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
!line.includes('2>/dev/null'),
|
||||
`codex exec must not discard stderr to /dev/null:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
/\[ ! -s \/tmp\/gsd-review-codex-\{phase\}\.md \]/.test(workflow),
|
||||
'review.md must guard against an empty codex review output and surface the failure'
|
||||
);
|
||||
});
|
||||
|
||||
test('--ephemeral appears before the prompt argument (flag ordering)', () => {
|
||||
for (const line of codexExecLines) {
|
||||
const ephemeralPos = line.indexOf('--ephemeral');
|
||||
const promptPos = line.indexOf(' - ');
|
||||
if (promptPos === -1) continue; // no stdin prompt arg on this line
|
||||
assert.ok(
|
||||
ephemeralPos < promptPos,
|
||||
`--ephemeral must appear before the stdin prompt argument:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('--skip-git-repo-check is preserved alongside automation flags', () => {
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
line.includes('--skip-git-repo-check'),
|
||||
`codex exec invocation lost --skip-git-repo-check:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1698 regression: codex review is captured via --output-last-message, not stdout', () => {
|
||||
// WHY: on some platforms (Windows) `codex exec` writes process-teardown output
|
||||
// to stdout *after* the final agent message. A `> FILE` stdout redirect appends
|
||||
// that noise to a non-empty file, so it slips past the `[ ! -s … ]` empty-output
|
||||
// guard and downstream consumers (severity extraction, the
|
||||
// plan-review-convergence "concerns resolved?" gate) parse a polluted review.
|
||||
// `-o/--output-last-message <FILE>` writes only the final message — robust on
|
||||
// every platform — so each codex invocation must capture via -o and discard stdout.
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(process.cwd(), 'gsd-core', 'workflows', 'review.md'),
|
||||
'utf8'
|
||||
);
|
||||
const codexExecLines = workflow
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes('codex exec') && !line.includes('codex exec --help'));
|
||||
|
||||
test('every codex exec invocation captures the review via -o <FILE>', () => {
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
/\s-o\s+\/tmp\/gsd-review-codex-\{phase\}\.md\b/.test(line),
|
||||
`codex exec invocation must capture the review via -o /tmp/gsd-review-codex-{phase}.md:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('no codex exec invocation redirects stdout into the review file', () => {
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
!/>\s*\/tmp\/gsd-review-codex-\{phase\}\.md\b/.test(line),
|
||||
`codex exec must not redirect stdout into the review file (teardown noise pollutes it); use -o + >/dev/null:\n ${line.trim()}`
|
||||
);
|
||||
assert.ok(
|
||||
/>\s*\/dev\/null\b/.test(line),
|
||||
`codex exec must discard stdout to /dev/null so teardown output is not captured:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -65,3 +65,355 @@ describe('execute-phase command: active flags are explicit', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2396-makefile-test-priority.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2396-makefile-test-priority (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #2396)
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2396: hardcoded host-level test commands bypass
|
||||
* container-only project Makefiles.
|
||||
*
|
||||
* Fix: execute-phase.md, verify-phase.md, and audit-fix.md must check for
|
||||
* Makefile with a test target (and other wrappers) before falling through
|
||||
* to hardcoded language-sniffed commands.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const EXECUTE_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
const VERIFY_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md');
|
||||
const AUDIT_FIX_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'audit-fix.md');
|
||||
|
||||
function assertMakefileCheckBeforeNpmTest(filePath, label) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// Must check for Makefile with test target
|
||||
const hasMakefileCheck = /Makefile.*grep.*test:|grep.*test:.*Makefile/s.test(content) ||
|
||||
(content.includes('Makefile') && content.includes('"^test:"'));
|
||||
assert.ok(
|
||||
hasMakefileCheck,
|
||||
`${label}: must check for Makefile with test: target before falling through to hardcoded commands`
|
||||
);
|
||||
|
||||
// make test must appear before npm test in the file
|
||||
const makeTestIdx = content.indexOf('make test');
|
||||
const npmTestIdx = content.indexOf('npm test');
|
||||
assert.ok(makeTestIdx !== -1, `${label}: must contain "make test"`);
|
||||
assert.ok(npmTestIdx !== -1, `${label}: must still contain "npm test" as fallback`);
|
||||
assert.ok(
|
||||
makeTestIdx < npmTestIdx,
|
||||
`${label}: "make test" must appear before "npm test" (Makefile takes priority)`
|
||||
);
|
||||
}
|
||||
|
||||
function assertConfigGetBeforeMakefile(filePath, label) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
// Must check workflow.test_command config before Makefile sniff.
|
||||
// Verify within each bash code block: the workflow.test_command lookup
|
||||
// appears before the Makefile grep in the same block.
|
||||
assert.ok(
|
||||
content.includes('workflow.test_command'),
|
||||
`${label}: must check workflow.test_command config before Makefile/language sniff`
|
||||
);
|
||||
|
||||
// Extract bash blocks to check ordering within each block.
|
||||
// Use the actual Makefile test ([ -f "Makefile" ]) not just the word "Makefile"
|
||||
// (which appears in comments before the config-get call).
|
||||
const bashBlockRe = /```bash([\s\S]*?)```/g;
|
||||
let match;
|
||||
let anyBlockCorrectlyOrdered = false;
|
||||
while ((match = bashBlockRe.exec(content)) !== null) {
|
||||
const block = match[1];
|
||||
if (block.includes('workflow.test_command') && block.includes('[ -f "Makefile"')) {
|
||||
const configIdx = block.indexOf('workflow.test_command');
|
||||
const makefileIdx = block.indexOf('[ -f "Makefile"');
|
||||
if (configIdx < makefileIdx) {
|
||||
anyBlockCorrectlyOrdered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
anyBlockCorrectlyOrdered,
|
||||
`${label}: within a bash block, workflow.test_command config check must appear before Makefile test ([ -f "Makefile" ])`
|
||||
);
|
||||
}
|
||||
|
||||
describe('bug-2396: Makefile test target must take priority over hardcoded commands', () => {
|
||||
test('execute-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(EXECUTE_PHASE_PATH), 'execute-phase.md should exist');
|
||||
});
|
||||
|
||||
test('verify-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(VERIFY_PHASE_PATH), 'verify-phase.md should exist');
|
||||
});
|
||||
|
||||
test('audit-fix.md exists', () => {
|
||||
assert.ok(fs.existsSync(AUDIT_FIX_PATH), 'audit-fix.md should exist');
|
||||
});
|
||||
|
||||
test('execute-phase.md: Makefile check precedes npm test (post-merge gate)', () => {
|
||||
assertMakefileCheckBeforeNpmTest(EXECUTE_PHASE_PATH, 'execute-phase.md');
|
||||
});
|
||||
|
||||
test('verify-phase.md: Makefile check precedes npm test', () => {
|
||||
assertMakefileCheckBeforeNpmTest(VERIFY_PHASE_PATH, 'verify-phase.md');
|
||||
});
|
||||
|
||||
test('audit-fix.md: Makefile check precedes npm test', () => {
|
||||
assertMakefileCheckBeforeNpmTest(AUDIT_FIX_PATH, 'audit-fix.md');
|
||||
});
|
||||
|
||||
test('execute-phase.md: workflow.test_command config checked first (within bash block)', () => {
|
||||
assertConfigGetBeforeMakefile(EXECUTE_PHASE_PATH, 'execute-phase.md');
|
||||
});
|
||||
|
||||
test('verify-phase.md: workflow.test_command config checked first (within bash block)', () => {
|
||||
assertConfigGetBeforeMakefile(VERIFY_PHASE_PATH, 'verify-phase.md');
|
||||
});
|
||||
|
||||
test('audit-fix.md: workflow.test_command config checked first (within bash block)', () => {
|
||||
assertConfigGetBeforeMakefile(AUDIT_FIX_PATH, 'audit-fix.md');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2516-inherit-model-execute-phase.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2516-inherit-model-execute-phase (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #2516)
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for bug #2516
|
||||
*
|
||||
* When `.planning/config.json` has `model_profile: "inherit"`, the
|
||||
* `init.execute-phase` query returns `executor_model: "inherit"`. The
|
||||
* execute-phase workflow was passing this literal string directly to the
|
||||
* Task tool via `model="{executor_model}"`, causing Task to fall back to
|
||||
* its default model instead of inheriting the orchestrator model.
|
||||
*
|
||||
* Fix: the workflow must document that when `executor_model` is `"inherit"`,
|
||||
* the `model=` parameter must be OMITTED from Task() calls entirely.
|
||||
* Omitting `model=` causes Claude Code to inherit the current orchestrator
|
||||
* model automatically.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase.md'
|
||||
);
|
||||
|
||||
describe('bug #2516: executor_model "inherit" must not be passed literally to Task()', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist');
|
||||
});
|
||||
|
||||
test('workflow contains instructions for handling the "inherit" case', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist');
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const hasInheritInstruction =
|
||||
content.includes('"inherit"') &&
|
||||
(content.includes('omit') || content.includes('Omit') || content.includes('omitting') || content.includes('Omitting'));
|
||||
assert.ok(
|
||||
hasInheritInstruction,
|
||||
'execute-phase.md must document that when executor_model is "inherit", ' +
|
||||
'the model= parameter must be omitted from Task() calls. ' +
|
||||
'Found "inherit" mention: ' + content.includes('"inherit"') + '. ' +
|
||||
'Found omit mention: ' + (content.includes('omit') || content.includes('Omit'))
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow does not instruct passing model="inherit" literally to Task', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist');
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
// The workflow must not have an unconditional model="{executor_model}" template
|
||||
// that would pass "inherit" through. It should document conditional logic.
|
||||
const hasConditionalModelParam =
|
||||
content.includes('inherit') &&
|
||||
(
|
||||
content.includes('Only set `model=`') ||
|
||||
content.includes('only set `model=`') ||
|
||||
content.includes('Only set model=') ||
|
||||
content.includes('omit the `model=`') ||
|
||||
content.includes('omit the model=') ||
|
||||
content.includes('omit `model=`') ||
|
||||
content.includes('omit model=')
|
||||
);
|
||||
const lines = content.split(/\r?\n/);
|
||||
const hasLiteralInheritInTask = lines.some(line => {
|
||||
if (!/model\s*=\s*["']inherit["']/.test(line)) return false;
|
||||
// Exclude instructional/explanatory lines that document what NOT to do
|
||||
return !/\b(not|NOT|don'?t|do not|DO NOT|never|NEVER)\b/.test(line);
|
||||
});
|
||||
assert.ok(
|
||||
!hasLiteralInheritInTask,
|
||||
'execute-phase workflow must not pass literal "inherit" string to Task() model parameter'
|
||||
);
|
||||
assert.ok(
|
||||
hasConditionalModelParam && !hasLiteralInheritInTask,
|
||||
'execute-phase.md must conditionally omit model= when executor_model is "inherit", never pass it literally. ' +
|
||||
'The unconditional model="{executor_model}" template would pass the literal ' +
|
||||
'string "inherit" to Task(), which falls back to the default model instead ' +
|
||||
'of the orchestrator model (root cause of #2516).'
|
||||
);
|
||||
// Guard against a future contributor adding an unconditional model="{executor_model}"
|
||||
// template alongside the conditional docs — that would pass "inherit" literally to Task().
|
||||
const hasUnsafeTemplate = lines.some(line => {
|
||||
if (!/model\s*=\s*['"]\{executor_model\}['"]/.test(line)) return false;
|
||||
return !/\b(not|NOT|do not|DO NOT|don'?t|never|NEVER|omit)\b/i.test(line);
|
||||
});
|
||||
assert.ok(!hasUnsafeTemplate,
|
||||
'execute-phase.md must not contain an unconditional model="{executor_model}" template — ' +
|
||||
'it would pass "inherit" literally to Task() when executor_model is "inherit"'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow documents that omitting model= causes inheritance from orchestrator', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist');
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const hasInheritanceExplanation =
|
||||
content.includes('inherit') &&
|
||||
(
|
||||
content.includes('orchestrator model') ||
|
||||
content.includes('orchestrator\'s model') ||
|
||||
content.includes('inherits the') ||
|
||||
content.includes('inherit the current')
|
||||
);
|
||||
assert.ok(
|
||||
hasInheritanceExplanation,
|
||||
'execute-phase.md must explain that omitting model= causes Claude Code to ' +
|
||||
'inherit the current orchestrator model — this is the mechanism that makes ' +
|
||||
'"inherit" work correctly.'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2002-offer-next-context.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2002-offer-next-context (consolidation epic #1969 B4 #1973)", () => {
|
||||
/**
|
||||
* Regression tests for bug #2002
|
||||
*
|
||||
* offer_next in execute-phase.md must present conditional next steps
|
||||
* based on whether CONTEXT.md already exists for the next phase.
|
||||
* The previous flat list offered all options equally with no primary
|
||||
* recommendation, leaving agents without guidance on the correct first step.
|
||||
*
|
||||
* Fixed: offer_next now checks for {next}-CONTEXT.md in the phase directory.
|
||||
* - If CONTEXT.md is missing: primary suggestion is /gsd-discuss-phase
|
||||
* - If CONTEXT.md exists: primary suggestion is /gsd-plan-phase
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const workflowPath = path.resolve(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'
|
||||
);
|
||||
|
||||
describe('bug #2002: offer_next checks CONTEXT.md before suggesting next step', () => {
|
||||
let content;
|
||||
|
||||
// Read once — all tests share the same file content
|
||||
test('setup: workflow file is readable', () => {
|
||||
content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(content.length > 0, 'execute-phase.md must not be empty');
|
||||
});
|
||||
|
||||
test('offer_next section checks for CONTEXT.md existence', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// The workflow must check for CONTEXT.md in the next phase directory
|
||||
assert.ok(
|
||||
content.includes('CONTEXT.md'),
|
||||
'offer_next must reference CONTEXT.md to determine primary next step'
|
||||
);
|
||||
});
|
||||
|
||||
test('offer_next presents /gsd-discuss-phase when CONTEXT.md does not exist', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// Must have a conditional path where discuss-phase is the primary step
|
||||
// when CONTEXT.md is missing — look for proximity of "not exist"/"missing"/
|
||||
// "does not exist" and "gsd-discuss-phase" in the offer_next step
|
||||
const offerNextIdx = content.indexOf('offer_next');
|
||||
assert.ok(offerNextIdx !== -1, 'offer_next step must exist');
|
||||
|
||||
// Use 5000-char window — the step is ~60 lines of prose before the conditionals
|
||||
const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000);
|
||||
assert.ok(
|
||||
/CONTEXT\.md.*does not exist|CONTEXT\.md.*not.*exist|If CONTEXT\.md does/i.test(offerNextSection) ||
|
||||
/gsd-discuss-phase.*recommended|recommended.*gsd-discuss-phase/i.test(offerNextSection),
|
||||
'offer_next must present /gsd-discuss-phase as primary when CONTEXT.md does not exist'
|
||||
);
|
||||
});
|
||||
|
||||
test('offer_next presents /gsd-plan-phase when CONTEXT.md exists', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
const offerNextIdx = content.indexOf('offer_next');
|
||||
assert.ok(offerNextIdx !== -1, 'offer_next step must exist');
|
||||
|
||||
const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000);
|
||||
assert.ok(
|
||||
/CONTEXT\.md.*exists|exists.*CONTEXT\.md|If CONTEXT\.md/i.test(offerNextSection),
|
||||
'offer_next must present /gsd-plan-phase as primary when CONTEXT.md exists'
|
||||
);
|
||||
});
|
||||
|
||||
test('offer_next section contains at least one conditional guard before listing commands', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
const offerNextIdx = content.indexOf('offer_next');
|
||||
assert.ok(offerNextIdx !== -1, 'offer_next step must exist');
|
||||
|
||||
const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000);
|
||||
|
||||
// The fixed version must contain at least one "If CONTEXT.md" conditional
|
||||
// guard before presenting command options. The old flat list had no guard.
|
||||
assert.ok(
|
||||
/If CONTEXT\.md/i.test(offerNextSection),
|
||||
'offer_next must contain at least one "If CONTEXT.md" conditional guard'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,3 +158,141 @@ describe('execute-phase step 5.5: cross-wave-deviation cleanup documentation (#3
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/fix-3722-execute-phase-human-needed-checkpoint.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:fix-3722-execute-phase-human-needed-checkpoint (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #3722)
|
||||
// execute-phase.md IS the runtime contract loaded by the orchestrator.
|
||||
// Asserting that the "ack-and-advance" path is absent is the only way to verify
|
||||
// the state machine lie (issue #38) cannot regress at runtime.
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* execute-phase.md human_needed branch — issue #38 / fix #3722
|
||||
*
|
||||
* The old design offered '"approved" → continue' as a shortcut that advanced
|
||||
* ROADMAP.md without completing human verification. This is a state machine lie:
|
||||
* the phase appears complete in the project record while HUMAN-UAT.md items
|
||||
* remain unresolved.
|
||||
*
|
||||
* The correct design:
|
||||
* - human_needed branch creates a {phase_num}-UAT.md file (not {phase_num}-HUMAN-UAT.md)
|
||||
* - directs the user to /gsd:verify-work to complete verification
|
||||
* - does NOT call update_roadmap directly (phase completion goes through verify-work)
|
||||
* - does NOT offer "approved" → continue as a bypass
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const EXECUTE_PHASE = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase.md'
|
||||
);
|
||||
|
||||
describe('execute-phase.md human_needed branch — issue #38', () => {
|
||||
let content;
|
||||
|
||||
// Read once; all tests share the string.
|
||||
test('workflow file is readable', () => {
|
||||
content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
assert.ok(content.length > 0, 'execute-phase.md must be non-empty');
|
||||
});
|
||||
|
||||
test('human_needed section exists', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('human_needed'),
|
||||
'execute-phase.md must contain a human_needed branch'
|
||||
);
|
||||
});
|
||||
|
||||
test('"approved" → continue bypass is absent from human_needed branch', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
// The old prompt offered '"approved" → continue' as a shortcut that advanced
|
||||
// ROADMAP.md without completing verification. That path must not exist.
|
||||
assert.ok(
|
||||
!content.includes('"approved" → continue'),
|
||||
'human_needed branch must not offer "approved" → continue: it marks the phase complete without verification (issue #38)'
|
||||
);
|
||||
});
|
||||
|
||||
test('human_needed branch does NOT call update_roadmap directly', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
// Locate the human_needed section and check that update_roadmap does not
|
||||
// appear before the gaps_found section (i.e. it is not reachable from human_needed).
|
||||
const humanNeededIdx = content.indexOf('**If human_needed:**');
|
||||
const gapsFoundIdx = content.indexOf('**If gaps_found:**');
|
||||
const updateRoadmapIdx = content.indexOf('update_roadmap', humanNeededIdx);
|
||||
|
||||
assert.ok(humanNeededIdx !== -1, '**If human_needed:** section must exist');
|
||||
assert.ok(gapsFoundIdx !== -1, '**If gaps_found:** section must exist');
|
||||
assert.ok(
|
||||
humanNeededIdx < gapsFoundIdx,
|
||||
'human_needed section must appear before gaps_found section'
|
||||
);
|
||||
|
||||
// update_roadmap must not appear between human_needed and gaps_found sections
|
||||
const updateRoadmapBetween =
|
||||
updateRoadmapIdx !== -1 &&
|
||||
updateRoadmapIdx > humanNeededIdx &&
|
||||
updateRoadmapIdx < gapsFoundIdx;
|
||||
|
||||
assert.ok(
|
||||
!updateRoadmapBetween,
|
||||
'update_roadmap must not be reachable directly from the human_needed branch — phase completion must go through verify-work (issue #38)'
|
||||
);
|
||||
});
|
||||
|
||||
test('human_needed branch directs user to /gsd:verify-work', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
const humanNeededIdx = content.indexOf('**If human_needed:**');
|
||||
const gapsFoundIdx = content.indexOf('**If gaps_found:**');
|
||||
assert.ok(humanNeededIdx !== -1, '**If human_needed:** section must exist');
|
||||
|
||||
const humanNeededSection = content.slice(
|
||||
humanNeededIdx,
|
||||
gapsFoundIdx !== -1 ? gapsFoundIdx : undefined
|
||||
);
|
||||
assert.ok(
|
||||
humanNeededSection.includes('verify-work'),
|
||||
'human_needed branch must direct the user to /gsd:verify-work to complete verification'
|
||||
);
|
||||
});
|
||||
|
||||
test('human_needed branch creates {phase_num}-UAT.md (not HUMAN-UAT.md)', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
const humanNeededIdx = content.indexOf('**If human_needed:**');
|
||||
const gapsFoundIdx = content.indexOf('**If gaps_found:**');
|
||||
assert.ok(humanNeededIdx !== -1, '**If human_needed:** section must exist');
|
||||
|
||||
const humanNeededSection = content.slice(
|
||||
humanNeededIdx,
|
||||
gapsFoundIdx !== -1 ? gapsFoundIdx : undefined
|
||||
);
|
||||
|
||||
// The file should be named {phase_num}-UAT.md so verify-work's glob picks it up
|
||||
assert.ok(
|
||||
humanNeededSection.includes('-UAT.md'),
|
||||
'human_needed branch must create a {phase_num}-UAT.md file for verify-work to resume'
|
||||
);
|
||||
|
||||
// HUMAN-UAT.md causes a naming mismatch with verify-work's create_uat_file step
|
||||
assert.ok(
|
||||
!humanNeededSection.includes('HUMAN-UAT.md'),
|
||||
'human_needed branch must NOT create HUMAN-UAT.md — use {phase_num}-UAT.md to align with verify-work\'s resume path (issue #38 edge case 3)'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,3 +292,450 @@ describe('use_worktrees config: cross-workflow structural coverage', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2410-stream-checkpoint-heartbeats.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2410-stream-checkpoint-heartbeats (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #2410)
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Bug #2410 — /gsd:manager background execute-phase Task fails with
|
||||
* "Stream idle timeout" on multi-plan phases.
|
||||
*
|
||||
* Fix: execute-phase.md instructs the orchestrator to emit `[checkpoint]`
|
||||
* heartbeat lines at every wave boundary AND every plan boundary so the
|
||||
* Claude API SSE stream never idles long enough to trigger the platform
|
||||
* timeout. This test validates the workflow contract that backs that fix.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase.md'
|
||||
);
|
||||
const COMMANDS_DOC_PATH = path.join(__dirname, '..', 'docs', 'COMMANDS.md');
|
||||
|
||||
describe('bug #2410: execute-phase emits checkpoint heartbeats', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
|
||||
test('workflow references the stream idle timeout symptom by name', () => {
|
||||
assert.ok(
|
||||
/Stream idle timeout/.test(workflow),
|
||||
'workflow should name the API error it is preventing'
|
||||
);
|
||||
assert.ok(
|
||||
workflow.includes('#2410'),
|
||||
'workflow should cite the tracking issue for future maintainers'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow defines a [checkpoint] heartbeat line format', () => {
|
||||
assert.ok(
|
||||
workflow.includes('[checkpoint]'),
|
||||
'workflow should document the [checkpoint] marker prefix'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a wave-start heartbeat (A: wave-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} starting/.test(workflow),
|
||||
'workflow should emit a wave-start [checkpoint] marker before spawning agents'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a wave-complete heartbeat (A: wave-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(workflow),
|
||||
'workflow should emit a wave-complete [checkpoint] marker after spot-checks'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a plan-start heartbeat (B: plan-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(workflow),
|
||||
'workflow should emit a plan-start [checkpoint] marker before each Task() dispatch'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a plan-complete heartbeat (B: plan-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(workflow),
|
||||
'workflow should emit a plan-complete [checkpoint] marker after executor returns'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow handles plan failure and checkpoint-gate heartbeats too', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} failed/.test(workflow),
|
||||
'workflow should emit a plan-failed [checkpoint] marker on executor error'
|
||||
);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} checkpoint/.test(workflow),
|
||||
'workflow should emit a heartbeat when a plan returns a human-gate checkpoint'
|
||||
);
|
||||
});
|
||||
|
||||
test('heartbeats include a monotonic plans-done counter', () => {
|
||||
// The {P}/{Q} counter lets grep-based recovery tools reconstruct progress
|
||||
// from a truncated transcript if the agent dies mid-phase.
|
||||
assert.ok(
|
||||
/\{P\}\/\{Q\} plans done/.test(workflow),
|
||||
'heartbeats should include a {P}/{Q} phase-wide completed-plan counter'
|
||||
);
|
||||
});
|
||||
|
||||
test('wave-start heartbeat precedes the "Describe what\'s being built" text', () => {
|
||||
const describeIdx = workflow.indexOf("Describe what's being built");
|
||||
const heartbeatIdx = workflow.indexOf(
|
||||
'[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} starting'
|
||||
);
|
||||
assert.ok(describeIdx !== -1, 'workflow should still have the describe step');
|
||||
assert.ok(heartbeatIdx !== -1, 'wave-start heartbeat template should be present');
|
||||
// The instruction to emit the heartbeat appears in step 2, which is the
|
||||
// step titled "Describe what's being built". The actual sentinel text we
|
||||
// look for is the inline literal template — it must be emitted BEFORE any
|
||||
// tool calls in that step.
|
||||
const step2 = workflow.slice(
|
||||
describeIdx,
|
||||
workflow.indexOf('3. **Spawn executor agents', describeIdx)
|
||||
);
|
||||
assert.ok(
|
||||
step2.includes('[checkpoint]'),
|
||||
'step 2 should instruct the orchestrator to emit a [checkpoint] heartbeat'
|
||||
);
|
||||
assert.ok(
|
||||
/before any further reasoning or spawning/i.test(step2) ||
|
||||
/before any tool call/i.test(step2) ||
|
||||
/no tool call/i.test(step2),
|
||||
'step 2 should make clear the heartbeat is an assistant-text line, not a tool call'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-start heartbeat is inside the spawn step', () => {
|
||||
const spawnIdx = workflow.indexOf('3. **Spawn executor agents');
|
||||
const waitIdx = workflow.indexOf('4. **Wait for all agents', spawnIdx);
|
||||
assert.ok(spawnIdx !== -1 && waitIdx !== -1, 'spawn and wait steps must exist');
|
||||
const step3 = workflow.slice(spawnIdx, waitIdx);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(step3),
|
||||
'plan-start heartbeat should be emitted inside step 3 (spawn executor agents)'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-complete and wave-complete heartbeats are inside the wait/report steps', () => {
|
||||
const waitIdx = workflow.indexOf('4. **Wait for all agents');
|
||||
const hookIdx = workflow.indexOf('5. **Post-wave hook validation', waitIdx);
|
||||
assert.ok(waitIdx !== -1 && hookIdx !== -1, 'wait + hook steps must exist');
|
||||
const step4 = workflow.slice(waitIdx, hookIdx);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(step4),
|
||||
'plan-complete heartbeat should be emitted in step 4 (wait for agents)'
|
||||
);
|
||||
|
||||
const reportIdx = workflow.indexOf('6. **Report completion');
|
||||
const failureIdx = workflow.indexOf('7. **Handle failures', reportIdx);
|
||||
assert.ok(reportIdx !== -1 && failureIdx !== -1, 'report + failure steps must exist');
|
||||
const step6 = workflow.slice(reportIdx, failureIdx);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(step6),
|
||||
'wave-complete heartbeat should be emitted in step 6 (report completion)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2410: checkpoint heartbeat format is user-documented', () => {
|
||||
const commandsDoc = fs.readFileSync(COMMANDS_DOC_PATH, 'utf-8');
|
||||
|
||||
test('COMMANDS.md documents the [checkpoint] format under /gsd-manager', () => {
|
||||
const managerIdx = commandsDoc.indexOf('### `/gsd-manager`');
|
||||
assert.ok(managerIdx !== -1, '/gsd-manager section should exist');
|
||||
const section = commandsDoc.slice(managerIdx, managerIdx + 4000);
|
||||
assert.ok(
|
||||
/\[checkpoint\]/.test(section),
|
||||
'COMMANDS.md /gsd-manager section should document [checkpoint] heartbeat markers'
|
||||
);
|
||||
assert.ok(
|
||||
/Stream idle timeout/i.test(section),
|
||||
'COMMANDS.md should explain what the heartbeats prevent'
|
||||
);
|
||||
assert.ok(
|
||||
/#2410/.test(section),
|
||||
'COMMANDS.md should reference the tracking issue'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/fix-1369-wave-stale-base.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:fix-1369-wave-stale-base (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product #1369
|
||||
// Workflow .md files are the installed AI instructions — their text IS what the runtime
|
||||
// loads. Testing text content tests the deployed contract. Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression tests for bug #1369: execute-phase worktree agents fork from stale base after
|
||||
* a wave merge advances orchestrator HEAD past origin/HEAD.
|
||||
*
|
||||
* Steps 0.5 and 7b+7c are extracted to reference files to satisfy the ADR-857 size cap.
|
||||
* execute-phase.md contains @-reference pointers; the reference files hold the content.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
const WAVE_GUARD_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-wave-guard.md');
|
||||
const BETWEEN_WAVE_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-between-wave-reset.md');
|
||||
|
||||
describe('execute-phase: inter-wave worktree base re-check (#1369)', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'workflows/execute-phase.md should exist');
|
||||
});
|
||||
|
||||
test('wave-guard reference file exists', () => {
|
||||
assert.ok(fs.existsSync(WAVE_GUARD_PATH), 'references/execute-phase-wave-guard.md should exist');
|
||||
});
|
||||
|
||||
test('workflow contains @-reference pointer to wave-guard (step 0.5 injected at runtime)', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('execute-phase-wave-guard.md'),
|
||||
'execute-phase.md must have an @-reference to execute-phase-wave-guard.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow contains step 0.5 inter-wave base re-check section', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('0.5.') && content.includes('Inter-wave worktree base re-check'),
|
||||
'execute-phase-wave-guard.md must have step 0.5 "Inter-wave worktree base re-check"'
|
||||
);
|
||||
});
|
||||
|
||||
test('step 0.5 references #1369', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#1369'), 'step 0.5 must reference #1369 for traceability');
|
||||
});
|
||||
|
||||
test('step 0.5 runs worktree.base-check inside the For-each-wave loop', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const forEachIdx = workflow.indexOf('**For each wave:**');
|
||||
const refIdx = workflow.indexOf('execute-phase-wave-guard.md');
|
||||
assert.ok(forEachIdx !== -1, '"For each wave:" section must exist in execute-phase.md');
|
||||
assert.ok(refIdx !== -1, '@-reference to wave-guard must exist in execute-phase.md');
|
||||
assert.ok(refIdx > forEachIdx, 'wave-guard @-reference must appear AFTER "For each wave:" so step 0.5 runs per-wave');
|
||||
});
|
||||
|
||||
test('step 0.5 runs worktree.base-check command', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('worktree.base-check'), 'step 0.5 must invoke worktree.base-check');
|
||||
});
|
||||
|
||||
test('step 0.5 sets USE_WORKTREES=false when shouldDegrade is true', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('USE_WORKTREES=false'), 'step 0.5 must override USE_WORKTREES=false when base divergence is detected');
|
||||
});
|
||||
|
||||
test('step 0.5 appears before step 1 (intra-wave overlap check)', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const forEachIdx = workflow.indexOf('**For each wave:**');
|
||||
const refIdx = workflow.indexOf('execute-phase-wave-guard.md');
|
||||
const step1Idx = workflow.indexOf('1. **Intra-wave', forEachIdx);
|
||||
assert.ok(refIdx !== -1, 'wave-guard @-reference must exist');
|
||||
assert.ok(step1Idx !== -1, 'step 1 (intra-wave overlap check) must exist');
|
||||
assert.ok(refIdx < step1Idx, 'wave-guard @-reference must appear before step 1');
|
||||
});
|
||||
|
||||
test('step 0.5 guards on RUNTIME=claude (worktree isolation is Claude Code-specific)', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")),
|
||||
'step 0.5 must guard on RUNTIME=claude'
|
||||
);
|
||||
});
|
||||
|
||||
test('step 0.5 explains root cause: wave merges advance HEAD past origin/HEAD', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('origin/HEAD'), 'step 0.5 must name origin/HEAD as the stale fork base');
|
||||
});
|
||||
|
||||
test('step 0.5 cross-references #683 for worktree.baseRef configuration', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#683'), 'step 0.5 must cross-reference #683');
|
||||
});
|
||||
|
||||
test('step 0.5 mentions worktree.baseRef:"head" as permanent fix', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('worktree.baseRef') && content.includes('head'),
|
||||
'step 0.5 must mention worktree.baseRef:"head"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute-phase: between-wave manifest reset (#1369, #3384)', () => {
|
||||
test('between-wave reference file exists', () => {
|
||||
assert.ok(fs.existsSync(BETWEEN_WAVE_PATH), 'references/execute-phase-between-wave-reset.md should exist');
|
||||
});
|
||||
|
||||
test('workflow contains @-reference pointer to between-wave-reset', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('execute-phase-between-wave-reset.md'),
|
||||
'execute-phase.md must have an @-reference to execute-phase-between-wave-reset.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('step 7c exists with between-wave manifest reset (#1369)', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('7c.') && content.includes('Between-wave manifest reset'),
|
||||
'execute-phase-between-wave-reset.md must have step 7c "Between-wave manifest reset"'
|
||||
);
|
||||
});
|
||||
|
||||
test('step 7c unsets WAVE_WORKTREE_MANIFEST between waves', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(content.includes('unset WAVE_WORKTREE_MANIFEST'), 'step 7c must unset WAVE_WORKTREE_MANIFEST');
|
||||
});
|
||||
|
||||
test('step 7c references #1369 and #3384 for traceability', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#1369'), 'step 7c must reference #1369');
|
||||
assert.ok(content.includes('#3384'), 'step 7c must reference #3384');
|
||||
});
|
||||
|
||||
test('step 7c calls worktree.set-baseref to re-assert head config', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(content.includes('worktree.set-baseref'), 'step 7c must call worktree.set-baseref');
|
||||
});
|
||||
|
||||
test('step 7c appears after step 7b and before step 8 in the wave loop', () => {
|
||||
const ref = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const idx7b = ref.indexOf('7b.');
|
||||
const idx7c = ref.indexOf('7c.');
|
||||
const refPtr = workflow.indexOf('execute-phase-between-wave-reset.md');
|
||||
const idx8 = workflow.indexOf('8. **Execute checkpoint', refPtr);
|
||||
assert.ok(idx7b !== -1, 'step 7b must exist in between-wave reference file');
|
||||
assert.ok(idx7c !== -1, 'step 7c must exist in between-wave reference file');
|
||||
assert.ok(idx8 !== -1, 'step 8 must exist in execute-phase.md after the between-wave @-reference');
|
||||
assert.ok(idx7b < idx7c, 'step 7c must appear after step 7b');
|
||||
assert.ok(refPtr < idx8, 'between-wave @-reference must appear before step 8');
|
||||
});
|
||||
|
||||
test('step 7c guards on RUNTIME=claude for worktree-specific operations', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")),
|
||||
'step 7c must guard on RUNTIME=claude'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3096-ai-integration-phase-parallel-race.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3096-ai-integration-phase-parallel-race (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
// allow-test-rule: reads product workflow markdown (ai-integration-phase.md) to verify structural ordering contract — not a source-grep test (see #3096)
|
||||
|
||||
// Regression guard for bug #3096.
|
||||
//
|
||||
// ai-integration-phase.md listed Steps 7+8 (gsd-ai-researcher +
|
||||
// gsd-domain-researcher) without an explicit sequential ordering constraint.
|
||||
// An orchestrator optimizing for speed could reasonably parallelize them
|
||||
// since the sections appeared disjoint. When parallelized, gsd-domain-researcher's
|
||||
// Write call at finalization replaced the whole AI-SPEC.md file with its
|
||||
// in-memory copy (pre-researcher state), silently overwriting Sections 3/4.
|
||||
//
|
||||
// Confirmed at 40% incidence rate on a real run (2 of 5 worktree agents hit it).
|
||||
// Recovery cost: one extra ai-researcher dispatch (~18 min wall).
|
||||
//
|
||||
// Fix:
|
||||
// 1. Explicit "MUST run sequentially" note on Steps 7 and 8
|
||||
// 2. Edit-only tool discipline injected into both agent prompts
|
||||
|
||||
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 src = fs.readFileSync(
|
||||
path.join(ROOT, 'gsd-core', 'workflows', 'ai-integration-phase.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('bug #3096: ai-integration-phase sequential ordering and Edit-only discipline', () => {
|
||||
test('Step 7 documents sequential ordering requirement', () => {
|
||||
assert.ok(
|
||||
src.includes('sequentially') || src.includes('sequential'),
|
||||
'Steps 7+8 ordering note is missing — parallel dispatch race can recur',
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 7 gsd-ai-researcher prompt includes Edit-only tool discipline', () => {
|
||||
// The discipline block must appear before </objective> for gsd-ai-researcher
|
||||
const step7Idx = src.indexOf('## 7. Spawn gsd-ai-researcher');
|
||||
const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher');
|
||||
assert.ok(step7Idx !== -1, 'Step 7 not found');
|
||||
assert.ok(step8Idx !== -1, 'Step 8 not found');
|
||||
const step7Block = src.slice(step7Idx, step8Idx);
|
||||
assert.ok(
|
||||
step7Block.includes('Edit tool') && step7Block.includes('NEVER use Write'),
|
||||
'Step 7 agent prompt missing Edit-only tool discipline',
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 8 gsd-domain-researcher prompt includes Edit-only tool discipline', () => {
|
||||
const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher');
|
||||
const step9Idx = src.indexOf('## 9. Spawn gsd-eval-planner');
|
||||
assert.ok(step8Idx !== -1, 'Step 8 not found');
|
||||
assert.ok(step9Idx !== -1, 'Step 9 not found');
|
||||
const step8Block = src.slice(step8Idx, step9Idx);
|
||||
assert.ok(
|
||||
step8Block.includes('Edit tool') && step8Block.includes('NEVER use Write'),
|
||||
'Step 8 agent prompt missing Edit-only tool discipline',
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 8 references the wait instruction', () => {
|
||||
const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher');
|
||||
const step9Idx = src.indexOf('## 9. Spawn gsd-eval-planner');
|
||||
const step8Block = src.slice(step8Idx, step9Idx);
|
||||
assert.ok(
|
||||
step8Block.includes('Wait') || step8Block.includes('wait') || step8Block.includes('complete'),
|
||||
'Step 8 does not instruct orchestrator to wait for Step 7',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
// allow-test-rule: docs-parity
|
||||
// Verifies that issue #22 drift-guard surfacing changes are present:
|
||||
// - new-project workflow mentions plan_review.source_grounding
|
||||
// - CONFIGURATION.md documents both new config keys
|
||||
// - COMMANDS.md mentions gsd-tools intel api-surface
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
const NEW_PROJECT_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'new-project.md');
|
||||
const SETTINGS_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'settings.md');
|
||||
const CONFIGURATION_PATH = path.join(ROOT, 'docs', 'CONFIGURATION.md');
|
||||
const COMMANDS_PATH = path.join(ROOT, 'docs', 'COMMANDS.md');
|
||||
const USER_GUIDE_PATH = path.join(ROOT, 'docs', 'USER-GUIDE.md');
|
||||
const ARCHITECTURE_PATH = path.join(ROOT, 'docs', 'ARCHITECTURE.md');
|
||||
|
||||
describe('feat-22-surfacing-docs', () => {
|
||||
// ── A1: new-project workflow ─────────────────────────────────────────────
|
||||
|
||||
test('new-project workflow mentions source_grounding', () => {
|
||||
const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('source_grounding'),
|
||||
'new-project.md must mention source_grounding'
|
||||
);
|
||||
});
|
||||
|
||||
test('new-project workflow has Drift Guard question', () => {
|
||||
const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('Drift Guard'),
|
||||
'new-project.md must include a "Drift Guard" question header'
|
||||
);
|
||||
});
|
||||
|
||||
test('new-project workflow wires source_grounding into config-new-project call', () => {
|
||||
const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('"plan_review":{"source_grounding":'),
|
||||
'new-project.md config-new-project call must include plan_review.source_grounding'
|
||||
);
|
||||
});
|
||||
|
||||
test('new-project workflow has Drift Guard default-yes option', () => {
|
||||
const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8');
|
||||
// Both question blocks (auto and interactive) should have the yes option
|
||||
const count = (content.match(/Yes \(Recommended\).*catches hallucinated names/g) || []).length;
|
||||
assert.ok(
|
||||
count >= 1,
|
||||
'new-project.md must have at least one Drift Guard "Yes (Recommended)" option'
|
||||
);
|
||||
});
|
||||
|
||||
// ── A2: settings workflow ────────────────────────────────────────────────
|
||||
|
||||
test('settings workflow mentions source_grounding in read_current step', () => {
|
||||
const content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('plan_review.source_grounding'),
|
||||
'settings.md must mention plan_review.source_grounding in the read_current step'
|
||||
);
|
||||
});
|
||||
|
||||
test('settings workflow has Drift Guard AskUserQuestion toggle', () => {
|
||||
const content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('Drift Guard'),
|
||||
'settings.md must include a "Drift Guard" question header'
|
||||
);
|
||||
});
|
||||
|
||||
test('settings workflow update_config includes plan_review.source_grounding', () => {
|
||||
const content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('"source_grounding": true/false'),
|
||||
'settings.md update_config block must include source_grounding: true/false'
|
||||
);
|
||||
});
|
||||
|
||||
test('settings workflow mentions source_grounding_authority', () => {
|
||||
const content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('source_grounding_authority'),
|
||||
'settings.md must mention source_grounding_authority'
|
||||
);
|
||||
});
|
||||
|
||||
test('settings confirm table includes Plan Drift Guard row', () => {
|
||||
const content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('Plan Drift Guard'),
|
||||
'settings.md confirm table must include a "Plan Drift Guard" row'
|
||||
);
|
||||
});
|
||||
|
||||
// ── B1: CONFIGURATION.md ─────────────────────────────────────────────────
|
||||
|
||||
test('CONFIGURATION.md documents plan_review.source_grounding', () => {
|
||||
const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('`plan_review.source_grounding`'),
|
||||
'CONFIGURATION.md must document plan_review.source_grounding'
|
||||
);
|
||||
});
|
||||
|
||||
test('CONFIGURATION.md documents plan_review.source_grounding_authority', () => {
|
||||
const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('`plan_review.source_grounding_authority`'),
|
||||
'CONFIGURATION.md must document plan_review.source_grounding_authority'
|
||||
);
|
||||
});
|
||||
|
||||
test('CONFIGURATION.md documents grep as default authority', () => {
|
||||
const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('`grep`') && content.includes('source_grounding_authority'),
|
||||
'CONFIGURATION.md must document grep as the default source_grounding_authority'
|
||||
);
|
||||
});
|
||||
|
||||
test('CONFIGURATION.md lists all five authority enum values', () => {
|
||||
const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8');
|
||||
const authorities = ['grep', 'intel', 'treesitter', 'lsp', 'scip'];
|
||||
const missing = authorities.filter(a => !content.includes(a));
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
`CONFIGURATION.md must list all authority values; missing: ${missing.join(', ')}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── B2: COMMANDS.md ───────────────────────────────────────────────────────
|
||||
|
||||
test('COMMANDS.md mentions intel api-surface', () => {
|
||||
const content = fs.readFileSync(COMMANDS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('intel api-surface'),
|
||||
'COMMANDS.md must document the gsd-tools intel api-surface command'
|
||||
);
|
||||
});
|
||||
|
||||
test('COMMANDS.md documents api-surface gating on intel.enabled', () => {
|
||||
const content = fs.readFileSync(COMMANDS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('intel.enabled'),
|
||||
'COMMANDS.md intel api-surface section must mention the intel.enabled gate'
|
||||
);
|
||||
});
|
||||
|
||||
test('COMMANDS.md mentions API-SURFACE.md output', () => {
|
||||
const content = fs.readFileSync(COMMANDS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('API-SURFACE.md'),
|
||||
'COMMANDS.md must mention the API-SURFACE.md output file'
|
||||
);
|
||||
});
|
||||
|
||||
// ── B3: USER-GUIDE.md ─────────────────────────────────────────────────────
|
||||
|
||||
test('USER-GUIDE.md has Plan Drift Guard subsection', () => {
|
||||
const content = fs.readFileSync(USER_GUIDE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('### Plan Drift Guard'),
|
||||
'USER-GUIDE.md must have a "### Plan Drift Guard" subsection'
|
||||
);
|
||||
});
|
||||
|
||||
test('USER-GUIDE.md mentions needs-acknowledgement behavior', () => {
|
||||
const content = fs.readFileSync(USER_GUIDE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('needs-acknowledgement'),
|
||||
'USER-GUIDE.md drift guard section must describe needs-acknowledgement behavior'
|
||||
);
|
||||
});
|
||||
|
||||
test('USER-GUIDE.md explains drift guard works without intel', () => {
|
||||
const content = fs.readFileSync(USER_GUIDE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('without intel') || content.includes('Works without intel'),
|
||||
'USER-GUIDE.md must explain that the drift guard works without intel'
|
||||
);
|
||||
});
|
||||
|
||||
// ── B4: ARCHITECTURE.md ───────────────────────────────────────────────────
|
||||
|
||||
test('ARCHITECTURE.md links to ADR 22', () => {
|
||||
const content = fs.readFileSync(ARCHITECTURE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('adr/22-plan-drift-guard.md') || content.includes('ADR 22'),
|
||||
'ARCHITECTURE.md must link to ADR 22 (adr/22-plan-drift-guard.md)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product #1369
|
||||
// Workflow .md files are the installed AI instructions — their text IS what the runtime
|
||||
// loads. Testing text content tests the deployed contract. Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression tests for bug #1369: execute-phase worktree agents fork from stale base after
|
||||
* a wave merge advances orchestrator HEAD past origin/HEAD.
|
||||
*
|
||||
* Steps 0.5 and 7b+7c are extracted to reference files to satisfy the ADR-857 size cap.
|
||||
* execute-phase.md contains @-reference pointers; the reference files hold the content.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
const WAVE_GUARD_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-wave-guard.md');
|
||||
const BETWEEN_WAVE_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-between-wave-reset.md');
|
||||
|
||||
describe('execute-phase: inter-wave worktree base re-check (#1369)', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'workflows/execute-phase.md should exist');
|
||||
});
|
||||
|
||||
test('wave-guard reference file exists', () => {
|
||||
assert.ok(fs.existsSync(WAVE_GUARD_PATH), 'references/execute-phase-wave-guard.md should exist');
|
||||
});
|
||||
|
||||
test('workflow contains @-reference pointer to wave-guard (step 0.5 injected at runtime)', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('execute-phase-wave-guard.md'),
|
||||
'execute-phase.md must have an @-reference to execute-phase-wave-guard.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow contains step 0.5 inter-wave base re-check section', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('0.5.') && content.includes('Inter-wave worktree base re-check'),
|
||||
'execute-phase-wave-guard.md must have step 0.5 "Inter-wave worktree base re-check"'
|
||||
);
|
||||
});
|
||||
|
||||
test('step 0.5 references #1369', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#1369'), 'step 0.5 must reference #1369 for traceability');
|
||||
});
|
||||
|
||||
test('step 0.5 runs worktree.base-check inside the For-each-wave loop', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const forEachIdx = workflow.indexOf('**For each wave:**');
|
||||
const refIdx = workflow.indexOf('execute-phase-wave-guard.md');
|
||||
assert.ok(forEachIdx !== -1, '"For each wave:" section must exist in execute-phase.md');
|
||||
assert.ok(refIdx !== -1, '@-reference to wave-guard must exist in execute-phase.md');
|
||||
assert.ok(refIdx > forEachIdx, 'wave-guard @-reference must appear AFTER "For each wave:" so step 0.5 runs per-wave');
|
||||
});
|
||||
|
||||
test('step 0.5 runs worktree.base-check command', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('worktree.base-check'), 'step 0.5 must invoke worktree.base-check');
|
||||
});
|
||||
|
||||
test('step 0.5 sets USE_WORKTREES=false when shouldDegrade is true', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('USE_WORKTREES=false'), 'step 0.5 must override USE_WORKTREES=false when base divergence is detected');
|
||||
});
|
||||
|
||||
test('step 0.5 appears before step 1 (intra-wave overlap check)', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const forEachIdx = workflow.indexOf('**For each wave:**');
|
||||
const refIdx = workflow.indexOf('execute-phase-wave-guard.md');
|
||||
const step1Idx = workflow.indexOf('1. **Intra-wave', forEachIdx);
|
||||
assert.ok(refIdx !== -1, 'wave-guard @-reference must exist');
|
||||
assert.ok(step1Idx !== -1, 'step 1 (intra-wave overlap check) must exist');
|
||||
assert.ok(refIdx < step1Idx, 'wave-guard @-reference must appear before step 1');
|
||||
});
|
||||
|
||||
test('step 0.5 guards on RUNTIME=claude (worktree isolation is Claude Code-specific)', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")),
|
||||
'step 0.5 must guard on RUNTIME=claude'
|
||||
);
|
||||
});
|
||||
|
||||
test('step 0.5 explains root cause: wave merges advance HEAD past origin/HEAD', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('origin/HEAD'), 'step 0.5 must name origin/HEAD as the stale fork base');
|
||||
});
|
||||
|
||||
test('step 0.5 cross-references #683 for worktree.baseRef configuration', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#683'), 'step 0.5 must cross-reference #683');
|
||||
});
|
||||
|
||||
test('step 0.5 mentions worktree.baseRef:"head" as permanent fix', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('worktree.baseRef') && content.includes('head'),
|
||||
'step 0.5 must mention worktree.baseRef:"head"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute-phase: between-wave manifest reset (#1369, #3384)', () => {
|
||||
test('between-wave reference file exists', () => {
|
||||
assert.ok(fs.existsSync(BETWEEN_WAVE_PATH), 'references/execute-phase-between-wave-reset.md should exist');
|
||||
});
|
||||
|
||||
test('workflow contains @-reference pointer to between-wave-reset', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('execute-phase-between-wave-reset.md'),
|
||||
'execute-phase.md must have an @-reference to execute-phase-between-wave-reset.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('step 7c exists with between-wave manifest reset (#1369)', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('7c.') && content.includes('Between-wave manifest reset'),
|
||||
'execute-phase-between-wave-reset.md must have step 7c "Between-wave manifest reset"'
|
||||
);
|
||||
});
|
||||
|
||||
test('step 7c unsets WAVE_WORKTREE_MANIFEST between waves', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(content.includes('unset WAVE_WORKTREE_MANIFEST'), 'step 7c must unset WAVE_WORKTREE_MANIFEST');
|
||||
});
|
||||
|
||||
test('step 7c references #1369 and #3384 for traceability', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#1369'), 'step 7c must reference #1369');
|
||||
assert.ok(content.includes('#3384'), 'step 7c must reference #3384');
|
||||
});
|
||||
|
||||
test('step 7c calls worktree.set-baseref to re-assert head config', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(content.includes('worktree.set-baseref'), 'step 7c must call worktree.set-baseref');
|
||||
});
|
||||
|
||||
test('step 7c appears after step 7b and before step 8 in the wave loop', () => {
|
||||
const ref = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const idx7b = ref.indexOf('7b.');
|
||||
const idx7c = ref.indexOf('7c.');
|
||||
const refPtr = workflow.indexOf('execute-phase-between-wave-reset.md');
|
||||
const idx8 = workflow.indexOf('8. **Execute checkpoint', refPtr);
|
||||
assert.ok(idx7b !== -1, 'step 7b must exist in between-wave reference file');
|
||||
assert.ok(idx7c !== -1, 'step 7c must exist in between-wave reference file');
|
||||
assert.ok(idx8 !== -1, 'step 8 must exist in execute-phase.md after the between-wave @-reference');
|
||||
assert.ok(idx7b < idx7c, 'step 7c must appear after step 7b');
|
||||
assert.ok(refPtr < idx8, 'between-wave @-reference must appear before step 8');
|
||||
});
|
||||
|
||||
test('step 7c guards on RUNTIME=claude for worktree-specific operations', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")),
|
||||
'step 7c must guard on RUNTIME=claude'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product (#1520)
|
||||
// Workflow .md text IS what the runtime loads and the agent executes, so
|
||||
// asserting on its shell invocations tests the deployed contract directly.
|
||||
//
|
||||
// Repo-wide regression guard for #1520: NO workflow .md may invoke `mktemp`
|
||||
// with a template whose `XXXXXX` run is followed by a filename suffix
|
||||
// (e.g. `…-XXXXXX.json`, `…-XXXXXX.md`). BSD/macOS `mktemp` only substitutes
|
||||
// the `X` run when it is the FINAL path component; a trailing suffix yields a
|
||||
// literal, non-randomized path, so concurrent workflow runs collide on the same
|
||||
// temp file (one run overwriting or consuming another's). The portable fix is
|
||||
// `mktemp …-XXXXXX` (suffix-less) then `mv` to add the extension.
|
||||
//
|
||||
// This is a copy-paste-prone shell idiom — the same defect first shipped across
|
||||
// five workflows before #1520 — so a prose guard is the right lock-out, mirroring
|
||||
// the bug-637 hardcoded-$HOME workflow scan.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
|
||||
// Match `mktemp <token>` where, within the single whitespace-delimited template
|
||||
// token, a maximal run of 3+ `X` is immediately followed by a filename
|
||||
// character (`.`, alnum, `-`, `_`) — i.e. a suffix the BSD/macOS substitution
|
||||
// can't reach.
|
||||
// - `\s+` requires an argument (bare `mktemp` is fine — path-final
|
||||
// is N/A — and prose like "mktemp only randomizes XXXXXX"
|
||||
// is excluded because the X-run is in a later token).
|
||||
// - `["']?\S*?` walks within the one quoted/unquoted template token.
|
||||
// - `X{3,}(?!X)` anchors on the WHOLE X-run (so `XXXXXX)` does not match
|
||||
// via a sub-run leaving a trailing `X`).
|
||||
// - `[.A-Za-z0-9_-]` the offending suffix char. A legitimate path-final form
|
||||
// ends the token with `"`, `'`, whitespace, or `)`, none of
|
||||
// which are in this class.
|
||||
const SUFFIXED_MKTEMP_TEMPLATE = /mktemp\s+["']?\S*?X{3,}(?!X)[.A-Za-z0-9_-]/;
|
||||
|
||||
function collectWorkflowMarkdown(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...collectWorkflowMarkdown(full));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('#1520: workflow mktemp templates keep XXXXXX path-final', () => {
|
||||
test('no gsd-core/workflows/**/*.md calls mktemp with a suffix after the XXXXXX run', () => {
|
||||
const files = collectWorkflowMarkdown(WORKFLOWS_DIR);
|
||||
assert.ok(files.length > 0, 'expected workflow markdown files to exist');
|
||||
|
||||
const offenders = [];
|
||||
for (const file of files) {
|
||||
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
||||
lines.forEach((line, i) => {
|
||||
if (SUFFIXED_MKTEMP_TEMPLATE.test(line)) {
|
||||
offenders.push(`${path.relative(WORKFLOWS_DIR, file)}:${i + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
offenders,
|
||||
[],
|
||||
'Workflow mktemp templates must keep XXXXXX as the final path component ' +
|
||||
'(create suffix-less, then `mv` to add the extension) so BSD/macOS ' +
|
||||
'randomizes the path. Offenders:\n' +
|
||||
offenders.join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// execute-phase.md IS the runtime contract loaded by the orchestrator.
|
||||
// Asserting that the "ack-and-advance" path is absent is the only way to verify
|
||||
// the state machine lie (issue #38) cannot regress at runtime.
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* execute-phase.md human_needed branch — issue #38 / fix #3722
|
||||
*
|
||||
* The old design offered '"approved" → continue' as a shortcut that advanced
|
||||
* ROADMAP.md without completing human verification. This is a state machine lie:
|
||||
* the phase appears complete in the project record while HUMAN-UAT.md items
|
||||
* remain unresolved.
|
||||
*
|
||||
* The correct design:
|
||||
* - human_needed branch creates a {phase_num}-UAT.md file (not {phase_num}-HUMAN-UAT.md)
|
||||
* - directs the user to /gsd:verify-work to complete verification
|
||||
* - does NOT call update_roadmap directly (phase completion goes through verify-work)
|
||||
* - does NOT offer "approved" → continue as a bypass
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const EXECUTE_PHASE = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase.md'
|
||||
);
|
||||
|
||||
describe('execute-phase.md human_needed branch — issue #38', () => {
|
||||
let content;
|
||||
|
||||
// Read once; all tests share the string.
|
||||
test('workflow file is readable', () => {
|
||||
content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
assert.ok(content.length > 0, 'execute-phase.md must be non-empty');
|
||||
});
|
||||
|
||||
test('human_needed section exists', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('human_needed'),
|
||||
'execute-phase.md must contain a human_needed branch'
|
||||
);
|
||||
});
|
||||
|
||||
test('"approved" → continue bypass is absent from human_needed branch', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
// The old prompt offered '"approved" → continue' as a shortcut that advanced
|
||||
// ROADMAP.md without completing verification. That path must not exist.
|
||||
assert.ok(
|
||||
!content.includes('"approved" → continue'),
|
||||
'human_needed branch must not offer "approved" → continue: it marks the phase complete without verification (issue #38)'
|
||||
);
|
||||
});
|
||||
|
||||
test('human_needed branch does NOT call update_roadmap directly', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
// Locate the human_needed section and check that update_roadmap does not
|
||||
// appear before the gaps_found section (i.e. it is not reachable from human_needed).
|
||||
const humanNeededIdx = content.indexOf('**If human_needed:**');
|
||||
const gapsFoundIdx = content.indexOf('**If gaps_found:**');
|
||||
const updateRoadmapIdx = content.indexOf('update_roadmap', humanNeededIdx);
|
||||
|
||||
assert.ok(humanNeededIdx !== -1, '**If human_needed:** section must exist');
|
||||
assert.ok(gapsFoundIdx !== -1, '**If gaps_found:** section must exist');
|
||||
assert.ok(
|
||||
humanNeededIdx < gapsFoundIdx,
|
||||
'human_needed section must appear before gaps_found section'
|
||||
);
|
||||
|
||||
// update_roadmap must not appear between human_needed and gaps_found sections
|
||||
const updateRoadmapBetween =
|
||||
updateRoadmapIdx !== -1 &&
|
||||
updateRoadmapIdx > humanNeededIdx &&
|
||||
updateRoadmapIdx < gapsFoundIdx;
|
||||
|
||||
assert.ok(
|
||||
!updateRoadmapBetween,
|
||||
'update_roadmap must not be reachable directly from the human_needed branch — phase completion must go through verify-work (issue #38)'
|
||||
);
|
||||
});
|
||||
|
||||
test('human_needed branch directs user to /gsd:verify-work', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
const humanNeededIdx = content.indexOf('**If human_needed:**');
|
||||
const gapsFoundIdx = content.indexOf('**If gaps_found:**');
|
||||
assert.ok(humanNeededIdx !== -1, '**If human_needed:** section must exist');
|
||||
|
||||
const humanNeededSection = content.slice(
|
||||
humanNeededIdx,
|
||||
gapsFoundIdx !== -1 ? gapsFoundIdx : undefined
|
||||
);
|
||||
assert.ok(
|
||||
humanNeededSection.includes('verify-work'),
|
||||
'human_needed branch must direct the user to /gsd:verify-work to complete verification'
|
||||
);
|
||||
});
|
||||
|
||||
test('human_needed branch creates {phase_num}-UAT.md (not HUMAN-UAT.md)', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
const humanNeededIdx = content.indexOf('**If human_needed:**');
|
||||
const gapsFoundIdx = content.indexOf('**If gaps_found:**');
|
||||
assert.ok(humanNeededIdx !== -1, '**If human_needed:** section must exist');
|
||||
|
||||
const humanNeededSection = content.slice(
|
||||
humanNeededIdx,
|
||||
gapsFoundIdx !== -1 ? gapsFoundIdx : undefined
|
||||
);
|
||||
|
||||
// The file should be named {phase_num}-UAT.md so verify-work's glob picks it up
|
||||
assert.ok(
|
||||
humanNeededSection.includes('-UAT.md'),
|
||||
'human_needed branch must create a {phase_num}-UAT.md file for verify-work to resume'
|
||||
);
|
||||
|
||||
// HUMAN-UAT.md causes a naming mismatch with verify-work's create_uat_file step
|
||||
assert.ok(
|
||||
!humanNeededSection.includes('HUMAN-UAT.md'),
|
||||
'human_needed branch must NOT create HUMAN-UAT.md — use {phase_num}-UAT.md to align with verify-work\'s resume path (issue #38 edge case 3)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -338,3 +338,101 @@ describe('#1406: setGsdConfig prototype-pollution guard', () => {
|
||||
assert.strictEqual(cfg.git.base_branch, 'develop');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2004-pr-branch-milestone.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2004-pr-branch-milestone (consolidation epic #1969 B4 #1973)", () => {
|
||||
/**
|
||||
* Regression tests for bug #2004
|
||||
*
|
||||
* /gsd-pr-branch must not exclude milestone archive and structural planning
|
||||
* commits. The previous implementation filtered ALL .planning/-only commits,
|
||||
* including STATE.md, ROADMAP.md, MILESTONES.md, and milestones/** updates
|
||||
* that are needed to preserve repository planning state after a merge.
|
||||
*
|
||||
* Fixed: pr-branch.md now distinguishes:
|
||||
* - Transient planning commits (phase plans, summaries, research, context) → EXCLUDE
|
||||
* - Structural planning commits (STATE.md, ROADMAP.md, MILESTONES.md,
|
||||
* PROJECT.md, milestones/**) → INCLUDE
|
||||
* - Code commits (any non-.planning/ file) → INCLUDE
|
||||
* - Mixed commits (code + planning) → INCLUDE
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const workflowPath = path.resolve(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'pr-branch.md'
|
||||
);
|
||||
|
||||
describe('bug #2004: pr-branch preserves structural planning commits', () => {
|
||||
let content;
|
||||
|
||||
test('setup: pr-branch workflow is readable', () => {
|
||||
content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(content.length > 0, 'pr-branch.md must not be empty');
|
||||
});
|
||||
|
||||
test('workflow distinguishes structural vs transient planning commits', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// Must contain language distinguishing structural from transient/phase planning files
|
||||
assert.ok(
|
||||
/structural|milestone.*archive|STATE\.md.*INCLUDE|preserve.*milestone|milestone.*preserve/i.test(content),
|
||||
'pr-branch.md must distinguish structural planning commits from transient ones'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow lists STATE.md and ROADMAP.md as structural files to preserve', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('STATE.md'),
|
||||
'pr-branch.md must reference STATE.md as a structural file to preserve'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('ROADMAP.md'),
|
||||
'pr-branch.md must reference ROADMAP.md as a structural file to preserve'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow lists MILESTONES.md or milestones/ as structural files to preserve', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('MILESTONES.md') || content.includes('milestones/'),
|
||||
'pr-branch.md must reference MILESTONES.md or milestones/ as structural files to preserve'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow has four commit categories (code, planning-only, mixed, structural)', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// Must have at least a "structural" or "milestone" category beyond the original three
|
||||
assert.ok(
|
||||
/structural.*commit|milestone.*commit|commit.*structural|commit.*milestone/i.test(content) ||
|
||||
/INCLUDE.*STATE\.md|STATE\.md.*INCLUDE/i.test(content),
|
||||
'pr-branch.md must classify structural planning commits as INCLUDE'
|
||||
);
|
||||
});
|
||||
|
||||
test('create_pr_branch step does not rm -r --cached all of .planning/', () => {
|
||||
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
||||
// The original bug: `git rm -r --cached .planning/` nuked structural files.
|
||||
// The fix must either remove this wholesale rm or scope it to transient dirs.
|
||||
// Acceptable: narrowed rm targeting only phase/, quick/, research/, etc.
|
||||
// Not acceptable: `git rm -r --cached .planning/` with no scoping.
|
||||
const hasUnscoped = /git rm -r --cached \.planning\/(?!\*)?(?!phases|quick|research|threads|todos|debug|seeds|ui-reviews|codebase)/
|
||||
.test(content);
|
||||
assert.ok(
|
||||
!hasUnscoped,
|
||||
'create_pr_branch must not use unscoped "git rm -r --cached .planning/" — scope to transient subdirectories only'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -385,3 +385,275 @@ describe('gsd-settings-advanced — negative scenarios', () => {
|
||||
assert.strictEqual(stored.workflow.max_discuss_passes, 7);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2506-settings-profile-nonclaude-warning.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2506-settings-profile-nonclaude-warning (consolidation epic #1969 B4 #1973)", () => {
|
||||
/**
|
||||
* Regression test for bug #2506
|
||||
*
|
||||
* /gsd-settings presents Quality/Balanced/Budget model profiles without any
|
||||
* warning that on non-Claude runtimes (Codex, Gemini CLI, etc.) these profiles
|
||||
* select Claude model tiers and have no effect on actual agent model selection.
|
||||
*
|
||||
* Fix: settings.md must include a non-Claude runtime note instructing users to
|
||||
* use "Inherit" or configure model_overrides manually, and the Inherit option
|
||||
* description must explicitly call out non-Claude runtimes.
|
||||
*
|
||||
* Closes: #2506
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SETTINGS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'settings.md');
|
||||
|
||||
describe('bug #2506: settings.md non-Claude runtime warning for model profiles', () => {
|
||||
let content;
|
||||
|
||||
before(() => {
|
||||
content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
});
|
||||
|
||||
test('settings.md contains a non-Claude runtime note for model profiles', () => {
|
||||
assert.ok(
|
||||
content.includes('non-Claude runtime') || content.includes('non-Claude runtimes'),
|
||||
'settings.md must include a note about non-Claude runtimes and model profiles'
|
||||
);
|
||||
});
|
||||
|
||||
test('non-Claude note explains profiles are no-ops without model_overrides', () => {
|
||||
assert.ok(
|
||||
content.includes('model_overrides') || content.includes('no effect'),
|
||||
'note must explain profiles have no effect on non-Claude runtimes without model_overrides'
|
||||
);
|
||||
});
|
||||
|
||||
test('Inherit option description explicitly mentions non-Claude runtimes', () => {
|
||||
// The Inherit option in AskUserQuestion must call out non-Claude runtimes
|
||||
const inheritOptionMatch = content.match(/label:\s*"Inherit"[^}]*description:\s*"([^"]+)"/s);
|
||||
assert.ok(inheritOptionMatch, 'Inherit option with label/description must exist in settings.md');
|
||||
const desc = inheritOptionMatch[1];
|
||||
assert.ok(
|
||||
desc.includes('non-Claude') || desc.includes('Codex') || desc.includes('Gemini'),
|
||||
`Inherit option description must mention non-Claude runtimes; got: "${desc}"`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3784-gsd-settings-model-profile-ui-omits-adaptive (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (see #3784)
|
||||
// The deployed settings.md IS the product — testing its text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* Regression test for bug #3784
|
||||
*
|
||||
* /gsd-settings model profile UI omits `adaptive`. The AskUserQuestion block
|
||||
* for model_profile in settings.md lists only four options (Quality, Balanced,
|
||||
* Budget, Inherit) but the settings schema registers five valid profiles:
|
||||
* quality, balanced, budget, adaptive, inherit. The `adaptive` profile is
|
||||
* reachable by name via `gsd:config --profile adaptive` but cannot be selected
|
||||
* interactively through `/gsd:settings`.
|
||||
*
|
||||
* Root cause: the options array in the model-profile AskUserQuestion block was
|
||||
* written before the `adaptive` profile was introduced and was never updated.
|
||||
* Because AskUserQuestion enforces a hard 4-option cap, the fix uses a two-
|
||||
* question split: Q1 asks "Standard tier or Adaptive?" (2 options); if the
|
||||
* user picks Standard, Q2 asks which of the three standard profiles to use
|
||||
* (Quality, Balanced, Budget). This keeps every call within the 4-option cap
|
||||
* while making all five profiles reachable.
|
||||
*
|
||||
* Fixes: #3784
|
||||
*/
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SETTINGS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'settings.md');
|
||||
|
||||
/**
|
||||
* Extract all AskUserQuestion option labels from a text block.
|
||||
* Returns them lowercased for case-insensitive comparison.
|
||||
*/
|
||||
function extractOptionLabels(block) {
|
||||
const labelPattern = /label:\s*"([^"]+)"/g;
|
||||
const labels = [];
|
||||
let match;
|
||||
while ((match = labelPattern.exec(block)) !== null) {
|
||||
labels.push(match[1].toLowerCase());
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
describe('bug #3784: settings.md model profile UI exposes all 5 profiles', () => {
|
||||
let content;
|
||||
let presentBlock;
|
||||
|
||||
before(() => {
|
||||
content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
const presentMatch = content.match(/<step name="present_settings">[\s\S]*?<\/step>/);
|
||||
assert.ok(presentMatch, 'settings.md must have a present_settings step');
|
||||
presentBlock = presentMatch[0];
|
||||
});
|
||||
|
||||
// ── Core contract: all five valid profiles reachable via the settings UI ──
|
||||
|
||||
test('present_settings step includes Adaptive as a selectable option (#3784)', () => {
|
||||
// This is the primary assertion for bug #3784 — adaptive was missing.
|
||||
const labels = extractOptionLabels(presentBlock);
|
||||
assert.ok(
|
||||
labels.some(l => l === 'adaptive' || l.startsWith('adaptive')),
|
||||
[
|
||||
'Bug #3784: present_settings step must include an "Adaptive" label in',
|
||||
'its model profile AskUserQuestion options so users can select it',
|
||||
`interactively. Got labels: [${labels.join(', ')}]`,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
|
||||
test('present_settings step includes Quality as a selectable option', () => {
|
||||
const labels = extractOptionLabels(presentBlock);
|
||||
assert.ok(
|
||||
labels.some(l => l === 'quality' || l.startsWith('quality')),
|
||||
`present_settings step must include a "Quality" option. Got: [${labels.join(', ')}]`
|
||||
);
|
||||
});
|
||||
|
||||
test('present_settings step includes Balanced as a selectable option', () => {
|
||||
const labels = extractOptionLabels(presentBlock);
|
||||
assert.ok(
|
||||
labels.some(l => l === 'balanced' || l.startsWith('balanced')),
|
||||
`present_settings step must include a "Balanced" option. Got: [${labels.join(', ')}]`
|
||||
);
|
||||
});
|
||||
|
||||
test('present_settings step includes Budget as a selectable option', () => {
|
||||
const labels = extractOptionLabels(presentBlock);
|
||||
assert.ok(
|
||||
labels.some(l => l === 'budget' || l.startsWith('budget')),
|
||||
`present_settings step must include a "Budget" option. Got: [${labels.join(', ')}]`
|
||||
);
|
||||
});
|
||||
|
||||
test('present_settings step includes Inherit as a selectable option', () => {
|
||||
const labels = extractOptionLabels(presentBlock);
|
||||
assert.ok(
|
||||
labels.some(l => l === 'inherit' || l.startsWith('inherit')),
|
||||
`present_settings step must include an "Inherit" option. Got: [${labels.join(', ')}]`
|
||||
);
|
||||
});
|
||||
|
||||
// ── update_config step writes adaptive as a valid value ──
|
||||
|
||||
test('update_config step lists adaptive as a valid model_profile value', () => {
|
||||
const updateMatch = content.match(/<step name="update_config">[\s\S]*?<\/step>/);
|
||||
assert.ok(updateMatch, 'settings.md must have an update_config step');
|
||||
const block = updateMatch[0];
|
||||
assert.ok(
|
||||
block.includes('adaptive'),
|
||||
'update_config step must list "adaptive" as a valid model_profile value'
|
||||
);
|
||||
});
|
||||
|
||||
// ── confirm step displays adaptive as a possible profile value ──
|
||||
|
||||
test('confirm step table shows adaptive as a possible model profile value', () => {
|
||||
const confirmMatch = content.match(/<step name="confirm">[\s\S]*?<\/step>/);
|
||||
assert.ok(confirmMatch, 'settings.md must have a confirm step');
|
||||
const block = confirmMatch[0];
|
||||
assert.ok(
|
||||
block.includes('adaptive'),
|
||||
'confirm step must include "adaptive" in the Model Profile row placeholder'
|
||||
);
|
||||
});
|
||||
|
||||
// ── adaptive described with role-based routing semantics ──
|
||||
|
||||
test('settings.md describes adaptive profile with role-based routing semantics', () => {
|
||||
// Adaptive uses heavy/light role tiers per routingTier.
|
||||
// The UI description must convey role-based cost optimization and the heavy/light tier
|
||||
// split — not just mention "Adaptive" somewhere (that word appears 6+ times in the file).
|
||||
const lower = content.toLowerCase();
|
||||
assert.ok(
|
||||
lower.includes('role-based cost optimization') && lower.includes('heavy roles'),
|
||||
'settings.md must describe the adaptive profile with "role-based cost optimization" and "heavy roles" wording so the description is meaningful across all supported runtimes'
|
||||
);
|
||||
});
|
||||
|
||||
// ── 4-option cap enforcement ──
|
||||
|
||||
test('each question object in present_settings AskUserQuestion blocks has at most 4 options (AskUserQuestion runtime cap)', () => {
|
||||
// The AskUserQuestion runtime enforces a hard 4-option cap per individual question object
|
||||
// (each { question:..., options:[...] } entry). This test guards against a naïve revert
|
||||
// that puts all 5 profiles into a single question object instead of using the Q1/Q2 split.
|
||||
const ASK_USER_QUESTION_OPTION_CAP = 4; // hard limit enforced by the AskUserQuestion runtime
|
||||
|
||||
// Extract each individual options array by finding 'options: [' and walking to the
|
||||
// matching balanced ']', then count label: entries within that span.
|
||||
const optionsKeyRe = /\boptions\s*:\s*\[/g;
|
||||
let match;
|
||||
let questionIndex = 0;
|
||||
while ((match = optionsKeyRe.exec(presentBlock)) !== null) {
|
||||
questionIndex++;
|
||||
// Walk forward from the opening '[' to find the balanced close ']'.
|
||||
let depth = 0;
|
||||
const start = match.index + match[0].length - 1; // points at '['
|
||||
let end = start;
|
||||
for (let k = start; k < presentBlock.length; k++) {
|
||||
if (presentBlock[k] === '[') { depth++; }
|
||||
else if (presentBlock[k] === ']') {
|
||||
depth--;
|
||||
if (depth === 0) { end = k; break; }
|
||||
}
|
||||
}
|
||||
const optionsBody = presentBlock.slice(start, end + 1);
|
||||
const labelMatches = optionsBody.match(/label:\s*"[^"]+"/g) || [];
|
||||
const optionCount = labelMatches.length;
|
||||
assert.ok(
|
||||
optionCount <= ASK_USER_QUESTION_OPTION_CAP,
|
||||
`Question object ${questionIndex} in present_settings has ${optionCount} options — exceeds the runtime cap of ${ASK_USER_QUESTION_OPTION_CAP}. Split into multiple questions (as #3784 did for model_profile).`
|
||||
);
|
||||
}
|
||||
// Sanity check: there must be at least one options array found.
|
||||
assert.ok(questionIndex > 0, 'present_settings must contain at least one AskUserQuestion options array');
|
||||
});
|
||||
|
||||
// ── Brace-balance regression (bd53925f fixed duplicate '{' from 35fc1d21) ──
|
||||
|
||||
test('present_settings step has balanced braces — regression: brace-balance after #3784 split', () => {
|
||||
// commit bd53925f fixed a duplicate '{' introduced by 35fc1d21 when the model-profile
|
||||
// AskUserQuestion was split into Q1+Q2. This test guards against a recurrence.
|
||||
let depth = 0;
|
||||
for (const ch of presentBlock) {
|
||||
if (ch === '{') { depth++; }
|
||||
if (ch === '}') { depth--; }
|
||||
}
|
||||
assert.strictEqual(
|
||||
depth,
|
||||
0,
|
||||
`present_settings step has unbalanced braces: net depth after full scan is ${depth} (positive = extra '{', negative = extra '}'). Regression guard for bd53925f / #3784.`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,3 +67,56 @@ describe('Issue #498: the live repo passes the drift lint', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-170-workflow-fallback-install-hint.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-170-workflow-fallback-install-hint (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
// allow-test-rule: workflow markdown is shipped product text; this test validates fallback hint literals across all workflow files (see #170)
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
const LEGACY_HINT = 'npx get-shit-done-cc@latest --claude --local';
|
||||
const CURRENT_HINT = 'npx -y @opengsd/gsd-core@latest --claude --local';
|
||||
|
||||
function findMarkdownFiles(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...findMarkdownFiles(full));
|
||||
else if (entry.isFile() && full.endsWith('.md')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('bug #170: workflow fallback hints do not reference get-shit-done-cc', () => {
|
||||
const files = findMarkdownFiles(WORKFLOWS_DIR);
|
||||
let legacyCount = 0;
|
||||
let currentCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
if (src.includes(LEGACY_HINT)) legacyCount += 1;
|
||||
if (src.includes(CURRENT_HINT)) currentCount += 1;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
legacyCount,
|
||||
0,
|
||||
`workflow fallback hints must not reference legacy package (${LEGACY_HINT})`
|
||||
);
|
||||
assert.ok(
|
||||
currentCount > 0,
|
||||
`expected at least one workflow fallback hint to use current package (${CURRENT_HINT})`
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,3 +42,115 @@ test('issue #815: command documents --next/--rc and routes it to the update work
|
||||
assert.match(CMD, /--rc/);
|
||||
assert.match(CMD, /argument-hint:.*--next/);
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2470-update-md-claude-path.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2470-update-md-claude-path (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #2470)
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
'use strict';
|
||||
|
||||
|
||||
/**
|
||||
* Regression test for #2470.
|
||||
*
|
||||
* update.md is installed into every runtime directory including .gemini, .codex,
|
||||
* .opencode, etc. The installer's scanForLeakedPaths() uses the regex
|
||||
* /(?:~|\$HOME)\/\.claude\b/g to detect unresolved .claude path references after
|
||||
* copyWithPathReplacement() runs. The replacer handles "~/.claude/" (trailing slash)
|
||||
* but not "~/.claude" (bare, no trailing slash) — so any bare reference in
|
||||
* update.md would slip through and trigger the installer warning for non-Claude runtimes.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const UPDATE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md');
|
||||
|
||||
describe('update.md — no bare ~.claude path references (#2470)', () => {
|
||||
const content = fs.readFileSync(UPDATE_MD, 'utf-8');
|
||||
|
||||
test('update.md does not contain bare ~/\\.claude (without trailing slash)', () => {
|
||||
// This is the exact pattern from the installer's scanForLeakedPaths():
|
||||
// /(?:~|\$HOME)\/\.claude\b/g
|
||||
// The replacer handles ~/\.claude\/ (with trailing slash) but misses bare ~/\.claude
|
||||
// so we must not have bare references in the source file.
|
||||
const matches = content.match(/(?:~|\$HOME)\/\.claude(?!\/)/g);
|
||||
assert.strictEqual(
|
||||
matches,
|
||||
null,
|
||||
`update.md must not contain bare ~/.claude (without trailing slash) — installer scanner flags these as unresolved path refs: ${JSON.stringify(matches)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3130-update-npx-robust-invocation.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3130-update-npx-robust-invocation (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
// allow-test-rule: reads product workflow markdown (update.md) to verify structural invocation contract — not a source-grep test (see #3130)
|
||||
|
||||
// Regression guard for bug #3130.
|
||||
//
|
||||
// Two failure modes were observed with the pre-fix npx invocation form:
|
||||
// 1. Cache-stale: bare `npx -y @opengsd/gsd-core@<tag>` hits npx's local
|
||||
// cache and may pull an older version instead of the target tag.
|
||||
// 2. Token-routing: Bash-tool wrappers misroute the `@` token in
|
||||
// `@opengsd/gsd-core@<tag>`, causing npm to error with
|
||||
// "Unknown command: @opengsd/gsd-core@<tag>".
|
||||
//
|
||||
// The robust form is:
|
||||
// npx -y --package=@opengsd/gsd-core@"$TAG" -- gsd-core $ARGS
|
||||
//
|
||||
// `--package=` forces a fresh registry fetch, bypassing the npx cache.
|
||||
// `--` clearly delineates npx flags from the run-command, preventing
|
||||
// Bash-tool @-token misrouting.
|
||||
// `$TAG` is a shell variable (latest by default, next under --next/--rc),
|
||||
// set by the parse_update_channel step (#815).
|
||||
|
||||
const { 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 UPDATE_WF = path.join(ROOT, 'gsd-core', 'workflows', 'update.md');
|
||||
|
||||
const src = fs.readFileSync(UPDATE_WF, 'utf8');
|
||||
|
||||
test('bug #3130: update.md contains no bare npx invocations (cache-stale form)', () => {
|
||||
// Any occurrence of `npx -y @opengsd/gsd-core@<something>` without `--package=`
|
||||
// is the stale form that triggers the two failure modes.
|
||||
const stale = (src.match(/npx -y @opengsd\/gsd-core@\S+[^\r\n]*/g) || []);
|
||||
assert.deepEqual(
|
||||
stale,
|
||||
[],
|
||||
`Stale npx forms found in update.md (must use --package= form): ${stale.join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('bug #3130: update.md has >=3 robust npx invocations (--package= + -- separator)', () => {
|
||||
// Three sibling invocations: local, global, and unknown/fallback.
|
||||
// The tag is now a $TAG variable (latest by default, next under --next/--rc).
|
||||
const robust = (src.match(/npx -y --package=@opengsd\/gsd-core@\S+ -- gsd-core/g) || []);
|
||||
assert.ok(
|
||||
robust.length >= 3,
|
||||
`Expected >=3 robust npx invocations in update.md, found ${robust.length}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -242,3 +242,100 @@ describe('phases clear: uncommitted-changes guard (#1447)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/enh-2433-todo-phase-linking.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:enh-2433-todo-phase-linking (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (see #2433)
|
||||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||||
// runtime loads — testing text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* Tests for gsd-new-milestone todo-to-phase linking (#2433).
|
||||
* Verifies the workflow text contains the correct linking and auto-close steps.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const NEW_MILESTONE = fs.readFileSync(
|
||||
path.join(ROOT, 'gsd-core/workflows/new-milestone.md'), 'utf-8'
|
||||
);
|
||||
const EXECUTE_PHASE = fs.readFileSync(
|
||||
path.join(ROOT, 'gsd-core/workflows/execute-phase.md'), 'utf-8'
|
||||
);
|
||||
|
||||
test('new-milestone.md: step 10.5 links pending todos to roadmap phases', () => {
|
||||
assert.ok(NEW_MILESTONE.includes('10.5'), 'step 10.5 should exist');
|
||||
assert.ok(NEW_MILESTONE.includes('resolves_phase'), 'should reference resolves_phase field');
|
||||
assert.ok(NEW_MILESTONE.includes('.planning/todos/pending'), 'should scan pending todos directory');
|
||||
});
|
||||
|
||||
test('new-milestone.md: todo linking runs after roadmap commit', () => {
|
||||
const roadmapCommitIdx = NEW_MILESTONE.indexOf('docs: create milestone v[X.Y] roadmap');
|
||||
const step105Idx = NEW_MILESTONE.indexOf('10.5. Link Pending Todos');
|
||||
const step11Idx = NEW_MILESTONE.indexOf('## 11. Done');
|
||||
assert.ok(roadmapCommitIdx < step105Idx, 'step 10.5 should come after roadmap commit');
|
||||
assert.ok(step105Idx < step11Idx, 'step 10.5 should come before step 11');
|
||||
});
|
||||
|
||||
test('new-milestone.md: todo linking is best-effort and leaves unmatched todos unmodified', () => {
|
||||
assert.ok(NEW_MILESTONE.includes('best-effort'), 'should describe best-effort matching');
|
||||
assert.ok(NEW_MILESTONE.includes('unmatched'), 'should mention leaving unmatched todos alone');
|
||||
assert.ok(NEW_MILESTONE.includes('confident match'), 'should gate on confident match');
|
||||
});
|
||||
|
||||
test('new-milestone.md: step 10.5 commits tagged todos', () => {
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
assert.ok(NEW_MILESTONE.includes('gsd_run query commit'), 'should commit tagged todos');
|
||||
assert.ok(NEW_MILESTONE.includes('resolves_phase after milestone'), 'commit message should mention resolves_phase');
|
||||
});
|
||||
|
||||
test('new-milestone.md: success_criteria includes todo linking', () => {
|
||||
assert.ok(NEW_MILESTONE.includes('resolves_phase: N'), 'success_criteria should mention resolves_phase tagging');
|
||||
});
|
||||
|
||||
test('execute-phase.md: close_phase_todos step exists', () => {
|
||||
assert.ok(EXECUTE_PHASE.includes('close_phase_todos'), 'close_phase_todos step should exist');
|
||||
assert.ok(EXECUTE_PHASE.includes('resolves_phase'), 'should check resolves_phase in todos');
|
||||
});
|
||||
|
||||
test('execute-phase.md: auto-close moves todos to completed directory', () => {
|
||||
assert.ok(EXECUTE_PHASE.includes('.planning/todos/completed'), 'should move to completed dir');
|
||||
assert.ok(EXECUTE_PHASE.includes('.planning/todos/pending'), 'should scan pending dir');
|
||||
assert.ok(EXECUTE_PHASE.includes('mv "$TODO_FILE" "$COMPLETED_DIR/"'), 'should use mv to move files');
|
||||
});
|
||||
|
||||
test('execute-phase.md: close_phase_todos runs after update_roadmap', () => {
|
||||
const updateRoadmapIdx = EXECUTE_PHASE.indexOf('name="update_roadmap"');
|
||||
const closeTodosIdx = EXECUTE_PHASE.indexOf('name="close_phase_todos"');
|
||||
assert.ok(updateRoadmapIdx < closeTodosIdx, 'close_phase_todos should run after update_roadmap');
|
||||
});
|
||||
|
||||
test('execute-phase.md: auto-close never blocks phase completion', () => {
|
||||
const closeTodosSection = EXECUTE_PHASE.slice(
|
||||
EXECUTE_PHASE.indexOf('name="close_phase_todos"'),
|
||||
EXECUTE_PHASE.indexOf('name="update_project_md"')
|
||||
);
|
||||
assert.ok(
|
||||
closeTodosSection.includes('never blocks') || closeTodosSection.includes('additive'),
|
||||
'close_phase_todos should be non-blocking'
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md: awk extracts resolves_phase from YAML frontmatter', () => {
|
||||
assert.ok(EXECUTE_PHASE.includes('awk'), 'should use awk for frontmatter extraction');
|
||||
assert.ok(EXECUTE_PHASE.includes('resolves_phase:'), 'awk pattern should match resolves_phase key');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -166,3 +166,41 @@ describe('reference files — no <sub>/clear patterns', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3083-resume-route-clear.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3083-resume-route-clear (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
describe('bug #3083: resume-project next-step routing should not include /clear then:', () => {
|
||||
test('route_to_workflow block omits /clear then: in resume templates', () => {
|
||||
const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'resume-project.md');
|
||||
const content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
const routeStart = content.indexOf('<step name="route_to_workflow">');
|
||||
const routeEnd = content.indexOf('</step>', routeStart);
|
||||
const routeBlock = content.slice(routeStart, routeEnd);
|
||||
|
||||
assert.equal(routeBlock.includes('/clear` then:'), false, 'resume route templates must not include `/clear` then:');
|
||||
});
|
||||
|
||||
test('route_to_workflow block includes exception note explaining resume behavior', () => {
|
||||
const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'resume-project.md');
|
||||
const content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
const routeStart = content.indexOf('<step name="route_to_workflow">');
|
||||
const routeEnd = content.indexOf('</step>', routeStart);
|
||||
const routeBlock = content.slice(routeStart, routeEnd);
|
||||
|
||||
assert.match(routeBlock, /resume.*exception|exception.*resume/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,3 +119,168 @@ test('each scan dir (agents/, commands/, gsd-core/references/) exists and contai
|
||||
`Per-directory bash-block floor failed:\n${failures.join('\n')}\nCheck that each scan dir is non-empty and contains bash fences.`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-637-workflow-no-hardcoded-home-tool (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #637)
|
||||
// Workflow .md text IS what the runtime loads and the agent executes, so
|
||||
// asserting on its shell invocations tests the deployed contract directly.
|
||||
//
|
||||
// Repo-wide regression guard for #637 (generalizes the plan-phase-only guard
|
||||
// from #621): NO workflow .md may invoke gsd-tools via a hardcoded
|
||||
// `node "$HOME/.../gsd-tools.cjs"` path. On a global/shim-only install with no
|
||||
// project-local runtime, that path can miss a working install, so the step
|
||||
// reports the tool "not found" instead of resolving it. Every invocation must
|
||||
// go through the `gsd_run` launcher (defined once per file in the canonical
|
||||
// preamble, which resolves RUNTIME_DIR → .claude → PATH → $HOME in order).
|
||||
//
|
||||
// The parity test (runtime-launcher-parity) guards the retired $GSD_SDK and
|
||||
// bare /gsd-tools tokens but NOT this hardcoded-node form — which is exactly
|
||||
// how it survived across plan-phase.md (#621) and three more files (#637).
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
|
||||
// Hardcoded direct invocation form. Distinct from the canonical preamble, which
|
||||
// references $HOME only inside a `[ -f "$HOME/..." ]` probe / `GSD_TOOLS=`
|
||||
// assignment and always invokes `node "$GSD_TOOLS"` — never `node "$HOME/..."`.
|
||||
const HARDCODED_HOME_INVOCATION = /node\s+"\$HOME\/[^"]*gsd-tools\.cjs"/;
|
||||
|
||||
function collectWorkflowMarkdown(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...collectWorkflowMarkdown(full));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('bug #637: no workflow .md hardcodes a $HOME gsd-tools invocation', () => {
|
||||
test('every gsd-core/workflows/**/*.md resolves gsd-tools via gsd_run, not a hardcoded $HOME path', () => {
|
||||
const files = collectWorkflowMarkdown(WORKFLOWS_DIR);
|
||||
assert.ok(files.length > 0, 'expected workflow markdown files to exist');
|
||||
|
||||
const offenders = [];
|
||||
for (const file of files) {
|
||||
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
||||
lines.forEach((line, i) => {
|
||||
if (HARDCODED_HOME_INVOCATION.test(line)) {
|
||||
offenders.push(`${path.relative(WORKFLOWS_DIR, file)}:${i + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
offenders,
|
||||
[],
|
||||
'Workflow files must invoke gsd-tools via the resolved `gsd_run` launcher, ' +
|
||||
'not a hardcoded `node "$HOME/.../gsd-tools.cjs"` path. Offenders:\n' +
|
||||
offenders.join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/fix-1520-workflow-mktemp-suffix-final.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:fix-1520-workflow-mktemp-suffix-final (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (#1520)
|
||||
// Workflow .md text IS what the runtime loads and the agent executes, so
|
||||
// asserting on its shell invocations tests the deployed contract directly.
|
||||
//
|
||||
// Repo-wide regression guard for #1520: NO workflow .md may invoke `mktemp`
|
||||
// with a template whose `XXXXXX` run is followed by a filename suffix
|
||||
// (e.g. `…-XXXXXX.json`, `…-XXXXXX.md`). BSD/macOS `mktemp` only substitutes
|
||||
// the `X` run when it is the FINAL path component; a trailing suffix yields a
|
||||
// literal, non-randomized path, so concurrent workflow runs collide on the same
|
||||
// temp file (one run overwriting or consuming another's). The portable fix is
|
||||
// `mktemp …-XXXXXX` (suffix-less) then `mv` to add the extension.
|
||||
//
|
||||
// This is a copy-paste-prone shell idiom — the same defect first shipped across
|
||||
// five workflows before #1520 — so a prose guard is the right lock-out, mirroring
|
||||
// the bug-637 hardcoded-$HOME workflow scan.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
|
||||
// Match `mktemp <token>` where, within the single whitespace-delimited template
|
||||
// token, a maximal run of 3+ `X` is immediately followed by a filename
|
||||
// character (`.`, alnum, `-`, `_`) — i.e. a suffix the BSD/macOS substitution
|
||||
// can't reach.
|
||||
// - `\s+` requires an argument (bare `mktemp` is fine — path-final
|
||||
// is N/A — and prose like "mktemp only randomizes XXXXXX"
|
||||
// is excluded because the X-run is in a later token).
|
||||
// - `["']?\S*?` walks within the one quoted/unquoted template token.
|
||||
// - `X{3,}(?!X)` anchors on the WHOLE X-run (so `XXXXXX)` does not match
|
||||
// via a sub-run leaving a trailing `X`).
|
||||
// - `[.A-Za-z0-9_-]` the offending suffix char. A legitimate path-final form
|
||||
// ends the token with `"`, `'`, whitespace, or `)`, none of
|
||||
// which are in this class.
|
||||
const SUFFIXED_MKTEMP_TEMPLATE = /mktemp\s+["']?\S*?X{3,}(?!X)[.A-Za-z0-9_-]/;
|
||||
|
||||
function collectWorkflowMarkdown(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...collectWorkflowMarkdown(full));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('#1520: workflow mktemp templates keep XXXXXX path-final', () => {
|
||||
test('no gsd-core/workflows/**/*.md calls mktemp with a suffix after the XXXXXX run', () => {
|
||||
const files = collectWorkflowMarkdown(WORKFLOWS_DIR);
|
||||
assert.ok(files.length > 0, 'expected workflow markdown files to exist');
|
||||
|
||||
const offenders = [];
|
||||
for (const file of files) {
|
||||
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
||||
lines.forEach((line, i) => {
|
||||
if (SUFFIXED_MKTEMP_TEMPLATE.test(line)) {
|
||||
offenders.push(`${path.relative(WORKFLOWS_DIR, file)}:${i + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
offenders,
|
||||
[],
|
||||
'Workflow mktemp templates must keep XXXXXX as the final path component ' +
|
||||
'(create suffix-less, then `mv` to add the extension) so BSD/macOS ' +
|
||||
'randomizes the path. Offenders:\n' +
|
||||
offenders.join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8007,3 +8007,79 @@ describe('issue #1159 (Defect B): deferred/future requirement IDs must not trigg
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2502-insert-phase-state-update.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2502-insert-phase-state-update (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #2502)
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2502: insert-phase does not update STATE.md's
|
||||
* next-phase recommendation after inserting a decimal phase.
|
||||
*
|
||||
* Root cause: insert-phase.md's update_project_state step only added a
|
||||
* "Roadmap Evolution" note to STATE.md, but never updated the "Current Phase"
|
||||
* / next-run recommendation to point at the newly inserted phase.
|
||||
*
|
||||
* Fix: insert-phase.md must include a step that updates STATE.md's next-phase
|
||||
* pointer (current_phase / next recommended run) to the newly inserted phase.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INSERT_PHASE_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'insert-phase.md'
|
||||
);
|
||||
|
||||
describe('bug-2502: insert-phase must update STATE.md next-phase recommendation', () => {
|
||||
test('insert-phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(INSERT_PHASE_PATH), 'insert-phase.md should exist');
|
||||
});
|
||||
|
||||
test('insert-phase.md contains a STATE.md next-phase update instruction', () => {
|
||||
const content = fs.readFileSync(INSERT_PHASE_PATH, 'utf-8');
|
||||
|
||||
// Must reference STATE.md and the concept of updating the next/current phase pointer
|
||||
const mentionsStateUpdate = (
|
||||
/STATE\.md.{0,200}(next.phase|current.phase|next.run|recommendation)/is.test(content) ||
|
||||
/(next.phase|current.phase|next.run|recommendation).{0,200}STATE\.md/is.test(content)
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
mentionsStateUpdate,
|
||||
'insert-phase.md must instruct updating STATE.md\'s next-phase recommendation to point to the newly inserted phase'
|
||||
);
|
||||
});
|
||||
|
||||
test('insert-phase.md update_project_state step covers next-phase pointer', () => {
|
||||
const content = fs.readFileSync(INSERT_PHASE_PATH, 'utf-8');
|
||||
|
||||
const stepMatch = content.match(/<step name="update_project_state">([\s\S]*?)<\/step>/i);
|
||||
assert.ok(stepMatch, 'insert-phase.md must contain update_project_state step');
|
||||
const stepContent = stepMatch[1];
|
||||
|
||||
const hasNextPhasePointerUpdate = (
|
||||
/\bcurrent[_ -]?phase\b/i.test(stepContent) ||
|
||||
/\bnext[_ -]?phase\b/i.test(stepContent) ||
|
||||
/\bnext recommended run\b/i.test(stepContent)
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
hasNextPhasePointerUpdate,
|
||||
'insert-phase.md update_project_state step must update STATE.md\'s next-phase pointer (current_phase) to the inserted decimal phase'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -981,3 +981,722 @@ describe('enh #3209: plan-phase ADR ingest express path', () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-621-plan-phase-gap-analysis-gsd-run.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-621-plan-phase-gap-analysis-gsd-run (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #621)
|
||||
// The post-planning-gaps gap-analysis invocation is deployed workflow text the
|
||||
// runtime executes; the contract is that it routes through the gsd_run launcher,
|
||||
// not a hardcoded $HOME path (#621).
|
||||
|
||||
/**
|
||||
* Regression test for #621: plan-phase gap-analysis must route through gsd_run
|
||||
*
|
||||
* Prior to the fix, line 1631 of plan-phase.md hardcoded:
|
||||
* node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" gap-analysis ...
|
||||
* twice on the same line, breaking non-default install layouts.
|
||||
*
|
||||
* After the fix, both invocations route through gsd_run (the launcher defined
|
||||
* at line ~34 of the same file that resolves gsd-tools.cjs against
|
||||
* RUNTIME_DIR / git-toplevel / PATH / $HOME in order).
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'plan-phase.md'
|
||||
);
|
||||
|
||||
// ─── Fixture ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
|
||||
// ─── #621 regression: gap-analysis routes through gsd_run ────────────────────
|
||||
|
||||
describe('plan-phase workflow: post-planning-gaps gap-analysis uses gsd_run launcher (#621)', () => {
|
||||
test('gap-analysis dispatches via gsd_run loop render-hooks plan:post (ADR-857 capability gate)', () => {
|
||||
assert.ok(
|
||||
workflow.includes('gsd_run loop render-hooks plan:post'),
|
||||
'workflow must dispatch gap-analysis via gsd_run loop render-hooks plan:post, not a hardcoded node path or direct gsd_run gap-analysis call'
|
||||
);
|
||||
});
|
||||
|
||||
test('inner phase_req_ids query also routes through gsd_run', () => {
|
||||
assert.ok(
|
||||
workflow.includes('gsd_run query init.plan-phase'),
|
||||
'workflow must invoke the inner phase_req_ids query via gsd_run launcher'
|
||||
);
|
||||
});
|
||||
|
||||
test('no hardcoded node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" invocations remain (#621)', () => {
|
||||
const hardcodedCount = (
|
||||
workflow.match(/node "\$HOME\/\.claude\/gsd-core\/bin\/gsd-tools\.cjs"/g) || []
|
||||
).length;
|
||||
assert.strictEqual(
|
||||
hardcodedCount,
|
||||
0,
|
||||
[
|
||||
'#621 regression: workflow must not contain any hardcoded',
|
||||
'node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" invocations;',
|
||||
`found ${hardcodedCount}`,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
|
||||
test('post-planning-gaps block still gates on workflow.post_planning_gaps and preserves required args', () => {
|
||||
const hasGate = workflow.includes('workflow.post_planning_gaps');
|
||||
const hasPhaseDir = workflow.includes('gsd_run check ${hook.check.query} "${PHASE_DIR}" "${PHASE_REQ_IDS}"');
|
||||
const hasPickArg = workflow.includes('--pick phase_req_ids');
|
||||
assert.ok(
|
||||
hasGate,
|
||||
'workflow must still gate the gap-analysis step on workflow.post_planning_gaps config key'
|
||||
);
|
||||
assert.ok(
|
||||
hasPhaseDir,
|
||||
'gap-analysis check dispatch must pass "${PHASE_DIR}" (and "${PHASE_REQ_IDS}") positionally to gsd_run check'
|
||||
);
|
||||
assert.ok(
|
||||
hasPickArg,
|
||||
'inner query must still pass --pick phase_req_ids to extract phase requirement IDs'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/feat-22-surfacing-docs.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:feat-22-surfacing-docs (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: docs-parity (see #22)
|
||||
// Verifies that issue #22 drift-guard surfacing changes are present:
|
||||
// - new-project workflow mentions plan_review.source_grounding
|
||||
// - CONFIGURATION.md documents both new config keys
|
||||
// - COMMANDS.md mentions gsd-tools intel api-surface
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
const NEW_PROJECT_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'new-project.md');
|
||||
const SETTINGS_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'settings.md');
|
||||
const CONFIGURATION_PATH = path.join(ROOT, 'docs', 'CONFIGURATION.md');
|
||||
const COMMANDS_PATH = path.join(ROOT, 'docs', 'COMMANDS.md');
|
||||
const USER_GUIDE_PATH = path.join(ROOT, 'docs', 'USER-GUIDE.md');
|
||||
const ARCHITECTURE_PATH = path.join(ROOT, 'docs', 'ARCHITECTURE.md');
|
||||
|
||||
describe('feat-22-surfacing-docs', () => {
|
||||
// ── A1: new-project workflow ─────────────────────────────────────────────
|
||||
|
||||
test('new-project workflow mentions source_grounding', () => {
|
||||
const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('source_grounding'),
|
||||
'new-project.md must mention source_grounding'
|
||||
);
|
||||
});
|
||||
|
||||
test('new-project workflow has Drift Guard question', () => {
|
||||
const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('Drift Guard'),
|
||||
'new-project.md must include a "Drift Guard" question header'
|
||||
);
|
||||
});
|
||||
|
||||
test('new-project workflow wires source_grounding into config-new-project call', () => {
|
||||
const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('"plan_review":{"source_grounding":'),
|
||||
'new-project.md config-new-project call must include plan_review.source_grounding'
|
||||
);
|
||||
});
|
||||
|
||||
test('new-project workflow has Drift Guard default-yes option', () => {
|
||||
const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8');
|
||||
// Both question blocks (auto and interactive) should have the yes option
|
||||
const count = (content.match(/Yes \(Recommended\).*catches hallucinated names/g) || []).length;
|
||||
assert.ok(
|
||||
count >= 1,
|
||||
'new-project.md must have at least one Drift Guard "Yes (Recommended)" option'
|
||||
);
|
||||
});
|
||||
|
||||
// ── A2: settings workflow ────────────────────────────────────────────────
|
||||
|
||||
test('settings workflow mentions source_grounding in read_current step', () => {
|
||||
const content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('plan_review.source_grounding'),
|
||||
'settings.md must mention plan_review.source_grounding in the read_current step'
|
||||
);
|
||||
});
|
||||
|
||||
test('settings workflow has Drift Guard AskUserQuestion toggle', () => {
|
||||
const content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('Drift Guard'),
|
||||
'settings.md must include a "Drift Guard" question header'
|
||||
);
|
||||
});
|
||||
|
||||
test('settings workflow update_config includes plan_review.source_grounding', () => {
|
||||
const content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('"source_grounding": true/false'),
|
||||
'settings.md update_config block must include source_grounding: true/false'
|
||||
);
|
||||
});
|
||||
|
||||
test('settings workflow mentions source_grounding_authority', () => {
|
||||
const content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('source_grounding_authority'),
|
||||
'settings.md must mention source_grounding_authority'
|
||||
);
|
||||
});
|
||||
|
||||
test('settings confirm table includes Plan Drift Guard row', () => {
|
||||
const content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('Plan Drift Guard'),
|
||||
'settings.md confirm table must include a "Plan Drift Guard" row'
|
||||
);
|
||||
});
|
||||
|
||||
// ── B1: CONFIGURATION.md ─────────────────────────────────────────────────
|
||||
|
||||
test('CONFIGURATION.md documents plan_review.source_grounding', () => {
|
||||
const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('`plan_review.source_grounding`'),
|
||||
'CONFIGURATION.md must document plan_review.source_grounding'
|
||||
);
|
||||
});
|
||||
|
||||
test('CONFIGURATION.md documents plan_review.source_grounding_authority', () => {
|
||||
const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('`plan_review.source_grounding_authority`'),
|
||||
'CONFIGURATION.md must document plan_review.source_grounding_authority'
|
||||
);
|
||||
});
|
||||
|
||||
test('CONFIGURATION.md documents grep as default authority', () => {
|
||||
const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('`grep`') && content.includes('source_grounding_authority'),
|
||||
'CONFIGURATION.md must document grep as the default source_grounding_authority'
|
||||
);
|
||||
});
|
||||
|
||||
test('CONFIGURATION.md lists all five authority enum values', () => {
|
||||
const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8');
|
||||
const authorities = ['grep', 'intel', 'treesitter', 'lsp', 'scip'];
|
||||
const missing = authorities.filter(a => !content.includes(a));
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
`CONFIGURATION.md must list all authority values; missing: ${missing.join(', ')}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── B2: COMMANDS.md ───────────────────────────────────────────────────────
|
||||
|
||||
test('COMMANDS.md mentions intel api-surface', () => {
|
||||
const content = fs.readFileSync(COMMANDS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('intel api-surface'),
|
||||
'COMMANDS.md must document the gsd-tools intel api-surface command'
|
||||
);
|
||||
});
|
||||
|
||||
test('COMMANDS.md documents api-surface gating on intel.enabled', () => {
|
||||
const content = fs.readFileSync(COMMANDS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('intel.enabled'),
|
||||
'COMMANDS.md intel api-surface section must mention the intel.enabled gate'
|
||||
);
|
||||
});
|
||||
|
||||
test('COMMANDS.md mentions API-SURFACE.md output', () => {
|
||||
const content = fs.readFileSync(COMMANDS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('API-SURFACE.md'),
|
||||
'COMMANDS.md must mention the API-SURFACE.md output file'
|
||||
);
|
||||
});
|
||||
|
||||
// ── B3: USER-GUIDE.md ─────────────────────────────────────────────────────
|
||||
|
||||
test('USER-GUIDE.md has Plan Drift Guard subsection', () => {
|
||||
const content = fs.readFileSync(USER_GUIDE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('### Plan Drift Guard'),
|
||||
'USER-GUIDE.md must have a "### Plan Drift Guard" subsection'
|
||||
);
|
||||
});
|
||||
|
||||
test('USER-GUIDE.md mentions needs-acknowledgement behavior', () => {
|
||||
const content = fs.readFileSync(USER_GUIDE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('needs-acknowledgement'),
|
||||
'USER-GUIDE.md drift guard section must describe needs-acknowledgement behavior'
|
||||
);
|
||||
});
|
||||
|
||||
test('USER-GUIDE.md explains drift guard works without intel', () => {
|
||||
const content = fs.readFileSync(USER_GUIDE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('without intel') || content.includes('Works without intel'),
|
||||
'USER-GUIDE.md must explain that the drift guard works without intel'
|
||||
);
|
||||
});
|
||||
|
||||
// ── B4: ARCHITECTURE.md ───────────────────────────────────────────────────
|
||||
|
||||
test('ARCHITECTURE.md links to ADR 22', () => {
|
||||
const content = fs.readFileSync(ARCHITECTURE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('adr/22-plan-drift-guard.md') || content.includes('ADR 22'),
|
||||
'ARCHITECTURE.md must link to ADR 22 (adr/22-plan-drift-guard.md)'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/enh-2430-learnings-consumption.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:enh-2430-learnings-consumption (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (see #2430)
|
||||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||||
// runtime loads — testing text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* Tests for #2430 — LEARNINGS.md consumption loop.
|
||||
*
|
||||
* Part A: plan-phase.md cross-phase context load includes LEARNINGS.md
|
||||
* Part B: transition.md graduation_scan step + graduation.md helper
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '../gsd-core/workflows');
|
||||
|
||||
function readWorkflow(name) {
|
||||
return fs.readFileSync(path.join(WORKFLOWS_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
describe('enh-2430 Part A — plan-phase LEARNINGS.md context load', () => {
|
||||
let content;
|
||||
|
||||
test('plan-phase.md includes LEARNINGS.md in cross-phase context load', () => {
|
||||
content = readWorkflow('plan-phase.md');
|
||||
assert.ok(
|
||||
content.includes('LEARNINGS.md files from the 3 most recent completed phases'),
|
||||
'plan-phase.md must mention LEARNINGS.md in cross-phase context block'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md LEARNINGS load is inside the 1M context-window gate', () => {
|
||||
content = content || readWorkflow('plan-phase.md');
|
||||
const windowBlock = content.match(/\$\{CONTEXT_WINDOW >= 500000[\s\S]*?` : ''\}/);
|
||||
assert.ok(windowBlock, 'CONTEXT_WINDOW gate block must exist');
|
||||
assert.ok(
|
||||
windowBlock[0].includes('LEARNINGS.md'),
|
||||
'LEARNINGS.md load must be inside the CONTEXT_WINDOW >= 500000 gate'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md source attribution mentioned for LEARNINGS load', () => {
|
||||
content = content || readWorkflow('plan-phase.md');
|
||||
assert.ok(
|
||||
content.includes('[from Phase N LEARNINGS]') || content.includes('source attribution'),
|
||||
'plan-phase.md must document source attribution for loaded LEARNINGS.md content'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md handles missing LEARNINGS.md gracefully (silent skip)', () => {
|
||||
content = content || readWorkflow('plan-phase.md');
|
||||
assert.ok(
|
||||
content.includes('skip silently if a phase has no LEARNINGS.md') ||
|
||||
content.includes('skip silently'),
|
||||
'plan-phase.md must document silent skip when LEARNINGS.md is absent'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md LEARNINGS load includes Depends-on chain', () => {
|
||||
content = content || readWorkflow('plan-phase.md');
|
||||
content.match(/Depends on.*?(\n.*?)+/);
|
||||
assert.ok(
|
||||
content.includes('LEARNINGS.md from any phases listed in'),
|
||||
'plan-phase.md must load LEARNINGS.md for Depends on chain phases'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md specifies context budget limit for LEARNINGS', () => {
|
||||
content = content || readWorkflow('plan-phase.md');
|
||||
assert.ok(
|
||||
content.includes('15%') || content.includes('drop oldest'),
|
||||
'plan-phase.md must specify budget limit and truncation strategy for LEARNINGS'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-2430 Part B — graduation_scan in transition.md', () => {
|
||||
let content;
|
||||
|
||||
test('transition.md contains graduation_scan step', () => {
|
||||
content = readWorkflow('transition.md');
|
||||
assert.ok(
|
||||
content.includes('graduation_scan'),
|
||||
'transition.md must contain graduation_scan step'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation_scan is placed after evolve_project step', () => {
|
||||
content = content || readWorkflow('transition.md');
|
||||
const evolvePos = content.indexOf('name="evolve_project"');
|
||||
const graduationPos = content.indexOf('name="graduation_scan"');
|
||||
assert.ok(evolvePos >= 0, 'evolve_project step must exist');
|
||||
assert.ok(graduationPos >= 0, 'graduation_scan step must exist');
|
||||
assert.ok(
|
||||
graduationPos > evolvePos,
|
||||
'graduation_scan must appear after evolve_project in transition.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation_scan is non-blocking (transition continues regardless)', () => {
|
||||
content = content || readWorkflow('transition.md');
|
||||
const scanBlock = content.match(/name="graduation_scan"[\s\S]*?<\/step>/);
|
||||
assert.ok(scanBlock, 'graduation_scan step must be parseable');
|
||||
assert.ok(
|
||||
scanBlock[0].includes('non-blocking') || scanBlock[0].includes('always non-blocking'),
|
||||
'graduation_scan must be documented as non-blocking'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation_scan delegates to graduation.md helper', () => {
|
||||
content = content || readWorkflow('transition.md');
|
||||
assert.ok(
|
||||
content.includes('graduation.md'),
|
||||
'graduation_scan must reference graduation.md helper workflow'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-2430 Part B — graduation.md helper workflow', () => {
|
||||
let content;
|
||||
|
||||
test('graduation.md exists', () => {
|
||||
content = readWorkflow('graduation.md');
|
||||
assert.ok(content.length > 0, 'graduation.md must exist and be non-empty');
|
||||
});
|
||||
|
||||
test('graduation.md documents features.graduation config flag', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('features.graduation'),
|
||||
'graduation.md must document features.graduation config flag'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md documents graduation_window config', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('graduation_window'),
|
||||
'graduation.md must document features.graduation_window config'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md documents graduation_threshold config', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('graduation_threshold'),
|
||||
'graduation.md must document features.graduation_threshold config'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md specifies HITL: Promote / Defer / Dismiss', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(content.includes('Promote'), 'graduation.md must document Promote action');
|
||||
assert.ok(content.includes('Defer'), 'graduation.md must document Defer action');
|
||||
assert.ok(content.includes('Dismiss'), 'graduation.md must document Dismiss action');
|
||||
});
|
||||
|
||||
test('graduation.md specifies category→target routing', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('PROJECT.md') && content.includes('PATTERNS.md'),
|
||||
'graduation.md must route categories to appropriate target files'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md specifies graduation_backlog in STATE.md', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('graduation_backlog'),
|
||||
'graduation.md must document STATE.md graduation_backlog for Defer/Dismiss'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md skips items with graduated: annotation', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('graduated:') || content.includes('Graduated:'),
|
||||
'graduation.md must skip already-graduated items'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md has silent no-op for first phase / insufficient data', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('no-op') || content.includes('silent'),
|
||||
'graduation.md must silently no-op when there is insufficient data'
|
||||
);
|
||||
});
|
||||
|
||||
test('graduation.md specifies Defer-all shorthand (A key)', () => {
|
||||
content = content || readWorkflow('graduation.md');
|
||||
assert.ok(
|
||||
content.includes('Defer all') || content.includes('[Defer all]'),
|
||||
'graduation.md must document the Defer all shorthand for first-run batches'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-2430 — extract-learnings.md graduated: field', () => {
|
||||
test('extract-learnings.md documents optional graduated: annotation', () => {
|
||||
const content = readWorkflow('extract-learnings.md');
|
||||
assert.ok(
|
||||
content.includes('graduated:') || content.includes('Graduated:'),
|
||||
'extract-learnings.md must document optional graduated: field'
|
||||
);
|
||||
});
|
||||
|
||||
test('extract-learnings.md clarifies graduated: is written only by graduation workflow', () => {
|
||||
const content = readWorkflow('extract-learnings.md');
|
||||
assert.ok(
|
||||
content.includes('graduation workflow') || content.includes('graduation.md'),
|
||||
'extract-learnings.md must clarify that graduated: is written only by graduation.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-2430 — INVENTORY sync', () => {
|
||||
test('INVENTORY.md lists graduation.md', () => {
|
||||
const inventory = fs.readFileSync(
|
||||
path.join(__dirname, '../docs/INVENTORY.md'), 'utf-8'
|
||||
);
|
||||
assert.ok(inventory.includes('graduation.md'), 'INVENTORY.md must list graduation.md');
|
||||
});
|
||||
|
||||
test('INVENTORY-MANIFEST.json includes graduation.md', () => {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '../docs/INVENTORY-MANIFEST.json'), 'utf-8')
|
||||
);
|
||||
assert.ok(
|
||||
manifest.families.workflows.includes('graduation.md'),
|
||||
'INVENTORY-MANIFEST.json must include graduation.md in workflows array'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2492-context-coverage-gate.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2492-context-coverage-gate (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #2492)
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Bug #2492: Add gates to ensure discuss-phase decisions are translated to
|
||||
* plans (plan-phase, BLOCKING) and verified against shipped artifacts
|
||||
* (verify-phase, NON-BLOCKING).
|
||||
*
|
||||
* These workflow files are loaded as prompts by the corresponding subagents.
|
||||
* The tests below verify that the prompt text contains the gate steps and
|
||||
* the config-toggle skip clauses — losing them silently would regress the
|
||||
* fix.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PLAN_PHASE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md');
|
||||
const VERIFY_PHASE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md');
|
||||
const SCHEMA_MANIFEST_JSON = path.join(__dirname, '..', 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json');
|
||||
|
||||
describe('plan-phase decision-coverage gate (#2492)', () => {
|
||||
const md = fs.readFileSync(PLAN_PHASE, 'utf-8');
|
||||
|
||||
test('contains a Decision Coverage Gate step', () => {
|
||||
assert.ok(
|
||||
/Decision Coverage Gate/i.test(md),
|
||||
'plan-phase.md must define a Decision Coverage Gate step',
|
||||
);
|
||||
});
|
||||
|
||||
test('invokes the check.decision-coverage-plan handler', () => {
|
||||
assert.ok(
|
||||
md.includes('check.decision-coverage-plan'),
|
||||
'plan-phase.md must call gsd-sdk query check.decision-coverage-plan',
|
||||
);
|
||||
});
|
||||
|
||||
test('mentions workflow.context_coverage_gate skip clause', () => {
|
||||
assert.ok(
|
||||
md.includes('workflow.context_coverage_gate'),
|
||||
'plan-phase.md must reference workflow.context_coverage_gate to allow skipping',
|
||||
);
|
||||
});
|
||||
|
||||
test('decision gate appears AFTER the existing Requirements Coverage Gate', () => {
|
||||
// Anchored heading regexes — avoid prose-substring traps (review F8/F9).
|
||||
const reqIdx = md.search(/^## 13[a-z]?\.\s+Requirements Coverage Gate/m);
|
||||
const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m);
|
||||
assert.ok(reqIdx !== -1, 'Requirements Coverage Gate heading must exist as ## 13[a-z]?.');
|
||||
assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.');
|
||||
assert.ok(decIdx > reqIdx, 'Decision gate must run after Requirements gate');
|
||||
});
|
||||
|
||||
test('decision gate appears BEFORE plans are committed', () => {
|
||||
const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m);
|
||||
const commitIdx = md.search(/^## 13[a-z]?\.\s+Commit Plans/m);
|
||||
assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.');
|
||||
assert.ok(commitIdx !== -1, 'Commit Plans heading must exist as ## 13[a-z]?.');
|
||||
assert.ok(decIdx < commitIdx, 'Decision gate must run before commit so failures block the commit');
|
||||
});
|
||||
|
||||
test('plan-phase Decision Coverage Gate uses CONTEXT_PATH variable defined in INIT extraction (review F1)', () => {
|
||||
// The CONTEXT_PATH bash variable is defined at Step 4 (`CONTEXT_PATH=$(_gsd_field "$INIT" context_path)`).
|
||||
// The plan-phase gate snippet must reference the same casing — `${CONTEXT_PATH}` — not `${context_path}`,
|
||||
// otherwise the BLOCKING gate is invoked with an empty path and silently skips.
|
||||
const defIdx = md.indexOf('CONTEXT_PATH=$(_gsd_field "$INIT" context_path)');
|
||||
assert.ok(defIdx !== -1, 'CONTEXT_PATH must be defined from INIT JSON');
|
||||
|
||||
const gateIdx = md.indexOf('check.decision-coverage-plan');
|
||||
assert.ok(gateIdx !== -1, 'check.decision-coverage-plan invocation must exist');
|
||||
|
||||
// Slice the surrounding gate snippet (~600 chars) and verify variable casing matches the definition.
|
||||
const snippet = md.slice(Math.max(0, gateIdx - 200), gateIdx + 400);
|
||||
assert.ok(
|
||||
snippet.includes('${CONTEXT_PATH}'),
|
||||
'Gate snippet must reference ${CONTEXT_PATH} (uppercase) to match the variable defined in Step 4',
|
||||
);
|
||||
assert.ok(
|
||||
!snippet.includes('${context_path}'),
|
||||
'Gate snippet must NOT reference ${context_path} (lowercase) — that name is undefined in shell scope',
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase blocking gate exits non-zero on failure (review F15)', () => {
|
||||
// The gate is documented as BLOCKING. To actually block, the shell snippet must
|
||||
// exit with non-zero status when `passed` is false. Without exit-1 the workflow
|
||||
// continues silently past the failure.
|
||||
const gateIdx = md.indexOf('check.decision-coverage-plan');
|
||||
assert.ok(gateIdx !== -1);
|
||||
const snippet = md.slice(gateIdx, gateIdx + 800);
|
||||
// Accept either an inline `|| exit 1` or a `|| { ...; exit 1; }` group.
|
||||
const hasJqGuard =
|
||||
/jq[^\r\n]*\.data\.passed\s*==\s*true/.test(snippet) ||
|
||||
/jq[^\r\n]*\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet);
|
||||
const hasExitOne = /\|\|\s*(?:exit\s+1|\{[\s\S]{0,200}?exit\s+1)/.test(snippet);
|
||||
assert.ok(
|
||||
hasJqGuard && hasExitOne,
|
||||
'plan-phase gate must guard with `jq -e .passed == true || exit 1` (or `|| { ...; exit 1; }`) to actually block',
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase gate accepts top-level .passed field from CLI output (#275)', () => {
|
||||
const gateIdx = md.indexOf('check.decision-coverage-plan');
|
||||
assert.ok(gateIdx !== -1, 'check.decision-coverage-plan invocation must exist');
|
||||
const snippet = md.slice(gateIdx, gateIdx + 800);
|
||||
assert.ok(
|
||||
/\.(?:passed)\s*==\s*true\s*\|\|\s*\.data\.passed\s*==\s*true/.test(snippet) ||
|
||||
/\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet),
|
||||
'plan-phase gate must explicitly check top-level .passed with a compatibility fallback to .data.passed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify-phase decision-coverage gate (#2492)', () => {
|
||||
const md = fs.readFileSync(VERIFY_PHASE, 'utf-8');
|
||||
|
||||
test('contains a verify_decisions step', () => {
|
||||
assert.ok(
|
||||
/verify_decisions/.test(md),
|
||||
'verify-phase.md must define a verify_decisions step',
|
||||
);
|
||||
});
|
||||
|
||||
test('invokes the check.decision-coverage-verify handler', () => {
|
||||
assert.ok(
|
||||
md.includes('check.decision-coverage-verify'),
|
||||
'verify-phase.md must call gsd-sdk query check.decision-coverage-verify',
|
||||
);
|
||||
});
|
||||
|
||||
test('declares the decision gate as non-blocking / warning only', () => {
|
||||
const lower = md.toLowerCase();
|
||||
assert.ok(
|
||||
lower.includes('non-blocking') || lower.includes('warning only') || lower.includes('not block'),
|
||||
'verify-phase.md must declare the decision gate is non-blocking',
|
||||
);
|
||||
});
|
||||
|
||||
test('mentions workflow.context_coverage_gate skip clause', () => {
|
||||
assert.ok(
|
||||
md.includes('workflow.context_coverage_gate'),
|
||||
'verify-phase.md must reference workflow.context_coverage_gate to allow skipping',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime wiring for #2492 gates', () => {
|
||||
test('schema manifest includes context_coverage_gate', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(SCHEMA_MANIFEST_JSON, 'utf-8'));
|
||||
assert.ok(
|
||||
manifest.validKeys.includes('workflow.context_coverage_gate'),
|
||||
'workflow.context_coverage_gate must be present in config-schema manifest',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -306,3 +306,75 @@ function extractSection56(workflowPath) {
|
||||
assert.ok(end !== -1, '## 6. heading must follow §5.6 in plan-phase.md (§5.7 was removed; schema-gate is now a capability)');
|
||||
return content.slice(start, end);
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3072-optional-sketch-findings-guard.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3072-optional-sketch-findings-guard (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
|
||||
function read(rel) {
|
||||
return fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
}
|
||||
|
||||
function extractFindingsProbesFromBashBlocks(markdown) {
|
||||
const probes = [];
|
||||
const fenceRe = /```bash\r?\n([\s\S]*?)```/g;
|
||||
let fenceMatch;
|
||||
|
||||
while ((fenceMatch = fenceRe.exec(markdown)) !== null) {
|
||||
const block = fenceMatch[1];
|
||||
const baseLine = markdown.slice(0, fenceMatch.index).split(/\r?\n/).length;
|
||||
const lines = block.split(/\r?\n/);
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
if (!line.includes('.claude/skills/')) return;
|
||||
const kind = line.includes('sketch-findings-*/SKILL.md')
|
||||
? 'sketch'
|
||||
: line.includes('spike-findings-*/SKILL.md')
|
||||
? 'spike'
|
||||
: null;
|
||||
if (!kind) return;
|
||||
|
||||
probes.push({
|
||||
lineNumber: baseLine + idx,
|
||||
commandText: line.trim(),
|
||||
kind,
|
||||
hasNonFatalGuard: /\|\|\s*true/.test(line),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return probes;
|
||||
}
|
||||
|
||||
describe('bug #3072: optional sketch/spike findings probes are non-fatal', () => {
|
||||
test('all sketch/spike findings SKILL.md ls probes include || true', () => {
|
||||
const files = ['ui-phase.md', 'plan-phase.md', 'discuss-phase.md', 'new-project.md'];
|
||||
const offenders = [];
|
||||
|
||||
for (const file of files) {
|
||||
const content = read(file);
|
||||
const probes = extractFindingsProbesFromBashBlocks(content);
|
||||
for (const probe of probes) {
|
||||
if (!probe.hasNonFatalGuard) {
|
||||
offenders.push(`${file}:${probe.lineNumber} ${probe.commandText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(offenders, [], `missing non-fatal guard on optional findings probe:\n${offenders.join('\n')}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -908,3 +908,220 @@ describe('plan-review-convergence workflow: inline plan-phase dispatch (#936)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-936-no-nested-spawner-wrap.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-936-no-nested-spawner-wrap (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
/**
|
||||
* Structural guard — bug(#936): plan-review-convergence wrapped gsd-plan-phase
|
||||
* in Agent() at TWO sites (initial planning + replan). On Claude Code, a depth-1
|
||||
* Agent has no Agent tool, so plan-phase cannot spawn gsd-planner / gsd-plan-checker
|
||||
* → the replan loop never works when HIGHs are found.
|
||||
*
|
||||
* Fix: run plan-phase INLINE (bare Skill()) from the convergence orchestrator,
|
||||
* which runs at depth 0 and has Agent available — exactly how autonomous.md,
|
||||
* manager.md, and discuss-phase-assumptions.md already chain plan-phase.
|
||||
*
|
||||
* This guard dynamically derives the set of "spawner" workflows (those containing
|
||||
* `subagent_type=`) and asserts that NO workflow wraps a spawner inside Agent()
|
||||
* UNLESS the wrapping block includes a RUNTIME != claude carve-out (the #853
|
||||
* pattern already applied to autonomous.md / manager.md).
|
||||
*/
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (see #936)
|
||||
// The workflow markdown IS the runtime instruction — static guards over
|
||||
// workflow text are the canonical regression-test mechanism (per CONTRIBUTING
|
||||
// exception matrix and tests/bug-853-bg-dispatch-runtime-gating.test.cjs).
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
|
||||
// ── 1. Derive spawner skill names dynamically ──────────────────────────────
|
||||
// A "spawner" workflow is one that contains `subagent_type=` — it NEEDS the
|
||||
// Agent tool to run and therefore cannot safely be wrapped in another Agent()
|
||||
// on Claude Code (where depth-1 agents have no Agent tool).
|
||||
|
||||
// Recursively collect all *.md files under WORKFLOWS_DIR (covers nested fragments
|
||||
// like discuss-phase/modes/*.md and execute-phase/steps/*.md).
|
||||
function collectWorkflowFiles(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const results = [];
|
||||
for (const e of entries) {
|
||||
const fullPath = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
results.push(...collectWorkflowFiles(fullPath));
|
||||
} else if (e.name.endsWith('.md')) {
|
||||
results.push({
|
||||
name: path.relative(WORKFLOWS_DIR, fullPath),
|
||||
path: fullPath,
|
||||
content: fs.readFileSync(fullPath, 'utf8'),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const allWorkflowFiles = collectWorkflowFiles(WORKFLOWS_DIR);
|
||||
|
||||
// Map: base-slug → workflow filename (e.g. "plan-phase" → "plan-phase.md")
|
||||
// Skill() calls use the "gsd-<slug>" convention in all workflow files.
|
||||
// We build BOTH the bare slug set and the gsd-prefixed skill-name set.
|
||||
const SPAWNER_BASE_SLUGS = new Set(
|
||||
allWorkflowFiles
|
||||
.filter((w) => w.content.includes('subagent_type='))
|
||||
.map((w) => w.name.replace(/\.md$/, ''))
|
||||
);
|
||||
|
||||
// Skill invocations use "gsd-<slug>" (e.g. gsd-plan-phase, gsd-execute-phase).
|
||||
// Build the regex from the prefixed names so it actually matches what workflows write.
|
||||
const SPAWNER_GSD_NAMES = new Set([...SPAWNER_BASE_SLUGS].map((s) => `gsd-${s}`));
|
||||
|
||||
// Build a regex that matches Skill(skill='gsd-<spawner>') or Skill(skill="gsd-<spawner>")
|
||||
const spawnerPattern = new RegExp(
|
||||
`Skill\\(\\s*skill=['"](?:${[...SPAWNER_GSD_NAMES].join('|')})['"]`,
|
||||
's'
|
||||
);
|
||||
|
||||
// ── 2. Helper: extract Agent() blocks from a workflow ─────────────────────
|
||||
// Each block starts at "Agent(" and ends at the balancing ")". We collect
|
||||
// the text of each such block together with the surrounding context (a 400
|
||||
// char window before the block) so we can check for RUNTIME carve-outs.
|
||||
|
||||
function extractAgentBlocks(content) {
|
||||
const blocks = [];
|
||||
let pos = 0;
|
||||
while (pos < content.length) {
|
||||
const start = content.indexOf('Agent(', pos);
|
||||
if (start === -1) break;
|
||||
// Walk forward to find the balancing closing paren
|
||||
let depth = 0;
|
||||
let i = start + 'Agent('.length - 1; // at the '('
|
||||
for (; i < content.length; i++) {
|
||||
if (content[i] === '(') depth++;
|
||||
else if (content[i] === ')') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
const end = i + 1;
|
||||
const blockText = content.slice(start, end);
|
||||
// Capture context: 400 chars before the block (for RUNTIME gate detection)
|
||||
const contextBefore = content.slice(Math.max(0, start - 400), start);
|
||||
blocks.push({ start, end, blockText, contextBefore });
|
||||
pos = end;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ── 3. Helper: does a block have a RUNTIME != claude carve-out nearby? ────
|
||||
// The #853 pattern looks like: "RUNTIME is `claude`" in a preceding condition
|
||||
// that switches to inline Skill() instead of the Agent() block. A block is
|
||||
// considered guarded when the 400-char context window before it (or the block
|
||||
// body itself for block-internal guards) contains any of these markers.
|
||||
|
||||
function hasRuntimeCarveout(block) {
|
||||
const haystack = block.contextBefore + block.blockText;
|
||||
return (
|
||||
/RUNTIME[^`\n]{0,30}(?:!=|≠|is not|!==)\s*[`'"]?claude/i.test(haystack) ||
|
||||
/RUNTIME[^`\n]{0,30}claude[^`\n]{0,30}(?:inline|not.*Agent|do NOT)/i.test(haystack) ||
|
||||
/If `RUNTIME` is `claude`/i.test(haystack) ||
|
||||
/On Claude Code.*inline/is.test(haystack)
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. The guard: scan every workflow for unguarded Agent→spawner wraps ───
|
||||
|
||||
describe('bug-936 — no workflow wraps a spawner skill inside Agent() without a RUNTIME carve-out', () => {
|
||||
test('spawner set is non-empty (self-check: subagent_type= grep must find files)', () => {
|
||||
assert.ok(SPAWNER_BASE_SLUGS.size > 0, `No spawner workflows found in ${WORKFLOWS_DIR} — SPAWNER_BASE_SLUGS derivation is broken`);
|
||||
// plan-phase must be a spawner (base slug)
|
||||
assert.ok(SPAWNER_BASE_SLUGS.has('plan-phase'), 'plan-phase.md must be in the spawner set (contains subagent_type=)');
|
||||
// gsd-plan-phase must be in the prefixed set used by the regex
|
||||
assert.ok(SPAWNER_GSD_NAMES.has('gsd-plan-phase'), 'gsd-plan-phase must be in SPAWNER_GSD_NAMES — the prefixed form used in Skill() calls');
|
||||
});
|
||||
|
||||
for (const wf of allWorkflowFiles) {
|
||||
// Only scan files that have at least one Agent( call
|
||||
if (!wf.content.includes('Agent(')) continue;
|
||||
|
||||
test(`${wf.name}: no Agent() block wraps a spawner Skill without a RUNTIME carve-out`, () => {
|
||||
const blocks = extractAgentBlocks(wf.content);
|
||||
const violations = blocks.filter((b) => {
|
||||
const wrapsSpawner = spawnerPattern.test(b.blockText);
|
||||
if (!wrapsSpawner) return false;
|
||||
return !hasRuntimeCarveout(b);
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(
|
||||
violations.map((v) => v.blockText.slice(0, 120).replace(/\n/g, '\\n')),
|
||||
[],
|
||||
`${wf.name} wraps a spawner Skill inside Agent() without a RUNTIME != claude carve-out.\n` +
|
||||
`Fix: run the spawner Skill inline (bare Skill() call at depth 0) OR add a RUNTIME gate.\n` +
|
||||
`See: bug #936, tests/bug-853-bg-dispatch-runtime-gating.test.cjs for the guarded pattern.`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── 5. Focused regression: plan-review-convergence never wraps plan-phase ─
|
||||
|
||||
describe('bug-936 — plan-review-convergence runs plan-phase inline, not inside Agent()', () => {
|
||||
const CONVERGENCE = fs.readFileSync(
|
||||
path.join(WORKFLOWS_DIR, 'plan-review-convergence.md'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
test('plan-review-convergence does NOT wrap gsd-plan-phase inside Agent()', () => {
|
||||
// The anti-pattern: Agent( block whose body contains Skill(skill='gsd-plan-phase')
|
||||
const blocks = extractAgentBlocks(CONVERGENCE);
|
||||
const wrapping = blocks.filter((b) =>
|
||||
/Skill\(\s*skill=['"]gsd-plan-phase['"]/.test(b.blockText) &&
|
||||
!hasRuntimeCarveout(b)
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
wrapping.map((v) => v.blockText.slice(0, 120).replace(/\n/g, '\\n')),
|
||||
[],
|
||||
'plan-review-convergence must NOT wrap gsd-plan-phase inside Agent(). ' +
|
||||
'Run it inline (bare Skill() at depth 0) so it can spawn gsd-planner/gsd-plan-checker. ' +
|
||||
'See: bug #936'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-review-convergence calls gsd-plan-phase inline (bare Skill call outside Agent block)', () => {
|
||||
// After the fix: at least one bare Skill(skill="gsd-plan-phase") must appear
|
||||
// outside any Agent( block — that is the inline call from the depth-0 orchestrator.
|
||||
const blocks = extractAgentBlocks(CONVERGENCE);
|
||||
// Remove all Agent block ranges from the text
|
||||
let masked = CONVERGENCE;
|
||||
// Work from end to start so offsets stay valid
|
||||
const sorted = [...blocks].sort((a, b) => b.start - a.start);
|
||||
for (const b of sorted) {
|
||||
masked = masked.slice(0, b.start) + ' '.repeat(b.end - b.start) + masked.slice(b.end);
|
||||
}
|
||||
const hasInlineCall = /Skill\(\s*skill=["']gsd-plan-phase["']/.test(masked);
|
||||
assert.ok(
|
||||
hasInlineCall,
|
||||
'plan-review-convergence must contain at least one bare Skill(skill="gsd-plan-phase") ' +
|
||||
'outside any Agent() block — this is the inline call that lets plan-phase spawn its sub-agents. ' +
|
||||
'See: bug #936'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-review-convergence still wraps gsd-review inside Agent() (leaf — isolation is correct)', () => {
|
||||
// gsd-review is a leaf (shells out via Bash, no subagent_type) so the Agent wrap is fine and intentional.
|
||||
const blocks = extractAgentBlocks(CONVERGENCE);
|
||||
const reviewWrap = blocks.some((b) => /Skill\(\s*skill=['"]gsd-review['"]/.test(b.blockText));
|
||||
assert.ok(reviewWrap, 'gsd-review must still be wrapped in Agent() — it is a Bash leaf and isolation is intentional');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,3 +138,105 @@ describe('reference files contain key content from original mode sections', () =
|
||||
assert.ok(hasReviewsContent, 'planner-reviews.md must contain reviews mode content');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3320-planner-deep-work-rules.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3320-planner-deep-work-rules (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-product [#3320]
|
||||
// The bug is a contradiction in prompt/workflow source text. These assertions
|
||||
// intentionally pin the contract words that planner agents consume.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const PLANNER_AGENT = path.join(ROOT, 'agents', 'gsd-planner.md');
|
||||
const PLAN_PHASE_WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'plan-phase.md');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function extractDeepWorkRules() {
|
||||
const workflow = fs.readFileSync(PLAN_PHASE_WORKFLOW, 'utf8');
|
||||
const match = workflow.match(/<deep_work_rules>[\s\S]*?<\/deep_work_rules>/);
|
||||
assert.ok(match, 'plan-phase.md must contain a deep_work_rules block');
|
||||
return match[0];
|
||||
}
|
||||
|
||||
describe('bug #3320 planner action contract', () => {
|
||||
test('planner agent explicitly keeps implementation code out of action blocks', () => {
|
||||
const planner = fs.readFileSync(PLANNER_AGENT, 'utf8');
|
||||
|
||||
assert.match(
|
||||
planner,
|
||||
/NEVER place fenced code blocks \(```\) inside `<action>`/,
|
||||
'gsd-planner.md must explicitly forbid fenced implementation code in <action>'
|
||||
);
|
||||
assert.match(
|
||||
planner,
|
||||
/Code excerpts belong in `<read_first>` source files or referenced context/,
|
||||
'gsd-planner.md must route code excerpts to context/read-first material'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase deep_work_rules no longer requires self-sufficient code dumps', () => {
|
||||
const deepWorkRules = extractDeepWorkRules();
|
||||
|
||||
assert.doesNotMatch(
|
||||
deepWorkRules,
|
||||
/copy them into the action verbatim/,
|
||||
'deep_work_rules must not tell planners to copy source material verbatim into <action>'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
deepWorkRules,
|
||||
/complete the task from the action text alone/,
|
||||
'deep_work_rules must not make <action> self-sufficient without read_first/context'
|
||||
);
|
||||
assert.match(
|
||||
deepWorkRules,
|
||||
/Do not include full file contents, fenced code blocks, or complete implementations in `<action>`/,
|
||||
'deep_work_rules must explicitly bound concrete values to avoid code dumping'
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase acceptance criteria allow behavior and test assertions', () => {
|
||||
const deepWorkRules = extractDeepWorkRules();
|
||||
|
||||
assert.match(
|
||||
deepWorkRules,
|
||||
/behavior assertion/,
|
||||
'acceptance criteria must allow behavior assertions, not just grep checks'
|
||||
);
|
||||
assert.match(
|
||||
deepWorkRules,
|
||||
/test command/,
|
||||
'acceptance criteria must allow test-command assertions'
|
||||
);
|
||||
});
|
||||
|
||||
test('quality gate matches the reconciled planner contract', () => {
|
||||
const workflow = read('gsd-core/workflows/plan-phase.md');
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/Every task has `<acceptance_criteria>` with behavior, test-command, CLI, or source assertions/,
|
||||
'quality gate must not narrow acceptance criteria back to grep-only checks'
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/Every `<action>` contains concrete identifiers without fenced code blocks or full implementations/,
|
||||
'quality gate must enforce concrete prose without implementation dumps'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -336,3 +336,156 @@ describe('gsd-plan-checker.md — scope reduction detection includes time/comple
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3805-fast-md-log-to-state-schema.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3805-fast-md-log-to-state-schema (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (see #3805)
|
||||
// Reads gsd-core/workflows/fast.md whose deployed text IS the product —
|
||||
// the workflow markdown is executed verbatim by LLM runtimes.
|
||||
|
||||
/**
|
||||
* #3805 — fast.md log_to_state appends a schema-blind 4-column row to the
|
||||
* 5-column "Quick Tasks Completed" table created by quick.md Step 7.
|
||||
*
|
||||
* quick.md Step 7 creates the table with 5 columns:
|
||||
* | # | Description | Date | Commit | Directory |
|
||||
*
|
||||
* Before this fix, fast.md's log_to_state step appended a hardcoded 4-cell
|
||||
* row unconditionally:
|
||||
* echo "| $(date +%Y-%m-%d) | fast | $TASK | ✅ |" >> .planning/STATE.md
|
||||
*
|
||||
* This produces malformed Markdown when the existing table has a different
|
||||
* column count.
|
||||
*
|
||||
* Covers:
|
||||
* - fast.md does NOT contain the hardcoded 4-cell echo template
|
||||
* - fast.md log_to_state step reads/introspects the existing table header
|
||||
* before appending (schema-aware insertion)
|
||||
* - fast.md log_to_state step matches the 5-column schema from quick.md
|
||||
* Step 7 when that table is present
|
||||
* - fast.md log_to_state step skips (does not corrupt) the STATE.md write
|
||||
* when the table schema is unrecognized
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const FAST_MD_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'fast.md');
|
||||
|
||||
// The 5-column schema defined in quick.md Step 7 (non-validate mode).
|
||||
// Column count is 5: # | Description | Date | Commit | Directory
|
||||
// Named constant for traceability — mirrors quick.md Step 7's table header.
|
||||
const QUICK_MD_STEP7_COL_COUNT = 5;
|
||||
const QUICK_MD_STEP7_COLUMNS = ['#', 'Description', 'Date', 'Commit', 'Directory'];
|
||||
|
||||
describe('bug #3805: fast.md log_to_state must be schema-aware', () => {
|
||||
let fastMdContent;
|
||||
|
||||
test('fast.md workflow file exists and is readable', () => {
|
||||
assert.ok(fs.existsSync(FAST_MD_PATH), `fast.md not found at ${FAST_MD_PATH}`);
|
||||
fastMdContent = fs.readFileSync(FAST_MD_PATH, 'utf-8');
|
||||
});
|
||||
|
||||
test('fast.md log_to_state step does NOT hardcode a 4-cell row template', () => {
|
||||
// The old broken template: | date | fast | task | ✅ |
|
||||
// This regex matches the exact hardcoded pattern that ignores table schema.
|
||||
// A 4-cell row has exactly 4 pipe-delimited fields plus the surrounding pipes.
|
||||
const hardcoded4CellPattern = /echo\s+["'][|][^|]*[|][^|]*[|][^|]*[|][^|]*[|]\s*["']/;
|
||||
const match = fastMdContent.match(hardcoded4CellPattern);
|
||||
assert.ok(
|
||||
!match,
|
||||
[
|
||||
'fast.md log_to_state still contains the hardcoded 4-cell row template:',
|
||||
` "${match?.[0]}"`,
|
||||
'This appends a malformed row to the 5-column Quick Tasks Completed table',
|
||||
'created by quick.md Step 7.',
|
||||
`Expected table schema (${QUICK_MD_STEP7_COL_COUNT} cols): | ${QUICK_MD_STEP7_COLUMNS.join(' | ')} |`,
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
test('fast.md log_to_state step reads the existing table header (schema introspection)', () => {
|
||||
// The fix must inspect the existing STATE.md table header before appending.
|
||||
// Acceptable signals: reading STATE.md content, grepping for the header line,
|
||||
// or parsing columns from the header row.
|
||||
const hasHeaderRead =
|
||||
// Reads STATE.md to inspect it (awk/sed/grep on the file for header detection)
|
||||
/grep.*Quick Tasks Completed.*STATE\.md/.test(fastMdContent) ||
|
||||
/awk.*Quick Tasks Completed/.test(fastMdContent) ||
|
||||
/sed.*Quick Tasks Completed/.test(fastMdContent) ||
|
||||
// Reads the header line explicitly (head -n, sed -n, awk NR==)
|
||||
/head\s+-n/.test(fastMdContent) && /STATE\.md/.test(fastMdContent) ||
|
||||
// Counts pipe separators / columns from existing header
|
||||
/col.*count|column.*count|count.*col|NF|awk.*\|/.test(fastMdContent) ||
|
||||
// References schema detection in prose
|
||||
/schema|header|column\s+count|existing.*table/.test(fastMdContent);
|
||||
|
||||
assert.ok(
|
||||
hasHeaderRead,
|
||||
[
|
||||
'fast.md log_to_state step does not appear to introspect the existing table schema.',
|
||||
'The step must read the STATE.md table header to detect column count before appending.',
|
||||
'quick.md Step 7 uses schema-aware matching — fast.md must follow the same discipline.',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
test('fast.md log_to_state step references the 5-column quick.md schema', () => {
|
||||
// The fix must handle the 5-col schema: | # | Description | Date | Commit | Directory |
|
||||
// Test that all 5 column names appear in the log_to_state step's context.
|
||||
// Extract the log_to_state step content to scope the check.
|
||||
const logToStateMatch = fastMdContent.match(/<step name="log_to_state">([\s\S]*?)<\/step>/);
|
||||
assert.ok(logToStateMatch, 'fast.md must contain a <step name="log_to_state"> element');
|
||||
|
||||
const stepContent = logToStateMatch[1];
|
||||
|
||||
// All 5 column names from quick.md Step 7 must be referenced in the step.
|
||||
for (const col of QUICK_MD_STEP7_COLUMNS) {
|
||||
assert.ok(
|
||||
stepContent.toLowerCase().includes(col.toLowerCase()),
|
||||
[
|
||||
`fast.md log_to_state step does not reference column "${col}"`,
|
||||
`Expected all 5 columns from quick.md Step 7: ${QUICK_MD_STEP7_COLUMNS.join(', ')}`,
|
||||
`(${QUICK_MD_STEP7_COL_COUNT}-column schema)`,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('fast.md log_to_state step skips STATE.md write on unrecognized schema', () => {
|
||||
// The fix must not blindly append when the table schema is unknown.
|
||||
// Check for a guard that skips or logs rather than corrupting the file.
|
||||
const logToStateMatch = fastMdContent.match(/<step name="log_to_state">([\s\S]*?)<\/step>/);
|
||||
assert.ok(logToStateMatch, 'fast.md must contain a <step name="log_to_state"> element');
|
||||
|
||||
const stepContent = logToStateMatch[1];
|
||||
|
||||
// Must have a skip/guard path for unrecognized schemas
|
||||
const hasSkipGuard =
|
||||
/skip/i.test(stepContent) ||
|
||||
/unrecognized|unknown|mismatch/i.test(stepContent) ||
|
||||
/else\b/.test(stepContent) ||
|
||||
/warn|log/i.test(stepContent);
|
||||
|
||||
assert.ok(
|
||||
hasSkipGuard,
|
||||
[
|
||||
'fast.md log_to_state step does not appear to guard against unrecognized table schemas.',
|
||||
'When the existing STATE.md table does not match an expected schema,',
|
||||
'the step must skip the write (with a brief log) rather than append a malformed row.',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -284,3 +284,166 @@ describe('#3418: /gsd-progress flag routing prompt contract', () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2912-progress-context-authority.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2912-progress-context-authority (consolidation epic #1969 B4 #1973)", () => {
|
||||
/**
|
||||
* Tests for issue #2912 — /gsd-progress can use stale CLAUDE.md project block
|
||||
* instead of GSD tracking files as authoritative source.
|
||||
*
|
||||
* Fix: the `report` step in gsd-core/workflows/progress.md must contain
|
||||
* an explicit "context authority" directive establishing PROJECT.md, STATE.md,
|
||||
* and ROADMAP.md as the authoritative sources for the progress report, and
|
||||
* forbidding the use of CLAUDE.md `## Project` blocks as a source for any
|
||||
* report field.
|
||||
*
|
||||
* These tests parse the workflow markdown structurally (locate the
|
||||
* <step name="report"> ... </step> block, then locate the blockquote-style
|
||||
* directive inside it). They do NOT use `.includes()` over the whole file.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'progress.md'
|
||||
);
|
||||
|
||||
/** Extract the body of a <step name="..."> ... </step> block by parsing tags. */
|
||||
function extractStep(workflow, stepName) {
|
||||
const openTag = `<step name="${stepName}">`;
|
||||
const start = workflow.indexOf(openTag);
|
||||
if (start === -1) return null;
|
||||
const bodyStart = start + openTag.length;
|
||||
// Find the matching </step> — workflow steps in this file do not nest.
|
||||
const end = workflow.indexOf('</step>', bodyStart);
|
||||
if (end === -1) return null;
|
||||
return workflow.slice(bodyStart, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract contiguous markdown blockquote blocks from a chunk of markdown.
|
||||
* A blockquote is a run of consecutive lines starting with '>' (after any
|
||||
* leading whitespace). Returns the joined text of each blockquote with the
|
||||
* leading '>' markers stripped.
|
||||
*/
|
||||
function extractBlockquotes(md) {
|
||||
const lines = md.split(/\r?\n/);
|
||||
const blocks = [];
|
||||
let current = null;
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^\s*>\s?(.*)$/);
|
||||
if (m) {
|
||||
if (current === null) current = [];
|
||||
current.push(m[1]);
|
||||
} else {
|
||||
if (current !== null) {
|
||||
blocks.push(current.join('\n'));
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current !== null) blocks.push(current.join('\n'));
|
||||
return blocks;
|
||||
}
|
||||
|
||||
describe('#2912: progress report step has explicit context-authority directive', () => {
|
||||
test('progress.md workflow file exists and is readable', () => {
|
||||
const stat = fs.statSync(WORKFLOW_PATH);
|
||||
assert.ok(stat.isFile(), 'workflow file should exist');
|
||||
});
|
||||
|
||||
test('progress.md has a <step name="report"> section', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const reportStep = extractStep(workflow, 'report');
|
||||
assert.ok(reportStep, 'workflow should contain a report step');
|
||||
assert.ok(reportStep.length > 0, 'report step body should not be empty');
|
||||
});
|
||||
|
||||
test('report step contains a blockquote directive about context authority', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const reportStep = extractStep(workflow, 'report');
|
||||
assert.ok(reportStep, 'report step must be present');
|
||||
|
||||
const blockquotes = extractBlockquotes(reportStep);
|
||||
assert.ok(
|
||||
blockquotes.length > 0,
|
||||
'report step should contain at least one blockquote (the context-authority directive)'
|
||||
);
|
||||
|
||||
const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b));
|
||||
assert.ok(
|
||||
authorityBlock,
|
||||
'report step should contain a blockquote whose text includes "Context authority"'
|
||||
);
|
||||
});
|
||||
|
||||
test('context-authority directive names PROJECT.md, STATE.md, and ROADMAP.md as authoritative', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const reportStep = extractStep(workflow, 'report');
|
||||
assert.ok(reportStep, 'report step must exist');
|
||||
const blockquotes = extractBlockquotes(reportStep);
|
||||
const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b));
|
||||
assert.ok(authorityBlock, 'authority blockquote must exist');
|
||||
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/PROJECT\.md/,
|
||||
'directive should name PROJECT.md as authoritative'
|
||||
);
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/STATE\.md/,
|
||||
'directive should name STATE.md as authoritative'
|
||||
);
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/ROADMAP\.md/,
|
||||
'directive should name ROADMAP.md as authoritative'
|
||||
);
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/authoritative/i,
|
||||
'directive should describe these files as authoritative'
|
||||
);
|
||||
});
|
||||
|
||||
test('context-authority directive forbids using CLAUDE.md project block as a source', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const reportStep = extractStep(workflow, 'report');
|
||||
assert.ok(reportStep, 'report step must exist');
|
||||
const blockquotes = extractBlockquotes(reportStep);
|
||||
const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b));
|
||||
assert.ok(authorityBlock, 'authority blockquote must exist');
|
||||
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/CLAUDE\.md/,
|
||||
'directive should explicitly mention CLAUDE.md'
|
||||
);
|
||||
// Must explicitly forbid CLAUDE.md as a source — look for a NOT/do not directive
|
||||
// co-located with the CLAUDE.md mention.
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/(do\s+NOT|do\s+not|must\s+NOT|must\s+not|never)/i,
|
||||
'directive should contain an explicit prohibition (do NOT / must not / never)'
|
||||
);
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/## Project/,
|
||||
'directive should call out the CLAUDE.md "## Project" block specifically'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,3 +51,218 @@ describe('quick workflow commit boundary (#1503)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2432-quick-plan-predispatch-commit.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2432-quick-plan-predispatch-commit (consolidation epic #1969 B4 #1973)", () => {
|
||||
/**
|
||||
* Bug #2432: quick.md PLAN.md timing — worktree executor can't read PLAN.md
|
||||
*
|
||||
* The orchestrator must commit PLAN.md to the base branch BEFORE spawning the
|
||||
* worktree-isolated executor. Without this, the executor's first Read resolves
|
||||
* to a main-repo absolute path (not a worktree path), which primes CC's path
|
||||
* cache and causes subsequent Edit/Write calls to silently target the main repo
|
||||
* instead of the worktree (CC issue #36182 amplifier).
|
||||
*
|
||||
* Fix: Step 5.6 commits PLAN.md pre-dispatch when USE_WORKTREES is active.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const QUICK_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
describe('quick.md pre-dispatch PLAN.md commit (#2432)', () => {
|
||||
let content;
|
||||
|
||||
test('quick.md exists', () => {
|
||||
assert.ok(fs.existsSync(QUICK_MD), 'gsd-core/workflows/quick.md must exist');
|
||||
content = fs.readFileSync(QUICK_MD, 'utf-8');
|
||||
});
|
||||
|
||||
test('Step 5.6 exists between Step 5.5 and Step 6', () => {
|
||||
const step55 = content.indexOf('Step 5.5');
|
||||
const step56 = content.indexOf('Step 5.6');
|
||||
const step6 = content.indexOf('Step 6:');
|
||||
assert.ok(step55 !== -1, 'Step 5.5 must exist');
|
||||
assert.ok(step56 !== -1, 'Step 5.6 must exist');
|
||||
assert.ok(step6 !== -1, 'Step 6 must exist');
|
||||
assert.ok(step56 > step55, 'Step 5.6 must appear after Step 5.5');
|
||||
assert.ok(step56 < step6, 'Step 5.6 must appear before Step 6');
|
||||
});
|
||||
|
||||
test('Step 5.6 is gated on USE_WORKTREES', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
assert.ok(
|
||||
step56Block.includes('USE_WORKTREES'),
|
||||
'Step 5.6 must be gated on USE_WORKTREES — only commit pre-dispatch in worktree mode'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 5.6 is gated on commit_docs', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
assert.ok(
|
||||
step56Block.includes('commit_docs'),
|
||||
'Step 5.6 must respect commit_docs config — skip pre-dispatch commit when commit_docs is false'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 5.6 stages and commits PLAN.md', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
assert.ok(
|
||||
step56Block.includes('PLAN.md'),
|
||||
'Step 5.6 must reference PLAN.md in the pre-dispatch commit'
|
||||
);
|
||||
assert.ok(
|
||||
step56Block.includes('git add') || step56Block.includes('git commit'),
|
||||
'Step 5.6 must include git add/commit to stage and commit PLAN.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 5.6 uses --no-verify to avoid hook interference', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
assert.ok(
|
||||
step56Block.includes('--no-verify'),
|
||||
'Step 5.6 pre-dispatch commit must use --no-verify to avoid hook interference'
|
||||
);
|
||||
});
|
||||
|
||||
test('executor prompt references PLAN.md via relative (worktree-rooted) path', () => {
|
||||
// QUICK_DIR is always set to ".planning/quick/..." (relative) so ${QUICK_DIR}/...PLAN.md
|
||||
// resolves relative to the worktree root, not the main repo absolute path.
|
||||
// Verify the executor prompt uses QUICK_DIR variable (not a hardcoded absolute path).
|
||||
const executorTask = content.indexOf('subagent_type="gsd-executor"');
|
||||
assert.ok(executorTask !== -1, 'executor Task() spawn must exist');
|
||||
// Find the files_to_read block near the executor spawn
|
||||
const filesBlock = content.lastIndexOf('<files_to_read>', executorTask);
|
||||
const filesBlockEnd = content.indexOf('</files_to_read>', filesBlock);
|
||||
const filesContent = content.slice(filesBlock, filesBlockEnd);
|
||||
assert.ok(
|
||||
filesContent.includes('QUICK_DIR') || filesContent.includes('.planning/quick'),
|
||||
'executor files_to_read must reference PLAN.md via relative QUICK_DIR path'
|
||||
);
|
||||
assert.ok(
|
||||
!filesContent.match(/\/home\/|\/Users\/|\/root\//),
|
||||
'executor files_to_read must NOT contain hardcoded absolute paths'
|
||||
);
|
||||
});
|
||||
|
||||
test('#1265 records both parent base and plan commit for worktree dispatch', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
const step6Block = content.slice(step6Start, content.indexOf('After executor returns:', step6Start));
|
||||
|
||||
assert.ok(
|
||||
step56Block.includes('QUICK_PLAN_PARENT'),
|
||||
'quick worktree mode must record the pre-dispatch parent SHA before committing PLAN.md (#1265)'
|
||||
);
|
||||
assert.ok(
|
||||
step56Block.includes('QUICK_PLAN_COMMIT'),
|
||||
'quick worktree mode must record the PLAN.md commit SHA after the pre-dispatch commit (#1265)'
|
||||
);
|
||||
assert.ok(
|
||||
step6Block.includes('EXPECTED_BASE_ALTERNATE'),
|
||||
'executor guard embed must carry the alternate accepted base for parent-vs-plan worktree forks (#1265)'
|
||||
);
|
||||
});
|
||||
|
||||
test('#1265 executor materializes PLAN.md from the plan commit when worktree starts at parent', () => {
|
||||
const executorTask = content.indexOf('subagent_type="gsd-executor"');
|
||||
assert.ok(executorTask !== -1, 'executor Task() spawn must exist');
|
||||
const promptStart = content.lastIndexOf('prompt="', executorTask);
|
||||
const promptEnd = content.indexOf('subagent_type="gsd-executor"', promptStart);
|
||||
const executorPrompt = content.slice(promptStart, promptEnd);
|
||||
|
||||
assert.ok(
|
||||
executorPrompt.includes('git show') && executorPrompt.includes('QUICK_PLAN_COMMIT'),
|
||||
'executor prompt must materialize PLAN.md from git show <plan-commit>:<plan-path> when absent (#1265)'
|
||||
);
|
||||
assert.ok(
|
||||
executorPrompt.indexOf('git show') < executorPrompt.indexOf('<files_to_read>'),
|
||||
'PLAN.md materialization must be a first action before files_to_read can prime paths (#1265)'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2523-quick-deferred-items.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2523-quick-deferred-items (consolidation epic #1969 B4 #1973)", () => {
|
||||
/**
|
||||
* Regression test for bug #2523
|
||||
*
|
||||
* workflows/quick.md Step 8 ("Build file list") listed PLAN.md, SUMMARY.md,
|
||||
* STATE.md, and mode-conditional CONTEXT.md / RESEARCH.md / VERIFICATION.md —
|
||||
* but omitted deferred-items.md. When an executor logs out-of-scope findings
|
||||
* to ${QUICK_DIR}/${quick_id}-deferred-items.md during task execution, that
|
||||
* file was left untracked after the final commit even with commit_docs: true.
|
||||
*
|
||||
* Fix: add a file-existence-gated entry for deferred-items.md to Step 8.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
describe('bug #2523: quick-task final commit includes deferred-items.md', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/quick.md must exist');
|
||||
});
|
||||
|
||||
test('Step 8 file list references deferred-items.md', () => {
|
||||
const step8Idx = content.indexOf('Step 8: Final commit');
|
||||
assert.notEqual(step8Idx, -1, 'Step 8 section must exist in quick.md');
|
||||
|
||||
const step8Section = content.slice(step8Idx, step8Idx + 2000);
|
||||
assert.ok(
|
||||
step8Section.includes('deferred-items.md'),
|
||||
'Step 8 file list must include deferred-items.md. ' +
|
||||
'Without this, any out-of-scope findings logged by the executor to ' +
|
||||
'${QUICK_DIR}/${quick_id}-deferred-items.md are left untracked after commit (bug #2523).'
|
||||
);
|
||||
});
|
||||
|
||||
test('deferred-items.md entry is conditional on file existence', () => {
|
||||
const deferredIdx = content.indexOf('deferred-items.md');
|
||||
assert.notEqual(deferredIdx, -1, 'deferred-items.md must be mentioned in the workflow');
|
||||
|
||||
const surroundingContext = content.slice(Math.max(0, deferredIdx - 200), deferredIdx + 200);
|
||||
const isConditional =
|
||||
surroundingContext.toLowerCase().includes('exist') ||
|
||||
surroundingContext.toLowerCase().includes('if ');
|
||||
assert.ok(
|
||||
isConditional,
|
||||
'The deferred-items.md entry must be gated on file existence (not a mode flag). ' +
|
||||
'deferred-items.md can be created in any quick-task run, regardless of mode.'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -514,3 +514,135 @@ describe('reapply-patches pristine baseline detection (#2424)', () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3516-reapply-patches-gsd-update-filter.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3516-reapply-patches-gsd-update-filter (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #3516)
|
||||
// gsd-core/workflows/reapply-patches.md is the installed runtime workflow —
|
||||
// its text IS the deployed behavioral contract for the --reapply flag.
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Bug #3516: reapply-patches.md git-enhanced two-way merge filter misses
|
||||
* commits authored by the renamed `/gsd-update` flow.
|
||||
*
|
||||
* The `grep -v` alternation on line 231 only included the legacy `gsd:update`
|
||||
* marker. After the slash-command rename `/gsd:update` → `/gsd-update`, commits
|
||||
* authored by the current flow fall through the filter and are misclassified as
|
||||
* user customizations, prompting spurious merge conflicts during `--reapply`.
|
||||
*
|
||||
* Fix: add `gsd-update` arm to the alternation so both the legacy and current
|
||||
* commit-message prefixes are excluded. `GSD update` and `gsd-install`
|
||||
* exclusions are preserved.
|
||||
*
|
||||
* Per the repo's source-text-is-the-product exception: the workflow file's text
|
||||
* IS the deployed behavioral contract. Structural assertion against the parsed
|
||||
* shell command string is the correct test form here.
|
||||
*/
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'reapply-patches.md',
|
||||
);
|
||||
|
||||
/**
|
||||
* Extract the git log filter command from the workflow.
|
||||
*
|
||||
* Looks for the `grep -v "..."` shell snippet inside the Git-enhanced two-way
|
||||
* merge section and returns the alternation string between the quotes.
|
||||
* Returns null if the snippet is absent (signals a structural regression).
|
||||
*/
|
||||
function extractFilterAlternation(content) {
|
||||
// Match the grep -v "..." line in the bash block
|
||||
const match = content.match(/grep\s+-v\s+"([^"]+)"/);
|
||||
if (!match) return null;
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the alternation string (pipe-delimited) into individual arms.
|
||||
* Handles escaped pipes produced by shell regex syntax (`\|`).
|
||||
*/
|
||||
function parseAlternationArms(alternation) {
|
||||
// Shell grep alternation uses \| (escaped pipe); split on that
|
||||
return alternation.split(/\\\|/).map((arm) => arm.trim());
|
||||
}
|
||||
|
||||
describe('Bug #3516: git-enhanced two-way merge filter includes gsd-update arm', () => {
|
||||
let content;
|
||||
let alternation;
|
||||
let arms;
|
||||
|
||||
before(() => {
|
||||
content = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
alternation = extractFilterAlternation(content);
|
||||
arms = alternation ? parseAlternationArms(alternation) : [];
|
||||
});
|
||||
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(WORKFLOW_PATH),
|
||||
'gsd-core/workflows/reapply-patches.md must exist',
|
||||
);
|
||||
});
|
||||
|
||||
test('git-enhanced two-way merge section contains a grep -v filter', () => {
|
||||
assert.ok(
|
||||
alternation !== null,
|
||||
'reapply-patches.md must contain a `grep -v "..."` filter in the git-enhanced two-way merge section',
|
||||
);
|
||||
});
|
||||
|
||||
test('filter excludes legacy gsd:update commits (back-compat)', () => {
|
||||
assert.ok(
|
||||
arms.some((arm) => arm === 'gsd:update'),
|
||||
`filter must include 'gsd:update' arm for back-compat; got arms: ${JSON.stringify(arms)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('filter excludes renamed gsd-update commits (primary fix)', () => {
|
||||
assert.ok(
|
||||
arms.some((arm) => arm === 'gsd-update'),
|
||||
`filter must include 'gsd-update' arm (renamed flow); got arms: ${JSON.stringify(arms)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('filter excludes GSD update commits (no regression)', () => {
|
||||
assert.ok(
|
||||
arms.some((arm) => arm === 'GSD update'),
|
||||
`filter must include 'GSD update' arm; got arms: ${JSON.stringify(arms)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('filter excludes gsd-install commits (no regression)', () => {
|
||||
assert.ok(
|
||||
arms.some((arm) => arm === 'gsd-install'),
|
||||
`filter must include 'gsd-install' arm; got arms: ${JSON.stringify(arms)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('all four expected exclusion patterns are present in the filter', () => {
|
||||
const required = ['gsd:update', 'gsd-update', 'GSD update', 'gsd-install'];
|
||||
const missing = required.filter((p) => !arms.some((arm) => arm === p));
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`filter is missing required exclusion patterns: ${JSON.stringify(missing)}; got arms: ${JSON.stringify(arms)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,3 +186,128 @@ describe('research-agent-profiles: checkAgent malformed profile', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-222-research-synthesizer-write-contract.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-222-research-synthesizer-write-contract (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #222)
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SYNTHESIZER_PATH = path.join(REPO_ROOT, 'agents', 'gsd-research-synthesizer.md');
|
||||
|
||||
function readSynthesizerPrompt() {
|
||||
return fs.readFileSync(SYNTHESIZER_PATH, 'utf8');
|
||||
}
|
||||
|
||||
describe('bug #222: research synthesizer must write SUMMARY.md via Write tool', () => {
|
||||
test('step 6 has explicit hard-rule block forbidding return-message content fallback', () => {
|
||||
const prompt = readSynthesizerPrompt();
|
||||
|
||||
assert.match(
|
||||
prompt,
|
||||
/canonical output of this agent[\s\S]*existing on disk after you return/i,
|
||||
'Step 6 must define SUMMARY.md-on-disk as canonical output.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Hard rules \(must follow\):/i,
|
||||
'Step 6 must contain explicit hard rules block.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Use the `Write` tool[\s\S]*there are no restrictions/i,
|
||||
'Rule 1 must force Write tool usage and reject hallucinated restrictions.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Do NOT return the SUMMARY\.md content in your response/i,
|
||||
'Rule 2 must forbid returning SUMMARY content in the response body.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Do NOT ask permission to write/i,
|
||||
'Rule 3 must forbid write-permission asks for this agent.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Do NOT use `Bash\(cat << 'EOF'\)` or heredoc/i,
|
||||
'Rule 4 must forbid heredoc/Bash file creation fallback.'
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/If the Write tool errors[\s\S]*Do not silently fall back to returning content/i,
|
||||
'Rule 5 must force explicit error reporting for Write failures.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #222 recurrence: orchestrator self-heals when synthesizer returns SUMMARY.md inline', () => {
|
||||
const WORKFLOWS = [
|
||||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'new-project.md'),
|
||||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'new-milestone.md'),
|
||||
];
|
||||
|
||||
for (const wf of WORKFLOWS) {
|
||||
const name = path.basename(wf);
|
||||
|
||||
test(`${name} has the #222 synthesizer SUMMARY.md self-heal guard`, () => {
|
||||
const text = fs.readFileSync(wf, 'utf8');
|
||||
|
||||
// Marker tying the guard to the issue
|
||||
assert.match(text, /#222[^\n]*self-heal|self-heal[^\n]*#222/i,
|
||||
`${name} must contain a #222-tagged self-heal guard after the synthesizer returns.`);
|
||||
|
||||
// Verifies the file exists AND is substantive/non-empty
|
||||
assert.match(text, /SUMMARY\.md[\s\S]{0,120}?(non-empty|substantive|exists)/i,
|
||||
`${name} must verify .planning/research/SUMMARY.md exists AND is substantive — non-empty.`);
|
||||
|
||||
// Truncation/validator guard: references the continuation sentinel OR the verify-summary CLI
|
||||
assert.match(text, /gsd:write-continue|verify-summary/i,
|
||||
`${name} must guard against truncated/invalid SUMMARY.md (sentinel or verify-summary).`);
|
||||
|
||||
// Self-heal must commit ALL research artifacts, not just SUMMARY.md
|
||||
assert.match(text, /--files \.planning\/research\//,
|
||||
`${name} self-heal must commit ALL research artifacts, not just SUMMARY.md.`);
|
||||
|
||||
// Persists inline-returned document via Write rather than trusting the agent
|
||||
assert.match(text, /returned[\s\S]{0,200}?document[\s\S]{0,200}?Write tool/i,
|
||||
`${name} must instruct the orchestrator to persist inline-returned document with the Write tool.`);
|
||||
|
||||
// Must not proceed to roadmapper against a missing or incomplete SUMMARY.md
|
||||
assert.match(text, /gsd-roadmapper[\s\S]{0,200}?(missing|incomplete|do NOT)/i,
|
||||
`${name} must block spawning gsd-roadmapper when SUMMARY.md is missing or incomplete.`);
|
||||
|
||||
// Must name the FULL SUMMARY template markers so the orchestrator persists the real
|
||||
// document, not the brief structured return (resolves the HIGH finding).
|
||||
assert.match(text, /# Project Research Summary[\s\S]{0,260}?## Sources/,
|
||||
`${name}: self-heal must name the full SUMMARY.md template markers (# Project Research Summary … ## Sources).`);
|
||||
// Must reference the brief confirmation marker it must NOT mistake for file content.
|
||||
assert.match(text, /## SYNTHESIS COMPLETE/,
|
||||
`${name}: self-heal must distinguish the brief ## SYNTHESIS COMPLETE confirmation from the real document.`);
|
||||
});
|
||||
|
||||
test(`${name} runs the #222 self-heal AFTER the synthesizer and BEFORE gsd-roadmapper`, () => {
|
||||
const text = fs.readFileSync(wf, 'utf8');
|
||||
const synthIdx = text.indexOf('subagent_type="gsd-research-synthesizer"');
|
||||
const healIdx = text.indexOf('Synthesizer output self-heal (#222)');
|
||||
const roadIdx = text.indexOf('subagent_type="gsd-roadmapper"');
|
||||
assert.ok(synthIdx >= 0, `${name}: synthesizer dispatch not found`);
|
||||
assert.ok(healIdx >= 0, `${name}: #222 self-heal block not found`);
|
||||
assert.ok(roadIdx >= 0, `${name}: gsd-roadmapper dispatch not found`);
|
||||
assert.ok(healIdx > synthIdx, `${name}: self-heal must come AFTER the synthesizer dispatch`);
|
||||
assert.ok(roadIdx > healIdx, `${name}: self-heal must come BEFORE the gsd-roadmapper dispatch`);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,3 +150,208 @@ describe('review workflow source-grounding requirement in build_prompt (#1318)',
|
||||
assert.match(extracted, /cite `path\/to\/file:line`/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-687-agy-timeout.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-687-agy-timeout (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #687)
|
||||
// review.md is a workflow file whose deployed text IS the runtime contract; the
|
||||
// agy -p invocation cannot be run in CI, so we assert on its content (issue #687).
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const reviewPath = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'review.md');
|
||||
const read = () => fs.readFileSync(reviewPath, 'utf-8');
|
||||
|
||||
describe('bug #687: agy print mode must be bounded via its native --print-timeout', () => {
|
||||
test('invokes agy with its own --print-timeout flag (not an external killer)', () => {
|
||||
assert.match(read(), /agy --print-timeout \d+s? -p "\$\(cat/,
|
||||
'review.md must cap agy through `agy --print-timeout <N> -p …` (the tool\'s own mechanism)');
|
||||
});
|
||||
|
||||
test('discards partial output on non-zero exit so the fallback fires', () => {
|
||||
const c = read();
|
||||
assert.match(c, /_AGY_RC.*-ne 0/, 'review.md must check the agy exit code');
|
||||
assert.match(c, /: > \/tmp\/gsd-review-antigravity-/,
|
||||
'review.md must truncate the output file when agy timed out / failed');
|
||||
});
|
||||
|
||||
test('agy is bounded only by its own --print-timeout, not an external process killer', () => {
|
||||
const c = read();
|
||||
// Print-mode reviewers invoke the tool directly; agy must self-terminate via
|
||||
// --print-timeout, never via an external SIGKILL/timeout binary wrapped around it.
|
||||
assert.doesNotMatch(c, /-s KILL/, 'must not SIGKILL agy from the outside');
|
||||
// Any external timeout binary wrapping agy — `timeout 300s agy …`,
|
||||
// `gtimeout 300 agy …`, `timeout -s KILL 300 agy …`. The lookbehind keeps
|
||||
// agy's own `--print-timeout` flag from tripping it.
|
||||
assert.doesNotMatch(c, /(?<!print-)\bg?timeout[ \t]+[^\n]*\bagy\b/,
|
||||
'must not wrap agy in an external timeout binary — use its --print-timeout flag');
|
||||
assert.doesNotMatch(c, /kill -9 "\$_AGY/, 'must not use a kill -9 watchdog on agy');
|
||||
});
|
||||
|
||||
test('no unguarded bare "agy -p" invocation remains at line start', () => {
|
||||
// A bare `agy -p "$(cat …)"` with no cap was the original hang.
|
||||
assert.doesNotMatch(read(), /^agy -p "\$\(cat/m,
|
||||
'review.md must not invoke agy -p without --print-timeout');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/enh-773-codex-exec-automation-flags.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:enh-773-codex-exec-automation-flags (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (see #773)
|
||||
// Workflow markdown is runtime contract; these assertions verify that
|
||||
// automated codex exec invocations carry the correct automation flags.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
describe('enh-773: automated codex exec invocations include --ephemeral and --dangerously-bypass-hook-trust', () => {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(process.cwd(), 'gsd-core', 'workflows', 'review.md'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Extract codex exec INVOCATION lines from code fences. The #1115 capability
|
||||
// probe (`codex exec --help | grep …`) is not an automation invocation, so it
|
||||
// is excluded from the per-invocation flag assertions below.
|
||||
const codexExecLines = workflow
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes('codex exec') && !line.includes('codex exec --help'));
|
||||
|
||||
test('review.md contains at least one codex exec invocation', () => {
|
||||
assert.ok(
|
||||
codexExecLines.length > 0,
|
||||
'review.md must contain at least one codex exec invocation'
|
||||
);
|
||||
});
|
||||
|
||||
test('every codex exec invocation includes --ephemeral', () => {
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
line.includes('--ephemeral'),
|
||||
`codex exec invocation is missing --ephemeral:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('#1115: the hook-trust bypass is capability-gated, not passed unconditionally', () => {
|
||||
// --dangerously-bypass-hook-trust only exists on codex-cli >= 0.137.0. It must
|
||||
// be probed (`codex exec --help | grep`) and applied via $CODEX_BYPASS_FLAG so
|
||||
// older installs do not fail with "unexpected argument" (a silent empty review).
|
||||
assert.ok(
|
||||
/codex exec --help[^\r\n]*grep[^\r\n]*--dangerously-bypass-hook-trust/.test(workflow),
|
||||
'review.md must capability-probe --dangerously-bypass-hook-trust via `codex exec --help | grep`'
|
||||
);
|
||||
assert.ok(
|
||||
workflow.includes('CODEX_BYPASS_FLAG="--dangerously-bypass-hook-trust"'),
|
||||
'the probe must set CODEX_BYPASS_FLAG to the flag when the CLI supports it'
|
||||
);
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
line.includes('$CODEX_BYPASS_FLAG'),
|
||||
`codex exec invocation must apply the capability-gated $CODEX_BYPASS_FLAG, not an unconditional flag:\n ${line.trim()}`
|
||||
);
|
||||
// …and must NOT also pass the literal flag (that would reintroduce #1115).
|
||||
assert.ok(
|
||||
!line.includes('--dangerously-bypass-hook-trust'),
|
||||
`codex exec invocation must not pass the literal --dangerously-bypass-hook-trust (use the gated $CODEX_BYPASS_FLAG):\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('#1115: codex review failures are surfaced, not silently swallowed', () => {
|
||||
// stderr must be captured (not discarded to /dev/null) and an empty output
|
||||
// must be replaced with a diagnostic, so a broken reviewer is reported.
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
!line.includes('2>/dev/null'),
|
||||
`codex exec must not discard stderr to /dev/null:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
/\[ ! -s \/tmp\/gsd-review-codex-\{phase\}\.md \]/.test(workflow),
|
||||
'review.md must guard against an empty codex review output and surface the failure'
|
||||
);
|
||||
});
|
||||
|
||||
test('--ephemeral appears before the prompt argument (flag ordering)', () => {
|
||||
for (const line of codexExecLines) {
|
||||
const ephemeralPos = line.indexOf('--ephemeral');
|
||||
const promptPos = line.indexOf(' - ');
|
||||
if (promptPos === -1) continue; // no stdin prompt arg on this line
|
||||
assert.ok(
|
||||
ephemeralPos < promptPos,
|
||||
`--ephemeral must appear before the stdin prompt argument:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('--skip-git-repo-check is preserved alongside automation flags', () => {
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
line.includes('--skip-git-repo-check'),
|
||||
`codex exec invocation lost --skip-git-repo-check:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1698 regression: codex review is captured via --output-last-message, not stdout', () => {
|
||||
// WHY: on some platforms (Windows) `codex exec` writes process-teardown output
|
||||
// to stdout *after* the final agent message. A `> FILE` stdout redirect appends
|
||||
// that noise to a non-empty file, so it slips past the `[ ! -s … ]` empty-output
|
||||
// guard and downstream consumers (severity extraction, the
|
||||
// plan-review-convergence "concerns resolved?" gate) parse a polluted review.
|
||||
// `-o/--output-last-message <FILE>` writes only the final message — robust on
|
||||
// every platform — so each codex invocation must capture via -o and discard stdout.
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(process.cwd(), 'gsd-core', 'workflows', 'review.md'),
|
||||
'utf8'
|
||||
);
|
||||
const codexExecLines = workflow
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes('codex exec') && !line.includes('codex exec --help'));
|
||||
|
||||
test('every codex exec invocation captures the review via -o <FILE>', () => {
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
/\s-o\s+\/tmp\/gsd-review-codex-\{phase\}\.md\b/.test(line),
|
||||
`codex exec invocation must capture the review via -o /tmp/gsd-review-codex-{phase}.md:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('no codex exec invocation redirects stdout into the review file', () => {
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
!/>\s*\/tmp\/gsd-review-codex-\{phase\}\.md\b/.test(line),
|
||||
`codex exec must not redirect stdout into the review file (teardown noise pollutes it); use -o + >/dev/null:\n ${line.trim()}`
|
||||
);
|
||||
assert.ok(
|
||||
/>\s*\/dev\/null\b/.test(line),
|
||||
`codex exec must discard stdout to /dev/null so teardown output is not captured:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -706,3 +706,71 @@ describe('SECURE: security config variables resolved before use (#1625)', () =>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3120-secure-phase-empty-register.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3120-secure-phase-empty-register (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
// allow-test-rule: reads product workflow markdown (secure-phase.md) to verify structural guard contract — not a source-grep test (see #3120)
|
||||
|
||||
// Regression guard for bug #3120.
|
||||
//
|
||||
// secure-phase.md Step 3 short-circuited to Step 6 (write SECURITY.md)
|
||||
// whenever threats_open: 0, without distinguishing between:
|
||||
// Case A: All plan-time threat_model threats are CLOSED (legitimate skip)
|
||||
// Case B: No threat_model blocks were written at plan time (legacy phases)
|
||||
// → rubber-stamps a clean SECURITY.md with zero audit performed
|
||||
//
|
||||
// Fix: Step 2c tracks `register_authored_at_plan_time` (true iff ≥1 PLAN
|
||||
// file contained a parseable <threat_model> block). Step 3 now requires BOTH
|
||||
// threats_open: 0 AND register_authored_at_plan_time to skip. If only
|
||||
// threats_open: 0 and NOT register_authored_at_plan_time, Step 5 runs in
|
||||
// retroactive-STRIDE mode.
|
||||
|
||||
const { test, describe } = 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 src = fs.readFileSync(
|
||||
path.join(ROOT, 'gsd-core', 'workflows', 'secure-phase.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('bug #3120: secure-phase short-circuit guards', () => {
|
||||
test('Step 2c tracks register_authored_at_plan_time', () => {
|
||||
assert.ok(
|
||||
src.includes('register_authored_at_plan_time'),
|
||||
'secure-phase.md does not track register_authored_at_plan_time in Step 2c',
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 3 short-circuit requires both conditions', () => {
|
||||
assert.ok(
|
||||
src.includes('threats_open: 0 AND register_authored_at_plan_time'),
|
||||
'Step 3 short-circuit does not gate on both threats_open:0 AND register_authored_at_plan_time',
|
||||
);
|
||||
});
|
||||
|
||||
test('retroactive-STRIDE mode is documented for legacy phases', () => {
|
||||
assert.ok(
|
||||
src.includes('retroactive') || src.includes('Retroactive'),
|
||||
'secure-phase.md does not document retroactive-STRIDE mode for legacy phases (no <threat_model> blocks)',
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 5 auditor constraint varies by mode', () => {
|
||||
assert.ok(
|
||||
(src.includes('Verify mitigations') || src.includes('verify mitigations')) &&
|
||||
(src.includes('Retroactive') || src.includes('retroactive')),
|
||||
'Step 5 does not distinguish planned vs retroactive-STRIDE auditor constraint',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,3 +63,178 @@ describe('seed scanning in new-milestone workflow (#2169)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3236-capture-seed-one-shot.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3236-capture-seed-one-shot (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product — workflow and command .md files (see #3236)
|
||||
// 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 #3236.
|
||||
//
|
||||
// The `plant-seed.md` workflow gained a mandatory Trigger / Why / Scope
|
||||
// questionnaire (gather_context step) that blocks before the seed file is
|
||||
// written. Users capturing a stream of ideas lose flow because the AI must
|
||||
// receive three answers before a single write happens.
|
||||
//
|
||||
// Fix: the seed file must be written FIRST (one-shot), with sensible defaults
|
||||
// for Trigger / Why / Scope. The enrichment questions must be optional and must
|
||||
// come AFTER the file is written, not before.
|
||||
//
|
||||
// Behavioral contract tested here:
|
||||
// 1. The `write-seed` step exists and comes BEFORE any AskUserQuestion for
|
||||
// Trigger / Why / Scope enrichment.
|
||||
// 2. The workflow provides sensible defaults for trigger_when and scope when
|
||||
// the user supplies only the idea summary.
|
||||
// 3. The AskUserQuestion calls for Trigger / Why / Scope still exist (optional
|
||||
// enrichment path preserved) but are gated after the file is written.
|
||||
|
||||
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 PLANT_SEED = path.join(ROOT, 'gsd-core', 'workflows', 'plant-seed.md');
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function readPlantSeed() {
|
||||
try {
|
||||
return fs.readFileSync(PLANT_SEED, 'utf8');
|
||||
} catch (err) {
|
||||
throw new Error('gsd-core/workflows/plant-seed.md not found: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract step names in document order from workflow XML.
|
||||
*/
|
||||
function extractStepNames(src) {
|
||||
const names = [];
|
||||
const re = /<step name="([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null) {
|
||||
names.push(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return byte offset of the step opening tag, or -1 if absent.
|
||||
*/
|
||||
function stepOffset(src, stepName) {
|
||||
return src.indexOf('<step name="' + stepName + '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return byte offset of the first AskUserQuestion with the given header label.
|
||||
*/
|
||||
function askQuestionOffset(src, header) {
|
||||
return src.indexOf('header: "' + header + '"');
|
||||
}
|
||||
|
||||
// ── #3236: plant-seed one-shot contract ──────────────────────────────────────
|
||||
|
||||
describe('#3236: plant-seed.md one-shot capture contract', () => {
|
||||
test('plant-seed.md exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(PLANT_SEED),
|
||||
'gsd-core/workflows/plant-seed.md does not exist',
|
||||
);
|
||||
});
|
||||
|
||||
test('write-seed step exists (hyphenated name per CONTEXT.md rule)', () => {
|
||||
const src = readPlantSeed();
|
||||
const steps = extractStepNames(src);
|
||||
assert.ok(
|
||||
steps.includes('write-seed'),
|
||||
'plant-seed.md must contain a <step name="write-seed"> (hyphens, not underscores); found: ' + JSON.stringify(steps),
|
||||
);
|
||||
});
|
||||
|
||||
test('write-seed step appears before Trigger AskUserQuestion', () => {
|
||||
const src = readPlantSeed();
|
||||
const writeOff = stepOffset(src, 'write-seed');
|
||||
assert.ok(writeOff !== -1, 'write-seed step must exist');
|
||||
const triggerOff = askQuestionOffset(src, 'Trigger');
|
||||
if (triggerOff === -1) return; // fully optional — no question at all is fine
|
||||
assert.ok(
|
||||
writeOff < triggerOff,
|
||||
'write-seed (offset ' + writeOff + ') must precede Trigger question (offset ' + triggerOff + ')',
|
||||
);
|
||||
});
|
||||
|
||||
test('write-seed step appears before Why AskUserQuestion', () => {
|
||||
const src = readPlantSeed();
|
||||
const writeOff = stepOffset(src, 'write-seed');
|
||||
assert.ok(writeOff !== -1, 'write-seed step must exist');
|
||||
const whyOff = askQuestionOffset(src, 'Why');
|
||||
if (whyOff === -1) return;
|
||||
assert.ok(
|
||||
writeOff < whyOff,
|
||||
'write-seed (offset ' + writeOff + ') must precede Why question (offset ' + whyOff + ')',
|
||||
);
|
||||
});
|
||||
|
||||
test('write-seed step appears before Scope AskUserQuestion', () => {
|
||||
const src = readPlantSeed();
|
||||
const writeOff = stepOffset(src, 'write-seed');
|
||||
assert.ok(writeOff !== -1, 'write-seed step must exist');
|
||||
const scopeOff = askQuestionOffset(src, 'Scope');
|
||||
if (scopeOff === -1) return;
|
||||
assert.ok(
|
||||
writeOff < scopeOff,
|
||||
'write-seed (offset ' + writeOff + ') must precede Scope question (offset ' + scopeOff + ')',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow documents a default value for trigger_when', () => {
|
||||
const src = readPlantSeed();
|
||||
assert.ok(
|
||||
/trigger_when.*default|default.*trigger|when relevant|when scope matches|unspecified/i.test(src),
|
||||
'plant-seed.md must document a default for trigger_when when the user provides no Trigger',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow documents a default value for scope', () => {
|
||||
const src = readPlantSeed();
|
||||
assert.ok(
|
||||
/scope.*default|default.*scope|scope.*unknown|unknown|scope.*unspecified|unspecified/i.test(src),
|
||||
'plant-seed.md must document a default for scope when the user provides no Scope',
|
||||
);
|
||||
});
|
||||
|
||||
test('write-seed step precedes enrich/gather step in step order', () => {
|
||||
const src = readPlantSeed();
|
||||
const steps = extractStepNames(src);
|
||||
const writeIdx = steps.findIndex((s) => s === 'write-seed' || s === 'write_seed');
|
||||
const gatherIdx = steps.findIndex(
|
||||
(s) => s === 'gather-context' || s === 'gather_context' || s === 'enrich-seed' || s === 'enrich_seed',
|
||||
);
|
||||
if (gatherIdx === -1 || writeIdx === -1) return; // no gather step — one-shot only path, fine
|
||||
assert.ok(
|
||||
writeIdx < gatherIdx,
|
||||
'write-seed (step index ' + writeIdx + ') must appear before gather/enrich (index ' + gatherIdx + ') — capture first, enrich later',
|
||||
);
|
||||
});
|
||||
|
||||
test('optional enrichment path is preserved', () => {
|
||||
const src = readPlantSeed();
|
||||
const hasEnrichStep = /enrich|gather.context|gather_context/i.test(src);
|
||||
const hasTriggerQuestion = src.includes('header: "Trigger"');
|
||||
assert.ok(
|
||||
hasEnrichStep || hasTriggerQuestion,
|
||||
'plant-seed.md must preserve an optional enrichment path (enrich step or Trigger/Why/Scope AskUserQuestion)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -531,3 +531,36 @@ describe('detect-custom-files — skills/ directory missing from GSD_MANAGED_DIR
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3050-update-backup-eacces-nonfatal.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3050-update-backup-eacces-nonfatal (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
describe('bug #3050: update backup skips unreadable files non-fatally', () => {
|
||||
test('update workflow backup loop wraps copyFileSync in try/catch and logs non-fatal skip', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const hasTryCatch = /try\s*\{[\s\S]*copyFileSync\([\s\S]*\}[\s\S]*catch\s*\(err\)/.test(content);
|
||||
assert.ok(hasTryCatch, 'backup copy loop must catch per-file copy errors');
|
||||
|
||||
const hasNonFatalSkipMessage = /Skipped \(non-fatal\):/.test(content);
|
||||
assert.ok(
|
||||
hasNonFatalSkipMessage,
|
||||
'workflow must log a non-fatal skip message for unreadable custom files',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,3 +51,156 @@ describe('verify-work — MVP mode UAT framing', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2504-uat-foundation-phases.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2504-uat-foundation-phases (consolidation epic #1969 B4 #1973)", () => {
|
||||
/**
|
||||
* Regression test for bug #2504
|
||||
*
|
||||
* When UAT testing is mandated and a phase has no user-facing elements
|
||||
* (e.g., code foundations, database schema, internal APIs), the agent
|
||||
* invented artificial UAT steps — things like "manually run git commits",
|
||||
* "manually invoke methods", "manually check database state" — and left
|
||||
* work half-finished specifically to create things for a human to do.
|
||||
*
|
||||
* Fix: The verify-phase workflow's identify_human_verification step must
|
||||
* explicitly handle phases with no user-facing elements by auto-passing UAT
|
||||
* with a logged rationale instead of inventing manual steps.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const VERIFY_PHASE_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md'
|
||||
);
|
||||
|
||||
/**
|
||||
* Extract a named section from a markdown/workflow document.
|
||||
* Returns the text from `heading` up to (but not including) the next `## ` heading,
|
||||
* or to end-of-file if no subsequent heading exists.
|
||||
*/
|
||||
function extractSection(content, heading) {
|
||||
const start = content.indexOf(heading);
|
||||
if (start === -1) return '';
|
||||
const nextHeading = content.indexOf('\n## ', start + 1);
|
||||
return nextHeading === -1 ? content.slice(start) : content.slice(start, nextHeading);
|
||||
}
|
||||
|
||||
describe('bug #2504: UAT auto-pass for foundation/infrastructure phases', () => {
|
||||
test('verify-phase workflow file exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(VERIFY_PHASE_PATH),
|
||||
'gsd-core/workflows/verify-phase.md should exist'
|
||||
);
|
||||
});
|
||||
|
||||
test('identify_human_verification step handles phases with no user-facing elements', () => {
|
||||
const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8');
|
||||
const section = extractSection(content, 'identify_human_verification');
|
||||
// The step must explicitly call out the infrastructure/foundation case
|
||||
const hasInfrastructureHandling =
|
||||
section.includes('infrastructure') ||
|
||||
section.includes('foundation') ||
|
||||
section.includes('no user-facing') ||
|
||||
section.includes('no user facing') ||
|
||||
section.includes('internal API') ||
|
||||
section.includes('internal APIs') ||
|
||||
section.includes('database schema') ||
|
||||
section.includes('code foundation');
|
||||
|
||||
assert.ok(
|
||||
hasInfrastructureHandling,
|
||||
'verify-phase.md identify_human_verification step must explicitly handle ' +
|
||||
'infrastructure/foundation phases that have no user-facing elements. Without ' +
|
||||
'this, agents invent artificial manual steps to satisfy UAT requirements ' +
|
||||
'(root cause of #2504).'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow includes auto-pass or skip UAT language for non-user-facing phases', () => {
|
||||
const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8');
|
||||
const section = extractSection(content, 'identify_human_verification');
|
||||
const hasAutoPass =
|
||||
section.includes('auto-pass') ||
|
||||
section.includes('auto pass') ||
|
||||
section.includes('automatically pass') ||
|
||||
section.includes('skip UAT') ||
|
||||
section.includes('skip the UAT') ||
|
||||
section.includes('UAT does not apply') ||
|
||||
section.includes('UAT not applicable') ||
|
||||
section.includes('no UAT required');
|
||||
|
||||
assert.ok(
|
||||
hasAutoPass,
|
||||
'verify-phase.md identify_human_verification step must contain language about ' +
|
||||
'auto-passing or skipping UAT for phases without user-facing elements. Agents ' +
|
||||
'must not invent manual steps when there is nothing user-facing to test ' +
|
||||
'(root cause of #2504).'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow prohibits inventing artificial manual steps for infrastructure phases', () => {
|
||||
const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8');
|
||||
const section = extractSection(content, 'identify_human_verification');
|
||||
// The workflow must tell the agent NOT to invent steps when there's nothing to test.
|
||||
// Look for explicit prohibition or the inverse: "do not invent" or "must not create"
|
||||
// or equivalent framing like "only require human testing when..."
|
||||
const hasProhibition =
|
||||
section.includes('do not invent') ||
|
||||
section.includes('must not invent') ||
|
||||
section.includes('never invent') ||
|
||||
section.includes('Do not invent') ||
|
||||
section.includes('Must not invent') ||
|
||||
section.includes('Never invent') ||
|
||||
section.includes('only require human') ||
|
||||
section.includes('only add human') ||
|
||||
(section.includes('only flag') && section.includes('user-facing')) ||
|
||||
// Or via "N/A" framing
|
||||
(section.includes('N/A') && (
|
||||
section.includes('infrastructure') ||
|
||||
section.includes('foundation') ||
|
||||
section.includes('no user-facing')
|
||||
));
|
||||
|
||||
assert.ok(
|
||||
hasProhibition,
|
||||
'verify-phase.md identify_human_verification step must explicitly prohibit ' +
|
||||
'inventing artificial manual UAT steps for infrastructure phases. The current ' +
|
||||
'wording causes agents to create fake "manually run git commits" steps to ' +
|
||||
'satisfy UAT mandates (root cause of #2504).'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow includes a concept of N/A or not-applicable UAT state', () => {
|
||||
const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8');
|
||||
const section = extractSection(content, 'identify_human_verification');
|
||||
const hasNaState =
|
||||
section.includes('N/A') ||
|
||||
section.includes('not applicable') ||
|
||||
section.includes('not_applicable') ||
|
||||
section.includes('no_uat') ||
|
||||
section.includes('uat_not_applicable') ||
|
||||
section.includes('infrastructure phase') ||
|
||||
section.includes('foundation phase');
|
||||
|
||||
assert.ok(
|
||||
hasNaState,
|
||||
'verify-phase.md identify_human_verification step must include some concept of ' +
|
||||
'a "not applicable" or N/A UAT state for phases with no user-facing elements. ' +
|
||||
'This prevents agents from blocking phase completion on invented manual steps ' +
|
||||
'(root cause of #2504).'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,3 +128,93 @@ describe('verify-work.md — auto-transition after UAT passes with 0 issues', ()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3381-verify-work-workstream.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3381-verify-work-workstream (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product — verify-work.md is a runtime workflow contract. (see #3381)
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
describe('bug #1716: resume_from_file routes to complete_session when no [pending] tests remain', () => {
|
||||
test('resume_from_file step contains guard clause for zero-pending (all-blocked) case', () => {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-work.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const stepStart = workflow.indexOf('<step name="resume_from_file">');
|
||||
assert.ok(stepStart !== -1, 'resume_from_file step must exist');
|
||||
|
||||
const stepEnd = workflow.indexOf('</step>', stepStart);
|
||||
const stepBody = workflow.slice(stepStart, stepEnd);
|
||||
|
||||
// Guard must appear immediately after the find-pending instruction.
|
||||
// Without it, all-blocked sessions (pending_count==0, blocked_count>0)
|
||||
// silently terminate and never reach complete_session (#1716).
|
||||
const findIdx = stepBody.indexOf("Find first test with `result: [pending]`.");
|
||||
const guardIdx = stepBody.indexOf("If no `[pending]` test found → go to `complete_session`.");
|
||||
|
||||
assert.ok(findIdx !== -1, 'find-pending instruction must be present');
|
||||
assert.ok(guardIdx !== -1, 'guard clause for zero-pending case must be present');
|
||||
assert.ok(guardIdx > findIdx, 'guard must appear after find-pending instruction');
|
||||
|
||||
const between = stepBody
|
||||
.slice(findIdx + "Find first test with `result: [pending]`.".length, guardIdx)
|
||||
.trim();
|
||||
assert.strictEqual(between, '', 'guard must be the next non-whitespace line after find-pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #3381: verify-work forwards workstream context', () => {
|
||||
test('workflow forwards ${GSD_WS} to workstream-sensitive SDK queries', () => {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-work.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.match(workflow, /GSD_WS=""/, 'verify-work must initialize GSD_WS');
|
||||
assert.match(
|
||||
workflow,
|
||||
/grep -qE -- '--ws\[\[:space:\]\]\+\[\^\[:space:\]\]\+'/,
|
||||
'verify-work must detect --ws in $ARGUMENTS',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/grep -oE -- '--ws\[\[:space:\]\]\+\[\^\[:space:\]\]\+'/,
|
||||
'verify-work must extract the --ws flag pair from $ARGUMENTS',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/PHASE_ARG=\$\(echo "\$ARGUMENTS" \| sed -E 's\/--ws\[\[:space:\]\]\+\[\^\[:space:\]\]\+\/\/g' \| xargs\)/,
|
||||
'verify-work must derive PHASE_ARG after removing --ws',
|
||||
);
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
assert.match(
|
||||
workflow,
|
||||
/gsd_run query init\.verify-work "\$\{PHASE_ARG\}" \$\{GSD_WS\}/,
|
||||
'init.verify-work must receive GSD_WS so phase_dir resolves in workstreams',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/gsd_run query phase\.mvp-mode "\$\{phase_number\}" \$\{GSD_WS\} --pick active/,
|
||||
'phase.mvp-mode must receive GSD_WS so roadmap mode is workstream-scoped',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/gsd_run query roadmap\.get-phase "\$\{phase_number\}" \$\{GSD_WS\} --pick goal/,
|
||||
'roadmap.get-phase must receive GSD_WS so goals are workstream-scoped',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -798,3 +798,477 @@ describe('bug #48: orchestrator fail-closed handling of verify-only halts', () =
|
||||
assert.ok(/(blocked|do NOT merge|not merge)/i.test(withoutDispatchNote), 'execute-phase.md must document an orchestrator-side rule that an executor FATAL/exit 42 marks the plan blocked and is not merged (#48)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2384-post-merge-deletion-audit.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2384-post-merge-deletion-audit (consolidation epic #1969 B4 #1973)", () => {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for #2384.
|
||||
*
|
||||
* During execute-phase, the orchestrator merges per-plan worktree branches into
|
||||
* main. The pre-merge deletion check (git diff --diff-filter=D HEAD...WT_BRANCH)
|
||||
* only catches files deleted on the worktree branch. A post-merge audit is also
|
||||
* required to catch deletions that made it into the merge commit (e.g., files
|
||||
* that were in the common ancestor but deleted by the merged worktree) and to
|
||||
* provide a revert safety net.
|
||||
*
|
||||
* After #3797: execute-phase.md delegates worktree cleanup to the SDK's
|
||||
* worktree.cleanup-wave command, which implements pre-merge deletion checks
|
||||
* (diff --diff-filter=D) internally via executeWorktreeWaveCleanupPlan.
|
||||
* The manual post-merge shell audit (MERGE_DEL_COUNT, git reset --hard) has
|
||||
* been removed from the workflow — it was part of the SDK-absence fallback.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const EXECUTE_PHASE = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'
|
||||
);
|
||||
|
||||
/**
|
||||
* Parse execute-phase.md into a structured contract object.
|
||||
* Returns typed boolean fields so tests can assert on structure
|
||||
* rather than raw text.
|
||||
*/
|
||||
function parseExecutePhaseContract(filePath) {
|
||||
const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/);
|
||||
return {
|
||||
// Does the workflow call the worktree.cleanup-wave SDK command?
|
||||
delegatesToCleanupWave: lines.some(l => l.includes('worktree.cleanup-wave')),
|
||||
// Does the cleanup-wave invocation use || exit 1 (fail-closed)?
|
||||
cleanupWaveFailClosed: lines.some(
|
||||
l => /gsd_run query worktree\.cleanup-wave.*\|\| exit 1/.test(l),
|
||||
),
|
||||
// Does the workflow export/reference WAVE_WORKTREE_MANIFEST for the SDK?
|
||||
passesWaveManifest: lines.some(l => l.includes('WAVE_WORKTREE_MANIFEST')),
|
||||
};
|
||||
}
|
||||
|
||||
describe('execute-phase.md — post-merge deletion audit (#2384)', () => {
|
||||
const contract = parseExecutePhaseContract(EXECUTE_PHASE);
|
||||
|
||||
test('execute-phase delegates to worktree.cleanup-wave (which handles deletion audit)', () => {
|
||||
// After #3797: worktree.cleanup-wave in worktree-safety.cjs performs
|
||||
// diff --diff-filter=D checks (blocks branches with deletions) before merge.
|
||||
// The workflow delegates to the SDK rather than duplicating the check inline.
|
||||
assert.ok(
|
||||
contract.delegatesToCleanupWave,
|
||||
'execute-phase.md must delegate to gsd_run query worktree.cleanup-wave (#2384/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase cleanup-wave uses || exit 1 (fail-closed for blocked deletions)', () => {
|
||||
// If worktree.cleanup-wave detects deletions, it exits 1 (blocked).
|
||||
// The || exit 1 in the workflow propagates that refusal rather than swallowing it.
|
||||
assert.ok(
|
||||
contract.cleanupWaveFailClosed,
|
||||
'execute-phase.md must use || exit 1 so deletion-blocked cleanups surface to the orchestrator',
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase still has pre-merge deletion check (via guard before worktree.cleanup-wave)', () => {
|
||||
// The primary deletion guard is now in worktree-safety.cjs (SDK).
|
||||
// The workflow must still enforce WAVE_WORKTREE_MANIFEST so the SDK
|
||||
// has the info it needs to validate branches.
|
||||
assert.ok(
|
||||
contract.passesWaveManifest,
|
||||
'execute-phase.md must pass WAVE_WORKTREE_MANIFEST to worktree.cleanup-wave',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2501-resurrection-detection.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2501-resurrection-detection (consolidation epic #1969 B4 #1973)", () => {
|
||||
/**
|
||||
* Tests for bug #2501: resurrection-detection block in execute-phase.md must
|
||||
* check git history before deleting new .planning/ files.
|
||||
*
|
||||
* Root cause: the original logic deleted ANY .planning/ file that was absent
|
||||
* from PRE_MERGE_FILES, which includes brand-new files (e.g. SUMMARY.md)
|
||||
* that the executor just created. A true "resurrection" is a file that was
|
||||
* previously tracked on main, deliberately deleted, and then re-introduced by
|
||||
* a worktree merge. Detecting that requires a git history check, not just a
|
||||
* pre-merge tree membership check.
|
||||
*
|
||||
* After #3797: execute-phase.md delegates worktree cleanup to the SDK's
|
||||
* worktree.cleanup-wave command. Resurrection detection is handled internally
|
||||
* by the SDK. The inline WAS_DELETED shell check has been removed from the
|
||||
* workflow — it was part of the SDK-absence fallback which is no longer needed
|
||||
* since the preflight block exits if neither local nor global SDK is available.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const EXECUTE_PHASE = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'
|
||||
);
|
||||
|
||||
describe('execute-phase.md — resurrection-detection guard (#2501)', () => {
|
||||
let content;
|
||||
|
||||
// Load once; each test reads from the cached string.
|
||||
test('file is readable', () => {
|
||||
content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
assert.ok(content.length > 0, 'execute-phase.md must not be empty');
|
||||
});
|
||||
|
||||
test('cleanup delegates to SDK (handles resurrection detection internally)', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
// After #3797: execute-phase.md delegates to worktree.cleanup-wave, which
|
||||
// handles pre-merge deletion checks internally. The SDK checks diff --diff-filter=D
|
||||
// before merging, blocking branches that contain file deletions (#2384/#2501).
|
||||
assert.ok(
|
||||
content.includes('worktree.cleanup-wave'),
|
||||
'execute-phase.md must delegate to worktree.cleanup-wave (#2501/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase does not use the buggy PRE_MERGE_FILES form', () => {
|
||||
if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
||||
// The buggy pattern from before #2501 — deletion conditioned on absence
|
||||
// from PRE_MERGE_FILES snapshot. Must remain absent.
|
||||
const hasBuggyGuard =
|
||||
content.includes('PRE_MERGE_FILES') &&
|
||||
/if\s*!\s*echo\s*"\$PRE_MERGE_FILES"\s*\|\s*grep\s+-qxF\s*"\$RESURRECTED"/.test(content);
|
||||
assert.ok(
|
||||
!hasBuggyGuard,
|
||||
'execute-phase.md must NOT delete files based on the PRE_MERGE_FILES snapshot grep (inverted guard bug #2501)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3195-quick-resurrection-guard.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3195-quick-resurrection-guard (consolidation epic #1969 B4 #1973)", () => {
|
||||
/**
|
||||
* Drift-guard for bug #3195: quick.md and execute-phase.md must both use
|
||||
* the same resurrection-detection approach so they stay in sync.
|
||||
*
|
||||
* After #3797: both workflows delegate worktree cleanup to the SDK's
|
||||
* worktree.cleanup-wave command, which implements resurrection detection
|
||||
* (diff --diff-filter=D history checks) internally. The inline WAS_DELETED
|
||||
* shell variable form has been removed from both workflows — it was part of
|
||||
* the SDK-absence fallback which is now dead code since preflight exits if
|
||||
* neither local nor global SDK is available.
|
||||
*
|
||||
* This test ensures both workflows continue to use the same cleanup
|
||||
* mechanism (SDK delegation), not one inline and one delegated.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const QUICK_MD = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'quick.md'
|
||||
);
|
||||
const EXECUTE_PHASE_MD = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'
|
||||
);
|
||||
|
||||
describe('resurrection guard drift check — quick.md vs execute-phase.md (#3195)', () => {
|
||||
let quickContent;
|
||||
let executePhaseContent;
|
||||
|
||||
test('both workflow files are readable', () => {
|
||||
quickContent = fs.readFileSync(QUICK_MD, 'utf-8');
|
||||
executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8');
|
||||
assert.ok(quickContent.length > 0, 'quick.md must not be empty');
|
||||
assert.ok(executePhaseContent.length > 0, 'execute-phase.md must not be empty');
|
||||
});
|
||||
|
||||
test('quick.md delegates resurrection detection to SDK (worktree.cleanup-wave)', () => {
|
||||
if (!quickContent) quickContent = fs.readFileSync(QUICK_MD, 'utf-8');
|
||||
// After #3797: quick.md delegates to worktree.cleanup-wave, which handles
|
||||
// resurrection detection (diff --diff-filter=D) internally. The inline
|
||||
// WAS_DELETED form has been removed — it was part of the SDK-absence fallback.
|
||||
assert.ok(
|
||||
quickContent.includes('worktree.cleanup-wave'),
|
||||
'quick.md must delegate to worktree.cleanup-wave for resurrection detection (#3195/#3797)'
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md delegates resurrection detection to SDK (worktree.cleanup-wave)', () => {
|
||||
if (!executePhaseContent) executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8');
|
||||
// After #3797: execute-phase.md delegates to worktree.cleanup-wave, which handles
|
||||
// resurrection detection (diff --diff-filter=D) internally.
|
||||
assert.ok(
|
||||
executePhaseContent.includes('worktree.cleanup-wave'),
|
||||
'execute-phase.md must delegate to worktree.cleanup-wave for resurrection detection (#3195/#3797)'
|
||||
);
|
||||
});
|
||||
|
||||
test('both workflows use the same cleanup mechanism (SDK delegation parity)', () => {
|
||||
if (!quickContent) quickContent = fs.readFileSync(QUICK_MD, 'utf-8');
|
||||
if (!executePhaseContent) executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8');
|
||||
const quickDelegates = quickContent.includes('worktree.cleanup-wave');
|
||||
const executeDelegates = executePhaseContent.includes('worktree.cleanup-wave');
|
||||
assert.strictEqual(
|
||||
quickDelegates,
|
||||
executeDelegates,
|
||||
'quick.md and execute-phase.md must both use the same cleanup mechanism (SDK delegation parity, #3195)'
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md does not use the buggy PRE_MERGE_FILES grep form', () => {
|
||||
if (!quickContent) quickContent = fs.readFileSync(QUICK_MD, 'utf-8');
|
||||
// The buggy pattern: deletion conditioned on absence from PRE_MERGE_FILES snapshot
|
||||
const hasBuggyGuard =
|
||||
quickContent.includes('PRE_MERGE_FILES') &&
|
||||
/if\s*!\s*echo\s*"\$PRE_MERGE_FILES"\s*\|\s*grep\s+-qxF\s*"\$RESURRECTED"/.test(quickContent);
|
||||
assert.ok(
|
||||
!hasBuggyGuard,
|
||||
'quick.md must NOT delete files based on the PRE_MERGE_FILES snapshot grep (inverted guard bug #3195)'
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md does not use the buggy PRE_MERGE_FILES grep form', () => {
|
||||
if (!executePhaseContent) executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8');
|
||||
const hasBuggyGuard =
|
||||
executePhaseContent.includes('PRE_MERGE_FILES') &&
|
||||
/if\s*!\s*echo\s*"\$PRE_MERGE_FILES"\s*\|\s*grep\s+-qxF\s*"\$RESURRECTED"/.test(executePhaseContent);
|
||||
assert.ok(
|
||||
!hasBuggyGuard,
|
||||
'execute-phase.md must NOT delete files based on the PRE_MERGE_FILES snapshot grep (inverted guard bug)'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3521-quick-cleanup-cwd-pin.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3521-quick-cleanup-cwd-pin (consolidation epic #1969 B4 #1973)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #3521)
|
||||
// quick.md is the shipped orchestration contract for /gsd-quick; this
|
||||
// regression test previously locked the CWD-safety guard in the manual shell
|
||||
// cleanup loop. After #3797, quick.md delegates cleanup entirely to the SDK's
|
||||
// worktree.cleanup-wave command, which encapsulates CWD-pinning, STATE.md/
|
||||
// ROADMAP.md backup/restore, and deletion guards internally.
|
||||
//
|
||||
// This test file now verifies the delegation contract: quick.md calls
|
||||
// worktree.cleanup-wave with || exit 1 (fail-closed), which enforces the
|
||||
// safety semantics that were previously implemented inline in the shell loop.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const QUICK_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
function readQuickMd() {
|
||||
return fs.readFileSync(QUICK_MD, 'utf8');
|
||||
}
|
||||
|
||||
describe('bug #3521 — quick.md post-merge cleanup CWD safety (via SDK delegation, #3797)', () => {
|
||||
|
||||
test('quick.md is readable', () => {
|
||||
const content = readQuickMd();
|
||||
assert.ok(content.length > 0, 'quick.md must not be empty');
|
||||
});
|
||||
|
||||
test('quick.md cleanup delegates CWD-safe worktree cleanup to SDK (worktree.cleanup-wave)', () => {
|
||||
const content = readQuickMd();
|
||||
// After #3797: quick.md delegates to gsd_run query worktree.cleanup-wave
|
||||
// which handles CWD pinning, STATE.md backup, deletion guards, and branch
|
||||
// cleanup internally. The manual shell loop has been removed.
|
||||
assert.ok(
|
||||
content.includes('worktree.cleanup-wave'),
|
||||
'quick.md must delegate cleanup to gsd_run query worktree.cleanup-wave (#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md cleanup-wave call uses || exit 1 to enforce fail-closed safety (#3521 contract)', () => {
|
||||
const content = readQuickMd();
|
||||
// The || exit 1 enforces fail-closed: SDK safety refusals (e.g. branch
|
||||
// drift detection from #3174) surface immediately rather than being swallowed.
|
||||
// This is the equivalent of the pre-#3797 `gsd_run query ... || exit 1` in the
|
||||
// `if command -v gsd-sdk` branch.
|
||||
assert.match(
|
||||
content,
|
||||
/gsd_run query worktree\.cleanup-wave.*\|\| exit 1/,
|
||||
'quick.md cleanup-wave must use || exit 1 — fail-closed for safety refusals (#3521/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md manifest guard still blocks broad cleanup when manifest is missing (#3384)', () => {
|
||||
const content = readQuickMd();
|
||||
// The manifest guard must still be present before the cleanup-wave call
|
||||
// to prevent broad worktree cleanup when the manifest file is absent.
|
||||
assert.ok(
|
||||
content.includes('QUICK_WORKTREE_MANIFEST') || content.includes('WAVE_WORKTREE_MANIFEST'),
|
||||
'quick.md must still guard cleanup behind QUICK_WORKTREE_MANIFEST (#3384)',
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('refusing broad worktree cleanup') || content.includes('missing QUICK_WORKTREE_MANIFEST'),
|
||||
'quick.md must emit a blocked message when the manifest is missing (#3384)',
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2838-summary-rescue-gitignored-planning.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2838-summary-rescue-gitignored-planning (consolidation epic #1969 B4 #1973)", () => {
|
||||
/**
|
||||
* Regression tests for #2838: SUMMARY rescue silently fails when .planning/
|
||||
* is gitignored.
|
||||
*
|
||||
* After #3797: execute-phase.md and quick.md delegate worktree cleanup to the
|
||||
* SDK's worktree.cleanup-wave command. The SDK's executeWorktreeWaveCleanupPlan
|
||||
* handles SUMMARY rescue internally using a filesystem-level find+cp approach
|
||||
* (bypassing gitignore) rather than the old git ls-files --exclude-standard
|
||||
* form that silently dropped gitignored files.
|
||||
*
|
||||
* The inline "Safety net" shell rescue block that was previously in both
|
||||
* workflow files has been removed — it was part of the SDK-absence fallback
|
||||
* which is now dead code since preflight exits if neither local nor global SDK
|
||||
* is available.
|
||||
*
|
||||
* This test file verifies that both workflows correctly delegate to the SDK
|
||||
* for SUMMARY rescue, and that neither workflow retains the broken inline form.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
const QUICK_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
/**
|
||||
* Parse a workflow markdown file into a structured contract object.
|
||||
* Returns typed boolean fields so tests assert on structure, not raw text.
|
||||
*/
|
||||
function parseWorkflowContract(filePath) {
|
||||
const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/);
|
||||
return {
|
||||
// Non-empty check
|
||||
nonEmpty: lines.length > 0 && lines.some(l => l.length > 0),
|
||||
// Does the workflow delegate SUMMARY rescue to worktree.cleanup-wave?
|
||||
delegatesToCleanupWave: lines.some(l => l.includes('worktree.cleanup-wave')),
|
||||
// Does the cleanup-wave invocation use || exit 1 (fail-closed)?
|
||||
cleanupWaveFailClosed: lines.some(
|
||||
l => /gsd_run query worktree\.cleanup-wave.*\|\| exit 1/.test(l),
|
||||
),
|
||||
// Does the workflow still contain the broken ls-files --exclude-standard rescue form?
|
||||
hasBrokenLsFilesForm: lines.some(
|
||||
l => l.includes('ls-files --modified --others --exclude-standard'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const executePhaseContract = parseWorkflowContract(EXECUTE_PHASE_PATH);
|
||||
const quickContract = parseWorkflowContract(QUICK_PATH);
|
||||
|
||||
describe('bug-2838: SUMMARY rescue delegates to SDK (worktree.cleanup-wave)', () => {
|
||||
|
||||
test('execute-phase.md is readable', () => {
|
||||
assert.ok(executePhaseContract.nonEmpty, 'execute-phase.md must not be empty');
|
||||
});
|
||||
|
||||
test('quick.md is readable', () => {
|
||||
assert.ok(quickContract.nonEmpty, 'quick.md must not be empty');
|
||||
});
|
||||
|
||||
test('execute-phase.md delegates SUMMARY rescue to SDK (worktree.cleanup-wave)', () => {
|
||||
// After #3797: worktree.cleanup-wave handles SUMMARY rescue via find+cp
|
||||
// (bypasses gitignore, fixing the #2838 bug). The workflow delegates to the
|
||||
// SDK rather than implementing rescue inline.
|
||||
assert.ok(
|
||||
executePhaseContract.delegatesToCleanupWave,
|
||||
'execute-phase.md must delegate to worktree.cleanup-wave for SUMMARY rescue (#2838/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md delegates SUMMARY rescue to SDK (worktree.cleanup-wave)', () => {
|
||||
// After #3797: worktree.cleanup-wave handles SUMMARY rescue via find+cp
|
||||
// (bypasses gitignore, fixing the #2838 bug).
|
||||
assert.ok(
|
||||
quickContract.delegatesToCleanupWave,
|
||||
'quick.md must delegate to worktree.cleanup-wave for SUMMARY rescue (#2838/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md does not retain broken git ls-files --exclude-standard rescue form (#2838)', () => {
|
||||
// The broken form used --exclude-standard which silently filtered out
|
||||
// gitignored .planning/ files — the root cause of #2838.
|
||||
assert.ok(
|
||||
!executePhaseContract.hasBrokenLsFilesForm,
|
||||
'execute-phase.md must not use ls-files --exclude-standard for SUMMARY rescue (broken for gitignored .planning/)',
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md does not retain broken git ls-files --exclude-standard rescue form (#2838)', () => {
|
||||
assert.ok(
|
||||
!quickContract.hasBrokenLsFilesForm,
|
||||
'quick.md must not use ls-files --exclude-standard for SUMMARY rescue (broken for gitignored .planning/)',
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md cleanup-wave uses || exit 1 (fail-closed so rescue errors surface)', () => {
|
||||
// If the SDK's rescue fails (e.g. filesystem error), || exit 1 surfaces
|
||||
// the failure to the orchestrator rather than silently continuing and
|
||||
// losing the SUMMARY.
|
||||
assert.ok(
|
||||
executePhaseContract.cleanupWaveFailClosed,
|
||||
'execute-phase.md cleanup-wave must use || exit 1 so SUMMARY rescue failures surface (#2838/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md cleanup-wave uses || exit 1 (fail-closed so rescue errors surface)', () => {
|
||||
assert.ok(
|
||||
quickContract.cleanupWaveFailClosed,
|
||||
'quick.md cleanup-wave must use || exit 1 so SUMMARY rescue failures surface (#2838/#3797)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user