fix(#3585): close raw-git bypasses of the commit_docs gate (#3590)

* test(#3585): repo-wide guard for unguarded .planning/ git add

Replaces the two-file #1783 scan, which required .planning/ on the git add
line and so was structurally blind to fast.md's `git add -A` and to
new-milestone.md (never scanned).

Extracts the shell tokenizer, comment-position rule and gsd-scan-ignore
marker from the #2269 guard into tests/helpers/shipped-command-scan.cjs so
both guards consume one implementation. Commit-specific logic stays in
commit-files-pathspec.test.cjs; every pre-existing test there passes
unedited.

Fails RED on five sites: fast.md:58, new-milestone.md:262, spec-phase.md:480,
eval-review.md:148, ai-integration-phase.md:263. The last three carry a
markdown prose conditional outside the bash block it claims to guard.

* fix(#3585): close raw-git bypasses of the commit_docs gate

Five shipped workflow steps staged .planning/ with raw git. Two had no
check at all; three had a markdown prose conditional sitting outside the
bash block it claimed to guard, so the block ran unconditionally.

spec-phase, eval-review and ai-integration-phase now route through the
gsd_run query commit seam, which performs the commit_docs and gitignore
checks internally and returns a skipped envelope -- this deletes the raw
git pair rather than wrapping it.

new-milestone stages directories for a later commit and cannot use the
seam, so it takes the executable guard form, fail-open on a tooling error.

fast writes no planning artifacts and has no gsd_run in scope at that
point, so it excludes .planning via pathspec instead of reading config.

Guard now reports 0 offenders.

* test(#3585): pin skipped_gitignored to production behavior

COMMIT_REASON was a test-local frozen enum joined to production only by a
hand-maintained keep-in-sync comment -- the Generative Fix Divergence class,
whose required remedy is a parity assertion.

B1-B3 already pinned SKIPPED_COMMIT_DOCS_FALSE. SKIPPED_GITIGNORED was
pinned by nothing: production could rename it and every test still passed.

G1-G3 drive the gitignore auto-detect path and assert the canonical reason.
The fixture must OMIT .planning/config.json entirely -- with config.json
present the loader resolves commit_docs to false first and cmdCommit returns
skipped_commit_docs_false, never reaching its own isGitIgnored branch.

* docs(#3585): document the planning commit gate and its guard

CONTEXT.md had zero commit_docs entries. Adds a Planning Commit Gate
glossary entry covering the resolution chain, the typed skip envelope, the
measured ordering of the two reason codes, and why the gate is enforceable
only as a text guard.

CONTRIBUTING.md gains the contributor rule for the new guard, with the
prose-is-not-a-guard example that caused three of the five defects.

* fix(#3585): address review findings in the planning-add guard

Spec review (blocker): fast.md excluded .planning unconditionally, changing
behavior for commit_docs=true users and violating epic AC4. Now gated -- the
launcher preamble was MOVED from log_to_state into the commit block rather
than copied, so gsd_run is in scope for +4 lines instead of +4KB, and the
else branch is byte-identical to the previous git add -A.

Security review (major): git -C <dir> add was a false negative because the
flag-skip loop never modelled flags that consume a separate value. Fixed for
-C/-c/--git-dir/--work-tree/--namespace. The fail-closed rule now also covers
$(...) substitution args and --pathspec-from-file, which were opaque in the
same way $VAR is. git commit -a/-am is now classified as reaching, since it
stages every tracked modification.

Self-review: isSkippable treated any NAME= token as a skippable prefix, so
V=$(git add -A) escaped -- the exact divergence the shared-helper extraction
existed to prevent. Adopted the sibling predicate verbatim.

eval, xargs, one-line function bodies and line-continuation remain blind and
are now enumerated as declared limits in the guard docblock and CONTRIBUTING.
The ifDepth clamp is defensive only: a 200k-case differential fuzz found no
reproducing input, so its test is labeled a pin, not a failing-first test.

* test(#3585): acknowledge emitted growth in three workflow files

emitted-attribution has two arms: hash attribution AND per-file growth. The
growth arm needs an acknowledgment even when every moved byte is attributable
to the diff, which is why the first remote run went red on it.

fast.md +417: the launcher preamble moved into the commit block so gsd_run is
in scope for the commit_docs guard, plus the guard itself.
new-milestone.md +281: the executable guard plus one line recording that the
unstaged archive move is deliberate.
spec-phase.md +21: reworded prose describing the skipped envelope.

eval-review.md and ai-integration-phase.md shrank; no entry needed.

* test(#3585): drop duplicate spec-phase ack, shrink its prose instead

The base already acknowledges spec-phase.md (from #2733), and two ack sources
may never name the same path. But a base-side ack is SPENT -- it cannot clear
new growth -- so the two gates were in direct conflict: attribution wanted an
ack, the ack lint forbade one.

Resolved by removing the growth rather than the conflict. spec-phase.md's +21
was purely a prose reword; rewritten shorter, the file now shrinks 36 bytes
against base and needs no acknowledgment at all.

fast.md and new-milestone.md have no base ack and keep theirs.

* chore(#3585): backfill changeset pr number to 3590

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-17 13:34:55 -04:00
committed by GitHub
parent 0c00ef4a6d
commit 7c649a9970
13 changed files with 1293 additions and 402 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3590
---
**GSD no longer commits `.planning/` files you told it to ignore** — several workflow steps staged planning artifacts with raw `git add`, bypassing the `commit_docs` setting and the `.gitignore` auto-detect entirely, so planning docs reached shared history anyway. (#3585)

View File

@@ -221,6 +221,9 @@ Module owning agent-presence resolution and verification, extracted from the Cor
### Config Loader Module
Module owning project configuration loading: reads `.planning/config.json`, merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides. Primary interface: `loadConfigResolved(cwd, options) → ConfigResolution { config, source, degraded }` (provenance-aware, ADR-1411 P2 / #1415) — `source` ∈ `'workstream' | 'root' | 'builtin-defaults' | 'global-defaults'`; `degraded:true` when a workstream was requested but its config.json was absent (fell back to root config). `loadConfig(cwd, options) → Record<string,unknown>` is the back-compat thin wrapper over `loadConfigResolved` (byte-identical result). Resolution is **caller-anchored, not loader-anchored**: `loadConfigResolved` resolves `cwd` as-is (no walk-up), so `loadConfig` stays byte-identical for its callers; callers that need cwd-drift tolerance (e.g. `cmdAgentSkills`) anchor to the project root via `findProjectRoot` (Project-Root Resolution Module) *before* calling `loadConfigResolved`. Helper exports: `_deepMergeConfig`, `isGitIgnored`, `_warnUnknownProfileOverrides`. Depends only on leaf modules (`configuration`, `config-schema`, `planning-workspace`, `shell-command-projection`, `core-utils`, `model-catalog`) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call `loadConfig`); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/config-loader.cjs` (generated from `src/config-loader.cts`).
### Planning Commit Gate (`commit_docs`)
The seam deciding whether `.planning/` artifacts reach git. `commit_docs` resolves in the Config Loader Module (`loadConfigResolved`) through an ordered chain — an explicit value (top-level `commit_docs` or its `planning.commit_docs` alias) wins; absent that, `isGitIgnored(cwd, '.planning/')` auto-resolves it to `false`; otherwise the manifest default (`true`) applies. `cmdCommit` (Command Module) is the ONLY sanctioned writer: it returns the typed skip envelope `{ committed: false, skipped: true, hash: null, reason }` with `reason ∈ { 'skipped_commit_docs_false', 'skipped_gitignored' }` rather than erroring, and `skipped: true` is explicit so agent prompts match a first-class success signal instead of inferring a skip from a missing `committed` and improvising a raw-git fallback (#3678). Those `reason` strings are a de facto public contract — `agents/gsd-executor.md` pattern-matches on them — so they are additive-only. **The two reasons are ordered, not peers, and `skipped_gitignored` is near-unreachable in a real project** (measured #3585): `cmdCommit` tests resolved `commit_docs` FIRST, and whenever `.planning/config.json` exists — which it does in every initialized project — the loader's gitignore auto-detect has already resolved that value to `false`, so the first branch returns `skipped_commit_docs_false` and the `isGitIgnored` branch below it is never reached. `skipped_gitignored` fires only when `config.json` is absent entirely, so the loader falls back to the `true` default and `cmdCommit`'s own check is what fires. A gitignore-driven skip therefore reports the config-driven reason; both members are behaviorally pinned by `tests/commit-docs-bypass.test.cjs` (B1-B3 and G1) so a rename fails loudly, but the reason a user sees does not distinguish *which* input suppressed the commit. **The gate is bypassable only from OUTSIDE the code**: a workflow step that types `git add` into its own shell reaches the index without passing through `cmdCommit`, and no code change can intercept that. Two guards therefore enforce it as text rather than at runtime: `tests/commit-files-pathspec.test.cjs` (#2269) requires every shipped `commit` invocation to declare `--files`, and `tests/commit-docs-bypass.test.cjs` (#1783, made repo-wide by #3585) requires every shipped `git add` able to reach `.planning/` to sit inside an **executable** `commit_docs` check — a markdown prose conditional ("**If `commit_docs` is true:**") is not a guard, because the bash block below it runs regardless. Both consume one shell tokenizer (`tests/helpers/shipped-command-scan.cjs`) and one exemption marker (`# gsd-scan-ignore: #NNN`, reason must cite a tracking ref per ADR-456). Guard state does not cross a fenced-block boundary: each fenced block is its own shell, so a guard opened in one block does not protect a `git add` in the next. Known limit: `.gitignore` has no effect on files git already TRACKS, so a project that committed `.planning/` before ignoring it keeps staging those paths — see `gsd-core/references/planning-config.md` and #3586.
### Model Catalog Module
Leaf module owning the **static** model tables and the closed vocabularies derived from them — the tier/runtime/provider enums (`VALID_TIERS`, `VALID_AGENT_TIERS`, `KNOWN_RUNTIMES`, `KNOWN_PROVIDERS`, `RUNTIMES_WITH_REASONING_EFFORT`, `RUNTIMES_WITH_FAST_MODE`, `ADAPTIVE_TIER_VALUES`), the alias and profile maps (`MODEL_ALIAS_MAP`, `RUNTIME_PROFILE_MAP`, `PROVIDER_PRESETS`), effort rendering (`renderEffortForRuntime`), and the agent→model projections (`getAgentToModelMapForProfile`, `formatAgentToModelMapAsTable`). A **genuine leaf**: it imports `node:path` and its own `model-catalog.json` and nothing else, which is what makes it the correct home for anything several unrelated surfaces must agree on. Model *ids* live in `model-catalog.json`, never inline — changing one means regenerating goldens (`UPDATE_GOLDEN`). Also owns the **Anthropic-flavored-model rule** (#3241, ADR-2313): `CLAUDE_AGENT_ALIASES` (the frozen four-alias set `opus`/`sonnet`/`haiku`/`fable`) and `isAnthropicFlavoredModel(model)`, which is true for a bare tier alias or for any `claude-*` id in any provider namespacing (`anthropic/claude-*`, `us.anthropic.claude-*`); no OpenAI/Codex model id contains "claude", so the case-insensitive substring test is safe and exhaustive. It was **moved down here from the Model Resolver Module** rather than shared from there, because the Codex posture check (`agent-install-check`, epic #2313 Phase 2) and the Codex `.toml` sync (`commands`, Phase 3) both need the rule and neither may take the `config-loader` dependency `model-resolver` would have brought; `model-resolver` re-exports it for back-compat and a parity test fails if the two ever fork. _Avoid_: "the model list" (ambiguous between the catalog JSON and the derived enums). See Model Resolver Module, ADR-2313, and ADR-0003.

View File

@@ -1119,6 +1119,74 @@ commit, so you are told which of the two problems you actually have. A marker th
survives shell tokenization as an *argument* declares nothing: it reached argv, which
means the runtime executed the line.
### Every `git add` in shipped content that can reach `.planning/` must sit inside an *executable* `commit_docs` check
`tests/commit-docs-bypass.test.cjs` scans every `.md` under `gsd-core/workflows/`,
`gsd-core/references/`, `agents/`, `commands/` and `skills/` and fails if a `git add` that could
stage `.planning/` is not enclosed by a `commit_docs` check that actually runs.
This is the sibling of the `--files` guard above, and it exists for the complementary hole.
That one keeps a *seam* invocation honest; this one catches the steps that never reach the seam
at all. `commit_docs` is resolved and enforced inside `cmdCommit`, so a step that types
`git add` into its own shell bypasses it completely — no code change can intercept that, only a
guard over the shipped text.
**Prose is not a guard.** This is the failure the scan was written for. All three of these
*looked* gated and none of them were:
````markdown
**If `commit_docs` is true:**
```bash
git add "${EVAL_REVIEW_FILE}" ← runs unconditionally; the bold line is markdown
```
````
The bash block executes whatever the sentence above it says. Write the check in shell, which is
the form `gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md` already uses:
```bash
COMMIT_DOCS=$(gsd_run query config-get commit_docs 2>/dev/null || echo "true")
if [ "$COMMIT_DOCS" != "false" ]; then
git add "${ARTIFACT}"
fi
```
Both polarities are accepted (`!= "false"` and `= "true"`). The `|| echo "true"` fallback is
deliberate: a tooling failure must fail *open*, or a broken `gsd-tools` silently stops committing
planning docs for someone who wants them.
Three consequences worth knowing before you edit shipped content:
**Guard state does not cross a fenced block.** Each fenced block is its own shell, so an `if`
opened in one block does not protect a `git add` in the next — the same reason
`new-milestone.md` warns that a `GSD_WS` guard set in an earlier step reads as unset later. Put
the check and the `git add` in the same block.
**An unresolvable path is treated as reaching `.planning/`.** `git add "${ARTIFACT}"` is flagged,
because whether `$ARTIFACT` expands under `.planning/` is not knowable statically and the scan
fails closed. A `{placeholder}` in braces with no `$` is documentation notation and does not
trigger it. If your `git add` genuinely cannot touch `.planning/`, name the path literally.
**Only fenced lines are scanned.** Inline-backtick prose — including the anti-pattern
documentation that tells you never to run `git add -A` — is not executable and is not flagged.
The same `# gsd-scan-ignore: #NNN` declaration as the `--files` guard exempts a deliberate
counter-example, on the invocation's own line, in shell-comment position, with a reason naming a
tracking issue or URL. Both guards share one tokenizer and one marker implementation
(`tests/helpers/shipped-command-scan.cjs`) so the two conventions can never drift into two rules
wearing one name.
**Known limits.** The scan (`tests/helpers/planning-add-guard.cjs`) is a token-oriented text scan,
not a shell interpreter, and it targets accidental reintroduction of an unguarded stage by a
contributor editing shipped content — not a determined bypass. Four shapes are confirmed (#3585)
to stage `.planning/` at runtime while scoring zero offenders, and none is a shape GSD content
actually uses: `eval "git add -A"`, `find .planning -type f | xargs git add`, a one-line shell
function body (`f() { git add -A; }`), and a backslash line-continuation split across two physical
lines. The scan also only models `git add` and `git commit -a`/`--all` as staging commands — it
does not recognize `git stash`, `git rm --cached`, `git restore --staged`, or
`git update-index --add`, any of which can also move `.planning/` content into a state a later
commit picks up.
### CI Test Quality Checks
The following checks run on every PR in addition to the test suite:

View File

@@ -258,10 +258,8 @@ Read the completed AI-SPEC.md. Check that:
## 11. Commit
**If `commit_docs` is true:**
```bash
git add "${AI_SPEC_FILE}"
git commit -m "docs({phase_slug}): generate AI-SPEC.md — {primary_framework} + domain context + eval strategy"
gsd_run query commit "docs({phase_slug}): generate AI-SPEC.md — {primary_framework} + domain context + eval strategy" --files "${AI_SPEC_FILE}"
```
## 12. Display Completion

View File

@@ -143,10 +143,8 @@ Read the written EVAL-REVIEW.md. Extract:
## 6. Commit
**If `commit_docs` is true:**
```bash
git add "${EVAL_REVIEW_FILE}"
git commit -m "docs({phase_slug}): add EVAL-REVIEW.md — score {overall_score}/100 ({verdict})"
gsd_run query commit "docs({phase_slug}): add EVAL-REVIEW.md — score {overall_score}/100 ({verdict})" --files "${EVAL_REVIEW_FILE}"
```
</process>

View File

@@ -55,7 +55,16 @@ Do the work directly:
Commit the change atomically:
```bash
git add -A
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
# fast writes no planning artifacts (its own guardrails forbid PLAN.md/SUMMARY.md); .planning/
# is excluded from staging ONLY when commit_docs is false — otherwise this path is
# byte-identical to an unconditional `git add -A`, unchanged from before #3585.
COMMIT_DOCS=$(gsd_run query config-get commit_docs 2>/dev/null || echo "true")
if [ "$COMMIT_DOCS" = "false" ]; then
git add -A -- ':!.planning'
else
git add -A
fi
git commit -m "fix: {concise description of what changed}"
```
@@ -72,7 +81,6 @@ a column count — this replaces the prior inline `awk NF-2` arithmetic that was
the root cause of #2133.
```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
# Detect whether STATE.md has a Quick Tasks Completed table
if grep -q "Quick Tasks Completed" .planning/STATE.md 2>/dev/null; then
gsd_run quick-tasks-append --task "$TASK" || echo "⚠ fast.md log_to_state: could not append Quick Tasks row (see message above); continuing."

View File

@@ -259,9 +259,14 @@ rejects any `--archive-version` value that is not a plain version token (no path
Stage the phase archive move + source removal so they land in the same commit as the milestone start (atomic — no orphaned uncommitted deletions, no un-archived dirs carried forward). `phases.clear` archives each non-999 dir to `milestones/<version>-phases/`; staging both dirs captures the new archive and the removals together (#1871).
```bash
git add .planning/milestones/ .planning/phases/ 2>/dev/null || true
COMMIT_DOCS=$(gsd_run query config-get commit_docs 2>/dev/null || echo "true")
if [ "$COMMIT_DOCS" != "false" ]; then
git add .planning/milestones/ .planning/phases/ 2>/dev/null || true
fi
```
When `commit_docs` is false, the archive move and phase removals are deliberately left unstaged here — not a bug — since Step 6's commit is skipped too.
Stage PROJECT.md in both modes. Step 4's Part A guard — not this commit — is what protects the shared `## Current Milestone` heading (#2308): when a workstream is active Part A never writes it, so the only change PROJECT.md can carry here is Part B's idempotent `## Evolution` backfill, which must be committed rather than stranded as a dangling edit. Do NOT reintroduce a `[ -n "$GSD_WS" ]` branch around this commit: `GSD_WS` is set in Step 1's shell and each step's bash block runs in its own shell (the same reason Step 5 round-trips `OUTGOING_MILESTONE` through a file), so such a guard reads an unset variable, always takes the flat-mode branch, and only appears to work.
```bash

View File

@@ -477,11 +477,10 @@ Write to: `{phase_dir}/{padded_phase}-SPEC.md`
## Step 7: Commit
```bash
git add "${phase_dir}/${padded_phase}-SPEC.md"
git commit -m "spec(phase-${phase_number}): add SPEC.md for ${phase_name} — ${requirement_count} requirements (#2213)" -- "${phase_dir}/${padded_phase}-SPEC.md"
gsd_run query commit "spec(phase-${phase_number}): add SPEC.md for ${phase_name} — ${requirement_count} requirements (#2213)" --files "${phase_dir}/${padded_phase}-SPEC.md"
```
If `commit_docs` is false: Skip commit. Note that SPEC.md was written but not committed.
If `commit_docs` is false the CLI returns `skipped`; SPEC.md is written, not committed.
## Step 8: Wrap Up

View File

@@ -1,99 +1,381 @@
/**
* commit_docs bypass guard tests (#1783)
* commit_docs bypass guard (#1783; superseded/widened by #3585)
*
* When users set commit_docs: false during /gsd-new-project, .planning/
* files should never be staged or committed. The gsd-tools.cjs commit
* wrapper already checks this flag, but three locations in execute-phase.md
* and quick.md used raw `git add .planning/` commands that bypassed it.
* #1783: when users set commit_docs: false during /gsd-new-project,
* .planning/ files should never be staged or committed. The gsd-tools.cjs
* commit wrapper already checks this flag, but three locations in
* execute-phase.md and quick.md used raw `git add .planning/` commands that
* bypassed it.
*
* These tests verify that every `git add .planning/` invocation (explicit
* or via file_list) is preceded by a commit_docs config check.
* The original guard here was a two-file allowlist (execute-phase.md,
* quick.md) whose regex required the literal substring `.planning/` on the
* SAME line as `git add`. That is structurally blind to `git add -A` /
* `git add .` / `git add -u` — none of those lines mention `.planning/` at
* all, yet every one of them stages the whole index, including
* `.planning/`, regardless of the config. #3585 replaces it with a
* repo-wide scan that classifies `git add` invocations by what they can
* actually reach (the blanket/wildcard forms, `.planning`-qualified paths in
* either separator convention, and any argument carrying an unresolved
* shell variable, fail-closed), across every scan root that carries live
* workflow/agent/command/skill/reference content — not just the original
* two files.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const fc = require('fast-check');
const {
hasReachingGitAdd, scanText, scanRepo, SCAN_ROOTS,
} = require('./helpers/planning-add-guard.cjs');
const EXECUTE_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
const QUICK_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
const REPO_ROOT = path.join(__dirname, '..');
describe('commit_docs bypass guard (#1783)', () => {
describe('commit_docs bypass guard (#1783, repo-wide per #3585)', () => {
// ── Layer 1: the pure classifier, over inline string fixtures ──────────
test('execute-phase.md: every git add .planning/ has a commit_docs guard', () => {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
const lines = content.split(/\r?\n/);
describe('A: argument-reach rules', () => {
test('A1: git add .planning/STATE.md reaches', () => {
assert.strictEqual(hasReachingGitAdd('git add .planning/STATE.md'), true);
});
for (let i = 0; i < lines.length; i++) {
if (/git add\b.*\.planning\//.test(lines[i])) {
// Search backwards from this line for a config-get commit_docs check
const windowStart = Math.max(0, i - 10);
const window = lines.slice(windowStart, i).join('\n');
assert.ok(
window.includes('config-get commit_docs'),
`git add .planning/ at line ${i + 1} in execute-phase.md must be guarded by a commit_docs config check`
);
}
}
test('A2: git add -A reaches', () => {
assert.strictEqual(hasReachingGitAdd('git add -A'), true);
});
test('A3: git add --all reaches', () => {
assert.strictEqual(hasReachingGitAdd('git add --all'), true);
});
test('A4: git add . reaches', () => {
assert.strictEqual(hasReachingGitAdd('git add .'), true);
});
test('A5: git add -u reaches', () => {
assert.strictEqual(hasReachingGitAdd('git add -u'), true);
});
test('A6: git add src/api/auth.ts does NOT reach', () => {
assert.strictEqual(hasReachingGitAdd('git add src/api/auth.ts'), false);
});
test('A7: git add {test_files} does NOT reach (brace placeholder, no $)', () => {
assert.strictEqual(hasReachingGitAdd('git add {test_files}'), false);
});
test('A8: git add "${QUICK_DIR}/x-PLAN.md" reaches (rule c: unresolved variable)', () => {
assert.strictEqual(hasReachingGitAdd('git add "${QUICK_DIR}/x-PLAN.md"'), true);
});
test('A9: git add .planning\\STATE.md reaches (Windows separator)', () => {
assert.strictEqual(hasReachingGitAdd('git add .planning\\STATE.md'), true);
});
test("A10: git add -A -- ':!.planning' does NOT reach (exclude pathspec overrides -A)", () => {
assert.strictEqual(hasReachingGitAdd("git add -A -- ':!.planning'"), false);
});
test('A11: V=$(git add -A) reaches (substitution-glued assignment prefix, #3585)', () => {
assert.strictEqual(hasReachingGitAdd('V=$(git add -A)'), true);
});
test('A12: FOO=1 git add -A still reaches (plain assignment prefix, not a substitution)', () => {
assert.strictEqual(hasReachingGitAdd('FOO=1 git add -A'), true);
});
test('A13: git -C /tmp/x add -A reaches (git-level -C flag consumes its value, #3585)', () => {
assert.strictEqual(hasReachingGitAdd('git -C /tmp/x add -A'), true);
});
test('A14: git --no-pager add -A reaches (a value-less flag must not swallow "add")', () => {
assert.strictEqual(hasReachingGitAdd('git --no-pager add -A'), true);
});
test('A15: git -c foo.bar=1 add -A reaches (git-level -c flag consumes its value)', () => {
assert.strictEqual(hasReachingGitAdd('git -c foo.bar=1 add -A'), true);
});
test('A16: git add "$(cat list.txt)" reaches (command substitution, #3585 F2)', () => {
assert.strictEqual(hasReachingGitAdd('git add "$(cat list.txt)"'), true);
});
test('A17: git add --pathspec-from-file=paths.txt reaches (#3585 F2)', () => {
assert.strictEqual(hasReachingGitAdd('git add --pathspec-from-file=paths.txt'), true);
});
test('A18: git commit -a -m "x" reaches (#3585 F3)', () => {
assert.strictEqual(hasReachingGitAdd('git commit -a -m "x"'), true);
});
test('A19: git commit -am "x" reaches (combined short cluster, #3585 F3)', () => {
assert.strictEqual(hasReachingGitAdd('git commit -am "x"'), true);
});
test('A20: git commit -m "x" does NOT reach (no -a/--all flag, #3585 F3)', () => {
assert.strictEqual(hasReachingGitAdd('git commit -m "x"'), false);
});
});
test('quick.md: every git add .planning/ has a commit_docs guard', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
const lines = content.split(/\r?\n/);
describe('B: invocation recognition', () => {
test('B1: a bare fenced line is an invocation', () => {
assert.strictEqual(hasReachingGitAdd('git add -A'), true);
});
for (let i = 0; i < lines.length; i++) {
if (/git add\b.*\.planning\//.test(lines[i])) {
const windowStart = Math.max(0, i - 10);
const window = lines.slice(windowStart, i).join('\n');
assert.ok(
window.includes('config-get commit_docs'),
`git add .planning/ at line ${i + 1} in quick.md must be guarded by a commit_docs config check`
);
}
}
test('B3: a quoted echo of "git add -A" is NOT an invocation', () => {
assert.strictEqual(
hasReachingGitAdd('[ -n "$X" ] && echo " 1. git add -A && git commit -m \'wip\'" >&2'),
false,
);
});
test('B5: bash -c "git add -A" IS an invocation', () => {
assert.strictEqual(hasReachingGitAdd('bash -c "git add -A"'), true);
});
test('B6: echo bash -c "git add -A" is NOT an invocation (echo prints, does not run)', () => {
assert.strictEqual(hasReachingGitAdd('echo bash -c "git add -A"'), false);
});
test('B7: cd "$X" && git add -A IS an invocation', () => {
assert.strictEqual(hasReachingGitAdd('cd "$X" && git add -A'), true);
});
test('B8: # git add -A (a shell comment) is NOT an invocation', () => {
assert.strictEqual(hasReachingGitAdd('# git add -A'), false);
});
});
test('quick.md: git add ${file_list} has a commit_docs guard for .planning/ filtering', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
const lines = content.split(/\r?\n/);
// ── Layer 1 continued: fence + commit_docs guard state (needs multi-line
// file context, so these drive scanText over small synthetic documents) ──
// Find the line(s) that do `git add ${file_list}` — this variable
// includes .planning/STATE.md so it needs a commit_docs guard too
for (let i = 0; i < lines.length; i++) {
if (/git add\s+\$\{?file_list/.test(lines[i])) {
const windowStart = Math.max(0, i - 10);
const window = lines.slice(windowStart, i + 1).join('\n');
assert.ok(
window.includes('config-get commit_docs'),
`git add \${file_list} at line ${i + 1} in quick.md must be guarded by a commit_docs check ` +
`because file_list includes .planning/ files`
);
}
}
describe('C: fence and commit_docs guard tracking', () => {
const fenced = (bodyLines) => ['```bash', ...bodyLines, '```'].join('\n');
test('C1: a guard enclosing the add is guarded (no offender)', () => {
const { offenders } = scanText('fixture.md', fenced([
'COMMIT_DOCS=$(gsd_run query config-get commit_docs --default true)',
'if [ "$COMMIT_DOCS" != "false" ]; then',
' git add .planning/STATE.md',
'fi',
]));
assert.deepStrictEqual(offenders, []);
});
test('C2: an add BEFORE the if is unguarded', () => {
const { offenders } = scanText('fixture.md', fenced([
'git add .planning/STATE.md',
'if [ "$COMMIT_DOCS" != "false" ]; then',
' echo noop',
'fi',
]));
assert.strictEqual(offenders.length, 1);
});
test('C3: an add AFTER the fi is unguarded', () => {
const { offenders } = scanText('fixture.md', fenced([
'if [ "$COMMIT_DOCS" != "false" ]; then',
' echo noop',
'fi',
'git add .planning/STATE.md',
]));
assert.strictEqual(offenders.length, 1);
});
test('C4: markdown prose "**If commit_docs is true:**" outside the fence is not a guard', () => {
const text = [
'**If `commit_docs` is true:**',
'```bash',
'git add "${EVAL_REVIEW_FILE}"',
'```',
].join('\n');
const { offenders } = scanText('fixture.md', text);
assert.strictEqual(offenders.length, 1);
});
test('C5: the real eval-review.md shape (prose line immediately before the fence) is not a guard', () => {
const text = [
'## 6. Commit',
'',
'**If `commit_docs` is true:**',
'```bash',
'git add "${EVAL_REVIEW_FILE}"',
'git commit -m "docs: add EVAL-REVIEW.md"',
'```',
].join('\n');
const { offenders } = scanText('fixture.md', text);
assert.strictEqual(offenders.length, 1);
});
test('C6: a nested if inside the guard stays guarded', () => {
const { offenders } = scanText('fixture.md', fenced([
'COMMIT_DOCS=$(gsd_run query config-get commit_docs --default true)',
'if [ "$COMMIT_DOCS" != "false" ]; then',
' if [ -f somefile ]; then',
' git add .planning/STATE.md',
' fi',
'fi',
]));
assert.deepStrictEqual(offenders, []);
});
test('C7: a guard in a DIFFERENT fenced block does not carry over', () => {
const text = [
'```bash',
'if [ "$COMMIT_DOCS" != "false" ]; then',
'```',
'',
'```bash',
'git add .planning/STATE.md',
'```',
].join('\n');
const { offenders } = scanText('fixture.md', text);
assert.strictEqual(offenders.length, 1);
});
test('C8: CRLF line endings behave identically to LF (the C1 fixture, \\r\\n joined)', () => {
const { offenders } = scanText('fixture.md', fenced([
'COMMIT_DOCS=$(gsd_run query config-get commit_docs --default true)',
'if [ "$COMMIT_DOCS" != "false" ]; then',
' git add .planning/STATE.md',
'fi',
]).split('\n').join('\r\n'));
assert.deepStrictEqual(offenders, []);
});
test('C9: the = "true" polarity is a guard too (both polarities accepted)', () => {
const { offenders } = scanText('fixture.md', fenced([
'COMMIT_DOCS=$(gsd_run query config-get commit_docs --default true)',
'if [ "$COMMIT_DOCS" = "true" ]; then',
' git add .planning/STATE.md',
'fi',
]));
assert.deepStrictEqual(offenders, []);
});
// PIN, not a failing-first regression test (#3585 F6): this assertion
// does not distinguish the unclamped-ifDepth code from the clamped
// fix — a 200k-case differential fuzz over `if`/`fi` nestings found zero
// divergence between them, and this exact fixture passes identically on
// pre-fix code. That is because guardOpenDepth is only ever SET from a
// commit_docs/tracked-var-mentioning `if`, and that guard's own `fi`
// always closes it via a RELATIVE depth comparison regardless of the
// (possibly negative, pre-clamp) baseline. The `Math.max(0, ...)` clamp
// is kept anyway as DEFENSIVELY correct — no input was found, in 200k
// fuzzed cases, that makes the unclamped code diverge from the clamped
// one. This test PINS the malformed-shell ("stray fi") behavior for
// both versions; it does not demonstrate a fix for a reproduced defect.
test('C10: pins malformed-shell behavior — a stray fi before an unrelated if does not manufacture a guard (fails closed)', () => {
const { offenders } = scanText('fixture.md', fenced([
'fi',
'if [ "$X" = "1" ]; then',
' git add -A',
'fi',
]));
assert.strictEqual(offenders.length, 1, 'git add -A must still be reported as unguarded');
});
});
test('no raw git add .planning/ without commit_docs guard in any workflow', () => {
const workflows = [
{ name: 'execute-phase.md', path: EXECUTE_PHASE_PATH },
{ name: 'quick.md', path: QUICK_PATH },
];
describe('D: gsd-scan-ignore declaration handling', () => {
const fenced = (line) => ['```bash', line, '```'].join('\n');
for (const wf of workflows) {
const content = fs.readFileSync(wf.path, 'utf-8');
test('D1: declared with a tracked issue (#3585) is exempt', () => {
const { offenders, untracked } = scanText(
'fixture.md',
fenced('git add -A # gsd-scan-ignore: #3585 demonstrating the unscoped shape'),
);
assert.deepStrictEqual(offenders, []);
assert.deepStrictEqual(untracked, []);
});
// Find all occurrences of git add that reference .planning/
const regex = /git add\b[^\r\n]*\.planning\//g;
let match;
while ((match = regex.exec(content)) !== null) {
// Get the 500-char window before this match
const before = content.slice(Math.max(0, match.index - 500), match.index);
assert.ok(
before.includes('config-get commit_docs'),
`${wf.name}: found unguarded git add .planning/ near offset ${match.index}. ` +
`All raw git add .planning/ commands must check commit_docs config first.`
);
}
}
test('D2: an empty reason is malformed, not exempt', () => {
const { offenders, untracked } = scanText(
'fixture.md',
fenced('git add -A # gsd-scan-ignore:'),
);
assert.strictEqual(offenders.length, 1, 'not exempted — still an offender');
assert.strictEqual(untracked.length, 1, 'and reported as a malformed declaration');
});
test('D3: "just a note" (no tracking reference) is malformed, not exempt', () => {
const { offenders, untracked } = scanText(
'fixture.md',
fenced('git add -A # gsd-scan-ignore: just a note'),
);
assert.strictEqual(offenders.length, 1, 'not exempted — still an offender');
assert.strictEqual(untracked.length, 1, 'and reported as a malformed declaration');
});
test('D4: a marker surviving as an argv token declares nothing', () => {
const { offenders, untracked } = scanText(
'fixture.md',
fenced('git add .planning/STATE.md "gsd-scan-ignore: #3585"'),
);
assert.strictEqual(offenders.length, 1, 'plain unguarded offender');
assert.deepStrictEqual(untracked, [], 'the marker never reached comment position, so no declaration was attempted');
});
});
// ── Property test (#3585 F5): a parser needs a property test, per
// CLAUDE.md, mirroring the fast-check coverage already carried by the
// sibling classifier in tests/commit-files-pathspec.test.cjs. ───────────
describe('P: property — hasReachingGitAdd', () => {
// Bounded alphabet, deliberately small and non-overlapping with the
// trigger set below so a filler token never accidentally reaches on its
// own — every failure the property can find is attributable to the
// trigger token, not to generator noise.
const fillerToken = fc.constantFrom('src/a.ts', 'docs/readme.md', 'foo', 'bar123', 'lib/x.js');
const blanketFlag = fc.constantFrom('-A', '--all', '.', '-u');
const planningPath = fc.constantFrom(
'.planning/STATE.md',
'.planning/todos/pending/x.md',
'.planning/ROADMAP.md',
);
const triggerToken = fc.oneof(blanketFlag, planningPath);
test('P1: a line with a blanket flag or a .planning-rooted path, and no exclude pathspec, always reaches', () => {
fc.assert(
fc.property(
fc.array(fillerToken, { maxLength: 4 }),
triggerToken,
fc.array(fillerToken, { maxLength: 4 }),
(before, trigger, after) => {
const line = ['git', 'add', ...before, trigger, ...after].join(' ');
assert.strictEqual(
hasReachingGitAdd(line),
true,
`a git add line carrying a blanket flag or a .planning-rooted path, with no `
+ `exclude pathspec, must reach .planning/: ${line}`,
);
},
),
// Pinned seed + bounded numRuns per CONTRIBUTING.md/CLAUDE.md
// determinism rule — deterministic, replayable failures.
{ seed: 3585, numRuns: 200 },
);
});
});
// ── Layer 2: the real tree ──────────────────────────────────────────────
test('every git add invocation in shipped content is guarded or declared', () => {
const { offenders, untracked } = scanRepo(REPO_ROOT, SCAN_ROOTS);
assert.deepStrictEqual(
untracked,
[],
'gsd-scan-ignore: declarations without a tracking reference. Add a #NNN issue '
+ 'number or an http(s):// URL to the reason, per ADR-456:\n'
+ untracked.map((u) => `${u.file}:${u.line}: ${u.text}`).join('\n'),
);
assert.deepStrictEqual(
offenders,
[],
'git add invocations that can reach .planning/ without a commit_docs guard or a '
+ 'tracked gsd-scan-ignore declaration (#1783 / #3585):\n\n'
+ offenders.map((o) => `${o.file}:${o.line}: ${o.text}`).join('\n'),
);
});
});
@@ -243,8 +525,13 @@ const REPO_ROOT = path.resolve(__dirname, '..');
const EXECUTOR_AGENT = path.join(REPO_ROOT, 'agents', 'gsd-executor.md');
// Frozen reason enum mirrors the SDK source — keep in sync with
// `cmdCommit` in gsd-core/bin/lib/commands.cjs.
// Frozen reason enum mirrors the SDK source. Both members are now
// behaviorally pinned against the live envelope returned by `cmdCommit` in
// gsd-core/bin/lib/commands.cjs, not kept in sync by hand-reading the
// source: SKIPPED_COMMIT_DOCS_FALSE by describe block B (B1-B3, explicit
// `commit_docs: false`) and SKIPPED_GITIGNORED by describe block G (G1-G3,
// gitignore auto-detect). A production rename of either string now fails
// these tests instead of drifting silently behind the comment (#3585).
const COMMIT_REASON = Object.freeze({
SKIPPED_COMMIT_DOCS_FALSE: 'skipped_commit_docs_false',
SKIPPED_GITIGNORED: 'skipped_gitignored',
@@ -369,6 +656,87 @@ describe('bug #3678 — executor must respect commit_docs:false', () => {
});
});
describe('G — gitignore auto-detect pins the other COMMIT_REASON member', () => {
// Parity anchor for #3585's Generative Fix Divergence finding: describe
// block B above behaviorally pins SKIPPED_COMMIT_DOCS_FALSE (an explicit
// `commit_docs: false` in config.json), but SKIPPED_GITIGNORED was pinned
// by nothing except the "keep in sync" comment on COMMIT_REASON above —
// production could rename that string and every existing test here would
// still pass. This block drives the OTHER path: `.planning/config.json`
// is left absent entirely (not present-with-key-unset — a present,
// even-empty config.json routes through loadConfigResolved's own
// gitignore auto-detect override, which resolves `commit_docs` to
// `false` and returns SKIPPED_COMMIT_DOCS_FALSE first, never reaching
// the gitignored reason), so `commit_docs` is never set anywhere and the
// skip is driven purely by `.gitignore` containing `.planning/`.
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
// .planning/ already exists from createTempGitProject's setup, and no
// config.json is written — commit_docs is left completely absent.
const gitignorePath = path.join(tmpDir, '.gitignore');
fs.writeFileSync(gitignorePath, '.planning/\n');
git(['add', gitignorePath], tmpDir);
git(['commit', '-m', 'chore: add gitignore'], tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
if (!fs.existsSync(statePath)) {
fs.writeFileSync(statePath, '---\nproject: test\n---\n# State\n');
}
fs.appendFileSync(statePath, '\n<!-- token edit for #3585 gitignore-auto-detect parity anchor -->\n');
});
afterEach(() => cleanup(tmpDir));
test('G1: commit returns skipped:true with reason SKIPPED_GITIGNORED (parity anchor for the enum member the comment claims is kept in sync)', () => {
const result = runGsdTools(
'commit "docs(test): noop" --files .planning/STATE.md',
tmpDir,
);
assert.ok(result.success, `gsd-tools commit should exit 0 even when skipped: ${result.error || ''}`);
const envelope = JSON.parse(result.output);
assert.strictEqual(envelope.skipped, true, 'envelope must carry skipped:true for the gitignore auto-detect path');
assert.strictEqual(
envelope.reason,
COMMIT_REASON.SKIPPED_GITIGNORED,
'reason must be the canonical skipped_gitignored code (frozen enum) — this is the '
+ 'behavioral pin for the member describe block B never exercises',
);
});
test('G2: gitignore auto-detect skip leaves the git index empty (no .planning/ staged)', () => {
runGsdTools(
'commit "docs(test): noop" --files .planning/STATE.md',
tmpDir,
);
const stagedAll = git(['diff', '--cached', '--name-only'], tmpDir);
const stagedPlanning = stagedAll
.split(/\r?\n/)
.map(s => s.trim())
.filter(s => s.startsWith('.planning/'));
assert.deepStrictEqual(
stagedPlanning,
[],
'no .planning/ files should be staged when .planning/ is gitignored',
);
});
test('G3: gitignore auto-detect skip produces no new commits', () => {
const headBefore = git(['rev-parse', 'HEAD'], tmpDir).trim();
runGsdTools(
'commit "docs(test): noop" --files .planning/STATE.md',
tmpDir,
);
const headAfter = git(['rev-parse', 'HEAD'], tmpDir).trim();
assert.strictEqual(
headAfter,
headBefore,
'HEAD must not advance when .planning/ is gitignored',
);
});
});
test('checklist carve-out preserved for intentional skip', () => {
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
const checklistLine = body

View File

@@ -22,6 +22,10 @@ const { createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const {
bareCommandName, tokenize, shellDashCPayloads, commentPortion,
ISSUE_REF_RE, declarationReason, isDeclared, isUntrackedDeclaration,
} = require('./helpers/shipped-command-scan.cjs');
describe('commit --files: pathspec honors declared scope (#2112)', () => {
let tmpDir;
@@ -877,160 +881,8 @@ describe('workflow call sites declare --files (#2269)', () => {
//
// WHAT PRECEDES THE OPENER IS NOT ENUMERABLE, so it is not enumerated. The
// first cut of this rule keyed on an assignment prefix, because 437 of the
// 443 live substitution sites are `VAR=$(gsd_run …)`. That is the majority
// and it is the wrong generalisation — the remaining six are neither, and
// they are live today:
// for REVIEW_FLAG in $(gsd_run review-lane flags) (×3)
// ${PLAN_PRE_HOOKS_JSON:-$(gsd_run loop render-hooks …)} (×3)
// A parameter-expansion default glues just as hard as an assignment, and so
// does ordinary text (`pre$(…)`) and an indexed assignment (`A[0]=$(…)`).
// Enumerating prefixes means a new one every time the shell is used
// idiomatically, and each miss is silent. So the rule is positional instead:
// strip everything up to and including the LAST substitution opener in the
// token. Whatever preceded it was, by construction, not the command.
//
// ARITHMETIC IS EXCLUDED BY THE SAME MECHANISM, not by a special case.
// `$((x))` leaves a `(` in front of the name after the strip, and a leading
// `(` is not part of any binary name — so it is refused. That mirrors the
// shell, which also requires `$( (` with a space before it will read a nested
// subshell rather than an arithmetic expansion.
//
// The ARRAY LITERAL stays invisible for free: `arr=(a b c)` contains no `$(`
// at all, so neither alternative fires and the token is left whole. It is
// pinned as a zero-candidate case regardless, because that is a property of
// this regex rather than of the grammar and the next edit could lose it.
// THE STRIP IS REFUSED ON A TOKEN THAT CARRIED A QUOTE, because quoting is
// exactly what decides whether an opener is syntax or data. `printf %s
// '$(gsd_run' query commit fixup` runs no gsd command — the opener is
// single-quoted literal text — but quote removal leaves the token `$(gsd_run`
// and a strip would then read it as a binary, fabricating an invocation out
// of a string. The tokenizer already knows; it just was not saying.
//
// RESIDUAL, named rather than implied: this closes the strip's exposure, not
// the tokenizer's quote-blindness in general. `printf %s 'gsd_run' query
// commit fixup` still reads as an invocation, because no markup is stripped
// there — the token's value simply IS the binary name. That predates this
// rule and is unchanged by it; closing it needs quote provenance carried
// through the command-shape test, not just through the strip. The direction
// is a visible false positive with a declared remedy, not a silent miss.
const bareCommandName = (t) => {
const mask = t.qmask || '0'.repeat(t.value.length);
// The LAST opener whose own two characters were unquoted. Per-character,
// not per-token: a quote elsewhere in the word protects only itself, and
// `echo "pre"$(gsd_run …)` really does run.
for (let i = t.value.length - 2; i >= 0; i -= 1) {
if (t.value[i] === '$' && t.value[i + 1] === '(' && mask[i] === '0' && mask[i + 1] === '0') {
return t.value.slice(i + 2);
}
}
let j = 0;
while (j < t.value.length && t.value[j] === '(' && mask[j] === '0') j += 1;
return t.value.slice(j);
};
const isGsdBinary = (t) => GSD_BINARY_RE.test(bareCommandName(t));
// Shell-like word splitting. Honours BOTH quote characters (the previous
// parity walk counted only `"`, so a single-quoted message was transparent
// to it in both directions), backslash escapes, and unquoted control
// operators — which become their own tokens, so an operator glued to its
// neighbour (`"a"&&gsd_run`) still separates. Quoted operators and quoted
// whitespace stay literal, which is what keeps a command substitution inside
// a message ("$(gsd-tools query phase-list)") from reading as a second
// invocation, with no separator lookahead needed.
//
// Beyond the operators, the shell consumes THREE more things before argv
// exists, and each one previously leaked into the token list as a value —
// so `--files >/dev/null 2>&1 || true` (a live tail shape in
// gsd-core/references/execute-phase-requirement-revert.md) scored as scoped
// while the runtime saw files=[] and took the blanket-.planning/ default:
// - a single `&` is a control operator like `;` (`&&` is checked first);
// - an unquoted `#` at the START of a word begins a comment and ends the
// command (mid-word `#` stays literal, as in the shell);
// - redirections (`> x`, `>> x`, `2>&1`, `< x`, with or without a space
// before the target) are consumed as `redir: true` tokens, along with a
// glued all-digit IO number, and consumers exclude them from argv.
// Variable tokens ($VAR / ${VAR}) stay ordinary values DELIBERATELY: whether
// one expands to something is unknowable statically, and the three original
// #2269 fix sites all pass `--files "${PHASE_DIR}/…"` — flagging `$`-tokens
// would false-flag every one of them.
//
// Never throws, and an unterminated quote simply runs to end of input: these
// inputs are substrings sliced out of prose, so a torn quote is expected
// rather than exceptional. Tokens carry offsets so callers can slice the
// ORIGINAL text and report a real excerpt instead of a re-joined echo.
//
// Operators are marked STRUCTURALLY (`op: true`), never re-identified by
// comparing a token's text against the operator set. That distinction is the
// whole point of tokenizing: `commit '|' --files a.md` produces a token
// whose VALUE is `|` and which is ordinary message text, and a consumer that
// matched on value would split there and score a scoped invocation as
// unscoped — the identify-structure-by-text mistake this rewrite exists to
// remove, reintroduced one layer up. (Caught by the delimiter-generating
// property below, on its first run.)
const tokenize = (str) => {
const tokens = [];
let cur = '';
let started = false;
let start = 0;
let quote = null;
// A PER-CHARACTER quote mask, parallel to `cur`: '1' where the character was
// quoted or backslash-escaped (i.e. protected, therefore data), '0' where it
// was bare syntax. A per-TOKEN boolean is the obvious shape and it is wrong
// in the unaffordable direction — `echo "pre"$(gsd_run …)` executes, and a
// token-wide flag suppresses the strip on it, turning a false positive into
// a silent false negative.
let curMask = '';
const add = (text, protectedChar) => {
cur += text;
curMask += (protectedChar ? '1' : '0').repeat(text.length);
};
const begin = (i) => {
if (!started) { started = true; start = i; }
};
const flush = (end) => {
if (started) tokens.push({ value: cur, start, end, qmask: curMask });
cur = '';
curMask = '';
started = false;
};
for (let i = 0; i < str.length; i += 1) {
const ch = str[i];
if (quote) {
if (ch === '\\' && quote === '"' && i + 1 < str.length) { add(str[i + 1], true); i += 1; continue; }
if (ch === quote) { quote = null; continue; }
add(ch, true);
continue;
}
if (ch === '\\' && i + 1 < str.length) { begin(i); add(str[i + 1], true); i += 1; continue; }
if (ch === '"' || ch === "'") { begin(i); quote = ch; continue; }
if (/\s/.test(ch)) { flush(i); continue; }
// An unquoted # at the start of a word ends the command line — the rest
// is comment and never reaches argv. Mid-word (`PR#42`) it is literal.
if (ch === '#' && !started) { flush(i); break; }
// Redirections: the operator and its target are consumed by the shell,
// not passed as arguments. A glued all-digit word is an IO number
// (`2>&1`) and belongs to the redirection, not to argv.
if (ch === '>' || ch === '<') {
if (started && /^[0-9]+$/.test(cur)) { cur = ''; curMask = ''; started = false; } else { flush(i); }
let j = i + 1;
if (str[j] === ch) j += 1; // >> / <<
if (str[j] === '&') j += 1; // >& (2>&1)
while (j < str.length && /[ \t]/.test(str[j])) j += 1; // gap before target
while (j < str.length && !/[\s;|&<>]/.test(str[j])) j += 1; // the target word
tokens.push({ value: str.slice(i, j), start: i, end: j, redir: true });
i = j - 1;
continue;
}
const pair = str.slice(i, i + 2);
if (pair === '&&' || pair === '||') { flush(i); tokens.push({ value: pair, start: i, end: i + 2, op: true }); i += 1; continue; }
if (ch === ';' || ch === '|' || ch === '&') { flush(i); tokens.push({ value: ch, start: i, end: i + 1, op: true }); continue; }
begin(i);
add(ch, false);
}
flush(str.length);
return tokens;
};
// routeCommit's predicate, verbatim, over one invocation's tokens. Stops at
// the first control operator so that a later command's --files can never
// vouch for this one even when the caller passes an unsegmented line, and
@@ -1174,168 +1026,6 @@ describe('workflow call sites declare --files (#2269)', () => {
return spans;
};
// The comment portion of a line — the same rule tokenize() applies, so the
// two cannot disagree about where the command ends: an unquoted `#` at word
// start begins a comment, and a `#` inside quotes or mid-word is literal
// (ship.md commits with `PR #${PR_NUMBER}` in the message).
// It tracks WORD START the way tokenize does, not "preceded by whitespace".
// Those differ, and the difference is exploitable: in `commit docs:\ # x` the
// backslash escapes the space, so the shell keeps `docs: #` as ONE word and
// `#` is literal — while a preceded-by-whitespace test reads it as a comment.
// The scan would then exempt a line the runtime still executes unscoped.
const commentPortion = (line) => {
let quote = null;
let started = false;
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if (quote) {
if (ch === '\\' && quote === '"' && i + 1 < line.length) { i += 1; continue; }
if (ch === quote) quote = null;
continue;
}
if (ch === '\\' && i + 1 < line.length) { started = true; i += 1; continue; }
if (ch === '"' || ch === "'") { started = true; quote = ch; continue; }
if (/\s/.test(ch)) { started = false; continue; }
if (ch === '#' && !started) return line.slice(i);
if (ch === '>' || ch === '<' || ch === ';' || ch === '|' || ch === '&') { started = false; continue; }
started = true;
}
return '';
};
// The explicit opt-out for content that shows the defect ON PURPOSE. A
// wrong-example is otherwise indistinguishable from a regression — which is
// exactly why no property of the surrounding markup can stand in for the
// author's intent — so the author declares it, with a reason.
//
// IT IS ONLY A MARKER IN COMMENT POSITION. Matching the token anywhere on
// the line let a commit MESSAGE carry it — `commit "docs: explain
// gsd-scan-ignore: semantics"` — and silently exempt a real offender. A
// false-negative escape hatch is the one thing this file must not ship, and
// an exemption keyed to attacker-controlled-looking text is precisely that.
// AND IT MUST CARRY A TRACKING ISSUE. An exemption whose reason is free text
// has no expiry and no ledger — the `permanent-allow-test-rule` shape
// RULESET.TESTS.delete-bad-tests names. The repo already answered this for
// its sibling convention under ADR-456 (see #2269), enforced by
// scripts/lint-allow-test-rule-refs. That lint walks tests/ only, so it
// cannot see a marker living in a .md file; rather than teach a second token
// to a script whose whole contract is the ESLint comment form, this scan
// enforces the same rule over its own roots — it already runs in CI on every
// shard.
//
// THE PREDICATE IS THAT LINT'S, MIRRORED RATHER THAN RESTATED. Its
// ISSUE_REF_RE is `/#\d+|https?:\/\//` — `http://` counts, and `#\d+` has no
// trailing boundary. A hand-written near-copy drifts from it in exactly those
// details, and a contributor who satisfies one marker and not the other has
// been given two conventions wearing one name. (Both details were found by
// review against prose here that claimed HTTPS-only. The regex was right and
// the sentence describing it was not, which is its own lesson.)
const ISSUE_REF_RE = /#\d+|https?:\/\//;
//
// The reason is EXTRACTED, not pattern-matched in one shot, so that a marker
// with no reason at all is still recognized as an ATTEMPT. A rejected
// declaration must not fail as a MYSTERY: left un-exempt and nothing more,
// its author is told their commit is unscoped — a true statement about a line
// they had already explained, which is the mangle-until-it-shuts-up loop the
// marker exists to prevent. `# gsd-scan-ignore:` with an empty reason is the
// likeliest way to get this wrong and so is the case that most needs the
// specific diagnosis; a `\S`-requiring pattern silently drops it.
const SCAN_IGNORE_REASON_RE = /gsd-scan-ignore:(.*)$/;
const declarationReason = (portion) => {
const m = SCAN_IGNORE_REASON_RE.exec(portion);
return m === null ? null : m[1];
};
// Two independent conditions, and the second is the structural one: the
// marker must be in comment position AND must not survive tokenization as an
// ARGUMENT. tokenize() drops everything from a real comment onward, so a
// genuine declaration leaves no token carrying the token; anything that does
// reached argv, which means the runtime would have executed it. That makes
// "authored argument text can declare the line exempt" impossible by
// construction rather than by getting commentPortion's edges exactly right —
// and commentPortion has edges (redirection targets, escaped separators)
// where mirroring the tokenizer perfectly is fiddly and a miss is silent.
// The marker's reason, but only when the marker is in COMMENT position and
// survives neither as an argv token. Returns null when there is no attempt.
const declaredReasonFor = (line) => {
if (tokenize(line).some((t) => /gsd-scan-ignore:/.test(t.value))) return null;
return declarationReason(commentPortion(line));
};
const isDeclared = (line) => {
const reason = declaredReasonFor(line);
return reason !== null && ISSUE_REF_RE.test(reason);
};
// An ATTEMPTED declaration that carries no tracking reference — including one
// with no reason at all. Reported on its own terms rather than silently
// failing to exempt: see the extraction note above.
const isUntrackedDeclaration = (line) => {
const reason = declaredReasonFor(line);
return reason !== null && !ISSUE_REF_RE.test(reason);
};
// A shell invoked with -c runs its next argument AS A COMMAND, so the
// invocation lives inside a quoted token and no amount of markup-stripping
// reaches it: `bash -c "gsd_run query commit fixup"` scored zero candidates
// while being perfectly executable. The recursion is keyed on the INVOKER,
// not on "a quoted token that parses as an invocation" — that wider rule
// would flag a commit MESSAGE quoting a command, a false positive against
// ordinary documentation. Here the outer command is bash, so a gsd commit
// message can never reach it.
// The invoker set, widened past the four spellings the first cut guessed at.
// `ash`, `csh`, `tcsh`, `fish` and `yash` all take -c and all run what
// follows; leaving them out is a silent false negative in a function whose
// entire job is reaching a command the tokenizer cannot see.
const SHELL_INVOKER_RE = /^(?:.*\/)?(?:ba|da|k|z|a|c|tc|fi|ya)?sh$/;
const shellDashCPayloads = (str) => {
const payloads = [];
let group = [];
const drain = () => {
if (!group.length) { return; }
// THE INVOKER MUST BE THE COMMAND, not merely present in the segment.
// Searching anywhere made `echo bash -c "gsd_run query commit fixup"` a
// candidate — `echo` prints the string, it does not run it — which is a
// false positive against prose that merely quotes a command line. Only an
// env assignment, a shell keyword, or a list/prompt marker may precede the
// command name. Those are not commands; `echo` is, which is exactly the
// line the search-anywhere version got wrong.
// Shell keywords, prompt/list markers, and the command MODIFIERS that
// pass straight through to the command after them. The modifiers are the
// mirror of the invoker-set gap: omitting them is a false negative in the
// same direction, since `time bash -c "…"` really does run the payload.
//
// RESIDUAL, named rather than half-solved: a modifier that takes its OWN
// flags (`sudo -u alice bash -c …`) still stops the search, because
// skipping arbitrary flag/value pairs here would mean modelling each
// modifier's option grammar. No live instance exists in the six roots;
// the failure is a missed candidate, and the declaration marker is not
// involved either way.
const NON_COMMAND_PREFIX = new Set([
'then', 'else', 'do', '$', '-', '*', '&&', '||',
'time', 'exec', 'nohup', 'env', 'command',
]);
// AN ASSIGNMENT IS A SKIPPABLE PREFIX ONLY WHEN IT IS NOT A SUBSTITUTION.
// `FOO=1 bash -c "…"` prefixes the command; `V=$(bash -c "…")` IS the
// command, and skipping it walks the search past the invoker onto `-c`,
// which is not one — so the payload is never reached. Found by sweeping
// the substitution-glues-to-the-binary class across every command-name
// test rather than only the one the finding named.
const skippable = (t) => t.redir
|| (/^[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?=/.test(t.value) && !t.value.includes('$('))
|| NON_COMMAND_PREFIX.has(t.value);
const gi = group.findIndex((t) => !skippable(t));
if (gi !== -1 && SHELL_INVOKER_RE.test(bareCommandName(group[gi]))) {
const ci = group.findIndex((t, k) => k > gi && !t.redir && t.value === '-c');
const payload = ci !== -1 ? group.slice(ci + 1).find((t) => !t.redir) : undefined;
if (payload) payloads.push(payload.value);
}
group = [];
};
for (const t of tokenize(str)) {
if (t.op) { drain(); } else { group.push(t); }
}
drain();
return payloads;
};
// Candidates for one logical line: the whole line, unioned with each of its
// inline code spans and each shell -c payload. See the header for why the
// union rather than a choice.

View File

@@ -0,0 +1,11 @@
{
"version": 1,
"paths": {
"fast.md": {
"reason": "#3585: the commit step ran `git add -A`, sweeping .planning/ into every /gsd:fast commit regardless of commit_docs. Gating it needs `gsd_run` in that block, so the launcher preamble was MOVED from the later log_to_state step into the commit step (moved, not duplicated — log_to_state now relies on the one-preamble-per-workflow convention every other workflow uses). Growth is the preamble's new position plus four lines of guard; the commit_docs=true path stays byte-identical to the previous unconditional `git add -A`."
},
"new-milestone.md": {
"reason": "#3585: `git add .planning/milestones/ .planning/phases/` was ungated, and the correctly-gated `query commit` that follows it skips under commit_docs:false — leaving those paths in the index for the next commit to absorb. Growth is the executable commit_docs guard wrapping the stage, plus one sentence recording that the unstaged archive move is deliberate rather than a bug."
}
}
}

View File

@@ -0,0 +1,395 @@
'use strict';
/**
* Repo-wide `git add` -> `.planning/` reach guard (#1783, superseding the
* per-file `execute-phase.md`/`quick.md` scan; extended for #3585).
*
* The prior guard (tests/commit-docs-bypass.test.cjs before this module
* existed) matched a hardcoded regex requiring the literal substring
* `.planning/` on the SAME line as `git add`, over exactly two allowlisted
* files. That is structurally blind to `git add -A` / `git add .` / `git add
* -u` (no `.planning/` text on the line at all, yet every one of them stages
* the whole index including `.planning/`), and it never looked at any file
* outside its two-item allowlist.
*
* This module classifies `git add` (and `git commit -a`/`--all`) invocations
* INSIDE fenced code blocks across the repo's live
* workflow/agent/command/skill/reference surface and decides, per
* invocation, whether it can reach `.planning/` at all — covering the
* wildcard/blanket forms, `.planning`-qualified paths (either path-separator
* convention), any argument carrying an unresolved shell variable, a
* command/backtick substitution, or a `--pathspec-from-file` argument
* (fail-closed in all four cases: none of them is statically resolvable, and
* any one might expand to something planning-rooted). A `git commit -a`
* (including a combined short cluster like `-am`) is classified the same way
* `git add -A` is, because `-a` stages every tracked modification —
* including tracked `.planning/` files — before the commit runs (#3585 F3).
* An invocation that reaches is a violation UNLESS it sits inside an open
* `commit_docs` conditional in the SAME fenced block, carries an exclude
* pathspec naming `.planning`, or the line carries a tracked
* `# gsd-scan-ignore: #NNN` declaration (shared machinery — see
* tests/helpers/shipped-command-scan.cjs).
*
* ## Known limits
*
* This guard is a lightweight, line/token-oriented scan, not a shell
* interpreter — it targets ACCIDENTAL reintroduction of an unguarded
* `.planning/`-reaching stage by a contributor editing shipped content, not
* a determined attempt to defeat it. An isolated security review (#3585)
* empirically confirmed the following shapes stage `.planning/` at runtime
* while scoring ZERO offenders here, and none of them is a shape GSD
* workflow content actually uses — chasing them means writing a shell
* interpreter:
* - `eval "git add -A"` — the invocation lives inside a string literal
* `eval` re-parses at runtime, not inside argv this scan can see.
* - `find .planning -type f | xargs git add` — the reaching argument
* arrives via a pipeline at runtime, never appearing as a literal
* `git add` argument in the source text.
* - `f() { git add -A; }` — a one-line shell function body; this scan has
* no notion of function definitions or of a later, unseen call site.
* - a backslash line-continuation splitting one logical command across two
* physical lines — this scan is line-based and never rejoins them.
* Separately, and independently of the above, this module models only
* `git add` and `git commit -a`/`--all` as staging commands. It does NOT
* model `git stash`, `git rm --cached`, `git restore --staged`, or
* `git update-index --add` — each of these can also move `.planning/` files
* into a state a subsequent commit picks up, and none of them is recognized
* by this scan.
*/
const fs = require('fs');
const path = require('path');
const {
tokenize, bareCommandName, shellDashCPayloads, isDeclared, isUntrackedDeclaration,
} = require('./shipped-command-scan.cjs');
// A fenced code block opens/closes on a line whose TRIMMED form starts with
// a run of 3+ backticks or 3+ tildes. Only content INSIDE such a block is
// executable shell — everything else (prose, inline `git add` mentions, bare
// bullet lists) is documentation and is never a candidate.
const FENCE_RE = /^(`{3,}|~{3,})/;
// Shell prefixes that may legitimately precede the git binary itself without
// being the command: keywords that introduce a new command position, the
// modifiers that pass straight through to what follows, and the prompt/list
// markers a doc author might glue onto an example line. Kept in sync (by
// hand, deliberately — this file's domain is narrower than
// shellDashCPayloads's invoker search, so the sets are not shared) with the
// spec's literal list.
const NON_COMMAND_PREFIX = new Set(['then', 'else', 'do', 'time', 'exec', 'nohup', 'env', 'command', '$', '-', '*']);
// AN ASSIGNMENT IS A SKIPPABLE PREFIX ONLY WHEN IT IS NOT A SUBSTITUTION —
// deliberately the same rule as shellDashCPayloads's `skippable` in
// shipped-command-scan.cjs (see its comment for the `V=$(git add -A)` vs
// `FOO=1 git add -A` distinction). This divergence is precisely what the
// shared-helper extraction (#3585) was meant to prevent; kept duplicated
// here only because this file's domain (git-add reach) is narrower than
// that file's invoker search, per the NON_COMMAND_PREFIX comment above.
const isSkippable = (t) => t.redir
|| (/^[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?=/.test(t.value) && !t.value.includes('$('))
|| NON_COMMAND_PREFIX.has(t.value);
// One line, split into shell segments (top-level `;`/`&`/`&&`/`||`
// boundaries), unioned with every `shellDashCPayloads` extraction so
// `bash -c "git add -A"` is reached exactly as a bare `git add -A` is. Each
// segment carries its own SOURCE TEXT (the line, or the extracted payload
// string) so a classifier can slice raw, unescaped substrings out of it —
// see reachesPlanning's comment for why that matters.
const collectSegments = (line) => {
const sources = [line, ...shellDashCPayloads(line)];
const segments = [];
for (const src of sources) {
let group = [];
for (const tok of tokenize(src)) {
if (tok.op) {
if (group.length) segments.push({ tokens: group, src });
group = [];
} else {
group.push(tok);
}
}
if (group.length) segments.push({ tokens: group, src });
}
return segments;
};
// The RAW, as-authored substring behind a token — start/end are source
// offsets tokenize() carries regardless of what it did to escape sequences
// while building `t.value`. This matters for exactly one reason: tokenize()
// applies real (POSIX) unquoted-backslash-escape semantics, which consumes a
// literal `\` before whatever follows it (`.planning\STATE.md` dequotes to
// `.planningSTATE.md`). That is correct for what a POSIX shell would actually
// execute, but this scan is reading DOCUMENTATION that may show a Windows
// path verbatim — the separator itself is the thing being tested for, and
// the escape-eaten `t.value` would hide it. Slicing the original text instead
// preserves it.
const rawSlice = (src, t) => src.slice(t.start, t.end);
// Reach rules, in the order the spec states them. `-A`/`--all`/`.`/`-u` are
// exact-flag matches against the dequoted value (no escape/quote ambiguity
// possible for a bare flag); the `.planning` substring and the unresolved-
// variable check both read the RAW slice for the reason above.
//
// THE EXCLUDE PATHSPEC IS A FULL OVERRIDE, not just a veto on rule (b). Git's
// own semantics are why: `git add -A -- ':!.planning'` stages everything
// EXCEPT `.planning/`, so an exclude pathspec naming `.planning` neutralizes
// the wildcard/blanket forms too, not only a literal `.planning` path
// argument. Checked first, over every arg, before any reach rule fires.
// Shared with the `git commit -a` check below (#3585 F3) — the same
// pathspec override applies to a commit invocation carrying an exclude arg.
const hasPlanningExcludePathspec = (argTokens, src) => argTokens.some(
(t) => /:!.*\.planning/.test(rawSlice(src, t)),
);
const reachesPlanning = (argTokens, src) => {
if (hasPlanningExcludePathspec(argTokens, src)) return false;
for (const t of argTokens) {
if (t.value === '-A' || t.value === '--all' || t.value === '.' || t.value === '-u') return true;
}
for (const t of argTokens) {
const raw = rawSlice(src, t);
// Either path-separator convention: `.planning/x`, `.planning\x`, or a
// bare `.planning` token with nothing after it.
if (/\.planning(?:[/\\]|$)/.test(raw)) return true;
// An unresolved shell variable ($VAR / ${VAR}). `{placeholder}` with NO
// `$` is doc notation, not a shell variable, and must NOT trigger this —
// the regex requires the `$` explicitly so it never does.
if (/\$\{?[A-Za-z_]/.test(raw)) return true;
// Command substitution ($( or backtick) — equally opaque to static
// analysis as $VAR/${VAR} above, and for the same reason: whatever it
// expands to might be planning-rooted, and this scan cannot run the
// shell to find out. Fail closed (#3585 F2).
if (raw.includes('$(') || raw.includes('`')) return true;
// --pathspec-from-file names an external file listing the paths to
// stage; the file's contents are exactly as unknowable statically as an
// unresolved shell variable, so this also fails closed (#3585 F2).
if (/^--pathspec-from-file(?:=|$)/.test(raw)) return true;
}
return false;
};
// One segment -> null (not a git-add invocation) or { reaches, argTokens }.
// "git" is recognized by bareCommandName so a substitution/subshell-glued
// spelling still resolves (`$(git add -A)`), and by a trailing `/git` so an
// absolute/relative invocation (`/usr/bin/git add -A`) is still caught.
const classifySegment = ({ tokens, src }) => {
let ci = -1;
for (let i = 0; i < tokens.length; i += 1) {
if (isSkippable(tokens[i])) continue;
ci = i;
break;
}
if (ci === -1) return null;
const bare = bareCommandName(tokens[ci]);
if (bare !== 'git' && !/\/git$/.test(bare)) return null;
// A flag's VALUE is not itself a flag. `git -C <dir> add -A` must still
// reach `add` — `-C`, `--git-dir`, `--work-tree`, `--namespace`, and `-c`
// (in their SEPARATE-value spellings) all consume the next token as their
// argument. The glued forms (`--git-dir=<p>`, `-C<dir>`) already carry
// their value in the same token and need no extra consumption; an explicit
// set (rather than "consume the token after every flag") is what keeps
// `git --no-pager add -A` from swallowing `add` as `--no-pager`'s value.
const GIT_VALUE_FLAGS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace']);
let addIdx = -1;
for (let i = ci + 1; i < tokens.length; i += 1) {
if (tokens[i].redir) continue;
if (tokens[i].value.startsWith('-')) {
if (GIT_VALUE_FLAGS.has(tokens[i].value)) i += 1; // consume the flag's separate value
continue; // a flag on git itself; keep looking
}
addIdx = i;
break;
}
if (addIdx === -1) return null;
const subcommand = tokens[addIdx].value;
if (subcommand !== 'add' && subcommand !== 'commit') return null;
const argTokens = tokens.slice(addIdx + 1).filter((t) => !t.redir);
// tokenize() has no notion of subshell structure: `V=$(git add -A)`'s
// closing `)` is not consumed as syntax, it is just another character
// glued onto whatever token happens to be last (`-A)`). That is invisible
// to the substring-based reach rules (`.planning`, `$VAR`) but breaks the
// EXACT-flag comparisons (`t.value === '-A'`) reachesPlanning also relies
// on. Only strip it when the command itself was resolved via a `$(` glue
// (tokens[ci] carrying `$(` is how bareCommandName found "git" at all —
// see the comment above), and only the one trailing, unquoted `)` that
// substitution's own opener is owed.
if (tokens[ci].value.includes('$(') && argTokens.length) {
const last = argTokens[argTokens.length - 1];
const mask = last.qmask || '0'.repeat(last.value.length);
if (last.value.endsWith(')') && mask[mask.length - 1] === '0') {
argTokens[argTokens.length - 1] = { ...last, value: last.value.slice(0, -1) };
}
}
if (subcommand === 'commit') {
// `git commit -a`/`--all` (including a combined short cluster like
// `-am`) stages every tracked modification, which includes tracked
// `.planning/` files, so it bypasses the gate exactly as `git add -A`
// does (#3585 F3). A short cluster is any single-dash, letters-only
// flag that carries `a` among its letters (`-am`, `-ma`); `--amend` is
// a distinct double-dash flag and is never matched by this pattern.
const hasCommitAllFlag = argTokens.some((t) => {
if (t.value === '--all') return true;
return /^-[a-zA-Z]+$/.test(t.value) && t.value.includes('a');
});
if (!hasCommitAllFlag) return { reaches: false };
if (hasPlanningExcludePathspec(argTokens, src)) return { reaches: false };
return { reaches: true };
}
return { reaches: reachesPlanning(argTokens, src) };
};
// Whether LINE, considered in isolation (no fence/guard context), contains
// at least one git-add invocation whose arguments reach `.planning/`. Used
// directly by the pure per-line classifier tests; the file scanner below
// layers fence and commit_docs-guard state on top of this.
const hasReachingGitAdd = (line) => collectSegments(line).some((seg) => {
const result = classifySegment(seg);
return result !== null && result.reaches;
});
// A `VAR=$(... config-get commit_docs ...)` assignment — the shell-variable
// half of the guard trigger. Matched on the TRIMMED line; `config-get
// commit_docs` may carry trailing flags (`--default true`) after the name.
const CONFIG_GET_ASSIGN_RE = /^([A-Za-z_][A-Za-z0-9_]*)=.*config-get\s+commit_docs\b/;
/**
* Scan one document's TEXT for unguarded, undeclared `git add` invocations
* that can reach `.planning/`.
*
* State machine, one pass over `text.split(/\r?\n/)`:
* - fence tracking: only lines inside a ``` or ~~~ block are candidates.
* - inside a fence, an `if`/`fi` depth counter (first-token match) tracks
* nesting; a commit_docs guard OPENS at the depth an `if` is entered when
* its condition text mentions `commit_docs` or a tracked config-get
* variable, and CLOSES the first time depth drops below the depth it
* opened at (so a nested `if`/`fi` inside the guard leaves it open, and
* an `else` branch of the SAME `if` stays covered — the guard tracks the
* conditional's extent, not which branch is truthy).
* - guard state (depth, open-guard, tracked vars) resets at every fence
* boundary: each fenced block is its own shell, so a guard opened in one
* block can never protect a `git add` in a different one.
*/
const scanText = (file, text) => {
const lines = text.split(/\r?\n/);
const offenders = [];
const untracked = [];
let inFence = false;
let fenceChar = null;
let fenceLen = 0;
let ifDepth = 0;
let guardOpenDepth = null;
let guardVars = new Set();
const resetGuardState = () => {
ifDepth = 0;
guardOpenDepth = null;
guardVars = new Set();
};
for (let i = 0; i < lines.length; i += 1) {
const raw = lines[i];
const trimmed = raw.trim();
const fenceMatch = trimmed.match(FENCE_RE);
if (!inFence) {
if (fenceMatch) {
inFence = true;
fenceChar = fenceMatch[1][0];
fenceLen = fenceMatch[1].length;
resetGuardState();
}
continue;
}
// Inside a fence: a same-character run at least as long as the opener,
// with nothing else on the line, closes it.
if (fenceMatch && fenceMatch[1][0] === fenceChar && fenceMatch[1].length >= fenceLen
&& /^(`+|~+)\s*$/.test(trimmed)) {
inFence = false;
fenceChar = null;
fenceLen = 0;
resetGuardState();
continue;
}
// A `# gsd-scan-ignore:` attempt with no tracking reference is a
// malformed declaration — reported on its own terms, never silently
// folded into "unguarded" (see shipped-command-scan.cjs's declaration
// comment for why the diagnosis must be specific).
if (isUntrackedDeclaration(raw)) {
untracked.push({ file, line: i + 1, text: raw.trim() });
}
const assignMatch = trimmed.match(CONFIG_GET_ASSIGN_RE);
if (assignMatch) guardVars.add(assignMatch[1]);
const firstToken = trimmed.split(/\s+/)[0] || '';
if (firstToken === 'if') {
ifDepth += 1;
const cond = trimmed.slice(firstToken.length);
const mentionsCommitDocs = /commit_docs/.test(cond);
const mentionsTrackedVar = [...guardVars].some(
(v) => new RegExp(`\\$\\{?${v}\\b`).test(cond),
);
if ((mentionsCommitDocs || mentionsTrackedVar) && guardOpenDepth === null) {
guardOpenDepth = ifDepth;
}
} else if (firstToken === 'fi') {
// Clamped, not raw decrement. A stray/unbalanced `fi` must fail CLOSED
// (report), never open: an unclamped negative ifDepth lets a LATER
// `if` set guardOpenDepth = 0, and the guard test `guardOpenDepth !==
// null` reads zero as an OPEN guard — silently guarding an unrelated
// `git add -A` that follows.
ifDepth = Math.max(0, ifDepth - 1);
if (guardOpenDepth !== null && ifDepth < guardOpenDepth) guardOpenDepth = null;
}
const guarded = guardOpenDepth !== null;
if (!guarded && hasReachingGitAdd(raw) && !isDeclared(raw)) {
offenders.push({ file, line: i + 1, text: raw.trim() });
}
}
return { offenders, untracked };
};
// The repo-wide walk: every `.md` file (recursive) under each scan root.
const SCAN_ROOTS = [
'gsd-core/workflows',
'gsd-core/references',
'agents',
'commands',
'skills',
];
const scanRepo = (repoRoot, roots = SCAN_ROOTS) => {
const offenders = [];
const untracked = [];
for (const root of roots) {
const rootDir = path.join(repoRoot, root);
if (!fs.existsSync(rootDir)) continue;
const mdFiles = fs.readdirSync(rootDir, { recursive: true }).filter((f) => String(f).endsWith('.md'));
for (const file of mdFiles) {
const normalized = String(file).split(path.sep).join('/');
const label = `${root}/${normalized}`;
const text = fs.readFileSync(path.join(rootDir, String(file)), 'utf-8');
const result = scanText(label, text);
offenders.push(...result.offenders);
untracked.push(...result.untracked);
}
}
return { offenders, untracked };
};
module.exports = {
hasReachingGitAdd,
scanText,
scanRepo,
SCAN_ROOTS,
};

View File

@@ -0,0 +1,343 @@
'use strict';
/**
* Extracted from tests/commit-files-pathspec.test.cjs (#2269) so the #3585
* commit_docs guard consumes ONE shell tokenizer rather than hand-rolling a
* second. Only the primitives BOTH guards need live here — the shell
* tokenizer, the bare-command-name extraction, the -c payload search, and
* the `gsd-scan-ignore:` declaration machinery.
*
* Commit-specific logic stays in the test file and is deliberately NOT
* moved here: `isCommitInvocation`, `hasScopedFiles`, `SYNOPSIS_TOKEN_RE`,
* `METAVAR_REDIR_RE`, `segmentInvocations`, `codeSpans`, and
* `invocationCandidates` all encode assumptions specific to the commit
* scan and do not generalize to other guards.
*/
// 443 live substitution sites are `VAR=$(gsd_run …)`. That is the majority
// and it is the wrong generalisation — the remaining six are neither, and
// they are live today:
// for REVIEW_FLAG in $(gsd_run review-lane flags) (×3)
// ${PLAN_PRE_HOOKS_JSON:-$(gsd_run loop render-hooks …)} (×3)
// A parameter-expansion default glues just as hard as an assignment, and so
// does ordinary text (`pre$(…)`) and an indexed assignment (`A[0]=$(…)`).
// Enumerating prefixes means a new one every time the shell is used
// idiomatically, and each miss is silent. So the rule is positional instead:
// strip everything up to and including the LAST substitution opener in the
// token. Whatever preceded it was, by construction, not the command.
//
// ARITHMETIC IS EXCLUDED BY THE SAME MECHANISM, not by a special case.
// `$((x))` leaves a `(` in front of the name after the strip, and a leading
// `(` is not part of any binary name — so it is refused. That mirrors the
// shell, which also requires `$( (` with a space before it will read a nested
// subshell rather than an arithmetic expansion.
//
// The ARRAY LITERAL stays invisible for free: `arr=(a b c)` contains no `$(`
// at all, so neither alternative fires and the token is left whole. It is
// pinned as a zero-candidate case regardless, because that is a property of
// this regex rather than of the grammar and the next edit could lose it.
// THE STRIP IS REFUSED ON A TOKEN THAT CARRIED A QUOTE, because quoting is
// exactly what decides whether an opener is syntax or data. `printf %s
// '$(gsd_run' query commit fixup` runs no gsd command — the opener is
// single-quoted literal text — but quote removal leaves the token `$(gsd_run`
// and a strip would then read it as a binary, fabricating an invocation out
// of a string. The tokenizer already knows; it just was not saying.
//
// RESIDUAL, named rather than implied: this closes the strip's exposure, not
// the tokenizer's quote-blindness in general. `printf %s 'gsd_run' query
// commit fixup` still reads as an invocation, because no markup is stripped
// there — the token's value simply IS the binary name. That predates this
// rule and is unchanged by it; closing it needs quote provenance carried
// through the command-shape test, not just through the strip. The direction
// is a visible false positive with a declared remedy, not a silent miss.
const bareCommandName = (t) => {
const mask = t.qmask || '0'.repeat(t.value.length);
// The LAST opener whose own two characters were unquoted. Per-character,
// not per-token: a quote elsewhere in the word protects only itself, and
// `echo "pre"$(gsd_run …)` really does run.
for (let i = t.value.length - 2; i >= 0; i -= 1) {
if (t.value[i] === '$' && t.value[i + 1] === '(' && mask[i] === '0' && mask[i + 1] === '0') {
return t.value.slice(i + 2);
}
}
let j = 0;
while (j < t.value.length && t.value[j] === '(' && mask[j] === '0') j += 1;
return t.value.slice(j);
};
// Shell-like word splitting. Honours BOTH quote characters (the previous
// parity walk counted only `"`, so a single-quoted message was transparent
// to it in both directions), backslash escapes, and unquoted control
// operators — which become their own tokens, so an operator glued to its
// neighbour (`"a"&&gsd_run`) still separates. Quoted operators and quoted
// whitespace stay literal, which is what keeps a command substitution inside
// a message ("$(gsd-tools query phase-list)") from reading as a second
// invocation, with no separator lookahead needed.
//
// Beyond the operators, the shell consumes THREE more things before argv
// exists, and each one previously leaked into the token list as a value —
// so `--files >/dev/null 2>&1 || true` (a live tail shape in
// gsd-core/references/execute-phase-requirement-revert.md) scored as scoped
// while the runtime saw files=[] and took the blanket-.planning/ default:
// - a single `&` is a control operator like `;` (`&&` is checked first);
// - an unquoted `#` at the START of a word begins a comment and ends the
// command (mid-word `#` stays literal, as in the shell);
// - redirections (`> x`, `>> x`, `2>&1`, `< x`, with or without a space
// before the target) are consumed as `redir: true` tokens, along with a
// glued all-digit IO number, and consumers exclude them from argv.
// Variable tokens ($VAR / ${VAR}) stay ordinary values DELIBERATELY: whether
// one expands to something is unknowable statically, and the three original
// #2269 fix sites all pass `--files "${PHASE_DIR}/…"` — flagging `$`-tokens
// would false-flag every one of them.
//
// Never throws, and an unterminated quote simply runs to end of input: these
// inputs are substrings sliced out of prose, so a torn quote is expected
// rather than exceptional. Tokens carry offsets so callers can slice the
// ORIGINAL text and report a real excerpt instead of a re-joined echo.
//
// Operators are marked STRUCTURALLY (`op: true`), never re-identified by
// comparing a token's text against the operator set. That distinction is the
// whole point of tokenizing: `commit '|' --files a.md` produces a token
// whose VALUE is `|` and which is ordinary message text, and a consumer that
// matched on value would split there and score a scoped invocation as
// unscoped — the identify-structure-by-text mistake this rewrite exists to
// remove, reintroduced one layer up. (Caught by the delimiter-generating
// property below, on its first run.)
const tokenize = (str) => {
const tokens = [];
let cur = '';
let started = false;
let start = 0;
let quote = null;
// A PER-CHARACTER quote mask, parallel to `cur`: '1' where the character was
// quoted or backslash-escaped (i.e. protected, therefore data), '0' where it
// was bare syntax. A per-TOKEN boolean is the obvious shape and it is wrong
// in the unaffordable direction — `echo "pre"$(gsd_run …)` executes, and a
// token-wide flag suppresses the strip on it, turning a false positive into
// a silent false negative.
let curMask = '';
const add = (text, protectedChar) => {
cur += text;
curMask += (protectedChar ? '1' : '0').repeat(text.length);
};
const begin = (i) => {
if (!started) { started = true; start = i; }
};
const flush = (end) => {
if (started) tokens.push({ value: cur, start, end, qmask: curMask });
cur = '';
curMask = '';
started = false;
};
for (let i = 0; i < str.length; i += 1) {
const ch = str[i];
if (quote) {
if (ch === '\\' && quote === '"' && i + 1 < str.length) { add(str[i + 1], true); i += 1; continue; }
if (ch === quote) { quote = null; continue; }
add(ch, true);
continue;
}
if (ch === '\\' && i + 1 < str.length) { begin(i); add(str[i + 1], true); i += 1; continue; }
if (ch === '"' || ch === "'") { begin(i); quote = ch; continue; }
if (/\s/.test(ch)) { flush(i); continue; }
// An unquoted # at the start of a word ends the command line — the rest
// is comment and never reaches argv. Mid-word (`PR#42`) it is literal.
if (ch === '#' && !started) { flush(i); break; }
// Redirections: the operator and its target are consumed by the shell,
// not passed as arguments. A glued all-digit word is an IO number
// (`2>&1`) and belongs to the redirection, not to argv.
if (ch === '>' || ch === '<') {
if (started && /^[0-9]+$/.test(cur)) { cur = ''; curMask = ''; started = false; } else { flush(i); }
let j = i + 1;
if (str[j] === ch) j += 1; // >> / <<
if (str[j] === '&') j += 1; // >& (2>&1)
while (j < str.length && /[ \t]/.test(str[j])) j += 1; // gap before target
while (j < str.length && !/[\s;|&<>]/.test(str[j])) j += 1; // the target word
tokens.push({ value: str.slice(i, j), start: i, end: j, redir: true });
i = j - 1;
continue;
}
const pair = str.slice(i, i + 2);
if (pair === '&&' || pair === '||') { flush(i); tokens.push({ value: pair, start: i, end: i + 2, op: true }); i += 1; continue; }
if (ch === ';' || ch === '|' || ch === '&') { flush(i); tokens.push({ value: ch, start: i, end: i + 1, op: true }); continue; }
begin(i);
add(ch, false);
}
flush(str.length);
return tokens;
};
// The comment portion of a line — the same rule tokenize() applies, so the
// two cannot disagree about where the command ends: an unquoted `#` at word
// start begins a comment, and a `#` inside quotes or mid-word is literal
// (ship.md commits with `PR #${PR_NUMBER}` in the message).
// It tracks WORD START the way tokenize does, not "preceded by whitespace".
// Those differ, and the difference is exploitable: in `commit docs:\ # x` the
// backslash escapes the space, so the shell keeps `docs: #` as ONE word and
// `#` is literal — while a preceded-by-whitespace test reads it as a comment.
// The scan would then exempt a line the runtime still executes unscoped.
const commentPortion = (line) => {
let quote = null;
let started = false;
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if (quote) {
if (ch === '\\' && quote === '"' && i + 1 < line.length) { i += 1; continue; }
if (ch === quote) quote = null;
continue;
}
if (ch === '\\' && i + 1 < line.length) { started = true; i += 1; continue; }
if (ch === '"' || ch === "'") { started = true; quote = ch; continue; }
if (/\s/.test(ch)) { started = false; continue; }
if (ch === '#' && !started) return line.slice(i);
if (ch === '>' || ch === '<' || ch === ';' || ch === '|' || ch === '&') { started = false; continue; }
started = true;
}
return '';
};
// The explicit opt-out for content that shows the defect ON PURPOSE. A
// wrong-example is otherwise indistinguishable from a regression — which is
// exactly why no property of the surrounding markup can stand in for the
// author's intent — so the author declares it, with a reason.
//
// IT IS ONLY A MARKER IN COMMENT POSITION. Matching the token anywhere on
// the line let a commit MESSAGE carry it — `commit "docs: explain
// gsd-scan-ignore: semantics"` — and silently exempt a real offender. A
// false-negative escape hatch is the one thing this file must not ship, and
// an exemption keyed to attacker-controlled-looking text is precisely that.
// AND IT MUST CARRY A TRACKING ISSUE. An exemption whose reason is free text
// has no expiry and no ledger — the `permanent-allow-test-rule` shape
// RULESET.TESTS.delete-bad-tests names. The repo already answered this for
// its sibling convention under ADR-456 (see #2269), enforced by
// scripts/lint-allow-test-rule-refs. That lint walks tests/ only, so it
// cannot see a marker living in a .md file; rather than teach a second token
// to a script whose whole contract is the ESLint comment form, this scan
// enforces the same rule over its own roots — it already runs in CI on every
// shard.
//
// THE PREDICATE IS THAT LINT'S, MIRRORED RATHER THAN RESTATED. Its
// ISSUE_REF_RE is `/#\d+|https?:\/\//` — `http://` counts, and `#\d+` has no
// trailing boundary. A hand-written near-copy drifts from it in exactly those
// details, and a contributor who satisfies one marker and not the other has
// been given two conventions wearing one name. (Both details were found by
// review against prose here that claimed HTTPS-only. The regex was right and
// the sentence describing it was not, which is its own lesson.)
const ISSUE_REF_RE = /#\d+|https?:\/\//;
//
// The reason is EXTRACTED, not pattern-matched in one shot, so that a marker
// with no reason at all is still recognized as an ATTEMPT. A rejected
// declaration must not fail as a MYSTERY: left un-exempt and nothing more,
// its author is told their commit is unscoped — a true statement about a line
// they had already explained, which is the mangle-until-it-shuts-up loop the
// marker exists to prevent. `# gsd-scan-ignore:` with an empty reason is the
// likeliest way to get this wrong and so is the case that most needs the
// specific diagnosis; a `\S`-requiring pattern silently drops it.
const SCAN_IGNORE_REASON_RE = /gsd-scan-ignore:(.*)$/;
const declarationReason = (portion) => {
const m = SCAN_IGNORE_REASON_RE.exec(portion);
return m === null ? null : m[1];
};
// Two independent conditions, and the second is the structural one: the
// marker must be in comment position AND must not survive tokenization as an
// ARGUMENT. tokenize() drops everything from a real comment onward, so a
// genuine declaration leaves no token carrying the token; anything that does
// reached argv, which means the runtime would have executed it. That makes
// "authored argument text can declare the line exempt" impossible by
// construction rather than by getting commentPortion's edges exactly right —
// and commentPortion has edges (redirection targets, escaped separators)
// where mirroring the tokenizer perfectly is fiddly and a miss is silent.
// The marker's reason, but only when the marker is in COMMENT position and
// survives neither as an argv token. Returns null when there is no attempt.
const declaredReasonFor = (line) => {
if (tokenize(line).some((t) => /gsd-scan-ignore:/.test(t.value))) return null;
return declarationReason(commentPortion(line));
};
const isDeclared = (line) => {
const reason = declaredReasonFor(line);
return reason !== null && ISSUE_REF_RE.test(reason);
};
// An ATTEMPTED declaration that carries no tracking reference — including one
// with no reason at all. Reported on its own terms rather than silently
// failing to exempt: see the extraction note above.
const isUntrackedDeclaration = (line) => {
const reason = declaredReasonFor(line);
return reason !== null && !ISSUE_REF_RE.test(reason);
};
// A shell invoked with -c runs its next argument AS A COMMAND, so the
// invocation lives inside a quoted token and no amount of markup-stripping
// reaches it: `bash -c "gsd_run query commit fixup"` scored zero candidates
// while being perfectly executable. The recursion is keyed on the INVOKER,
// not on "a quoted token that parses as an invocation" — that wider rule
// would flag a commit MESSAGE quoting a command, a false positive against
// ordinary documentation. Here the outer command is bash, so a gsd commit
// message can never reach it.
// The invoker set, widened past the four spellings the first cut guessed at.
// `ash`, `csh`, `tcsh`, `fish` and `yash` all take -c and all run what
// follows; leaving them out is a silent false negative in a function whose
// entire job is reaching a command the tokenizer cannot see.
const SHELL_INVOKER_RE = /^(?:.*\/)?(?:ba|da|k|z|a|c|tc|fi|ya)?sh$/;
const shellDashCPayloads = (str) => {
const payloads = [];
let group = [];
const drain = () => {
if (!group.length) { return; }
// THE INVOKER MUST BE THE COMMAND, not merely present in the segment.
// Searching anywhere made `echo bash -c "gsd_run query commit fixup"` a
// candidate — `echo` prints the string, it does not run it — which is a
// false positive against prose that merely quotes a command line. Only an
// env assignment, a shell keyword, or a list/prompt marker may precede the
// command name. Those are not commands; `echo` is, which is exactly the
// line the search-anywhere version got wrong.
// Shell keywords, prompt/list markers, and the command MODIFIERS that
// pass straight through to the command after them. The modifiers are the
// mirror of the invoker-set gap: omitting them is a false negative in the
// same direction, since `time bash -c "…"` really does run the payload.
//
// RESIDUAL, named rather than half-solved: a modifier that takes its OWN
// flags (`sudo -u alice bash -c …`) still stops the search, because
// skipping arbitrary flag/value pairs here would mean modelling each
// modifier's option grammar. No live instance exists in the six roots;
// the failure is a missed candidate, and the declaration marker is not
// involved either way.
const NON_COMMAND_PREFIX = new Set([
'then', 'else', 'do', '$', '-', '*', '&&', '||',
'time', 'exec', 'nohup', 'env', 'command',
]);
// AN ASSIGNMENT IS A SKIPPABLE PREFIX ONLY WHEN IT IS NOT A SUBSTITUTION.
// `FOO=1 bash -c "…"` prefixes the command; `V=$(bash -c "…")` IS the
// command, and skipping it walks the search past the invoker onto `-c`,
// which is not one — so the payload is never reached. Found by sweeping
// the substitution-glues-to-the-binary class across every command-name
// test rather than only the one the finding named.
const skippable = (t) => t.redir
|| (/^[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?=/.test(t.value) && !t.value.includes('$('))
|| NON_COMMAND_PREFIX.has(t.value);
const gi = group.findIndex((t) => !skippable(t));
if (gi !== -1 && SHELL_INVOKER_RE.test(bareCommandName(group[gi]))) {
const ci = group.findIndex((t, k) => k > gi && !t.redir && t.value === '-c');
const payload = ci !== -1 ? group.slice(ci + 1).find((t) => !t.redir) : undefined;
if (payload) payloads.push(payload.value);
}
group = [];
};
for (const t of tokenize(str)) {
if (t.op) { drain(); } else { group.push(t); }
}
drain();
return payloads;
};
module.exports = {
bareCommandName,
tokenize,
shellDashCPayloads,
SHELL_INVOKER_RE,
commentPortion,
ISSUE_REF_RE,
declarationReason,
declaredReasonFor,
isDeclared,
isUntrackedDeclaration,
};