* test(#3676): add failing tests for quick-batch dispatch core Failing-first tests for Phase 4 of epic #3344 (ADR-1239 "Quick-batch binding"): quick-batch-dispatch.test.cjs / .property.test.cjs cover the new pure decision-logic module (arg validation, effective concurrency, deterministic merge order, spawn backpressure, verification/merge routing, cleanup-entry construction — design doc rows 3-15,24,26-28, 30-36,39; property rows 51-53). quick-batch-update-items.test.cjs covers the new updateBatchItems export on src/quick-batch.cts (rows 15,22-23, including the negative cycle-rejection case). quick-batch-command-router.test.cjs covers the new gsd-tools quick-batch CLI family (rows 46-47). These reference modules/ exports that do not exist yet. * feat(#3676): implement quick-batch dispatch core, updateBatchItems, and command router Phase 4 of epic #3344 (ADR-1239 "Quick-batch binding") CORE decision layer — CLI verbs and pure orchestration logic only; no workflow markdown, no Agent()/git-worktree I/O. - src/quick-batch-dispatch.cts (new): pure decision functions consumed by the (separate, follow-up) /gsd:quick-batch workflow markdown — parseQuickBatchArgs, computeEffectiveConcurrency, computeMergeOrder, computeSpawnPlan, routeVerificationOutcome, routeMergeOutcome, buildCleanupManifestEntry (the last parses caller-supplied plan text via the existing parsePlanDocument; no filesystem access). - src/quick-batch.cts: adds updateBatchItems, resolving the design doc's Open Question 1 as ONE additive export on this module instead of the second, independent BATCH.json writer the design doc originally proposed. Reuses the same withPlanningLock transaction shape, computeWaves, and platformWriteSync call resumeBatch/ completeQuickItem already use; fails closed without persisting on an unknown item, an unknown/self dependency, or an introduced cycle. - src/quick-batch-command-router.cts (new): gsd-tools quick-batch CLI family, wired into HOST_COMMAND_ROUTERS (gsd-core/bin/gsd-tools.cjs) as a first-party always-on command (like /gsd:quick), not the opt-in capability-registry path graphify uses. Verbs: create/update/resume/ complete (wrap quick-batch.cts) and effective-concurrency/ merge-eligible/spawn-plan/verification-routing/merge-routing/ cleanup-entry/parse-args (wrap quick-batch-dispatch.cts). Design doc rows covered: 3-15, 22-24, 26-28, 30-39, 46-47. Property rows 51-53. Rows covering workflow markdown / Agent() dispatch / `git worktree` behavior (16-21, 25, 29, 40-45, 48-50) remain for the follow-up markdown-authoring pass, per the phase brief's explicit scope boundary. * docs(#3676): register quick-batch-dispatch/command-router modules in bookkeeping surfaces New-.cts-module ripple for the two Phase 4 modules (epic #3344, ADR-1239 "Quick-batch binding"): .gitignore (compiled .cjs artifacts, ADR-457 build-at-publish), eslint.config.mjs (lint the .cts source, not the emitted .cjs), docs/INVENTORY.md + docs/INVENTORY-MANIFEST.json (via `node scripts/gen-inventory-manifest.cjs --write`, after `npm run build:lib`), and CONTEXT.md glossary entries for "Quick-Batch Dispatch Core Module" and "Quick-Batch Command Router Module", plus an update to the existing "Quick-Batch Core Primitives Module" entry documenting the new updateBatchItems export. * test(#3676): fold updateBatchItems tests into quick-batch.test.cjs (fix lint-test-file-count) scripts/lint-test-file-count.cjs buckets any quick-batch-*.test.cjs file under the quick-batch production module by longest-prefix match, and that module is already at its 2-file cap (quick-batch.test.cjs + quick-batch.property.test.cjs). The standalone tests/quick-batch-update-items.test.cjs added in the prior commit pushed it to 3 and failed `npm run lint:ci`. Fold its content into quick-batch.test.cjs (append-only — no existing test in that file is modified) and update the CONTEXT.md glossary reference to match. Surfaced while re-running `GITHUB_BASE_REF=next npm run lint:ci` after `npm ci` (this worktree previously had no local node_modules, which also made gen-scripts-cli-exit/gen-hooks-cli-exit/gen-exit-code-* unable to resolve typescript — resolved by npm ci, no code change needed there). `npm run lint:ci` and `npx tsc -p tsconfig.build.json --noEmit` are both green after this fix. * test(#3676): add failing tests for the quick-batch command/workflow markdown Failing-first tests for Phase 4's markdown-authoring pass (epic #3344, ADR-1239 "Quick-batch binding"): gsd-quick-batch-workflow.test.cjs covers commands/gsd/quick-batch.md's frontmatter/objective/process, gsd-core/workflows/quick-batch.md's byte-size boundary (row 49, ADR 1610 NEW_FILE_CAP) and step-fragment count, the isolation model (rows 20-22), the executor single-writer invariant (row 18), merge validation reusing the existing bounded primitive (row 25), the optional research/plan-checker/verification leaves (rows 16,17,19, 30,31), planning-failure blocking execution (row 29), the submodule guard (rows 36,44), and the new agents/gsd-planner.md quick-batch mode (rows 13-15). gsd-quick-batch-quick-regression.test.cjs covers row 48 (ordinary /gsd:quick stays byte-identical). Named `gsd-quick-batch-*` (not `quick-batch-*`) so lint-test-file-count's longest-prefix bucketing doesn't fold these markdown-only tests into the already-capped quick-batch/quick-batch-dispatch/ quick-batch-command-router production-module buckets from the CORE pass. These reference files that do not exist yet. * feat(#3676): author the quick-batch command, workflow, and planner mode Phase 4 markdown-authoring pass (epic #3344, ADR-1239 "Quick-batch binding") — the orchestration layer that calls into Pass 1's CLI verbs (src/quick-batch-command-router.cts). - commands/gsd/quick-batch.md (new): frontmatter/objective/process, delegates argument validation to `quick-batch parse-args` (parseQuickBatchArgs) rather than re-deriving the grammar. - gsd-core/workflows/quick-batch.md (new, 11843 bytes — under ADR 1610's 32768-byte NEW_FILE_CAP for a brand-new file) + 9 lazy-loaded step fragments under gsd-core/workflows/quick-batch/steps/: resume-mode, batch-init, research-phase (flag:--research), planner-wave (+ nested plan-checker-loop when --validate), worktree-dispatch, merge-wave, verification-wave (flag:--validate), completion. Covers design doc rows 3-45: capacity/isolation resolution (reusing dispatch-isolation-gate.md verbatim), per-DAG- layer planning with full-task-catalog prompts and always-required depends_on/files_modified frontmatter, serialized worktree create/ merge/cleanup via the existing worktree.cleanup-wave primitive, deterministic wave-order merging, verification routing (human_needed/gaps_found), the executor single-writer invariant, submodule fail-loud guard, and #1941 fork-base auto-degrade. - agents/gsd-planner.md: additive new `load_mode_context` bullet for `**Mode:** quick-batch`, pointing at the new gsd-core/references/planner-quick-batch.md reference (documents the always-required depends_on/files_modified contract, reusing the existing frontmatter grammar — no new keys). Existing modes byte-identical, only a new bullet added. - src/init.cts (+init-command-router.cts, +command-aliases.cts): cmdInitQuickBatch / `init.quick-batch` — model profiles, commit_docs, roadmap/planning existence checks, and the section_manifest field gating research-phase/verification-wave (reuses the existing flag:--research/flag:--validate WHEN_VOCABULARY atoms — no new atom needed). Rows 16-21, 25, 29, 36, 38, 39, 44, 46-50 covered structurally by the prior test(#3676) commit; rows 3-15, 22-24, 26-28, 30-35, 37, 40-43, 45 covered by construction (verb wiring, single-writer prompt constraints, crash-window resume via unmodified Phase 3 primitives). * docs(#3676): regenerate skills/inventory/section-manifest/install-tree; baseline the intentional word-splitting pattern npm run regen:derived output for the new command/workflow/reference (epic #3344, ADR-1239 "Quick-batch binding"): - skills/gsd-quick-batch/SKILL.md (generated from commands/gsd/quick-batch.md) - docs/INVENTORY.md rows for /gsd-quick-batch, quick-batch.md, planner-quick-batch.md, and the quick-batch-dispatch.cjs/ quick-batch-command-router.cjs CLI-module rows' now-live `/gsd-quick-batch` cross-reference (was "(separate, follow-up)") + docs/INVENTORY-MANIFEST.json (`node scripts/gen-inventory-manifest.cjs --write`) - gsd-core/workflows/section-manifest.json (`npm run gen:section-manifest`) — research-phase/verification-wave gsd:section entries for the new quick-batch workflow - tests/fixtures/install-tree/*.json (`npm run gen:install-tree`) — the new command/workflow/skill/reference files now ship to every runtime scripts/lint-workflow-shellcheck-baseline.json: 3 new entries for gsd-core/workflows/quick-batch.md's intentional flag-token/$ARGUMENTS word-splitting (SC2046/SC2086) — the same deliberate unquoted-optional- flag pattern gsd-core/workflows/quick.md already carries baselined (e.g. `$DISCUSS_PARAM $RESEARCH_PARAM` in quick.md's own Step 2); quoting would break the intended "omit this arg when the flag is false" splitting. * fix(#3676): close prompt-injection and argv/glob-injection gaps in quick-batch leaf dispatch Security review pass findings, both confirmed real: 1. HIGH — prompt injection, no boundaries. Every leaf-dispatch fragment interpolated the raw, attacker-influenced task ${description} (and the shared ${TASK_CATALOG_TABLE}, broadcasting every item's raw description into every planner's prompt in the layer) straight into Agent() prompt bodies with no boundary. Fixed by wrapping every such interpolation in a <security_context> + DATA_START/DATA_END boundary, matching the CONCRETE convention already implemented in this repo (agents/gsd-debug-session-manager.md, agents/gsd-debugger.md, gsd-core/workflows/debug.md) — commands/gsd/quick.md's own <security_notes> only asserts this convention in prose, so the debug-agent files are the real precedent followed here. Added a new <security_notes> block to commands/gsd/quick-batch.md (it had none) documenting both this fix and the one below. 2. MEDIUM — unquoted $ARGUMENTS -> argv/glob injection. gsd-core/workflows/quick-batch.md and commands/gsd/quick-batch.md both ran `gsd_run quick-batch parse-args --raw -- $ARGUMENTS` UNQUOTED, causing shell word-splitting and pathname expansion on raw task-list text before the parser ever saw it. Fixed at the source: added a `--text <string>` form to the `parse-args` verb (src/quick-batch-command-router.cts) that accepts the ENTIRE $ARGUMENTS as ONE quoted argv element and does the whitespace split itself, in Node — which is never glob-aware, unlike the shell. Both call sites now use `--text "$ARGUMENTS"`. The `-- <tokens>` form is kept for direct/test callers that already have a real argv array. The SC2086 baseline entry added for the original unquoted line is now stale (`node scripts/lint-workflow-shellcheck.cjs` no longer reports it) and has been removed; the two SC2046 entries for the UNRELATED, still-unquoted `$([ "$VALIDATE_MODE" = true ] && echo --validate)`-style conditional-flag splitting remain — that line only ever expands to one of a few known-safe literal strings (never raw user text), matching quick.md's own already-baselined convention exactly. Tests: quick-batch-command-router.test.cjs covers the new --text form (token splitting, glob-shaped text passing through literally unexpanded, whitespace-only input). gsd-quick-batch-workflow.test.cjs asserts the DATA_START/DATA_END boundary on every leaf prompt (research-phase/planner-wave/plan-checker-loop/verification-wave, including the shared task catalog) and the quoted --text call sites. * fix(#3676): strengthen test-depth gaps in rows 9, 18, 24, 34, 35 Spec review pass findings — the test matrix claimed "yes" coverage these assertions did not actually support: - Row 9 (--jobs 0/-1/abc hostile case): previously asserted rejection only. Added an end-to-end assertion (tests/quick-batch-command-router.test.cjs, committed alongside the security fix that touches the same file) that .planning/quick-batches/ is never created for any rejected value — createBatch is genuinely never reached. - Row 18 (--resume <unknown-batch-id>): previously only exercised a hand-corrupted BATCH.json, never a genuinely nonexistent batch directory. Added the real nonexistent-id case (also in quick-batch-command-router.test.cjs). - Row 24 (post-planning updateBatchItems racing a concurrent completeQuickItem for a different item, both through withPlanningLock): zero test existed. Added a property test (tests/quick-batch.property.test.cjs, appended — Phase 3's own file, no existing test touched) exercising both call orders and asserting no lost update in the final on-disk manifest — the same technique Phase 3's own row-15 lock-contention property test uses (sequential calls through the real lock; a working mutex makes any interleaving equivalent to some serial order, so this is the same claim a literal concurrent-thread test would make without OS-level threading). - Row 34 (worktree preserved on merge_failed) and row 35 (undeclared- deletion detection): both were previously asserted only at the pure routeMergeOutcome level. Added tests/gsd-quick-batch-merge-integration.test.cjs using the SAME real-git-fixture pattern tests/worktree-safety.test.cjs already establishes for executeWorktreeWaveCleanupPlan (real repo, real worktree, a REAL merge conflict / a REAL file deletion diffed against declared_deletions) — asserting the actual worktree directory survives on disk, not just that a pure function returns a preserveWorktree:true field. Named gsd-quick-batch-* so lint-test- file-count's bucketing doesn't fold it into any capped module bucket. Row 48 (/gsd:quick regression) intentionally left as-is per the reviewer's own framing: the byte-identity claim is already mechanically proven by the changed-path diff (git diff --name-only empty on those two paths IS byte-identity), and a genuine execution- level regression test would require actually running the workflow — out of scope for this repo's unit-test model (no other quick.md regression test in this repo does that either). * docs(#3676): add the changeset and user-facing docs the command needed Standards review pass findings — both HARD: - Missing changeset. None of the 6 prior #3676 commits touched .changeset/*. /gsd-quick-batch is a new user-facing command; CLAUDE.md/CONTRIBUTING.md require one. Added .changeset/silly-rams-caper.md (type: Added, pr: 0 placeholder — backfilled after the PR opens, matching CLAUDE.md's own documented convention and Phase 3's own precedent, #4190's .changeset/mellow-yaks-squeak.md). Uses the docs-convention hyphen form `/gsd-quick-batch` throughout, never the source-artifact colon form (`scripts/lint-docs-command-form.cjs` confirms 0 violations; that check scans docs/**, not .changeset/, so it was never actually in scope for the fragment itself, but the wording still follows the doc convention for consistency, matching how Phase 3's own fragment named the not-yet-shipped command). - Missing docs. Added docs/how-to/batch-quick-tasks.md (Diátaxis how-to, matching docs/how-to/handle-quick-and-fast-tasks.md's existing convention for /gsd-quick /gsd-fast) covering --jobs, --validate, --research, --resume, --file, the capacity/isolation interaction, and resume/failure recovery. Cross-linked from docs/README.md's how-to index and from handle-quick-and-fast-tasks.md's own "Related" section. Added a /gsd-quick-batch section to docs/COMMANDS.md (same table format as the existing /gsd-quick entry) and docs/features/quick-batch.md (REQ-QB-01..12, same frontmatter shape as docs/features/quick-mode.md) — regenerated docs/FEATURES.md (179 features) and skills/gsd-quick-batch/SKILL.md via the standard generators. * fix(#3676): close docs-parity, attribution, and generated-registry gaps gsd-test caught gsd-test's real run against 155e8975b3 found 43 failures, all rooted in this phase's own new command/workflow never being registered across ~10 independent generated/hand-maintained registries this repo keeps in parity by convention. Root-caused each, no test weakened or special-cased. - help.md ↔ commands/gsd/ bidirectional parity (docs-parity-live- registry.test.cjs): added a /gsd:quick-batch entry to gsd-core/workflows/help/modes/full.md (the real help.md content; gsd-core/workflows/help.md is a thin dispatcher) documenting every flag (--file/--jobs/--validate/--research/--resume), matching the existing /gsd:quick entry's format. - gen-section-manifest.test.cjs: quick-batch.md's `gsd_run query init.quick-batch` invocation used inline `$([ ... ] && echo --flag)` substitutions, which never satisfy the test's exact-whitespace-token / assigned-variable detection (the trailing `))` glued onto `--research` in the compound substitution broke the "exact token" match). Rewrote to the same VALIDATE_PARAM/RESEARCH_PARAM two-line pattern gsd-core/workflows/quick.md's own Step 2 already uses. - runtime-launcher-parity.test.cjs: the 8 quick-batch/steps/*.md fragments that call gsd_run each needed their OWN embedded copy of the canonical shim preamble (every workflow .md that calls gsd_run carries its own copy — reading one file does not persist shell state into another). Ran `node scripts/sync-runtime-launcher.cjs`, which inserted it before each file's first gsd_run call. plan-checker-loop.md correctly has none — it never calls gsd_run directly. - Namespace routing (skill-manifest.test.cjs, install-nested- layout.test.cjs, runtime-artifact-layout-surface.test.cjs): added `quick-batch` to commands/gsd/ns-workflow.md's `requires:` array and routing table (same namespace `quick` already routes through), and to src/clusters.cts's `utility` cluster (same cluster `quick` already belongs to). Verified by hand-running installRuntimeArtifacts + applySurface for augment/cline against a real temp install: exactly 6 top-level gsd-ns-* router dirs, gsd-quick-batch correctly nested under gsd-ns-workflow/skills/, never re-flattened. - mcp-server-catalog.test.cjs: hardcoded command count 71 -> 72 (a brand-new command is a real count change, not a bug this test should hide). - model-omit-when-inherit-guard.test.cjs: added the canonical `<!-- #2517 model-omit-on-inherit -->` marker block to gsd-core/workflows/quick-batch.md (every leaf dispatch — planner/ researcher/checker/executor/verifier — lives in a steps/ fragment, read combined with the host by this test's own readWorkflowCombined, same as quick.md's own research-phase.md carries it for its gated section). Also fixed a genuine pre-existing inconsistency in the test's own "#2711: the guarded set is derived from dispatch sites" check: its `nonDispatching` computation read the BARE host file while `derived` (the set it's checked against) reads the combined host+steps content — inconsistent with that same test file's own #2994 doc comment explaining why the combined read is necessary. quick-batch.md is the first workflow whose EVERY model="{...}" dispatch site lives in a mandatory (never gated) steps/ fragment — extracted to stay under ADR-1610's tighter NEW_FILE_CAP for a brand-new file — which is what exposed the mismatch. Fixed by using the same readWorkflowCombined read in both places. - skill-frontmatter-contract.test.cjs: shortened commands/gsd/quick-batch.md's frontmatter `description` from 107 to 91 chars (<=100 budget), and added `quick-batch.md` to the hand- maintained KNOWN_SKILLS consolidation allowlist with a #3676 justification comment (a genuinely new first-party command, not a consolidation of an existing skill). - workflow-fragments-emission.install.test.cjs: added `quick-batch.md` to the hand-maintained MARKED_WORKFLOWS set (composeWorkflow is deliberately NOT a no-op for it — its research-phase/verification- wave sections are gated). - Regenerated all downstream artifacts (npm run build:lib && npm run regen:derived && npm run gen:plugin-skills -- --write && npm run gen:features -- --write): skills/gsd-quick-batch/SKILL.md, skills/gsd-ns-workflow/SKILL.md, install-tree fixtures for augment/cline/hermes/qwen/trae/zcode. - emitted-attribution.test.cjs: agents/gsd-planner.md's #3676 addition (one new `load_mode_context` bullet pointing at the new gsd-core/references/planner-quick-batch.md reference) grew the file 124 bytes without an acknowledgment trailer. Acknowledged below — the growth is the deliberate, additive, single-bullet change from the earlier feat(#3676) commit, not drift. Verified: npm run build:lib clean, npx tsc -p tsconfig.build.json --noEmit clean, GITHUB_BASE_REF=next npm run lint:ci fully green (includes lint-workflow-shellcheck, lint-test-file-count, lint-docs-command-form). The deep install/spawn/registry tests gsd-test actually runs (docs-parity-live-registry, gen-section-manifest, runtime-launcher-parity, install-nested-layout, runtime-artifact-layout-surface, skill-manifest, skill-frontmatter- contract, mcp-server-catalog, model-omit-when-inherit-guard, workflow-fragments-emission) are not part of lint:ci — each fix above was independently verified by hand-invoking the exact production function the failing test calls (installRuntimeArtifacts, applySurface, composeWorkflow, the CLUSTERS union, the section-manifest forwarding regex) against the real repo tree and confirming the expected shape. Emitted-Drift-Ack-Growth: gsd-planner.md — additive #3676 quick-batch mode bullet in load_mode_context (one new line pointing at gsd-core/references/planner-quick-batch.md); not drift. * fix(#3676): trim the /gsd:quick-batch help.md entry to fit the LARGE tier line budget skill-frontmatter-contract.test.cjs's "feature #3039: tiered help — size budgets" enforces a SEPARATE line-count ceiling for gsd-core/workflows/help/modes/full.md (FULL_BUDGET = 844 lines, tighten-only ratchet, scripts/lib/allowlist-ratchet.cjs's assertTightCeiling) — independent of the skill-frontmatter description- length budget and consolidation allowlist I touched in the prior round; those are unrelated checks in the same test FILE, not the same check. Root cause: the /gsd:quick-batch entry I added to full.md in the docs-parity fix round was 17 lines, pushing the file from 834 to 851 lines — 7 over the 844 ceiling. Condensed the entry (merged the per-flag bullet list into one dense "Flags:" line, dropped from 3 Usage examples to 1) to 844 lines exactly — at the ceiling with zero slack, which assertTightCeiling accepts (it only fails on actualMax > ceiling, or on slack > grace when the ceiling is too LOOSE — zero slack triggers neither). Verified after trimming: full.md still contains a live /gsd:quick-batch reference (bidirectional parity) and all 5 argument-hint flags (--jobs/--validate/--research/--resume/--file) still appear as literal tokens (docs-parity-live-registry.test.cjs's own flag-coverage check, re-run by hand against the trimmed content). Verified: npm run build:lib clean, npx tsc -p tsconfig.build.json --noEmit clean, GITHUB_BASE_REF=next npm run lint:ci fully green. * docs(#3676): backfill changeset pr number to 4212 Follow-up to fix(#3676) commits — .changeset/silly-rams-caper.md's pr:0 placeholder backfilled with the real PR number now that gh api POST /pulls has returned it (#4212). Matches CLAUDE.md's PR Number Handling convention and Phase 3's own #4190 precedent (708c5a3f8c). Doc-only (root-level .changeset/*.md fragment), exempt from a fresh gsd-test run per pre-pr-gate.sh's DOC_ONLY_RE. * fix(#3676): resolve prompt-injection-scan false positive on test fixture tests/quick-batch.test.cjs:232's row 11b regression proves the task-list parser carries a prompt-injection-shaped task description through createBatch as inert data, never interpreted. The fixture has to be a real "ignore all previous instructions..." phrase or the test asserts nothing, but the full-file --diff scan flagged it once unrelated edits in the same file pulled it into the changed-file set. Add the file to prompt-injection-scan.sh's ALLOWLIST, matching the sanctioned, precedented exemption already used for other legitimate security-regression fixtures (tests/windsurf-conversion.test.cjs, tests/health-validation.test.cjs, tests/continuation-grammar-parity.test.cjs) per DEFECT.PROMPT-INJECTION-SCAN-COLLISION. --------- Co-authored-by: sim <sim@local>
485 lines
24 KiB
JavaScript
485 lines
24 KiB
JavaScript
'use strict';
|
|
|
|
// allow-test-rule: source-text-is-the-product — noSectionMarkerLeaksIntoEmittedArtifacts (#2930)
|
|
// asserts on the literal bytes of an EMITTED install artifact, which IS the
|
|
// deployed contract (a leaked `gsd:section` marker byte would ship to every user). This
|
|
// mirrors the test-matrix's own row-34/35 exemption from the "no source-grep" rule
|
|
// (50-test-matrix.md "No source-grep" note) — the ESLint rule itself only fires on
|
|
// readFileSync of a .cjs/.js/.ts SOURCE path, never on an installed .md artifact, so this
|
|
// annotation is documentation of intent, not a required suppression.
|
|
|
|
/**
|
|
* workflow-fragments-emission.install.test.cjs — 50-test-matrix.md rows 32-36
|
|
* (issue #2930, epic #1671 Phase 3).
|
|
*
|
|
* Real spawn-install coverage for `composeWorkflow`'s wiring into
|
|
* `bin/install.js`'s `copyWithPathReplacement` (ADR-1671 "Architecture and
|
|
* contracts": an engine-direct assertion is false-green for install
|
|
* behavior — only a real spawned installer proves bytes actually reach
|
|
* disk). The pure parser/composer itself is covered by
|
|
* tests/workflow-fragments.test.cjs (unit, rows 1-29/37) and
|
|
* tests/workflow-fragments.property.test.cjs (prop, rows 30-31).
|
|
*
|
|
* Each test builds and tears down its own tmp fixture(s) inline (no shared
|
|
* `before()` install cache) — independence per matrix row 38.
|
|
*
|
|
* ── The overlay technique (rows 33/36) ───────────────────────────────────
|
|
*
|
|
* Rows 33 and 36 need a spawned `bin/install.js` that reads a DIFFERENT
|
|
* `gsd-core/workflows/execute-phase.md` (malformed, row 36) or a different
|
|
* `gsd-core/bin/lib/workflow-fragments.cjs` (stubbed to identity, row 33)
|
|
* than this checkout's real files, without paying to copy the ~400 MB
|
|
* repository (mostly node_modules) for every run. `buildOverlayRepo` /
|
|
* `linkOrCopyFile` now live in `./helpers/overlay-repo.cjs` (extracted,
|
|
* #2933, shared with `tests/fragment-single-edit-propagation.install.test.cjs`
|
|
* so the mechanism has exactly ONE implementation) — see that file's own doc
|
|
* comment for the hard-link-mirror mechanism, the Dirent `isFile()`/
|
|
* `isDirectory()` quirks it works around, the EXDEV/EPERM copy fallback, and
|
|
* why only `node_modules`/`.git` are symlinked at the top level. Every
|
|
* overlay-spawned installer below still runs with `--preserve-symlinks
|
|
* --preserve-symlinks-main` as a defensive belt: with an all-hardlink leaf
|
|
* layout this checkout does not currently NEED symlink-preservation for
|
|
* correctness, but the flag is free insurance against a future install.js
|
|
* change that resolves a node_modules package by real path.
|
|
*/
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
const { RUNTIME_META, runMinimalInstall, installerEnv } = require('./helpers/install-shared.cjs');
|
|
const { buildOverlayRepo } = require('./helpers/overlay-repo.cjs');
|
|
const { executionContextRefs } = require('../scripts/command-contract-helpers.cjs');
|
|
const { composeWorkflow } = require('../gsd-core/bin/lib/workflow-fragments.cjs');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
|
|
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
const PILOT_REL = path.join('gsd-core', 'workflows', 'execute-phase.md');
|
|
const PILOT_PATH = path.join(REPO_ROOT, PILOT_REL);
|
|
// plan-phase.md was the original #2930 pilot but was reverted to unmarked
|
|
// (chore/2930 retarget: it sits 36 B under the ADR-857 Phase-6 PRE_PHASE6
|
|
// gate and cannot absorb marker overhead) — it was a genuinely unmarked
|
|
// file again, so row 33 used it instead of plan-phase.md. #2993 (epic #1671
|
|
// Phase 6.2) now marks plan-phase.md itself (6 sections, the fragmentization
|
|
// this change ships), so it is no longer a valid "genuinely unmarked" fixture
|
|
// either — retargeted a second time to discuss-phase.md, which carries no
|
|
// gsd:section markers as of this change.
|
|
const UNMARKED_REL = path.join('gsd-core', 'workflows', 'discuss-phase.md');
|
|
|
|
const RUNTIMES = Object.keys(RUNTIME_META);
|
|
|
|
// ─── Overlay-repo builder (rows 33/36) — see ./helpers/overlay-repo.cjs ────
|
|
|
|
/** Spawn a (possibly overlaid) installScript at global scope. Does NOT
|
|
* assert success — callers decide (row 36 expects failure). */
|
|
function spawnGlobalInstall(installScript, runtime, extraArgs = []) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-2930-dest-${runtime}-`));
|
|
// #3547 — same real config-home shape as runMinimalInstall: the strict
|
|
// <root>/<globalSuffix> subdirectory, not the collapsed <root>. The size
|
|
// comparisons below normalize each side's own root out of the text; a
|
|
// collapsed stub against a real-shape install bakes in a `.claude`-style
|
|
// suffix delta that has nothing to do with the compose wiring under test.
|
|
const configDir = path.join(root, RUNTIME_META[runtime].globalSuffix);
|
|
const args = [
|
|
'--preserve-symlinks',
|
|
'--preserve-symlinks-main',
|
|
installScript,
|
|
`--${runtime}`,
|
|
'--global',
|
|
'--config-dir',
|
|
configDir,
|
|
...extraArgs,
|
|
];
|
|
const seamResult = runNode(args, {
|
|
cwd: root,
|
|
env: installerEnv({ HOME: root, USERPROFILE: root }),
|
|
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
});
|
|
const result = { status: seamResult.exitCode, stdout: seamResult.stdout, stderr: seamResult.stderr };
|
|
return { result, configDir, root };
|
|
}
|
|
|
|
/** Convert native path separators to POSIX forward slashes, unconditionally
|
|
* (never gate on `path.sep` — CONTEXT.md's path-separator-normalization
|
|
* rule). Windows installs embed the SAME root in more than one spelling:
|
|
* the `@`-ref / pathPrefix rewrites always emit posix-normalized
|
|
* forward-slash paths, while other embedded content can still carry the
|
|
* native backslash spelling. `root` itself (from `fs.mkdtempSync`) is a
|
|
* native-separator string, so comparing it against text verbatim only
|
|
* matches ONE of those spellings. */
|
|
function toPosixSlashes(value) {
|
|
return value.replace(/\\/g, '/');
|
|
}
|
|
|
|
/** Strip an install's own absolute root out of emitted text so two installs
|
|
* under DIFFERENT temp roots (different lengths, different runtime-name
|
|
* prefixes) can be compared byte-for-byte. Normalizes BOTH the text and the
|
|
* root to forward-slash spelling first, so every embedded spelling of the
|
|
* root collapses onto the SAME placeholder — leaving the comparison
|
|
* measuring only composeWorkflow's own contribution. */
|
|
function stripRoot(text, root) {
|
|
return toPosixSlashes(text).split(toPosixSlashes(root)).join('<ROOT>');
|
|
}
|
|
|
|
// ─── Row 32: emitted execute-phase.md shrinks by exactly the marker bytes ────
|
|
//
|
|
// Defect found and fixed inline while verifying (chore/2930 review; not one
|
|
// of the five assigned findings, but discovered incidentally): this test
|
|
// previously compared the RAW `composeWorkflow(source)` byte length directly
|
|
// against `fs.statSync(emittedPath).size`. That equality only holds when the
|
|
// OTHER rewrites `copyWithPathReplacement` also runs (the `~/.claude/` ->
|
|
// `pathPrefix` substitution, attribution stamping, per-runtime converters)
|
|
// happen to be byte-neutral — which they are NOT here: `runMinimalInstall`
|
|
// passes `--config-dir root` with `HOME=root` (root IS the target, not
|
|
// `root/.claude`), so `computePathPrefix` degenerates to the short literal
|
|
// `$HOME/` instead of the real-world `$HOME/.claude/`, shrinking the
|
|
// installed `~/.claude/` references by additional bytes unrelated to
|
|
// composeWorkflow. Proven with a real spawned install and reverted to
|
|
// confirm this reproduces on the UNMODIFIED tree, before this change. Fixed
|
|
// by isolating composeWorkflow's OWN contribution the same way row 33
|
|
// already does: compare two REAL installs of the pilot workflow, one via
|
|
// this checkout's real composeWorkflow and one via an identity-stubbed
|
|
// composeWorkflow, and assert the size DELTA equals exactly the marker
|
|
// bytes stripped — never an absolute emitted byte count, which conflates
|
|
// unrelated rewrites this module does not own.
|
|
//
|
|
// A second, independent contaminant surfaced fixing the first one: opencode
|
|
// embeds the install's own absolute configDir path into execute-phase.md
|
|
// content (same fact row 33/atRefContractStillResolvesAfterComposition
|
|
// documents for SKILL.md), and `runMinimalInstall`'s temp-dir prefix
|
|
// (`gsd-<runtime>-<scope>-`) is a DIFFERENT length than
|
|
// `spawnGlobalInstall`'s (`gsd-2930-dest-<runtime>-`) — so comparing RAW
|
|
// file sizes between the two installs bakes in a root-path-length delta
|
|
// that has nothing to do with composeWorkflow. Normalize each side's own
|
|
// root out of the text before measuring, exactly as row 33 already does.
|
|
|
|
test('emittedWorkflowShrinksByMarkerBytesForEveryRuntime', (t) => {
|
|
const source = fs.readFileSync(PILOT_PATH, 'utf8');
|
|
const composed = composeWorkflow(source, { sourcePath: PILOT_PATH });
|
|
const sourceBytes = Buffer.byteLength(source, 'utf8');
|
|
const composedBytes = Buffer.byteLength(composed, 'utf8');
|
|
const expectedMarkerBytes = sourceBytes - composedBytes;
|
|
assert.ok(
|
|
expectedMarkerBytes > 0,
|
|
'sanity: the pilot workflow must actually carry gsd:section markers to strip',
|
|
);
|
|
|
|
const identityStubRepo = buildOverlayRepo({
|
|
'gsd-core/bin/lib/workflow-fragments.cjs': 'module.exports = { composeWorkflow: (c) => c };\n',
|
|
});
|
|
t.after(() => cleanup(identityStubRepo));
|
|
|
|
for (const runtime of RUNTIMES) {
|
|
// Belt-and-braces cleanup: t.after() is the failure-path safety net (a
|
|
// thrown assertion still tears the temp install dirs down when the test
|
|
// returns), but t.after() alone defers EVERY registered cleanup across
|
|
// all ~18 runtimes until the whole test finishes, so up to 36 full
|
|
// install trees would coexist on disk at once. The eager cleanup() calls
|
|
// below bound peak disk to one iteration's trees on the success path;
|
|
// t.after() still fires afterward as a no-op (cleanup is idempotent on
|
|
// an already-removed path — see helpers.cjs).
|
|
const real = runMinimalInstall({ runtime, scope: 'global' });
|
|
t.after(() => cleanup(real.root));
|
|
const stub = spawnGlobalInstall(path.join(identityStubRepo, 'bin', 'install.js'), runtime);
|
|
t.after(() => cleanup(stub.root));
|
|
assert.equal(
|
|
stub.result.status,
|
|
0,
|
|
`${runtime}: identity-stub install must succeed\nstderr: ${stub.result.stderr}`,
|
|
);
|
|
const realPath = path.join(real.configDir, PILOT_REL);
|
|
const stubPath = path.join(stub.configDir, PILOT_REL);
|
|
assert.ok(fs.existsSync(realPath), `${runtime}: real install is missing execute-phase.md`);
|
|
assert.ok(fs.existsSync(stubPath), `${runtime}: identity-stub install is missing execute-phase.md`);
|
|
const realText = stripRoot(fs.readFileSync(realPath, 'utf8'), real.root);
|
|
const stubText = stripRoot(fs.readFileSync(stubPath, 'utf8'), stub.root);
|
|
const realBytes = Buffer.byteLength(realText, 'utf8');
|
|
const stubBytes = Buffer.byteLength(stubText, 'utf8');
|
|
assert.equal(
|
|
stubBytes - realBytes,
|
|
expectedMarkerBytes,
|
|
`${runtime}: emitted size delta (stub ${stubBytes} - real ${realBytes}, root-normalized) must equal exactly the marker bytes stripped (${expectedMarkerBytes})`,
|
|
);
|
|
cleanup(real.root);
|
|
cleanup(stub.root);
|
|
}
|
|
});
|
|
|
|
// ─── Row 33: an unmarked workflow emits byte-identical for every runtime ──
|
|
//
|
|
// "Byte-identical" here means identical to what the SAME runtime's install
|
|
// pipeline would emit WITHOUT the #2930 composeWorkflow wiring — not
|
|
// necessarily identical to the raw repo source, since path-prefix rewrites,
|
|
// attribution stamping, and per-runtime .md converters already ran before
|
|
// this change and still run today. Proven empirically per runtime by
|
|
// comparing two REAL installs of the SAME unmarked file: one through this
|
|
// checkout's real composeWorkflow, one through an overlay whose
|
|
// gsd-core/bin/lib/workflow-fragments.cjs is stubbed to plain identity — any
|
|
// difference is attributable ONLY to the compose wiring, never to an
|
|
// unrelated converter (which fires identically on both sides).
|
|
|
|
test('unmarkedWorkflowEmitsByteIdenticalForEveryRuntime', (t) => {
|
|
const identityStubRepo = buildOverlayRepo({
|
|
'gsd-core/bin/lib/workflow-fragments.cjs': 'module.exports = { composeWorkflow: (c) => c };\n',
|
|
});
|
|
t.after(() => cleanup(identityStubRepo));
|
|
|
|
for (const runtime of RUNTIMES) {
|
|
const real = runMinimalInstall({ runtime, scope: 'global' });
|
|
t.after(() => cleanup(real.root));
|
|
const stub = spawnGlobalInstall(path.join(identityStubRepo, 'bin', 'install.js'), runtime);
|
|
t.after(() => cleanup(stub.root));
|
|
assert.equal(
|
|
stub.result.status,
|
|
0,
|
|
`${runtime}: identity-stub install must succeed\nstderr: ${stub.result.stderr}`,
|
|
);
|
|
const realPath = path.join(real.configDir, UNMARKED_REL);
|
|
const stubPath = path.join(stub.configDir, UNMARKED_REL);
|
|
assert.ok(fs.existsSync(realPath), `${runtime}: real install is missing discuss-phase.md`);
|
|
assert.ok(fs.existsSync(stubPath), `${runtime}: stub install is missing discuss-phase.md`);
|
|
|
|
// Normalize each side's own randomly-generated temp root out of the
|
|
// content before hashing: some runtimes (opencode) embed the
|
|
// install's own absolute configDir path in execution_context refs,
|
|
// and the two installs necessarily used DIFFERENT temp roots — an
|
|
// unnormalized compare would report a spurious mismatch driven by
|
|
// temp-path length, not by anything composeWorkflow's wiring did.
|
|
const realText = stripRoot(fs.readFileSync(realPath, 'utf8'), real.root);
|
|
const stubText = stripRoot(fs.readFileSync(stubPath, 'utf8'), stub.root);
|
|
assert.equal(
|
|
Buffer.byteLength(realText, 'utf8'),
|
|
Buffer.byteLength(stubText, 'utf8'),
|
|
`${runtime}: discuss-phase.md byte size drifted between real compose and identity-stub compose`,
|
|
);
|
|
const realHash = crypto.createHash('sha256').update(realText).digest('hex');
|
|
const stubHash = crypto.createHash('sha256').update(stubText).digest('hex');
|
|
assert.equal(
|
|
realHash,
|
|
stubHash,
|
|
`${runtime}: discuss-phase.md content drifted between real compose and identity-stub compose`,
|
|
);
|
|
cleanup(real.root);
|
|
cleanup(stub.root);
|
|
}
|
|
});
|
|
|
|
// ─── Row 34: no gsd:section marker survives into any emitted artifact ─────
|
|
|
|
test('noSectionMarkerLeaksIntoEmittedArtifacts', (t) => {
|
|
for (const runtime of RUNTIMES) {
|
|
const { configDir, root } = runMinimalInstall({ runtime, scope: 'global' });
|
|
t.after(() => cleanup(root));
|
|
const emittedPath = path.join(configDir, PILOT_REL);
|
|
assert.ok(fs.existsSync(emittedPath), `${runtime}: emitted execute-phase.md is missing`);
|
|
const emittedText = fs.readFileSync(emittedPath, 'utf8');
|
|
assert.equal(
|
|
emittedText.includes('gsd:section'),
|
|
false,
|
|
`${runtime}: emitted execute-phase.md still contains a gsd:section marker token`,
|
|
);
|
|
cleanup(root);
|
|
}
|
|
});
|
|
|
|
// ─── Row 35: ADR-0002 @-ref contract still resolves after composition ─────
|
|
//
|
|
// Two representative runtimes chosen to cover BOTH observed @-ref forms
|
|
// (empirically confirmed, #2930 dispatch): claude/cursor/codex rewrite to
|
|
// `@$HOME/...`, while opencode rewrites to a bare `@<absolute-path>/...`.
|
|
// Both installed SKILL.md files themselves pass through composeWorkflow too
|
|
// (as a no-op, being unmarked) — this proves that pass never corrupts or
|
|
// relocates the referenced workflow file.
|
|
|
|
/** Detect absoluteness from the token's own shape only — never from
|
|
* `process.platform` — so the same logic runs identically on every OS.
|
|
* Covers POSIX (`/...`), Windows drive-letter (`C:/...` or `C:\...`), and
|
|
* UNC (`\\server\share`) forms. */
|
|
function isAbsoluteRefTarget(candidate) {
|
|
return (
|
|
candidate.startsWith('/') ||
|
|
/^[a-zA-Z]:[\\/]/.test(candidate) ||
|
|
candidate.startsWith('\\\\')
|
|
);
|
|
}
|
|
|
|
function resolveExecutionContextRefTarget(token, root) {
|
|
const withoutAt = token.replace(/^@/, '');
|
|
if (isAbsoluteRefTarget(withoutAt)) return withoutAt; // already absolute (opencode form)
|
|
const stripped = withoutAt.replace(/^(?:~|\$HOME)\//, '');
|
|
return path.join(root, stripped);
|
|
}
|
|
|
|
test('atRefContractStillResolvesAfterComposition', (t) => {
|
|
for (const runtime of ['claude', 'opencode']) {
|
|
const { configDir, root } = runMinimalInstall({ runtime, scope: 'global' });
|
|
t.after(() => cleanup(root));
|
|
const skillPath = path.join(configDir, 'skills', 'gsd-plan-phase', 'SKILL.md');
|
|
assert.ok(fs.existsSync(skillPath), `${runtime}: installed gsd-plan-phase SKILL.md is missing`);
|
|
const skillContent = fs.readFileSync(skillPath, 'utf8');
|
|
const refs = executionContextRefs(skillContent);
|
|
assert.ok(refs.length > 0, `${runtime}: SKILL.md has no execution_context @-refs to check`);
|
|
for (const { token } of refs) {
|
|
const target = resolveExecutionContextRefTarget(token, root);
|
|
assert.ok(
|
|
fs.existsSync(target),
|
|
`${runtime}: execution_context @-ref "${token}" resolved to "${target}", which does not exist on disk`,
|
|
);
|
|
}
|
|
cleanup(root);
|
|
}
|
|
});
|
|
|
|
// ─── FIX 1 (chore/2930 review): composeWorkflow is scoped to gsd-core/workflows/ ──
|
|
//
|
|
// copyWithPathReplacement is the emit path for the ENTIRE gsd-core/ tree
|
|
// (skillSrc = path.join(src, 'gsd-core'), bin/install.js:10806-10809), not
|
|
// just gsd-core/workflows/. A non-workflow .md elsewhere under gsd-core/
|
|
// (e.g. gsd-core/references/) that merely DOCUMENTS the marker syntax with
|
|
// an unfenced, structurally-invalid example line must never be run through
|
|
// composeWorkflow — doing so would either throw (breaking install for an
|
|
// unrelated file class) or silently strip/mis-parse the documentation line.
|
|
// Proven here by overlaying BOTH a marked workflow (must still compose) and
|
|
// an EXISTING non-workflow reference doc (buildOverlayRepo can only replace
|
|
// the content of a real leaf file, not graft in a net-new path — see the
|
|
// module doc comment's overlay-technique note) rewritten to carry an
|
|
// intentionally-UNCLOSED marker-shaped line (would throw if composeWorkflow
|
|
// ever touched it) in the SAME install run.
|
|
|
|
test('nonWorkflowMarkdownWithMarkerShapedLineIsNotComposed', (t) => {
|
|
const markedWorkflow = '<!-- gsd:section id="a" when="always" -->\nbody\n<!-- /gsd:section -->\n';
|
|
const nonWorkflowDoc =
|
|
'# Marker syntax\n\nExample (deliberately unfenced and unclosed to prove non-composition):\n\n<!-- gsd:section id="x" when="always" -->\nnever closed on purpose\n';
|
|
const NON_WORKFLOW_DOC_REL = path.join('gsd-core', 'references', 'context-budget.md');
|
|
const overlayRepo = buildOverlayRepo({
|
|
'gsd-core/workflows/execute-phase.md': markedWorkflow,
|
|
[NON_WORKFLOW_DOC_REL.split(path.sep).join('/')]: nonWorkflowDoc,
|
|
});
|
|
t.after(() => cleanup(overlayRepo));
|
|
|
|
const dest = spawnGlobalInstall(path.join(overlayRepo, 'bin', 'install.js'), 'claude');
|
|
t.after(() => cleanup(dest.root));
|
|
assert.equal(
|
|
dest.result.status,
|
|
0,
|
|
`install must succeed: a non-workflow doc's marker-shaped line must never reach composeWorkflow\nstderr: ${dest.result.stderr}`,
|
|
);
|
|
|
|
const emittedWorkflowPath = path.join(dest.configDir, PILOT_REL);
|
|
assert.ok(fs.existsSync(emittedWorkflowPath), 'emitted execute-phase.md is missing');
|
|
assert.equal(
|
|
fs.readFileSync(emittedWorkflowPath, 'utf8'),
|
|
'body\n',
|
|
'gsd-core/workflows/execute-phase.md must still compose (markers stripped)',
|
|
);
|
|
|
|
const emittedDocPath = path.join(dest.configDir, NON_WORKFLOW_DOC_REL);
|
|
assert.ok(fs.existsSync(emittedDocPath), 'emitted context-budget.md is missing');
|
|
assert.equal(
|
|
fs.readFileSync(emittedDocPath, 'utf8'),
|
|
nonWorkflowDoc,
|
|
'a non-workflow .md must pass through composeWorkflow untouched, byte-identical, including its marker-shaped line',
|
|
);
|
|
});
|
|
|
|
// ─── Row 36: a malformed marker fails install loudly, with no partial emit ─
|
|
|
|
// ─── Row 63 (50-test-matrix.md, issue #2932 Phase 5): extracting
|
|
// execute-phase.md's 3 sections must not perturb any OTHER workflow's
|
|
// emission — independence guard for the CRITICAL blast radius Phase 5's own
|
|
// design doc calls out. Pure-function check (no spawn needed): composeWorkflow
|
|
// is a documented no-op for every unmarked file, so any file other than the
|
|
// one Phase 5 migrates must still compose to itself, byte-identical. ────────
|
|
|
|
test('leavesUnmarkedWorkflowEmissionByteIdentical', () => {
|
|
const workflowsDir = path.join(REPO_ROOT, 'gsd-core', 'workflows');
|
|
// execute-phase.md (#2932 Phase 5), plan-phase.md (#2993 Phase 6.2), and
|
|
// the thirteen workflows #2994 (epic #1671 Phase 6.3) fragmentizes onto the
|
|
// marker grammar are the files this repo has fragmentized with gsd:section
|
|
// markers — all excluded here since composeWorkflow is deliberately NOT a
|
|
// no-op for them. This set is DELIBERATELY hardcoded rather than derived
|
|
// from "does the file contain a marker": deriving it would make the guard
|
|
// tautological — a marker that LEAKED into an unrelated file via a bad
|
|
// extraction would just get silently excluded instead of failing the
|
|
// independence check this test exists to enforce. Update this list by hand
|
|
// whenever a workflow is legitimately marked.
|
|
const MARKED_WORKFLOWS = new Set([
|
|
'autonomous.md',
|
|
'code-review.md',
|
|
'complete-milestone.md',
|
|
'discuss-phase-assumptions.md',
|
|
'docs-update.md',
|
|
'execute-phase.md',
|
|
'new-milestone.md',
|
|
'new-project.md',
|
|
'plan-phase.md',
|
|
'progress.md',
|
|
// #3676 (epic #3344, ADR-1239 "Quick-batch binding"): research-phase and
|
|
// verification-wave sections are gated on flag:--research/flag:--validate.
|
|
'quick-batch.md',
|
|
'quick.md',
|
|
'review.md',
|
|
'transition.md',
|
|
'update.md',
|
|
'verify-work.md',
|
|
]);
|
|
const workflowFiles = fs
|
|
.readdirSync(workflowsDir, { withFileTypes: true })
|
|
.filter((d) => d.isFile() && d.name.endsWith('.md'))
|
|
.map((d) => d.name);
|
|
assert.ok(workflowFiles.length > MARKED_WORKFLOWS.size, 'sanity: there must be more than the marked workflows on disk');
|
|
|
|
let checkedCount = 0;
|
|
for (const fileName of workflowFiles) {
|
|
if (MARKED_WORKFLOWS.has(fileName)) continue;
|
|
const filePath = path.join(workflowsDir, fileName);
|
|
const source = fs.readFileSync(filePath, 'utf8');
|
|
const composed = composeWorkflow(source, { sourcePath: filePath });
|
|
assert.equal(
|
|
composed,
|
|
source,
|
|
`${fileName}: emission drifted — a marked workflow's extraction must not touch any other workflow`,
|
|
);
|
|
checkedCount += 1;
|
|
}
|
|
assert.equal(
|
|
checkedCount,
|
|
workflowFiles.length - MARKED_WORKFLOWS.size,
|
|
'every workflow file except the marked ones must have been checked',
|
|
);
|
|
});
|
|
|
|
test('malformedMarkersFailInstallWithoutPartialEmit', (t) => {
|
|
const malformed = '<!-- gsd:section id="broken" when="always" -->\nnever closed\n';
|
|
const overlayRepo = buildOverlayRepo({ 'gsd-core/workflows/execute-phase.md': malformed });
|
|
t.after(() => cleanup(overlayRepo));
|
|
|
|
const dest = spawnGlobalInstall(path.join(overlayRepo, 'bin', 'install.js'), 'claude');
|
|
t.after(() => cleanup(dest.root));
|
|
// stderr text is a child process's rendered prose, not a typed value
|
|
// this test can assert on across the process boundary (CONTRIBUTING.md
|
|
// "Prohibited: Raw Text Matching on Test Outputs" — err.reason is only
|
|
// reachable in-process; see tests/workflow-fragments.test.cjs's REASON
|
|
// assertions for the in-process equivalent of this same failure mode).
|
|
// Assert typed, observable facts instead: the install process exits
|
|
// non-zero, and no output file is written for the file that failed to
|
|
// compose.
|
|
assert.notEqual(
|
|
dest.result.status,
|
|
0,
|
|
`install must fail loudly on a malformed marker, got exit 0\nstdout: ${dest.result.stdout}`,
|
|
);
|
|
const emittedPath = path.join(dest.configDir, PILOT_REL);
|
|
assert.equal(
|
|
fs.existsSync(emittedPath),
|
|
false,
|
|
'a half-composed execute-phase.md must never be written when composition throws',
|
|
);
|
|
});
|