Emitted-Drift-Ack-Growth: execute-phase.md — #4628 consumer wiring: ready_plans parse pointer, not-ready named skip, and waiting condition 2b reference to the ready-wave-gate step file Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/nimble-pumas-roar.md
Normal file
5
.changeset/nimble-pumas-roar.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 4781
|
||||
---
|
||||
**phase-plan-index now exposes DAG-ready plans** — `ready_plans` (and per-plan `ready`/`unresolved_dependencies`) distinguish plans whose dependencies all have completion evidence from those merely unblocked by a halt; /gsd-execute-phase dispatches only ready plans and reports what it is waiting on instead of skipping incomplete predecessors. (#4628)
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 4768
|
||||
---
|
||||
**`/gsd-execute-phase`, `/gsd-autonomous --from|--to|--only`, `/gsd-plan-review-convergence` and the TDD plan path now handle letter-variant phase ids (`12A`, `3A`, `23A.1.2`)** — seven shell sites outside #4660's six still assumed a phase number was digits-and-dots: the post-#4619 `$((10#$PHASE_INT))` split aborted bash on `03A`, the review-file lookup's `printf "%02d"` printed the wrong file (and read an already-padded `08` as octal), and the `--from`/`--to`/`--only` and plan-review-convergence extractions silently truncated `12A` to `12` (and `23.1.2` to `23.1`). The split now stops at the first non-digit, `init execute-phase` emits `padded_phase` for the lookup, the extractions use the canonical grammar, the legacy normalizer pads a letter id, a parity test drives every site's live shell against a letter-suffixed fixture, and `lint-phase-id-drift` gains three ratchets so none of the shapes can return silently. (#4748)
|
||||
@@ -660,6 +660,7 @@
|
||||
"execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"execute-phase/steps/post-merge-gate.md",
|
||||
"execute-phase/steps/protected-branch.md",
|
||||
"execute-phase/steps/ready-wave-gate.md",
|
||||
"execute-phase/steps/regression-gate-run.md",
|
||||
"execute-phase/steps/regression-gate.md",
|
||||
"execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
@@ -27,18 +27,16 @@ Returns JSON with:
|
||||
|
||||
## Manual Normalization (Legacy)
|
||||
|
||||
Zero-pad the leading integer to 2 digits. Preserve a letter suffix and any dotted
|
||||
segments — the canonical grammar in `src/phase-id.cts` (`normalizePhaseName`):
|
||||
`8 → 08`, `2.1 → 02.1`, `3A → 03A`, `23.1.2 → 23.1.2`.
|
||||
Zero-pad integer phases to 2 digits. Preserve decimal suffixes.
|
||||
|
||||
```bash
|
||||
# Normalize phase number
|
||||
# #4748: one branch for the whole canonical token — digits, optional [A-Z],
|
||||
# dotted segments. Pad through $((10#…)) so an already-padded `08` is not read
|
||||
# as octal by printf; anything non-canonical passes through untouched.
|
||||
if [[ "$PHASE" =~ ^([0-9]+)([A-Z]?)((\.[0-9]+)*)$ ]]; then
|
||||
PHASE_INT=${BASH_REMATCH[1]}
|
||||
PHASE=$(printf "%02d" "$((10#$PHASE_INT))")${BASH_REMATCH[2]}${BASH_REMATCH[3]}
|
||||
if [[ "$PHASE" =~ ^[0-9]+$ ]]; then
|
||||
# Integer: 8 → 08
|
||||
PHASE=$(printf "%02d" "$PHASE")
|
||||
elif [[ "$PHASE" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
|
||||
# Decimal: 2.1 → 02.1
|
||||
PHASE=$(printf "%02d.%s" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}")
|
||||
fi
|
||||
```
|
||||
|
||||
|
||||
@@ -297,9 +297,8 @@ After completing a `type: tdd` plan, the executor validates the git log:
|
||||
# The commit protocol promises no zero-padding for ${PHASE}/${PLAN} — strip both and
|
||||
# match the commit-scope position anchored (#4003). #4619: PHASE may be decimal/
|
||||
# N-segment; zero-strip only the leading integer segment, escape the rest.
|
||||
# #4748: it may also carry a letter suffix (03A), so split at the first non-digit.
|
||||
PHASE_INT=${PHASE%%[!0-9]*}; PHASE_REST=${PHASE#"$PHASE_INT"}
|
||||
PHASE_N="$((10#$PHASE_INT))${PHASE_REST//./\\.}"
|
||||
PHASE_INT=${PHASE%%.*}; PHASE_FRAC=${PHASE#"$PHASE_INT"}
|
||||
PHASE_N="$((10#$PHASE_INT))${PHASE_FRAC//./\\.}"
|
||||
PLAN_N=$((10#${PLAN}))
|
||||
# Check for RED gate commit
|
||||
git log --oneline -E --grep="^test\((0*${PHASE_N})-(0*${PLAN_N})\):" | head -1
|
||||
|
||||
@@ -21,22 +21,19 @@ Read all files referenced by the invoking prompt's execution_context before star
|
||||
Parse `$ARGUMENTS` for `--from N`, `--to N`, `--only N`, `--interactive`, `--converge`/`--cross-ai`, reviewer selector flags, and `--max-cycles N`:
|
||||
|
||||
```bash
|
||||
# #4748: the phase token is the canonical grammar (src/phase-id.cts) — digits,
|
||||
# an optional uppercase letter, any number of dotted segments — so `12A` and
|
||||
# `23.1.2` extract whole instead of truncating to `12` / `23.1`.
|
||||
FROM_PHASE=""
|
||||
if echo "$ARGUMENTS" | grep -qE '\-\-from\s+[0-9]'; then
|
||||
FROM_PHASE=$(echo "$ARGUMENTS" | grep -oE '\-\-from\s+[0-9]+[A-Z]?(\.[0-9]+)*' | awk '{print $2}')
|
||||
FROM_PHASE=$(echo "$ARGUMENTS" | grep -oE '\-\-from\s+[0-9]+\.?[0-9]*' | awk '{print $2}')
|
||||
fi
|
||||
|
||||
TO_PHASE=""
|
||||
if echo "$ARGUMENTS" | grep -qE '\-\-to\s+[0-9]'; then
|
||||
TO_PHASE=$(echo "$ARGUMENTS" | grep -oE '\-\-to\s+[0-9]+[A-Z]?(\.[0-9]+)*' | awk '{print $2}')
|
||||
TO_PHASE=$(echo "$ARGUMENTS" | grep -oE '\-\-to\s+[0-9]+\.?[0-9]*' | awk '{print $2}')
|
||||
fi
|
||||
|
||||
ONLY_PHASE=""
|
||||
if echo "$ARGUMENTS" | grep -qE '\-\-only\s+[0-9]'; then
|
||||
ONLY_PHASE=$(echo "$ARGUMENTS" | grep -oE '\-\-only\s+[0-9]+[A-Z]?(\.[0-9]+)*' | awk '{print $2}')
|
||||
ONLY_PHASE=$(echo "$ARGUMENTS" | grep -oE '\-\-only\s+[0-9]+\.?[0-9]*' | awk '{print $2}')
|
||||
FROM_PHASE="$ONLY_PHASE"
|
||||
fi
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS=$(gsd_run query agent-skills gsd-executor)
|
||||
```
|
||||
|
||||
Parse JSON for: `executor_model`, `verifier_model`, `commit_docs`, `parallelization`, `branching_strategy`, `branch_name`, `phase_found`, `phase_dir`, `phase_number`, `padded_phase`, `phase_name`, `phase_slug`, `plans`, `incomplete_plans`, `plan_count`, `incomplete_count`, `state_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`, `requirements_path`, `section_manifest`.
|
||||
Parse JSON for: `executor_model`, `verifier_model`, `commit_docs`, `parallelization`, `branching_strategy`, `branch_name`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `plans`, `incomplete_plans`, `plan_count`, `incomplete_count`, `state_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`, `requirements_path`, `section_manifest`.
|
||||
|
||||
`section_manifest` (#2932) gates the three `steps/*.md` reads below: read a step file only when its `id` is in `section_manifest.included` (equivalently, its path is in `section_manifest.read`); skip it — without reading — when its `id` is in `section_manifest.excluded`. When `section_manifest` is `null` (degraded: manifest artifact missing/unreadable), read all three unconditionally — the safe superset.
|
||||
|
||||
@@ -194,11 +194,8 @@ PHASE_NUMBER="{phase_number}"
|
||||
# #4619: {phase_number} may be decimal (01.1) or N-segment (23.1.2) — $((10#...))
|
||||
# is a hard shell syntax error on a non-integer, so zero-strip only the LEADING
|
||||
# integer segment and keep the rest as an escaped-dot string for the ERE below.
|
||||
# #4748: it may also carry a letter suffix (03A, 23A.1.2 — the canonical grammar
|
||||
# is digits, optional [A-Z], dotted segments), so split at the first NON-DIGIT,
|
||||
# not the first dot: the letter rides along in the rest, unescaped.
|
||||
PHASE_INT=${PHASE_NUMBER%%[!0-9]*}; PHASE_REST=${PHASE_NUMBER#"$PHASE_INT"}
|
||||
PHASE_N="$((10#$PHASE_INT))${PHASE_REST//./\\.}"
|
||||
PHASE_INT=${PHASE_NUMBER%%.*}; PHASE_FRAC=${PHASE_NUMBER#"$PHASE_INT"}
|
||||
PHASE_N="$((10#$PHASE_INT))${PHASE_FRAC//./\\.}"
|
||||
PLAN_N=$((10#{plan_padded}))
|
||||
PLAN_SCOPE_RE="^[a-z]+\((0*${PHASE_N})-(0*${PLAN_N})\):"
|
||||
MILESTONE_BASE=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
@@ -220,10 +217,9 @@ if [ "$TDD_MODE" = "true" ]; then
|
||||
# #4003: same anchored scope and milestone bound as safe_resume_gate — a padded
|
||||
# literal grep hard-halts on a correct unpadded RED commit.
|
||||
# #4619: PHASE_NUMBER may be decimal/N-segment; zero-strip only the leading
|
||||
# integer segment, escape the rest for the ERE below. #4748: it may carry a
|
||||
# letter suffix (03A), so the split is at the first non-digit, not the dot.
|
||||
PHASE_INT=${PHASE_NUMBER%%[!0-9]*}; PHASE_REST=${PHASE_NUMBER#"$PHASE_INT"}
|
||||
PHASE_N="$((10#$PHASE_INT))${PHASE_REST//./\\.}"
|
||||
# integer segment, escape the rest for the ERE below.
|
||||
PHASE_INT=${PHASE_NUMBER%%.*}; PHASE_FRAC=${PHASE_NUMBER#"$PHASE_INT"}
|
||||
PHASE_N="$((10#$PHASE_INT))${PHASE_FRAC//./\\.}"
|
||||
PLAN_N=$((10#${PLAN_ID}))
|
||||
PLAN_SCOPE_RE="^[a-z]+\((0*${PHASE_N})-(0*${PLAN_N})\):" # TDD gate's own scope check
|
||||
TDD_MILESTONE_BASE=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
@@ -333,9 +329,9 @@ Load plan inventory with wave grouping in one call:
|
||||
PLAN_INDEX=$(gsd_run query phase-plan-index "${PHASE_NUMBER}")
|
||||
```
|
||||
|
||||
Parse JSON for: `phase`, `plans[]` (each with `id`, `wave`, `autonomous`, `objective`, `files_modified`, `task_count`, `has_summary`, `halted`, `blocked_by`), `waves` (map of wave number → plan IDs), `incomplete`, `runnable`, `has_checkpoints`.
|
||||
Parse JSON for: `phase`, `plans[]`, `waves`, `incomplete`, `runnable`, `ready_plans`, `has_checkpoints` — full per-plan fields and the #4628 readiness rules: read and follow `execute-phase/steps/ready-wave-gate.md`.
|
||||
|
||||
**Filtering:** Skip plans where `has_summary: true`. Additionally skip any plan whose `blocked_by` array is non-empty (#2830) — it depends, directly or transitively, on a plan that halted at a designed stop rather than completing — and report it by name: "Skipping {plan.id}: blocked by halted {blocked_by.join(', ')}". Never silently drop a blocked plan from the report; it must appear by name with its reason, not merely vanish from the executable list. This rule is additive to the `has_summary` skip, not a replacement for it. If `--gaps-only`: also skip non-gap_closure plans. If `WAVE_FILTER` is set: also skip plans whose `wave` does not equal `WAVE_FILTER`.
|
||||
**Filtering:** Skip plans where `has_summary: true`. Additionally skip any plan whose `blocked_by` array is non-empty (#2830) — it depends, directly or transitively, on a plan that halted at a designed stop rather than completing — and report it by name: "Skipping {plan.id}: blocked by halted {blocked_by.join(', ')}". Never silently drop a blocked plan from the report; it must appear by name with its reason, not merely vanish from the executable list. This rule is additive to the `has_summary` skip, not a replacement for it. Additionally skip any incomplete plan with `ready: false` (#4628 — see `execute-phase/steps/ready-wave-gate.md`; never dispatch a not-ready plan). If `--gaps-only`: also skip non-gap_closure plans. If `WAVE_FILTER` is set: also skip plans whose `wave` does not equal `WAVE_FILTER`.
|
||||
|
||||
**Wave safety check:** If `WAVE_FILTER` is set and there are still incomplete plans in any lower wave that match the current execution mode, STOP and tell the user to finish earlier waves first. Do not let Wave 2+ execute while prerequisite earlier-wave plans remain incomplete.
|
||||
|
||||
@@ -367,6 +363,7 @@ later conditions once one matches:
|
||||
because nothing was left to filter. Report:
|
||||
`"Phase stuck: {blocked plan ids} blocked by halted {their blocked_by ids} — resolve the halt, do not resume verification."`
|
||||
→ exit. Do not fall through to condition 3; this is not a completion state.
|
||||
2b. **No filter is active, no blocked-plan skip occurred, and at least one filtered plan was skipped because `ready: false` (#4628)** — the phase is WAITING on incomplete predecessors, not finished: report it by name and exit before any completion state (`execute-phase/steps/ready-wave-gate.md`).
|
||||
3. **No filter is active, and every filtered plan was filtered by `has_summary` alone** (no
|
||||
blocked-plan skip occurred):
|
||||
- **`VERIFY_STATUS == missing`**: the plans are all summarized but the run never reached the
|
||||
@@ -407,7 +404,7 @@ Report:
|
||||
</step>
|
||||
|
||||
<step name="cross_ai_delegation">
|
||||
**Optional step 2.5 — Delegate plans to an external AI runtime.** Runs after plan discovery, before wave execution. Activates when `--cross-ai` forces all incomplete plans, `--no-cross-ai` disables it entirely, or (default) a plan's `cross_ai: true` frontmatter agrees with the `workflow.cross_ai_execution` config. If no plan is marked, skip to execute_waves; if marked but `workflow.cross_ai_command` is unset, error and tell the user to set it.
|
||||
**Optional step 2.5 — Delegate plans to an external AI runtime.** Runs after plan discovery, before wave execution. Activates when `--cross-ai` forces all incomplete plans, `--no-cross-ai` disables it entirely, or (default) a plan's `cross_ai: true` frontmatter agrees with the `workflow.cross_ai_execution` config. **Only `ready` plans are eligible (#4628) — a not-ready plan is never delegated over incomplete predecessors.** If no plan is marked, skip to execute_waves; if marked but `workflow.cross_ai_command` is unset, error and tell the user to set it.
|
||||
|
||||
For each marked plan: build a self-contained prompt from the plan's `<objective>`/`<tasks>` plus PROJECT.md context, warn on a dirty working tree, then run the configured command **wrapped in `gsd_run run-with-timeout "${CROSS_AI_TIMEOUT}"` (config `workflow.cross_ai_timeout`, default 300s) — never run it unbounded** — with the prompt piped to **stdin, never shell-interpolated, to prevent injection**. On success (exit 0): validate the captured SUMMARY output is non-empty and structurally valid before writing it as the plan's SUMMARY.md, update STATE/ROADMAP, mark handled. On failure (non-zero exit, or the summary fails that validation): show the error, warn about possible partial edits, and offer **retry** / **skip** (falls back to the normal executor) / **abort**. Successfully handled plans are removed from execute_waves' list; skipped-to-fallback plans remain in it.
|
||||
|
||||
@@ -1158,9 +1155,7 @@ Skill(skill="gsd-${ref.skill}", args="${PHASE_NUMBER}")
|
||||
|
||||
**Check results using deterministic path (not glob):**
|
||||
```bash
|
||||
# #4748: bind init's normalized id — `printf "%02d"` cannot pad a letter id
|
||||
# (03A → `03`, exit 1) and reads an already-padded `08` as octal (→ `00`).
|
||||
PADDED="{padded_phase}"
|
||||
PADDED=$(printf "%02d" "${PHASE_NUMBER}")
|
||||
REVIEW_FILE="${PHASE_DIR}/${PADDED}-REVIEW.md"
|
||||
REVIEW_STATUS=$(sed -n '/^---$/,/^---$/p' "$REVIEW_FILE" | grep "^status:" | head -1 | cut -d: -f2 | tr -d ' ')
|
||||
```
|
||||
|
||||
@@ -63,7 +63,7 @@ executor skips them.
|
||||
**Activation logic:**
|
||||
|
||||
1. If `CROSS_AI_DISABLED` is true (`--no-cross-ai` flag): skip this step entirely.
|
||||
2. If `CROSS_AI_FORCE` is true (`--cross-ai` flag): mark ALL incomplete plans for cross-AI execution.
|
||||
2. If `CROSS_AI_FORCE` is true (`--cross-ai` flag): mark ALL incomplete plans that are `ready` (#4628 — every dependency has completion evidence) for cross-AI execution. A not-ready plan is never delegated: it would dispatch over an incomplete predecessor, the exact failure #4628 reports.
|
||||
3. Otherwise: check each plan's frontmatter for `cross_ai: true` AND verify config
|
||||
`workflow.cross_ai_execution` is `true`. Plans matching both conditions are marked for cross-AI.
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ block indefinitely waiting for a signal; verify via filesystem and git state.
|
||||
SUMMARY_EXISTS=$(test -f "{phase_dir}/{plan_number}-{plan_padded}-SUMMARY.md" && echo "true" || echo "false")
|
||||
# #4003: anchored, zero-pad-tolerant scope (see safe_resume_gate); --since stays.
|
||||
SPOT_PHASE_NUMBER="{phase_number}"
|
||||
# #4619 / #4748: same decimal/N-segment/letter-suffix handling as safe_resume_gate.
|
||||
SPOT_PHASE_INT=${SPOT_PHASE_NUMBER%%[!0-9]*}; SPOT_PHASE_REST=${SPOT_PHASE_NUMBER#"$SPOT_PHASE_INT"}
|
||||
SPOT_PHASE_N="$((10#$SPOT_PHASE_INT))${SPOT_PHASE_REST//./\\.}"
|
||||
# #4619: same decimal/N-segment handling as safe_resume_gate.
|
||||
SPOT_PHASE_INT=${SPOT_PHASE_NUMBER%%.*}; SPOT_PHASE_FRAC=${SPOT_PHASE_NUMBER#"$SPOT_PHASE_INT"}
|
||||
SPOT_PHASE_N="$((10#$SPOT_PHASE_INT))${SPOT_PHASE_FRAC//./\\.}"
|
||||
SPOT_PLAN_N=$((10#{plan_padded}))
|
||||
COMMITS_FOUND=$(git log --oneline --all -E --grep="^[a-z]+\((0*${SPOT_PHASE_N})-(0*${SPOT_PLAN_N})\):" --since="1 hour ago" | head -1)
|
||||
COMMITS_SINCE_DISPATCH=$(git log "${EXPECTED_BRANCH}" --since="${DISPATCH_TS}" --oneline | head -1)
|
||||
|
||||
37
gsd-core/workflows/execute-phase/steps/ready-wave-gate.md
Normal file
37
gsd-core/workflows/execute-phase/steps/ready-wave-gate.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Ready-wave gate (#4628)
|
||||
|
||||
Read and follow this fragment from `execute-phase.md` step 1
|
||||
(`discover_and_group_plans`). It owns the #4628 readiness rules so the host
|
||||
stays inside its ADR-857 Phase 6 byte budget (#1168).
|
||||
|
||||
`phase-plan-index` now exposes, additively: `ready_plans` (top level), and —
|
||||
on every INCOMPLETE plan — `ready` plus `unresolved_dependencies`. `runnable`
|
||||
keeps its #2830 meaning ("not halted-blocked") and says nothing about
|
||||
completion evidence: a runnable plan whose dependencies lack a SUMMARY is not
|
||||
DAG-ready.
|
||||
|
||||
**Named skip (#4628):** additionally skip any incomplete plan whose `ready` is
|
||||
`false` — it has direct dependencies without completion evidence, and
|
||||
dispatching it would violate the declared DAG — and report it by name with the
|
||||
missing predecessors: "Skipping {plan.id}: incomplete predecessor
|
||||
{unresolved_dependencies.join(', ')}". Readiness is transitive: a ready
|
||||
plan's own dependencies are already complete, so **a not-ready plan must
|
||||
never be dispatched in this run, even when a later wave's plans are ready**.
|
||||
|
||||
**Waiting is not done (#4628).** In step 1's filtered-out evaluation order,
|
||||
after the #2830 blocked condition and before the all-summarized condition,
|
||||
insert:
|
||||
|
||||
> 2b. **No filter is active, no blocked-plan skip occurred, and at least one
|
||||
> filtered plan was skipped because `ready: false` (#4624 cross-reference; #4628 scope)** — the wave
|
||||
> is WAITING on incomplete predecessors, not finished: report "Phase waiting:
|
||||
> {plan ids} await incomplete predecessors {unresolved_dependencies} —
|
||||
> dispatch the ready wave, then re-run." → exit. Do not fall through to the
|
||||
> completion states; nothing here is a completion state. (The ready plans
|
||||
> themselves were already dispatched this run — the executor wave loop
|
||||
> dispatches every ready plan of the earliest ready wave onward.)
|
||||
|
||||
This ordering is fail-closed: a later wave is never selected while any
|
||||
predecessor is incomplete, because a ready plan's own dependencies are
|
||||
complete by construction — anything not ready is reported and the run stops
|
||||
at the waiting condition.
|
||||
@@ -21,9 +21,7 @@ Read all files referenced by the invoking prompt's execution_context before star
|
||||
Extract from $ARGUMENTS: phase number, reviewer flags (the declared reviewer lane flags, plus `--all`), `--max-cycles N`, `--text`, `--ws`.
|
||||
|
||||
```bash
|
||||
# #4748: canonical phase grammar (digits, optional [A-Z], dotted segments) —
|
||||
# `12A` / `23.1.2` extract whole instead of truncating to `12` / `23.1`.
|
||||
PHASE=$(echo "$ARGUMENTS" | grep -oE '[0-9]+[A-Z]?(\.[0-9]+)*' | head -1)
|
||||
PHASE=$(echo "$ARGUMENTS" | grep -oE '[0-9]+\.?[0-9]*' | head -1)
|
||||
|
||||
# #2315: do NOT default REVIEWER_FLAGS to --codex here. The default is resolved
|
||||
# against review.default_reviewers in step 1.5 (after the config gate) so a bare
|
||||
|
||||
@@ -246,14 +246,11 @@ function findBranchSlugFallbackDrift(text) {
|
||||
// 1. Prose mentioning the literal pattern in a full-line `#`-comment
|
||||
// (filtered by the caller, not this regex — see below).
|
||||
// 2. `$((10#$PHASE_INT))` / `$((10#$SPOT_PHASE_INT))` — arithmetic on the
|
||||
// NOW-safe variable the #4619 fix produces via a leading-digit-run split
|
||||
// (`PHASE_INT=${PHASE_NUMBER%%[!0-9]*}` since #4748; `%%.*` before it);
|
||||
// a digit-run value can never contain a dot OR a letter, so base-10
|
||||
// arithmetic on it can never hit the #4619 / #4748 error classes. Any
|
||||
// name ending in `_INT` (case-insensitive) is that established "already
|
||||
// reduced to a safe integer" convention. That convention is a NAME, not
|
||||
// a proof — `findDotOnlyIntegerSplitDrift` below polices that the split
|
||||
// producing it actually stops at the first non-digit.
|
||||
// NOW-safe variable the #4619 fix produces via `PHASE_INT=${PHASE_NUMBER%%.*}`;
|
||||
// a `%%.*`-stripped value can never contain a dot, so base-10 arithmetic
|
||||
// on it can never hit the #4619 syntax-error class. Any name ending in
|
||||
// `_INT` (case-insensitive) is that established "already reduced to a
|
||||
// safe integer" convention.
|
||||
// 3. `$((10#{plan_padded}))` / `$((10#${PLAN_ID}))` — plan ids are plain
|
||||
// integers and were never in scope; this rule only polices variables
|
||||
// that carry a *phase* id.
|
||||
@@ -452,144 +449,6 @@ function scanMarkdownLetterlessPhaseMirror(root) {
|
||||
return violations;
|
||||
}
|
||||
|
||||
// #4748 (epic #4634): three shell shapes OUTSIDE the grammar-mirror family the
|
||||
// rules above police — consumers of a phase id rather than regexes for one —
|
||||
// each of which the letter axis broke while every rule above reported clean:
|
||||
//
|
||||
// a. `PHASE_INT=${PHASE_NUMBER%%.*}` — the post-#4619 dot-only split. The
|
||||
// `_INT` name it produces satisfies the shell-arithmetic rule's escape,
|
||||
// but on `03A` the "integer" is `03A` and `$((10#03A))` aborts. The safe
|
||||
// split stops at the first NON-digit: `${PHASE_NUMBER%%[!0-9]*}`.
|
||||
// b. `[0-9]+\.?[0-9]*` (and `\d+\.?\d*`) — a digit-then-optional-dot
|
||||
// extraction that is neither the bounded `(\.[0-9]+)?` shape the
|
||||
// single-segment rule bans nor the unbounded `(\.[0-9]+)*` shape the
|
||||
// letterless rule inspects, so both were blind to it. It captures `12`
|
||||
// from `12A` and `23.1` from `23.1.2`, silently.
|
||||
// c. `printf "%02d" "$PHASE_NUMBER"` — re-padding the whole id in shell.
|
||||
// Rejects a letter id (prints `03`, exit 1) and misreads an already
|
||||
// padded `08` as octal (prints `00`). Padding belongs to the canonical
|
||||
// normalizer (`padded_phase` from init, or `normalizePhaseName`); the one
|
||||
// legitimate shell pad is of an `_INT` value via `$((10#…))`.
|
||||
//
|
||||
// Same sanction as the other markdown rules: `<!-- phase-id-owner: … -->` on
|
||||
// the nearest preceding non-blank line. Same documented limit: a per-line
|
||||
// textual scan for the common accidental shape, not an obfuscated one.
|
||||
|
||||
// a. `<name>_INT=${<phase-carrying>%%.*}` — the `_INT` destination is the
|
||||
// discriminator, deliberately: it is the name the shell-arithmetic rule
|
||||
// trusts as "already a safe integer", so a dot-only split INTO it is the
|
||||
// exact promise this rule exists to check. A dot-only split into any other
|
||||
// name is a different, legitimate operation — `PARENT_PHASE="${PHASE_NUMBER%%.*}"`
|
||||
// (gap-closure-artifacts.md) wants everything before the first dot, letter
|
||||
// included, and is correct. Widening to any destination was tried and flagged
|
||||
// that site. The optional quote after `=` is the one spelling that site uses.
|
||||
const DOT_ONLY_INT_SPLIT_DRIFT_RE = /[A-Za-z0-9_]*_INT="?\$\{([A-Za-z0-9_]+)%%\\?\.\*\}/i;
|
||||
|
||||
/**
|
||||
* Pure: find every unsanctioned dot-only integer split of a phase-carrying
|
||||
* variable in `text`. Skips full-line `#` comments (prose). Returns [{ line, found }].
|
||||
*/
|
||||
function findDotOnlyIntegerSplitDrift(text) {
|
||||
const out = [];
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (/^\s*#/.test(line)) continue;
|
||||
const m = DOT_ONLY_INT_SPLIT_DRIFT_RE.exec(line);
|
||||
if (!m) continue;
|
||||
if (!/phase/i.test(m[1])) continue;
|
||||
if (isSanctionedByPrecedingComment(lines, i, MD_OWNER_RE)) continue;
|
||||
out.push({ line: i + 1, found: m[0] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// b. The digit-then-optional-dot shape, in both `[0-9]` and `\d` spellings.
|
||||
// Anchored on the trailing `*` of the second digit class so a bare `[0-9]+`
|
||||
// probe or a `[0-9]+\.[0-9]+` (mandatory-dot) shape is not matched.
|
||||
const LOOSE_DOTTED_PHASE_DRIFT_RE = /(?:\\{1,2}d|\[0-9\])\+\\{1,2}\.\?(?:\\{1,2}d|\[0-9\])\*/;
|
||||
|
||||
/**
|
||||
* Pure: find every unsanctioned `[0-9]+\.?[0-9]*`-shaped phase extraction in
|
||||
* `text`, restricted to phase-carrying lines like its two sibling regex rules.
|
||||
* Returns [{ line, found }].
|
||||
*/
|
||||
function findLooseDottedPhaseRegexDrift(text) {
|
||||
const out = [];
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const m = LOOSE_DOTTED_PHASE_DRIFT_RE.exec(line);
|
||||
if (!m) continue;
|
||||
if (!PHASE_CARRYING_LINE_RE.test(line)) continue;
|
||||
if (isSanctionedByPrecedingComment(lines, i, MD_OWNER_RE)) continue;
|
||||
out.push({ line: i + 1, found: m[0] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// c. `printf "%Nd" …` / `printf '%0Nd' …` — any integer conversion, either
|
||||
// quote, with or without the zero flag — whose argument list names a
|
||||
// phase-carrying variable that is NOT an `_INT` (the `$((10#$PHASE_INT))`
|
||||
// pad is the sanctioned shape). `%d` cannot parse a letter id under any
|
||||
// width, so the flag is not the discriminator. Captures the first such name
|
||||
// so the report says what was padded.
|
||||
const SHELL_PHASE_PRINTF_PAD_RE = /printf\s+(?:"%0?\d*d[^"]*"|'%0?\d*d[^']*')\s+(.*)$/;
|
||||
const SHELL_VAR_NAME_RE = /\$\{?([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
|
||||
/**
|
||||
* Pure: find every unsanctioned `printf "%02d"` re-pad of a phase-carrying,
|
||||
* non-`_INT` shell variable in `text`. Skips full-line `#` comments.
|
||||
* Returns [{ line, found }].
|
||||
*/
|
||||
function findShellPhasePrintfPadDrift(text) {
|
||||
const out = [];
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (/^\s*#/.test(line)) continue;
|
||||
const m = SHELL_PHASE_PRINTF_PAD_RE.exec(line);
|
||||
if (!m) continue;
|
||||
const offender = [...m[1].matchAll(SHELL_VAR_NAME_RE)]
|
||||
.map((v) => v[1])
|
||||
.find((name) => /phase/i.test(name) && !/_int$/i.test(name));
|
||||
if (!offender) continue;
|
||||
if (isSanctionedByPrecedingComment(lines, i, MD_OWNER_RE)) continue;
|
||||
out.push({ line: i + 1, found: `printf "%0…d" …$${offender}` });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the shell roots (`gsd-core/workflows/**\/*.md`, `gsd-core/references/**\/*.md`)
|
||||
* for the two shell-idiom rules (a, c) and the three regex roots (those plus
|
||||
* `agents/**\/*.md`) for the extraction-shape rule (b). Returns
|
||||
* [{ file, kind, line, found }] with repo-relative paths.
|
||||
*/
|
||||
function scanMarkdownLetterAxisConsumers(root) {
|
||||
const violations = [];
|
||||
const collect = (dirs, finder, kind) => {
|
||||
for (const dir of dirs) {
|
||||
for (const file of walkMd(path.join(root, dir), [])) {
|
||||
const rel = path.relative(root, file);
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const d of finder(text)) {
|
||||
violations.push({ file: rel, kind, ...d });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
collect(MD_SCAN_DIRS, findDotOnlyIntegerSplitDrift, 'dot-only-int-split');
|
||||
collect(SINGLE_SEGMENT_SCAN_DIRS, findLooseDottedPhaseRegexDrift, 'loose-dotted-phase-regex');
|
||||
collect(MD_SCAN_DIRS, findShellPhasePrintfPadDrift, 'shell-phase-printf-pad');
|
||||
return violations;
|
||||
}
|
||||
|
||||
// Authored TypeScript source only (the generated bin/lib/*.cjs mirror it).
|
||||
const SCAN_DIRS = ['src'];
|
||||
const SCAN_EXT = new Set(['.cts', '.ts', '.mts']);
|
||||
@@ -743,7 +602,6 @@ function scanAll(root) {
|
||||
...scanMarkdownShellArith(root),
|
||||
...scanMarkdownSingleSegmentPhaseRegex(root),
|
||||
...scanMarkdownLetterlessPhaseMirror(root),
|
||||
...scanMarkdownLetterAxisConsumers(root),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -773,11 +631,6 @@ function main() {
|
||||
process.stderr.write('A digit-only unbounded-segment phase regex `[0-9]+(\\.[0-9]+)*` on the same roots\n');
|
||||
process.stderr.write('is missing the canonical letter axis (#4660) — widen to `[0-9]+[A-Z]?(\\.[0-9]+)*`\n');
|
||||
process.stderr.write('or sanction with `<!-- phase-id-owner: <reason> -->`.\n');
|
||||
process.stderr.write('Three letter-hostile consumers of a phase id are banned on the same roots (#4748):\n');
|
||||
process.stderr.write('a dot-only integer split `X_INT=${PHASE%%.*}` (split at the first non-digit,\n');
|
||||
process.stderr.write('`${PHASE%%[!0-9]*}`); a `[0-9]+\\.?[0-9]*` extraction (use `[0-9]+[A-Z]?(\\.[0-9]+)*`);\n');
|
||||
process.stderr.write('and a `printf "%02d"` re-pad of a phase variable (bind init\'s `padded_phase`, or pad\n');
|
||||
process.stderr.write('only an `_INT` via `$((10#…))`) — or sanction with `<!-- phase-id-owner: <reason> -->`.\n');
|
||||
process.stderr.write('A `.replace(\'{slug}\', ... || \'phase\')` fallback is banned outright (#4126) —\n');
|
||||
process.stderr.write('use `renderPhaseBranchName(` or sanction with\n');
|
||||
process.stderr.write('`// phase-id-owner: <reason>` on the line directly above:\n');
|
||||
@@ -797,13 +650,9 @@ module.exports = {
|
||||
findShellPhaseArithDrift,
|
||||
findSingleSegmentPhaseRegexDrift,
|
||||
findLetterlessPhaseMirrorDrift,
|
||||
findDotOnlyIntegerSplitDrift,
|
||||
findLooseDottedPhaseRegexDrift,
|
||||
findShellPhasePrintfPadDrift,
|
||||
scanMarkdownShellArith,
|
||||
scanMarkdownSingleSegmentPhaseRegex,
|
||||
scanMarkdownLetterlessPhaseMirror,
|
||||
scanMarkdownLetterAxisConsumers,
|
||||
scanRepo,
|
||||
scanAll,
|
||||
countSelectorBaselines,
|
||||
@@ -815,7 +664,4 @@ module.exports = {
|
||||
SHELL_PHASE_ARITH_DRIFT_RE,
|
||||
SINGLE_SEGMENT_PHASE_DRIFT_RE,
|
||||
LETTERLESS_PHASE_MIRROR_DRIFT_RE,
|
||||
DOT_ONLY_INT_SPLIT_DRIFT_RE,
|
||||
LOOSE_DOTTED_PHASE_DRIFT_RE,
|
||||
SHELL_PHASE_PRINTF_PAD_RE,
|
||||
};
|
||||
|
||||
@@ -974,13 +974,6 @@ function cmdInitExecutePhase(
|
||||
? toPosixPath(path.join(cwd, phaseInfo['directory'] as string))
|
||||
: null,
|
||||
phase_number: phaseInfo?.['phase_number'] || null,
|
||||
// #4748: the disk path hands back the directory's padded number (`03A`)
|
||||
// but the ROADMAP fallback above hands back the heading's bare one (`3A`),
|
||||
// and execute-phase.md's review lookup needs the padded form for
|
||||
// `{PADDED}-REVIEW.md`. It used to re-pad in shell with `printf "%02d"`,
|
||||
// which cannot pad a letter id and reads an already-padded `08` as octal.
|
||||
// Emit the canonical normalization, as the plan-phase/code-review inits do.
|
||||
padded_phase: phaseInfo?.['phase_number'] ? normalizePhaseName(phaseInfo['phase_number']) : null,
|
||||
// #3171: prefer the ROADMAP's curated display name for `phase_name`. When
|
||||
// the phase directory already exists on disk, the disk-lookup path
|
||||
// (searchPhaseInDir) derives phase_name from the directory-name remainder
|
||||
|
||||
@@ -32,7 +32,7 @@ import frontmatterModule = require('./frontmatter.cjs');
|
||||
const { extractFrontmatter } = frontmatterModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planDependencyGraphModule = require('./plan-dependency-graph.cjs');
|
||||
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted } = planDependencyGraphModule;
|
||||
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSummaryFileBlocked } = planDependencyGraphModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import roadmapParserModule = require('./roadmap-parser.cjs');
|
||||
const { getMilestonePhaseFilter } = roadmapParserModule;
|
||||
@@ -74,8 +74,18 @@ interface PhaseSearchResult {
|
||||
* #2830: the runnable-only view — `incomplete_plans` filtered to exclude
|
||||
* anything present as a key in `blocked_by`. `incomplete_plans` itself
|
||||
* keeps its pre-#2830 meaning ("no matching SUMMARY yet") unchanged.
|
||||
* NOTE (#4628): runnable means "not halted-blocked", NOT DAG-ready — a
|
||||
* runnable plan whose dependencies lack completion evidence is NOT ready.
|
||||
* Consumers dispatch from `ready_plans`.
|
||||
*/
|
||||
runnable_plans: string[];
|
||||
/**
|
||||
* #4628: the DAG-ready view the dispatcher consumes — incomplete plans
|
||||
* whose every resolved dependency has completion evidence (a matching
|
||||
* SUMMARY file) and which are not halted-blocked. Ready-ness is transitive:
|
||||
* a ready plan's own dependencies are complete.
|
||||
*/
|
||||
ready_plans: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,6 +234,7 @@ function searchPhaseInDir(baseDir: string, relBase: string, normalized: string):
|
||||
halted_plans: [],
|
||||
blocked_by: {},
|
||||
runnable_plans: [],
|
||||
ready_plans: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -264,12 +275,22 @@ function searchPhaseInDir(baseDir: string, relBase: string, normalized: string):
|
||||
plans.map((p, i) => [extractCanonicalPlanId(p).toLowerCase(), planIds[i]]),
|
||||
);
|
||||
|
||||
// #4628: raw depends_on per plan, so readiness can fail closed on a
|
||||
// DROPPED edge (a dep token that resolves to nothing carries no evidence)
|
||||
// instead of the resolution silently shrinking the dependency list.
|
||||
const rawDeps = plans.map((p) => parsePlanDependsOn(phaseDir, p));
|
||||
// #4628: completion evidence excludes status:blocked summaries (#3345) —
|
||||
// a failure record is not completion, matching cmdPhasePlanIndex's count.
|
||||
const completionEvidence = buildSummaryFileIndex(
|
||||
summaries.filter((f) => !isSummaryFileBlocked(path.join(phaseDir, f))),
|
||||
extractCanonicalPlanId,
|
||||
);
|
||||
const haltNodes = plans.map((p, i) => {
|
||||
const planId = planIds[i];
|
||||
const canonical = extractCanonicalPlanId(p);
|
||||
const summaryFile = summaryFileByPlanId.get(planId) ?? summaryFileByPlanId.get(canonical);
|
||||
const halted = summaryFile !== undefined && isSummaryFileHalted(path.join(phaseDir, summaryFile));
|
||||
const resolvedDependsOn = parsePlanDependsOn(phaseDir, p)
|
||||
const resolvedDependsOn = rawDeps[i]
|
||||
.map((dep) => {
|
||||
const lower = dep.toLowerCase();
|
||||
return planIdByLower.get(lower) ?? canonicalToPlanId.get(lower) ?? null;
|
||||
@@ -283,15 +304,23 @@ function searchPhaseInDir(baseDir: string, relBase: string, normalized: string):
|
||||
const incompletePlanSet = new Set(incompletePlans);
|
||||
const blockedByFiles: Record<string, string[]> = {};
|
||||
const runnablePlans: string[] = [];
|
||||
const readyPlans: string[] = [];
|
||||
for (let i = 0; i < plans.length; i++) {
|
||||
const p = plans[i];
|
||||
if (!incompletePlanSet.has(p)) continue;
|
||||
const causes = blockedBy.get(planIds[i]) ?? [];
|
||||
if (causes.length > 0) {
|
||||
blockedByFiles[p] = causes;
|
||||
} else {
|
||||
runnablePlans.push(p);
|
||||
continue;
|
||||
}
|
||||
runnablePlans.push(p);
|
||||
// #4628: DAG-ready on top of runnable — every dependency must have
|
||||
// completion evidence (a matching, non-blocked SUMMARY) and no dropped
|
||||
// edge: both readers of this contract fail closed identically.
|
||||
const depsComplete =
|
||||
rawDeps[i].length === haltNodes[i].resolvedDependsOn.length &&
|
||||
haltNodes[i].resolvedDependsOn.every((dep) => completionEvidence.has(dep));
|
||||
if (depsComplete) readyPlans.push(p);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -315,6 +344,7 @@ function searchPhaseInDir(baseDir: string, relBase: string, normalized: string):
|
||||
halted_plans: haltedPlans,
|
||||
blocked_by: blockedByFiles,
|
||||
runnable_plans: runnablePlans,
|
||||
ready_plans: readyPlans,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -855,7 +855,7 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||||
phase: normalized,
|
||||
error: `Phase ${normalized} is ambiguous: ${ambiguousMatches.length} directories match (${ambiguousMatches.map((m) => `"${m}"`).join(', ')}).`,
|
||||
ambiguous_matches: ambiguousMatches,
|
||||
plans: [], waves: {}, incomplete: [], has_checkpoints: false,
|
||||
plans: [], waves: {}, incomplete: [], runnable: [], ready_plans: [], has_checkpoints: false,
|
||||
},
|
||||
raw,
|
||||
);
|
||||
@@ -864,7 +864,7 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||||
|
||||
if (!phaseDir) {
|
||||
output(
|
||||
{ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], runnable: [], has_checkpoints: false },
|
||||
{ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], runnable: [], ready_plans: [], has_checkpoints: false },
|
||||
raw,
|
||||
);
|
||||
return;
|
||||
@@ -1034,6 +1034,10 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||||
const waves: Record<string, string[]> = {};
|
||||
const incomplete: string[] = [];
|
||||
const runnable: string[] = [];
|
||||
// #4628: DAG-ready view — see the per-plan emission below. Keyed by id so
|
||||
// the per-plan pass can resolve each plan's dependency edges.
|
||||
const resolvedDepsByPlan = new Map(haltNodes.map((n) => [n.id, n.resolvedDependsOn]));
|
||||
const readyPlans: string[] = [];
|
||||
let hasCheckpoints = false;
|
||||
const warnings: string[] = [];
|
||||
|
||||
@@ -1044,8 +1048,12 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||||
// plan with no dropped edges and a genuinely wrong `wave:` still warns
|
||||
// (N3, D6, T25).
|
||||
const plansWithUnresolvedTokens = new Set<string>();
|
||||
const unresolvedTokensByPlan = new Map<string, string[]>();
|
||||
for (const { plan, token } of unresolved) {
|
||||
plansWithUnresolvedTokens.add(plan);
|
||||
const tokens = unresolvedTokensByPlan.get(plan) ?? [];
|
||||
tokens.push(formatDiagnosticToken(token));
|
||||
unresolvedTokensByPlan.set(plan, tokens);
|
||||
warnings.push(
|
||||
`Plan ${plan}: depends_on token ${formatDiagnosticToken(token)} does not resolve to any plan in this phase — edge dropped, wave placement for this plan may be unreliable`,
|
||||
);
|
||||
@@ -1056,14 +1064,34 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||||
hasCheckpoints = true;
|
||||
}
|
||||
const blockedByIds = blockedBy.get(rawPlan.id) ?? [];
|
||||
// #4628: readiness fails closed — every resolved dependency must have
|
||||
// completion evidence (has_summary), and a depends_on edge that never
|
||||
// resolved (#3427 — a dropped edge) carries no evidence to check. A
|
||||
// single evaluation feeds both the ready_plans list and the per-plan
|
||||
// `ready` / `unresolved_dependencies` fields below.
|
||||
// Case-fold the dep before the planMap lookup: planMap is lowercase-keyed
|
||||
// (#2237) while resolveDependencyId returns the raw id — a mixed-case id
|
||||
// must not read as 'no evidence' (over-blocking a ready plan).
|
||||
const missingEvidence = (resolvedDepsByPlan.get(rawPlan.id) ?? [])
|
||||
.filter((dep) => planMap.get(dep.toLowerCase())?.hasSummary !== true);
|
||||
if (plansWithUnresolvedTokens.has(rawPlan.id)) {
|
||||
missingEvidence.push(...(unresolvedTokensByPlan.get(rawPlan.id) ?? []));
|
||||
}
|
||||
const isReady = blockedByIds.length === 0 && missingEvidence.length === 0;
|
||||
if (!rawPlan.hasSummary) {
|
||||
incomplete.push(rawPlan.id);
|
||||
// #2830: the runnable-only view — incomplete AND not transitively
|
||||
// blocked by a halted upstream plan. Additive alongside `incomplete`,
|
||||
// which keeps its existing "no SUMMARY yet" meaning unchanged.
|
||||
// #4628: runnable says NOTHING about completion evidence — a runnable
|
||||
// plan whose dependencies lack a SUMMARY is not DAG-ready. Consumers
|
||||
// dispatch from `ready_plans`.
|
||||
if (blockedByIds.length === 0) {
|
||||
runnable.push(rawPlan.id);
|
||||
}
|
||||
if (isReady) {
|
||||
readyPlans.push(rawPlan.id);
|
||||
}
|
||||
}
|
||||
|
||||
const computedWave = (level.get(rawPlan.id) ?? 0) + levelOffset;
|
||||
@@ -1111,6 +1139,14 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||||
halted: rawPlan.halted,
|
||||
blocked_by: blockedByIds,
|
||||
};
|
||||
if (!rawPlan.hasSummary) {
|
||||
// #4628: readiness is a property of INCOMPLETE plans (a summarized plan
|
||||
// is filtered by has_summary before readiness is ever consulted). The
|
||||
// unresolved_dependencies list names exactly which predecessors lack
|
||||
// completion evidence, for the named-skip report.
|
||||
plan['ready'] = isReady;
|
||||
if (missingEvidence.length > 0) plan['unresolved_dependencies'] = missingEvidence;
|
||||
}
|
||||
|
||||
plans.push(plan);
|
||||
|
||||
@@ -1127,6 +1163,7 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||||
waves,
|
||||
incomplete,
|
||||
runnable,
|
||||
ready_plans: readyPlans,
|
||||
has_checkpoints: hasCheckpoints,
|
||||
};
|
||||
if (planNamingWarning) result['warning'] = planNamingWarning;
|
||||
|
||||
@@ -37,12 +37,9 @@ const TIMEOUT = 5000;
|
||||
// site 1/2 (execute-phase.md): source PHASE_NUMBER, prefix PHASE
|
||||
// site 3 (completion-reconciliation.md): source SPOT_PHASE_NUMBER, prefix SPOT_PHASE
|
||||
// site 4 (tdd.md): source PHASE, prefix PHASE
|
||||
// #4748: the split is at the first NON-DIGIT, not the first dot, so a letter
|
||||
// suffix (`03A`, `23A.1.2`) rides through in the rest instead of aborting the
|
||||
// base-10 arithmetic; the rest is `_REST`, no longer only a `_FRAC`.
|
||||
function fixedSnippet(sourceVar, prefix, indent = '') {
|
||||
return `${indent}${prefix}_INT=\${${sourceVar}%%[!0-9]*}; ${prefix}_REST=\${${sourceVar}#"$${prefix}_INT"}\n` +
|
||||
`${indent}${prefix}_N="$((10#$${prefix}_INT))\${${prefix}_REST//./\\\\.}"`;
|
||||
return `${indent}${prefix}_INT=\${${sourceVar}%%.*}; ${prefix}_FRAC=\${${sourceVar}#"$${prefix}_INT"}\n` +
|
||||
`${indent}${prefix}_N="$((10#$${prefix}_INT))\${${prefix}_FRAC//./\\\\.}"`;
|
||||
}
|
||||
|
||||
function runFixed(phaseNumberValue) {
|
||||
@@ -67,11 +64,6 @@ describe('#4619 — execute-phase decimal/N-segment phase-number arithmetic', ()
|
||||
assert.equal(runFixed('01'), '1');
|
||||
});
|
||||
|
||||
test('#4748: a letter-suffixed phase number keeps its letter and zero-strips the digit run (03A -> 3A, 23A.1.2 -> 23A\\.1\\.2)', () => {
|
||||
assert.equal(runFixed('03A'), '3A');
|
||||
assert.equal(runFixed('23A.1.2'), '23A\\.1\\.2');
|
||||
});
|
||||
|
||||
test('failing-first: the OLD $((10#...)) form is a hard shell syntax error on a decimal phase number', () => {
|
||||
assert.throws(() => {
|
||||
execFileSync('bash', ['-c', 'echo $((10#01.1))'], { encoding: 'utf8', timeout: TIMEOUT });
|
||||
@@ -131,13 +123,13 @@ describe('#4619 — execute-phase decimal/N-segment phase-number arithmetic', ()
|
||||
});
|
||||
|
||||
describe('source parity — each of the 4 production sites carries the fixed logic', () => {
|
||||
test('execute-phase.md safe_resume_gate carries the fixed PHASE_NUMBER/PHASE_INT/PHASE_REST/PHASE_N logic', () => {
|
||||
test('execute-phase.md safe_resume_gate carries the fixed PHASE_NUMBER/PHASE_INT/PHASE_FRAC/PHASE_N logic', () => {
|
||||
const w = fs.readFileSync(EXECUTE_PHASE, 'utf8');
|
||||
assert.ok(w.includes(fixedSnippet('PHASE_NUMBER', 'PHASE')),
|
||||
'safe_resume_gate must carry the byte-identical fixed decimal-tolerant snippet');
|
||||
});
|
||||
|
||||
test('execute-phase.md TDD gate carries the fixed PHASE_NUMBER/PHASE_INT/PHASE_REST/PHASE_N logic', () => {
|
||||
test('execute-phase.md TDD gate carries the fixed PHASE_NUMBER/PHASE_INT/PHASE_FRAC/PHASE_N logic', () => {
|
||||
const w = fs.readFileSync(EXECUTE_PHASE, 'utf8');
|
||||
// The TDD gate block is nested one level deeper (4-space indent) than
|
||||
// safe_resume_gate's top-level snippet.
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
"reductionPct": 16.51
|
||||
},
|
||||
"execute-phase": {
|
||||
"offTokens": 26264,
|
||||
"onTokens": 24013,
|
||||
"reductionPct": 8.57
|
||||
"offTokens": 26259,
|
||||
"onTokens": 23969,
|
||||
"reductionPct": 8.72
|
||||
},
|
||||
"new-project": {
|
||||
"offTokens": 14308,
|
||||
@@ -39,8 +39,8 @@
|
||||
}
|
||||
},
|
||||
"aggregate": {
|
||||
"offTokens": 108307,
|
||||
"onTokens": 91659,
|
||||
"reductionPct": 15.37
|
||||
"offTokens": 108302,
|
||||
"onTokens": 91615,
|
||||
"reductionPct": 15.41
|
||||
}
|
||||
}
|
||||
|
||||
1
tests/fixtures/install-tree/antigravity.json
vendored
1
tests/fixtures/install-tree/antigravity.json
vendored
@@ -449,6 +449,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/augment.json
vendored
1
tests/fixtures/install-tree/augment.json
vendored
@@ -521,6 +521,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
@@ -385,6 +385,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/claude.json
vendored
1
tests/fixtures/install-tree/claude.json
vendored
@@ -449,6 +449,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/cline.json
vendored
1
tests/fixtures/install-tree/cline.json
vendored
@@ -451,6 +451,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/codebuddy.json
vendored
1
tests/fixtures/install-tree/codebuddy.json
vendored
@@ -521,6 +521,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/codex.json
vendored
1
tests/fixtures/install-tree/codex.json
vendored
@@ -485,6 +485,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/copilot.json
vendored
1
tests/fixtures/install-tree/copilot.json
vendored
@@ -450,6 +450,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/cursor.json
vendored
1
tests/fixtures/install-tree/cursor.json
vendored
@@ -449,6 +449,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/hermes.json
vendored
1
tests/fixtures/install-tree/hermes.json
vendored
@@ -449,6 +449,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/kilo.json
vendored
1
tests/fixtures/install-tree/kilo.json
vendored
@@ -521,6 +521,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi-code.json
vendored
1
tests/fixtures/install-tree/kimi-code.json
vendored
@@ -450,6 +450,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi.json
vendored
1
tests/fixtures/install-tree/kimi.json
vendored
@@ -457,6 +457,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/opencode.json
vendored
1
tests/fixtures/install-tree/opencode.json
vendored
@@ -521,6 +521,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/pi.json
vendored
1
tests/fixtures/install-tree/pi.json
vendored
@@ -251,6 +251,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/qwen.json
vendored
1
tests/fixtures/install-tree/qwen.json
vendored
@@ -449,6 +449,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/trae.json
vendored
1
tests/fixtures/install-tree/trae.json
vendored
@@ -449,6 +449,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/windsurf.json
vendored
1
tests/fixtures/install-tree/windsurf.json
vendored
@@ -377,6 +377,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
1
tests/fixtures/install-tree/zcode.json
vendored
1
tests/fixtures/install-tree/zcode.json
vendored
@@ -521,6 +521,7 @@
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/protected-branch.md",
|
||||
"gsd-core/workflows/execute-phase/steps/ready-wave-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
|
||||
"gsd-core/workflows/execute-phase/steps/sequential-root-pin.md",
|
||||
|
||||
@@ -1087,34 +1087,6 @@ describe('init commands ROADMAP fallback when phase directory does not exist (#1
|
||||
assert.strictEqual(output.phase_name, 'Foundation Setup');
|
||||
assert.strictEqual(output.phase_slug, 'foundation-setup');
|
||||
assert.strictEqual(output.phase_req_ids, 'R-01, R-02');
|
||||
// #4748: the ROADMAP fallback hands the workflow an UNPADDED number, and
|
||||
// execute-phase.md used to re-pad it with `printf "%02d"` — which cannot
|
||||
// pad a letter id and reads an already-padded `08` as octal. The
|
||||
// normalized form is emitted here, like the plan-phase sibling above.
|
||||
assert.strictEqual(output.padded_phase, '01');
|
||||
});
|
||||
|
||||
test('#4748 — init execute-phase emits padded_phase for a letter-suffixed phase, from a directory and from the ROADMAP fallback', () => {
|
||||
fs.appendFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'\n### Phase 3A: Letter Variant\n**Goal:** On disk\n\n### Phase 4B: Roadmap Only\n**Goal:** No directory yet\n',
|
||||
);
|
||||
seedPhase(tmpDir, '03A-letter-variant', { '03A-01-PLAN.md': '# Plan' });
|
||||
|
||||
const onDisk = JSON.parse(runGsdTools('init execute-phase 3A', tmpDir).output);
|
||||
assert.strictEqual(onDisk.phase_found, true);
|
||||
assert.strictEqual(onDisk.phase_number, '03A');
|
||||
assert.strictEqual(onDisk.padded_phase, '03A');
|
||||
|
||||
const roadmapOnly = JSON.parse(runGsdTools('init execute-phase 4B', tmpDir).output);
|
||||
assert.strictEqual(roadmapOnly.phase_found, true);
|
||||
assert.strictEqual(roadmapOnly.phase_dir, null);
|
||||
assert.strictEqual(roadmapOnly.phase_number, '4B');
|
||||
assert.strictEqual(roadmapOnly.padded_phase, '04B');
|
||||
|
||||
const missing = JSON.parse(runGsdTools('init execute-phase 9Z', tmpDir).output);
|
||||
assert.strictEqual(missing.phase_found, false);
|
||||
assert.strictEqual(missing.padded_phase, null);
|
||||
});
|
||||
|
||||
test('init verify-work falls back to ROADMAP when no phase directory exists', () => {
|
||||
|
||||
@@ -12,10 +12,6 @@ const {
|
||||
scanMarkdownSingleSegmentPhaseRegex,
|
||||
findLetterlessPhaseMirrorDrift,
|
||||
scanMarkdownLetterlessPhaseMirror,
|
||||
findDotOnlyIntegerSplitDrift,
|
||||
findLooseDottedPhaseRegexDrift,
|
||||
findShellPhasePrintfPadDrift,
|
||||
scanMarkdownLetterAxisConsumers,
|
||||
} = require('../scripts/lint-phase-id-drift.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
@@ -216,119 +212,3 @@ test('findLetterlessPhaseMirrorDrift does NOT flag a site sanctioned with an HTM
|
||||
test('scanMarkdownLetterlessPhaseMirror against the real repo tree reports zero violations (#4660 fixed)', () => {
|
||||
assert.deepEqual(scanMarkdownLetterlessPhaseMirror(ROOT), []);
|
||||
});
|
||||
|
||||
// #4748 (epic #4634): the three letter-hostile CONSUMER shapes — a dot-only
|
||||
// integer split, a `[0-9]+\.?[0-9]*` extraction, a `printf "%02d"` re-pad.
|
||||
|
||||
// a. dot-only integer split
|
||||
test('findDotOnlyIntegerSplitDrift flags the post-#4619 `PHASE_INT=${PHASE_NUMBER%%.*}` split', () => {
|
||||
const text = 'PHASE_INT=${PHASE_NUMBER%%.*}; PHASE_FRAC=${PHASE_NUMBER#"$PHASE_INT"}';
|
||||
const found = findDotOnlyIntegerSplitDrift(text);
|
||||
assert.equal(found.length, 1);
|
||||
assert.equal(found[0].line, 1);
|
||||
assert.equal(found[0].found, 'PHASE_INT=${PHASE_NUMBER%%.*}');
|
||||
});
|
||||
|
||||
test('findDotOnlyIntegerSplitDrift flags the prefixed SPOT_ variant and the bare $PHASE source', () => {
|
||||
assert.equal(findDotOnlyIntegerSplitDrift('SPOT_PHASE_INT=${SPOT_PHASE_NUMBER%%.*}').length, 1);
|
||||
assert.equal(findDotOnlyIntegerSplitDrift('PHASE_INT=${PHASE%%.*}; PHASE_FRAC=${PHASE#"$PHASE_INT"}').length, 1);
|
||||
});
|
||||
|
||||
test('findDotOnlyIntegerSplitDrift flags the quoted spelling of an _INT split', () => {
|
||||
assert.equal(findDotOnlyIntegerSplitDrift('PHASE_INT="${PHASE_NUMBER%%.*}"').length, 1);
|
||||
});
|
||||
|
||||
test('findDotOnlyIntegerSplitDrift is SILENT on a dot split into a non-_INT name (a parent-phase derivation is correct as-is)', () => {
|
||||
// gap-closure-artifacts.md: the parent of `03A.1` is `03A` — everything before
|
||||
// the first dot, letter included. Not an integer, not fed to $((10#…)).
|
||||
assert.deepEqual(findDotOnlyIntegerSplitDrift('PARENT_PHASE="${PHASE_NUMBER%%.*}"'), []);
|
||||
assert.deepEqual(findDotOnlyIntegerSplitDrift('PHASE_PREFIX=${PHASE_NUMBER%%.*}'), []);
|
||||
});
|
||||
|
||||
test('findDotOnlyIntegerSplitDrift is SILENT on the fixed first-non-digit split', () => {
|
||||
const text = 'PHASE_INT=${PHASE_NUMBER%%[!0-9]*}; PHASE_REST=${PHASE_NUMBER#"$PHASE_INT"}';
|
||||
assert.deepEqual(findDotOnlyIntegerSplitDrift(text), []);
|
||||
});
|
||||
|
||||
test('findDotOnlyIntegerSplitDrift does NOT flag a split of a non-phase variable (a version, a plan)', () => {
|
||||
assert.deepEqual(findDotOnlyIntegerSplitDrift('MAJOR_INT=${VERSION%%.*}'), []);
|
||||
assert.deepEqual(findDotOnlyIntegerSplitDrift('PLAN_INT=${PLAN_ID%%.*}'), []);
|
||||
});
|
||||
|
||||
test('findDotOnlyIntegerSplitDrift skips a full-line comment and honours the HTML sanction', () => {
|
||||
assert.deepEqual(findDotOnlyIntegerSplitDrift('# was: PHASE_INT=${PHASE_NUMBER%%.*}'), []);
|
||||
const text = [
|
||||
'<!-- phase-id-owner: deliberate, this site never sees a letter id -->',
|
||||
'PHASE_INT=${PHASE_NUMBER%%.*}',
|
||||
].join('\n');
|
||||
assert.deepEqual(findDotOnlyIntegerSplitDrift(text), []);
|
||||
});
|
||||
|
||||
// b. loose dotted extraction
|
||||
test('findLooseDottedPhaseRegexDrift flags the `[0-9]+\\.?[0-9]*` grep -oE extraction on a phase-carrying line', () => {
|
||||
const text = "FROM_PHASE=$(echo \"$ARGUMENTS\" | grep -oE '\\-\\-from\\s+[0-9]+\\.?[0-9]*' | awk '{print $2}')";
|
||||
const found = findLooseDottedPhaseRegexDrift(text);
|
||||
assert.equal(found.length, 1);
|
||||
assert.equal(found[0].found, '[0-9]+\\.?[0-9]*');
|
||||
});
|
||||
|
||||
test('findLooseDottedPhaseRegexDrift flags the \\d near-variant', () => {
|
||||
assert.equal(findLooseDottedPhaseRegexDrift("PHASE=$(echo \"$ARGUMENTS\" | grep -oE '\\d+\\.?\\d*')").length, 1);
|
||||
});
|
||||
|
||||
test('findLooseDottedPhaseRegexDrift is SILENT on the canonical `[0-9]+[A-Z]?(\\.[0-9]+)*` form', () => {
|
||||
const text = "PHASE=$(echo \"$ARGUMENTS\" | grep -oE '[0-9]+[A-Z]?(\\.[0-9]+)*' | head -1)";
|
||||
assert.deepEqual(findLooseDottedPhaseRegexDrift(text), []);
|
||||
});
|
||||
|
||||
test('findLooseDottedPhaseRegexDrift does NOT overlap the single-segment or letterless rules', () => {
|
||||
const bounded = 'if ! [[ "$PADDED_PHASE" =~ ^[0-9]+(\\.[0-9]+)?$ ]]; then';
|
||||
const letterless = 'if ! [[ "$PADDED_PHASE" =~ ^[0-9]+(\\.[0-9]+)*$ ]]; then';
|
||||
assert.deepEqual(findLooseDottedPhaseRegexDrift(bounded), []);
|
||||
assert.deepEqual(findLooseDottedPhaseRegexDrift(letterless), []);
|
||||
});
|
||||
|
||||
test('findLooseDottedPhaseRegexDrift does NOT flag a non-phase-carrying line (a version) and honours the sanction', () => {
|
||||
assert.deepEqual(findLooseDottedPhaseRegexDrift("MAJOR=$(echo \"$VERSION\" | grep -oE '[0-9]+\\.?[0-9]*')"), []);
|
||||
const text = [
|
||||
'<!-- phase-id-owner: deliberate, tracked in #4748 -->',
|
||||
"PHASE=$(echo \"$ARGUMENTS\" | grep -oE '[0-9]+\\.?[0-9]*' | head -1)",
|
||||
].join('\n');
|
||||
assert.deepEqual(findLooseDottedPhaseRegexDrift(text), []);
|
||||
});
|
||||
|
||||
// c. printf re-pad
|
||||
test('findShellPhasePrintfPadDrift flags `printf "%02d"` of a phase variable, braced or bare', () => {
|
||||
const found = findShellPhasePrintfPadDrift('PADDED=$(printf "%02d" "${PHASE_NUMBER}")');
|
||||
assert.equal(found.length, 1);
|
||||
assert.equal(found[0].found, 'printf "%0…d" …$PHASE_NUMBER');
|
||||
assert.equal(findShellPhasePrintfPadDrift('PHASE=$(printf "%02d" "$PHASE")').length, 1);
|
||||
assert.equal(findShellPhasePrintfPadDrift('PHASE=$(printf "%02d.%s" "${PHASE_MAJOR}" "${PHASE_MINOR}")').length, 1);
|
||||
});
|
||||
|
||||
test('findShellPhasePrintfPadDrift flags the single-quoted format and a width without the zero flag', () => {
|
||||
assert.equal(findShellPhasePrintfPadDrift("PADDED=$(printf '%02d' \"$PHASE_NUMBER\")").length, 1);
|
||||
assert.equal(findShellPhasePrintfPadDrift('PADDED=$(printf "%2d" "$PHASE_NUMBER")').length, 1);
|
||||
});
|
||||
|
||||
test('findShellPhasePrintfPadDrift is SILENT on a pad of an _INT via $((10#…)) and on init\'s padded_phase binding', () => {
|
||||
assert.deepEqual(findShellPhasePrintfPadDrift('PHASE=$(printf "%02d" "$((10#$PHASE_INT))")${BASH_REMATCH[2]}'), []);
|
||||
assert.deepEqual(findShellPhasePrintfPadDrift('PADDED="{padded_phase}"'), []);
|
||||
});
|
||||
|
||||
test('findShellPhasePrintfPadDrift does NOT flag a pad of a non-phase variable (a plan number)', () => {
|
||||
assert.deepEqual(findShellPhasePrintfPadDrift('PLAN_PADDED=$(printf "%02d" "$PLAN_ID")'), []);
|
||||
});
|
||||
|
||||
test('findShellPhasePrintfPadDrift skips a full-line comment and honours the HTML sanction', () => {
|
||||
assert.deepEqual(findShellPhasePrintfPadDrift('# PADDED=$(printf "%02d" "${PHASE_NUMBER}")'), []);
|
||||
const text = [
|
||||
'<!-- phase-id-owner: integer-only surface, tracked in #4748 -->',
|
||||
'PADDED=$(printf "%02d" "${PHASE_NUMBER}")',
|
||||
].join('\n');
|
||||
assert.deepEqual(findShellPhasePrintfPadDrift(text), []);
|
||||
});
|
||||
|
||||
test('scanMarkdownLetterAxisConsumers against the real repo tree reports zero violations (#4748 fixed)', () => {
|
||||
assert.deepEqual(scanMarkdownLetterAxisConsumers(ROOT), []);
|
||||
});
|
||||
|
||||
@@ -191,7 +191,6 @@ describe('#4568 — plan-phase.md captures the full N-segment --research-phase v
|
||||
// The canonical grammar is read from the committed bin/lib mirror the other
|
||||
// grammar tests use (shell cannot import it; the test can).
|
||||
const { PHASE_NUMBER_TOKEN_SOURCE } = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||
const { splitLines } = require('../gsd-core/bin/lib/text-lines.cjs');
|
||||
const CANONICAL_ANCHORED = new RegExp('^(?:' + PHASE_NUMBER_TOKEN_SOURCE + ')$');
|
||||
|
||||
// Inputs the canonical grammar ACCEPTS. `03A` is what `normalizePhaseName('3A')`
|
||||
@@ -319,290 +318,3 @@ describe('#4660 — plan-phase.md captures the full letter-suffixed --research-p
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #4748 — the letter axis at the seven shell sites OUTSIDE #4660's six. These
|
||||
// are not grammar mirrors but consumers of the id: the post-#4619
|
||||
// `PHASE_INT=${PHASE_NUMBER%%.*}; $((10#$PHASE_INT))` split (four sites),
|
||||
// the `printf "%02d"` re-pad before the REVIEW.md lookup (one site), the
|
||||
// `[0-9]+\.?[0-9]*` argument extraction (two files, four lines) and the
|
||||
// legacy manual normalizer. On a letter-suffixed id the first aborts bash,
|
||||
// the second prints the wrong file, the last two silently truncate. Same
|
||||
// discipline as above: each site's live lines are read off disk by anchor
|
||||
// and executed in a real bash subprocess.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EXECUTE_PHASE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
const COMPLETION_RECONCILIATION = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase', 'steps', 'completion-reconciliation.md',
|
||||
);
|
||||
const TDD_REF = path.join(__dirname, '..', 'gsd-core', 'references', 'tdd.md');
|
||||
const AUTONOMOUS = path.join(__dirname, '..', 'gsd-core', 'workflows', 'autonomous.md');
|
||||
const PLAN_REVIEW_CONVERGENCE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-review-convergence.md');
|
||||
const PHASE_ARGUMENT_PARSING = path.join(__dirname, '..', 'gsd-core', 'references', 'phase-argument-parsing.md');
|
||||
|
||||
/**
|
||||
* Pure: the indexes of every line containing `anchor`. Asserts the count so a
|
||||
* site that is added, removed or renamed breaks this test loudly instead of
|
||||
* silently narrowing what it covers (execute-phase.md carries the split TWICE
|
||||
* — plan selection and the TDD gate — and both must stay under test).
|
||||
*/
|
||||
function findAnchoredLineIndexes(lines, anchor, expectedCount) {
|
||||
const idx = [];
|
||||
lines.forEach((l, i) => {
|
||||
if (l.includes(anchor)) idx.push(i);
|
||||
});
|
||||
assert.equal(
|
||||
idx.length,
|
||||
expectedCount,
|
||||
`expected ${expectedCount} line(s) containing ${JSON.stringify(anchor)}, found ${idx.length}`,
|
||||
);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Run `script` in bash with `env` merged in; never throws — returns { status, stdout, stderr }. */
|
||||
function runBash(script, env) {
|
||||
try {
|
||||
const stdout = execFileSync('bash', [], {
|
||||
input: script,
|
||||
encoding: 'utf8',
|
||||
timeout: TIMEOUT,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
return { status: 0, stdout: stdout.trim(), stderr: '' };
|
||||
} catch (e) {
|
||||
return { status: e.status, stdout: String(e.stdout || '').trim(), stderr: String(e.stderr || '').trim() };
|
||||
}
|
||||
}
|
||||
|
||||
// What each Class 1 site must compute from the id it is handed: the integer
|
||||
// half zero-stripped for the anchored `0*` commit-scope ERE, everything after
|
||||
// it carried through with dots escaped. `03A` is the padded form `init` emits
|
||||
// for a `03A-slug/` directory; `12A` / `3A` are the bare forms; `03A.1.2` is
|
||||
// the letter-and-N-segment combination the canonical grammar admits.
|
||||
const CLASS1_CASES = [
|
||||
// [PHASE_NUMBER, expected PHASE_N]
|
||||
['03A', '3A'],
|
||||
['12A', '12A'],
|
||||
['3A', '3A'],
|
||||
['03A.1.2', '3A\\.1\\.2'],
|
||||
];
|
||||
const CLASS1_CONTROLS = [
|
||||
['06', '6'],
|
||||
['7', '7'],
|
||||
['08.5', '8\\.5'],
|
||||
['23.1.2', '23\\.1\\.2'],
|
||||
];
|
||||
|
||||
describe('#4748 — the $((10#$PHASE_INT)) split sites carry a letter suffix into PHASE_N instead of aborting', () => {
|
||||
const sites = [
|
||||
// execute-phase.md: plan selection (safe_resume_gate) and the TDD gate a
|
||||
// few lines below are the same two lines twice; both must be under test.
|
||||
{ name: 'execute-phase.md', file: EXECUTE_PHASE, anchor: 'PHASE_INT=${PHASE_NUMBER%%', count: 2, input: 'PHASE_NUMBER', output: 'PHASE_N' },
|
||||
{ name: 'completion-reconciliation.md', file: COMPLETION_RECONCILIATION, anchor: 'SPOT_PHASE_INT=${SPOT_PHASE_NUMBER%%', count: 1, input: 'SPOT_PHASE_NUMBER', output: 'SPOT_PHASE_N' },
|
||||
{ name: 'tdd.md', file: TDD_REF, anchor: 'PHASE_INT=${PHASE%%', count: 1, input: 'PHASE', output: 'PHASE_N' },
|
||||
];
|
||||
|
||||
for (const site of sites) {
|
||||
describe(site.name, () => {
|
||||
const lines = splitLines(fs.readFileSync(site.file, 'utf8'));
|
||||
const indexes = findAnchoredLineIndexes(lines, site.anchor, site.count);
|
||||
|
||||
indexes.forEach((i, n) => {
|
||||
// The split line and the PHASE_N line directly below it, verbatim.
|
||||
const splitLine = lines[i].trim();
|
||||
const nLine = lines[i + 1].trim();
|
||||
assert.ok(nLine.startsWith(`${site.output}=`), `line after the split must assign ${site.output}: ${nLine}`);
|
||||
const snippet = ['set -e', splitLine, nLine, `printf '%s' "$${site.output}"`].join('\n');
|
||||
const label = site.count > 1 ? ` (occurrence ${n + 1})` : '';
|
||||
|
||||
for (const [id, expected] of CLASS1_CASES) {
|
||||
test(`${id} → ${site.output}=${expected} without a shell error${label} (fails before the fix)`, () => {
|
||||
const r = runBash(snippet, { [site.input]: id });
|
||||
assert.equal(r.status, 0, `bash exited ${r.status}: ${r.stderr}`);
|
||||
assert.equal(r.stdout, expected);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [id, expected] of CLASS1_CONTROLS) {
|
||||
test(`regression control: ${id} → ${site.output}=${expected}${label}`, () => {
|
||||
const r = runBash(snippet, { [site.input]: id });
|
||||
assert.equal(r.status, 0, `bash exited ${r.status}: ${r.stderr}`);
|
||||
assert.equal(r.stdout, expected);
|
||||
});
|
||||
}
|
||||
|
||||
test(`the commit-scope ERE built from PHASE_N matches both the padded and the unpadded scope of a letter phase${label}`, () => {
|
||||
// Each site feeds PHASE_N into `^[a-z]+\((0*${PHASE_N})-(0*${PLAN_N})\):`
|
||||
// — the #4003 zero-pad-tolerant scope. Prove the value it now yields
|
||||
// for `03A` matches the two subjects an executor could have written,
|
||||
// and does NOT match the letter-less phase 3.
|
||||
const script = [
|
||||
'set -e',
|
||||
splitLine,
|
||||
nLine,
|
||||
`SCOPE_RE="^[a-z]+\\((0*\${${site.output}})-(0*1)\\):"`,
|
||||
'for s in "feat(3A-01): x" "feat(03A-1): x"; do printf \'%s\\n\' "$s" | grep -qE "$SCOPE_RE" || { echo "MISS $s"; exit 3; }; done',
|
||||
'printf \'%s\\n\' "feat(3-01): x" | grep -qE "$SCOPE_RE" && { echo "FALSE-MATCH"; exit 4; }',
|
||||
'echo OK',
|
||||
].join('\n');
|
||||
const r = runBash(script, { [site.input]: '03A' });
|
||||
assert.equal(r.status, 0, `${r.stdout} ${r.stderr}`);
|
||||
assert.equal(r.stdout, 'OK');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#4748 — execute-phase.md resolves the REVIEW.md path from init\'s padded_phase, not a shell re-pad', () => {
|
||||
const lines = splitLines(fs.readFileSync(EXECUTE_PHASE, 'utf8'));
|
||||
const [i] = findAnchoredLineIndexes(lines, 'REVIEW_FILE="${PHASE_DIR}/${PADDED}-REVIEW.md"', 1);
|
||||
const paddedLine = lines[i - 1].trim();
|
||||
|
||||
test('the PADDED binding directly above the lookup reads {padded_phase} (fails before the fix)', () => {
|
||||
// `printf "%02d"` cannot pad `03A` (prints `03`, exits 1) — and cannot
|
||||
// even re-pad an already-padded `08` (bash reads it as octal, prints
|
||||
// `00`). `init execute-phase` now emits `padded_phase` through the
|
||||
// canonical normalizer, so the workflow binds it instead of re-deriving.
|
||||
assert.ok(paddedLine.startsWith('PADDED='), `line above the lookup must bind PADDED: ${paddedLine}`);
|
||||
assert.equal(paddedLine, 'PADDED="{padded_phase}"');
|
||||
});
|
||||
|
||||
test('regression control: the lookup line itself is unchanged', () => {
|
||||
assert.equal(lines[i].trim(), 'REVIEW_FILE="${PHASE_DIR}/${PADDED}-REVIEW.md"');
|
||||
});
|
||||
|
||||
test('composition: the value init emits, substituted into the live lookup lines, resolves the letter phase\'s own REVIEW.md', (t) => {
|
||||
// The model substitutes `{padded_phase}` from the init JSON, which is
|
||||
// `normalizePhaseName(phase_number)` (src/init.cts). Do that substitution
|
||||
// here and run the three live lines against a fixture, so the emitted
|
||||
// value, the binding, the path construction and the status extraction are
|
||||
// exercised together — the executable half of a `{template}` site.
|
||||
const { normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
for (const [id, status] of [['3A', 'clean'], ['8', 'issues'], ['9', 'skipped']]) {
|
||||
const emitted = normalizePhaseName(id);
|
||||
fs.writeFileSync(path.join(dir, `${emitted}-REVIEW.md`), `---\nstatus: ${status}\n---\n# review\n`);
|
||||
const script = [
|
||||
'set -e',
|
||||
paddedLine.replace('{padded_phase}', emitted),
|
||||
lines[i].trim(),
|
||||
lines[i + 1].trim(),
|
||||
'printf \'%s %s\' "$PADDED" "$REVIEW_STATUS"',
|
||||
].join('\n');
|
||||
assert.ok(lines[i + 1].includes('REVIEW_STATUS='), `line after the lookup must extract REVIEW_STATUS: ${lines[i + 1]}`);
|
||||
const r = runBash(script, { PHASE_DIR: dir });
|
||||
assert.equal(r.status, 0, `bash exited ${r.status}: ${r.stderr}`);
|
||||
assert.equal(r.stdout, `${emitted} ${status}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the workflow\'s init parse list names padded_phase, so the binding is not a literal (fails before the fix)', () => {
|
||||
// A `{field}` token is substituted from the init JSON only for fields the
|
||||
// workflow tells the model to parse; `phase_number` is on that list and
|
||||
// `padded_phase` was not (adversarial review, claim 2).
|
||||
const [p] = findAnchoredLineIndexes(lines, 'Parse JSON for: `executor_model`', 1);
|
||||
assert.match(lines[p], /`phase_number`, `padded_phase`,/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#4748 — autonomous.md --from/--to/--only and plan-review-convergence.md extract the full letter-suffixed phase', () => {
|
||||
const autonomousText = fs.readFileSync(AUTONOMOUS, 'utf8');
|
||||
const prcText = fs.readFileSync(PLAN_REVIEW_CONVERGENCE, 'utf8');
|
||||
|
||||
const sites = [
|
||||
{ name: 'autonomous.md --from', pattern: extractGrepPattern(autonomousText, 'FROM_PHASE=$(echo "$ARGUMENTS" | grep -oE'), args: (v) => `--from ${v}`, tail: "| awk '{print $2}'" },
|
||||
{ name: 'autonomous.md --to', pattern: extractGrepPattern(autonomousText, 'TO_PHASE=$(echo "$ARGUMENTS" | grep -oE'), args: (v) => `--from 1 --to ${v}`, tail: "| awk '{print $2}'" },
|
||||
{ name: 'autonomous.md --only', pattern: extractGrepPattern(autonomousText, 'ONLY_PHASE=$(echo "$ARGUMENTS" | grep -oE'), args: (v) => `--only ${v} --interactive`, tail: "| awk '{print $2}'" },
|
||||
{ name: 'plan-review-convergence.md', pattern: extractGrepPattern(prcText, 'PHASE=$(echo "$ARGUMENTS" | grep -oE'), args: (v) => `${v} --codex --max-cycles 3`, tail: '| head -1' },
|
||||
];
|
||||
|
||||
function extract(site, v) {
|
||||
const script = `echo "$ARGUMENTS" | grep -oE '${site.pattern}' ${site.tail}`;
|
||||
return runBash(script, { ARGUMENTS: site.args(v) }).stdout;
|
||||
}
|
||||
|
||||
for (const site of sites) {
|
||||
describe(site.name, () => {
|
||||
// Before the fix `[0-9]+\.?[0-9]*` stops at the letter: `12A` → `12`,
|
||||
// silently targeting a different phase. `23.1.2` → `23.1` is the same
|
||||
// truncation one axis over (#4568's class in a spelling neither lint saw).
|
||||
for (const v of ['12A', '3A', '23A.1.2', '23.1.2']) {
|
||||
test(`${v} extracts ${v}, not a truncated prefix (fails before the fix)`, () => {
|
||||
const got = extract(site, v);
|
||||
assert.equal(got, v);
|
||||
assert.equal(CANONICAL_ANCHORED.test(got), true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const v of ['6', '36.14']) {
|
||||
test(`regression control: ${v} extracts ${v}`, () => {
|
||||
assert.equal(extract(site, v), v);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('autonomous.md: the three flags extract independently from one argument string', () => {
|
||||
const script = [
|
||||
`FROM_PHASE=$(echo "$ARGUMENTS" | grep -oE '${sites[0].pattern}' | awk '{print $2}')`,
|
||||
`TO_PHASE=$(echo "$ARGUMENTS" | grep -oE '${sites[1].pattern}' | awk '{print $2}')`,
|
||||
'printf \'%s %s\' "$FROM_PHASE" "$TO_PHASE"',
|
||||
].join('\n');
|
||||
assert.equal(runBash(script, { ARGUMENTS: '--from 3A --to 5B --max-cycles 2' }).stdout, '3A 5B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#4748 — phase-argument-parsing.md\'s legacy normalizer pads a letter-suffixed id instead of leaving it alone', () => {
|
||||
const lines = splitLines(fs.readFileSync(PHASE_ARGUMENT_PARSING, 'utf8'));
|
||||
const [start] = findAnchoredLineIndexes(lines, '# Normalize phase number', 1);
|
||||
let end = start;
|
||||
while (end < lines.length && lines[end].trim() !== 'fi') end++;
|
||||
assert.ok(end < lines.length, 'normalizer block must close with `fi`');
|
||||
const block = lines.slice(start, end + 1).join('\n');
|
||||
|
||||
function normalize(v) {
|
||||
return runBash(`set -e\n${block}\nprintf '%s' "$PHASE"`, { PHASE: v });
|
||||
}
|
||||
|
||||
// Before the fix neither branch matches a letter id, so `12A` passes through
|
||||
// unpadded and `3A` is never zero-padded to the `03A` a directory carries.
|
||||
for (const [input, expected] of [['3A', '03A'], ['12A', '12A'], ['3A.1', '03A.1'], ['23A.1.2', '23A.1.2']]) {
|
||||
test(`${input} → ${expected} (fails before the fix)`, () => {
|
||||
const r = normalize(input);
|
||||
assert.equal(r.status, 0, `bash exited ${r.status}: ${r.stderr}`);
|
||||
assert.equal(r.stdout, expected);
|
||||
assert.equal(CANONICAL_ANCHORED.test(r.stdout), true);
|
||||
});
|
||||
}
|
||||
|
||||
// `08` is the octal trap: `printf "%02d" 08` is an invalid octal number in
|
||||
// bash (exit 1, prints `00`), so the old integer branch mangled any
|
||||
// already-padded id it was handed. `23.1.2` matched neither old branch and
|
||||
// passed through unchanged — the N-segment axis was silently unpadded.
|
||||
for (const [input, expected] of [['08', '08'], ['23.1.2', '23.1.2']]) {
|
||||
test(`${input} → ${expected} without a shell error (fails before the fix)`, () => {
|
||||
const r = normalize(input);
|
||||
assert.equal(r.status, 0, `bash exited ${r.status}: ${r.stderr}`);
|
||||
assert.equal(r.stdout, expected);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [input, expected] of [['8', '08'], ['2.1', '02.1'], ['36.14', '36.14']]) {
|
||||
test(`regression control: ${input} → ${expected}`, () => {
|
||||
const r = normalize(input);
|
||||
assert.equal(r.status, 0, `bash exited ${r.status}: ${r.stderr}`);
|
||||
assert.equal(r.stdout, expected);
|
||||
});
|
||||
}
|
||||
|
||||
test('a non-canonical value passes through untouched (the normalizer is not a validator)', () => {
|
||||
const r = normalize('AUTH-101');
|
||||
assert.equal(r.status, 0);
|
||||
assert.equal(r.stdout, 'AUTH-101');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1823,3 +1823,245 @@ describe('lint-phase-enumeration-drift.cjs guard (#3882 rows F1-F3)', () => {
|
||||
assert.ok(driftGuard.OWNER_FILES.has(path.join('src', 'phase-id.cts')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('phase-plan-index: DAG-ready view (#4628)', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
// Local copies: the #2830 fixtures live inside the folded block above and
|
||||
// are not in scope here (same idiom, same file — not a third definition of
|
||||
// a shared helper: these are this describe's own fixtures).
|
||||
function writePlan(phaseDir, filename, frontmatterLines, taskLine = '<task>Work</task>') {
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, filename),
|
||||
[
|
||||
'---',
|
||||
...frontmatterLines,
|
||||
'---',
|
||||
'',
|
||||
`# ${filename}`,
|
||||
'',
|
||||
`<objective>${filename}</objective>`,
|
||||
'',
|
||||
taskLine,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function writeSummary(phaseDir, filename, status = 'complete') {
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, filename),
|
||||
['---', 'status: ' + status, '---', '', '# Summary', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The issue's exact chain: completed deps feed a wave-7 pair (41/50), whose
|
||||
* incomplete state must hold wave-9 (42) and wave-10 (06) dependents out of
|
||||
* the ready view. Waves are the reporter's declared frontmatter values.
|
||||
*/
|
||||
function build4628Fixture(tmpDir) {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '07-chain');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
// completed predecessors of the wave-7 pair
|
||||
writePlan(phaseDir, '07-04-PLAN.md', ['wave: 6', 'objective: Done 04', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '07-04-SUMMARY.md', 'complete');
|
||||
writePlan(phaseDir, '07-05-PLAN.md', ['wave: 6', 'objective: Done 05', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '07-05-SUMMARY.md', 'complete');
|
||||
writePlan(phaseDir, '07-18-PLAN.md', ['wave: 5', 'objective: Done 18', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '07-18-SUMMARY.md', 'complete');
|
||||
writePlan(phaseDir, '07-39-PLAN.md', ['wave: 6', 'objective: Done 39', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '07-39-SUMMARY.md', 'complete');
|
||||
writePlan(phaseDir, '07-40-PLAN.md', ['wave: 6', 'objective: Done 40', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '07-40-SUMMARY.md', 'complete');
|
||||
|
||||
// the wave-7 pair: incomplete but ready (all deps completed)
|
||||
writePlan(phaseDir, '07-41-PLAN.md', [
|
||||
'wave: 7', 'objective: Ready A', 'autonomous: true',
|
||||
'depends_on:', ' - 07-04', ' - 07-40', ' - 07-39',
|
||||
]);
|
||||
writePlan(phaseDir, '07-50-PLAN.md', [
|
||||
'wave: 7', 'objective: Ready B', 'autonomous: true', 'depends_on:', ' - 07-18',
|
||||
]);
|
||||
|
||||
// wave-9: depends on incomplete (not halted) 07-41
|
||||
writePlan(phaseDir, '07-42-PLAN.md', [
|
||||
'wave: 9', 'objective: Waiting C', 'autonomous: true',
|
||||
'depends_on:', ' - 07-05', ' - 07-41', ' - 07-39', ' - 07-40',
|
||||
]);
|
||||
|
||||
// wave-10: depends on incomplete 07-42
|
||||
writePlan(phaseDir, '07-06-PLAN.md', [
|
||||
'wave: 10', 'objective: Waiting D', 'autonomous: true',
|
||||
'depends_on:', ' - 07-40', ' - 07-42', ' - 07-05',
|
||||
]);
|
||||
return phaseDir;
|
||||
}
|
||||
|
||||
test('locator: the wave-7 pair is ready; the wave-9/10 dependents are runnable but NOT ready', () => {
|
||||
tmpDir = createTempProject('gsd-4628-');
|
||||
build4628Fixture(tmpDir);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '7');
|
||||
assert.ok(result, 'expected a result');
|
||||
assert.deepEqual(
|
||||
[...result.ready_plans].sort(),
|
||||
['07-41-PLAN.md', '07-50-PLAN.md'],
|
||||
'exactly the wave-7 pair is ready — every dependency has completion evidence',
|
||||
);
|
||||
assert.ok(result.runnable_plans.includes('07-42-PLAN.md'),
|
||||
'42 stays runnable (not halted-blocked) — runnable must not be read as DAG-ready');
|
||||
assert.ok(!result.ready_plans.includes('07-42-PLAN.md'),
|
||||
'42 depends on incomplete 07-41 — not ready');
|
||||
assert.ok(!result.ready_plans.includes('07-06-PLAN.md'),
|
||||
'06 depends on incomplete 07-42 — not ready');
|
||||
});
|
||||
|
||||
test('completed plans never appear in ready_plans (ready is a property of incomplete plans)', () => {
|
||||
tmpDir = createTempProject('gsd-4628-');
|
||||
build4628Fixture(tmpDir);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '7');
|
||||
assert.ok(!result.ready_plans.includes('07-04-PLAN.md'));
|
||||
assert.ok(!result.ready_plans.includes('07-18-PLAN.md'));
|
||||
});
|
||||
|
||||
test('CLI: per-plan ready/unresolved_dependencies name the missing predecessors', () => {
|
||||
tmpDir = createTempProject('gsd-4628-');
|
||||
build4628Fixture(tmpDir);
|
||||
|
||||
const result = runGsdTools(['phase-plan-index', '7', '--json'], tmpDir);
|
||||
assert.ok(result.success, `phase-plan-index should succeed: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
assert.deepEqual(
|
||||
[...data.ready_plans].sort(),
|
||||
['07-41', '07-50'],
|
||||
'ready_plans is the DAG-ready dispatch view',
|
||||
);
|
||||
const p42 = data.plans.find((p) => p.id === '07-42');
|
||||
assert.strictEqual(p42.ready, false);
|
||||
assert.deepEqual(p42.unresolved_dependencies, ['07-41'],
|
||||
'only the actually-incomplete dependency is named');
|
||||
const p06 = data.plans.find((p) => p.id === '07-06');
|
||||
assert.strictEqual(p06.ready, false);
|
||||
assert.deepEqual(p06.unresolved_dependencies, ['07-42']);
|
||||
const p41 = data.plans.find((p) => p.id === '07-41');
|
||||
assert.strictEqual(p41.ready, true);
|
||||
assert.strictEqual(p41.unresolved_dependencies, undefined);
|
||||
|
||||
// a halted predecessor still routes through blocked_by (#2830) AND reads not-ready
|
||||
});
|
||||
|
||||
test('a halted dependency keeps blocked_by semantics AND reads not-ready', () => {
|
||||
tmpDir = createTempProject('gsd-4628-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '08-halt');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
writePlan(phaseDir, '08-01-PLAN.md', ['wave: 1', 'objective: Halted', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '08-01-SUMMARY.md', 'halted');
|
||||
writePlan(phaseDir, '08-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Dependent of halt', 'autonomous: true', 'depends_on:', ' - 08-01',
|
||||
]);
|
||||
|
||||
const result = phaseLocator.findPhaseInternal(tmpDir, '8');
|
||||
assert.deepEqual(result.blocked_by['08-02-PLAN.md'], ['08-01'], '#2830 semantics unchanged');
|
||||
assert.ok(!result.ready_plans.includes('08-02-PLAN.md'), 'halt-blocked is not ready');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('phase-plan-index: readiness fail-closed edges (#4628)', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
function writePlan(phaseDir, filename, frontmatterLines, taskLine = '<task>Work</task>') {
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, filename),
|
||||
[
|
||||
'---',
|
||||
...frontmatterLines,
|
||||
'---',
|
||||
'',
|
||||
'# ' + filename,
|
||||
'',
|
||||
'<objective>' + filename + '</objective>',
|
||||
'',
|
||||
taskLine,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function writeSummary(phaseDir, filename, status = 'complete') {
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, filename),
|
||||
['---', 'status: ' + status, '---', '', '# Summary', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
test('an unresolved depends_on token carries no evidence — the plan is not ready', () => {
|
||||
tmpDir = createTempProject('gsd-4628-tok-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '09-tok');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
writePlan(phaseDir, '09-01-PLAN.md', ['wave: 1', 'objective: Ready by itself', 'autonomous: true']);
|
||||
writePlan(phaseDir, '09-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Typo dependency', 'autonomous: true', 'depends_on:', ' - 99-99',
|
||||
]);
|
||||
|
||||
const locator = phaseLocator.findPhaseInternal(tmpDir, '9');
|
||||
assert.ok(locator, 'expected a result');
|
||||
assert.ok(locator.ready_plans.includes('09-01-PLAN.md'), '09-01 has no dependencies — ready');
|
||||
assert.ok(!locator.ready_plans.includes('09-02-PLAN.md'),
|
||||
'a dropped edge carries no completion evidence — fail closed');
|
||||
|
||||
const cli = runGsdTools(['phase-plan-index', '9', '--json'], tmpDir);
|
||||
const data = JSON.parse(cli.output);
|
||||
const p02 = data.plans.find((p) => p.id === '09-02');
|
||||
assert.strictEqual(p02.ready, false, 'CLI must fail closed identically to the locator');
|
||||
});
|
||||
|
||||
test('a dependency whose SUMMARY declares status: blocked is NOT completion evidence', () => {
|
||||
tmpDir = createTempProject('gsd-4628-blk-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '10-blk');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
writePlan(phaseDir, '10-01-PLAN.md', ['wave: 1', 'objective: Blocked dependency', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '10-01-SUMMARY.md', 'blocked');
|
||||
writePlan(phaseDir, '10-02-PLAN.md', [
|
||||
'wave: 2', 'objective: Dependent of blocked', 'autonomous: true', 'depends_on:', ' - 10-01',
|
||||
]);
|
||||
|
||||
const locator = phaseLocator.findPhaseInternal(tmpDir, '10');
|
||||
assert.ok(locator, 'expected a result');
|
||||
assert.ok(!locator.ready_plans.includes('10-02-PLAN.md'),
|
||||
'a status:blocked SUMMARY is a failure record, not completion evidence');
|
||||
|
||||
const cli = runGsdTools(['phase-plan-index', '10', '--json'], tmpDir);
|
||||
const data = JSON.parse(cli.output);
|
||||
const p02 = data.plans.find((p) => p.id === '10-02');
|
||||
assert.strictEqual(p02.ready, false, 'CLI and locator must agree: blocked dep = not ready');
|
||||
});
|
||||
|
||||
test('locator and CLI agree on the issue chain (lockstep pin)', () => {
|
||||
tmpDir = createTempProject('gsd-4628-lock-');
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '11-lock');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
// completed
|
||||
writePlan(phaseDir, '11-01-PLAN.md', ['wave: 1', 'objective: Done', 'autonomous: true']);
|
||||
writeSummary(phaseDir, '11-01-SUMMARY.md', 'complete');
|
||||
// incomplete + ready
|
||||
writePlan(phaseDir, '11-02-PLAN.md', ['wave: 2', 'objective: Ready', 'autonomous: true', 'depends_on:', ' - 11-01']);
|
||||
// incomplete + not ready
|
||||
writePlan(phaseDir, '11-03-PLAN.md', ['wave: 3', 'objective: Waiting', 'autonomous: true', 'depends_on:', ' - 11-02']);
|
||||
|
||||
const locator = phaseLocator.findPhaseInternal(tmpDir, '11');
|
||||
const cli = JSON.parse(runGsdTools(['phase-plan-index', '11', '--json'], tmpDir).output);
|
||||
// the locator reports FILENAMES in ready_plans; the CLI reports plan ids —
|
||||
// normalize the filename form before comparing the two views.
|
||||
const locatorIds = locator.ready_plans.map((f) => f.replace(/-PLAN\.md$/, '')).sort();
|
||||
assert.deepEqual(
|
||||
locatorIds,
|
||||
[...cli.ready_plans].sort(),
|
||||
'locator and CLI must never disagree on which plans are ready',
|
||||
);
|
||||
assert.deepEqual([...cli.ready_plans].sort(), ['11-02']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,9 +31,9 @@ describe('#4003 — safe_resume_gate commit-scope greps', () => {
|
||||
const w = fs.readFileSync(WORKFLOW, 'utf8');
|
||||
// Anchored, ERE, zero-pad-tolerant on BOTH components — matches feat(2-02): and
|
||||
// feat(02-02): alike, never a substring elsewhere in the message.
|
||||
assert.ok(w.includes('PHASE_INT=${PHASE_NUMBER%%[!0-9]*}; PHASE_REST=${PHASE_NUMBER#"$PHASE_INT"}') &&
|
||||
w.includes('PHASE_N="$((10#$PHASE_INT))${PHASE_REST//./\\\\.}"'),
|
||||
'phase component must be zero-stripped via arithmetic base-10 (#4619: leading integer segment only, decimal/N-segment tolerant; #4748: split at the first non-digit so a letter suffix rides through)');
|
||||
assert.ok(w.includes('PHASE_INT=${PHASE_NUMBER%%.*}; PHASE_FRAC=${PHASE_NUMBER#"$PHASE_INT"}') &&
|
||||
w.includes('PHASE_N="$((10#$PHASE_INT))${PHASE_FRAC//./\\\\.}"'),
|
||||
'phase component must be zero-stripped via arithmetic base-10 (#4619: leading integer segment only, decimal/N-segment tolerant)');
|
||||
assert.ok(w.includes('PLAN_N=$((10#{plan_padded}))'),
|
||||
'plan component must be zero-stripped via arithmetic base-10');
|
||||
assert.ok(w.includes('PLAN_SCOPE_RE="^[a-z]+\\((0*${PHASE_N})-(0*${PLAN_N})\\):"'),
|
||||
@@ -60,9 +60,9 @@ describe('#4003 — safe_resume_gate commit-scope greps', () => {
|
||||
|
||||
test('tdd red gate tolerates both commit-scope spellings (#4011 keying untouched)', () => {
|
||||
const w = fs.readFileSync(WORKFLOW, 'utf8');
|
||||
assert.ok(w.includes('PHASE_INT=${PHASE_NUMBER%%[!0-9]*}; PHASE_REST=${PHASE_NUMBER#"$PHASE_INT"}') &&
|
||||
w.includes('PHASE_N="$((10#$PHASE_INT))${PHASE_REST//./\\\\.}"') && w.includes('PLAN_N=$((10#${PLAN_ID}))'),
|
||||
'the TDD block derives zero-stripped components (#4619: leading integer segment only, decimal/N-segment tolerant; #4748: letter-suffix tolerant)');
|
||||
assert.ok(w.includes('PHASE_INT=${PHASE_NUMBER%%.*}; PHASE_FRAC=${PHASE_NUMBER#"$PHASE_INT"}') &&
|
||||
w.includes('PHASE_N="$((10#$PHASE_INT))${PHASE_FRAC//./\\\\.}"') && w.includes('PLAN_N=$((10#${PLAN_ID}))'),
|
||||
'the TDD block derives zero-stripped components (#4619: leading integer segment only, decimal/N-segment tolerant)');
|
||||
assert.ok(w.includes('RED_COMMIT=$(git log --oneline -E ${TDD_MILESTONE_BASE:+"$TDD_MILESTONE_BASE..HEAD"} --grep="${PLAN_SCOPE_RE}" -- "*.test.*"'),
|
||||
'the RED grep must use the same anchored padding-tolerant scope, milestone-bounded');
|
||||
assert.ok(!w.includes('--grep="^test(${PHASE_NUMBER}-${PLAN_ID})"'),
|
||||
@@ -80,9 +80,9 @@ describe('#4003 — safe_resume_gate commit-scope greps', () => {
|
||||
'the padded-literal example grep must not remain');
|
||||
assert.ok(ref.includes('--grep="^test\\((0*${PHASE_N})-(0*${PLAN_N})\\):"'),
|
||||
'the RED example is anchored and zero-pad-tolerant');
|
||||
assert.ok(ref.includes('PHASE_INT=${PHASE%%[!0-9]*}; PHASE_REST=${PHASE#"$PHASE_INT"}') &&
|
||||
ref.includes('PHASE_N="$((10#$PHASE_INT))${PHASE_REST//./\\\\.}"') && ref.includes('PLAN_N=$((10#${PLAN}))'),
|
||||
'the examples derive zero-stripped components (#4619: leading integer segment only, decimal/N-segment tolerant; #4748: letter-suffix tolerant)');
|
||||
assert.ok(ref.includes('PHASE_INT=${PHASE%%.*}; PHASE_FRAC=${PHASE#"$PHASE_INT"}') &&
|
||||
ref.includes('PHASE_N="$((10#$PHASE_INT))${PHASE_FRAC//./\\\\.}"') && ref.includes('PLAN_N=$((10#${PLAN}))'),
|
||||
'the examples derive zero-stripped components (#4619: leading integer segment only, decimal/N-segment tolerant)');
|
||||
});
|
||||
|
||||
test('completion spot-check uses the anchored scope and keeps its time bound', () => {
|
||||
@@ -96,10 +96,10 @@ describe('#4003 — safe_resume_gate commit-scope greps', () => {
|
||||
'execute-phase', 'steps', 'completion-reconciliation.md'), 'utf8');
|
||||
assert.ok(!w.includes('--grep="{phase_number}-{plan_padded}"') && !frag.includes('--grep="{phase_number}-{plan_padded}"'),
|
||||
'the raw padded placeholder substring grep must not remain');
|
||||
assert.ok(frag.includes('SPOT_PHASE_INT=${SPOT_PHASE_NUMBER%%[!0-9]*}; SPOT_PHASE_REST=${SPOT_PHASE_NUMBER#"$SPOT_PHASE_INT"}') &&
|
||||
frag.includes('SPOT_PHASE_N="$((10#$SPOT_PHASE_INT))${SPOT_PHASE_REST//./\\\\.}"') &&
|
||||
assert.ok(frag.includes('SPOT_PHASE_INT=${SPOT_PHASE_NUMBER%%.*}; SPOT_PHASE_FRAC=${SPOT_PHASE_NUMBER#"$SPOT_PHASE_INT"}') &&
|
||||
frag.includes('SPOT_PHASE_N="$((10#$SPOT_PHASE_INT))${SPOT_PHASE_FRAC//./\\\\.}"') &&
|
||||
frag.includes('SPOT_PLAN_N=$((10#{plan_padded}))'),
|
||||
'the spot-check derives zero-stripped components (#4619: leading integer segment only, decimal/N-segment tolerant; #4748: letter-suffix tolerant)');
|
||||
'the spot-check derives zero-stripped components (#4619: leading integer segment only, decimal/N-segment tolerant)');
|
||||
assert.ok(frag.includes('--since="1 hour ago"'), 'the spot-check keeps its temporal bound');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user