Skip to content
5 changes: 5 additions & 0 deletions .changeset/eager-jaguars-tumble.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
type: Fixed
pr: 5283
---
**`evaluation-scope --plan` no longer reports an empty scope for a project-coded plan id** — a plan id carrying the project's configured `project_code` (`PRJ-01-01`) now resolves that plan's own commits in either spelling (`feat(PRJ-01-01):` and `feat(01-01):`) instead of matching literally against a subject no executor commit ever carries, which reported an empty scope at exit 0 for a plan that has already started. Only the CONFIGURED code is stripped: an id that merely looks coded (`AUTH-01-02`, `setup-1-2`) is still matched literally and so can never resolve another plan's commits. When no `project_code` is configured every id keeps its previous literal behaviour.
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Module owning the value a `gsd-tools check <gate>` module returns (ADR-5057 §4,
`SEAM.gate-verdict.enforced-by=test:tests/gate-verdict.test.cjs`

### Evaluation Scope Module
Module owning the answer to "which commits and which files does this gate or workflow step evaluate for this phase, plan or quick task?" (ADR-5057 §4, epic #5056, #5164). `resolveEvaluationScope(projectDir, unit, options) → EvaluationScope` (`src/gate-evaluation-scope.cts`, reached as `gsd_run check evaluation-scope`) returns the UNION of the unit's own commits' file sets — a phase's commits are the `## Task Commits` rows of its SUMMARY files, a plan's are the branch-reachable commits whose subject is `<type>(<phase>-<plan>):` — never a range, and only commits reachable from the evaluated ref. `status` is a closed set: `resolved`, `degraded` (the union was empty, so `files` come from the phase-directory range and `reason` says why) or `unresolvable` (git or the phase could not be read). What the union drops is named by path, not counted: `outsideUnion`, `unreachable`, `missingOnDisk`. It is a resolver, not a policy: it never blocks. `scripts/lint-evaluation-scope-drift.cjs` fails CI on a bespoke `git log --all`, `..HEAD` range, `HEAD~N` anchor, hand-rolled phase-start anchor or commit-message lookup in a gate module, `verify`, a workflow or an agent.
Module owning the answer to "which commits and which files does this gate or workflow step evaluate for this phase, plan or quick task?" (ADR-5057 §4, epic #5056, #5164). `resolveEvaluationScope(projectDir, unit, options) → EvaluationScope` (`src/gate-evaluation-scope.cts`, reached as `gsd_run check evaluation-scope`) returns the UNION of the unit's own commits' file sets — a phase's commits are the `## Task Commits` rows of its SUMMARY files, a plan's are the branch-reachable commits whose subject is `<type>(<phase>-<plan>):` — never a range, and only commits reachable from the evaluated ref. A plan id carrying the project's CONFIGURED `project_code` (`PRJ-01-01` with `project_code: "PRJ"`, #5271) matches either spelling of the same plan, `feat(PRJ-01-01):` and `feat(01-01):`, because `init.execute-phase` hands out `phase_number: "PRJ-01"` where `init.plan-phase` hands out `padded_phase: "01"` and every gate builds its `--plan` from the former; only the CONFIGURED code is stripped, gated on the phase-id owner's one grammar `PROJECT_CODE_VALUE_RE` (`^[A-Z][A-Z0-9_]*$`), so an id that merely looks coded and any out-of-grammar `project_code` value keep the literal behaviour (`src/gate-evaluation-scope.cts` `planSubjectPattern`/`configuredProjectCode`/`qualifiedPlanSubjectPattern` (the one renderer of `^[a-z]+\((<qualifier>-)?<fragment>\)!?:`), `src/phase-id.cts` `PROJECT_CODE_VALUE_RE`). `status` is a closed set: `resolved`, `degraded` (the union was empty, so `files` come from the phase-directory range and `reason` says why) or `unresolvable` (git or the phase could not be read). What the union drops is named by path, not counted: `outsideUnion`, `unreachable`, `missingOnDisk`. It is a resolver, not a policy: it never blocks. `scripts/lint-evaluation-scope-drift.cjs` fails CI on a bespoke `git log --all`, `..HEAD` range, `HEAD~N` anchor, hand-rolled phase-start anchor or commit-message lookup in a gate module, `verify`, a workflow or an agent.

`SEAM.evaluation-scope.owns=resolveEvaluationScope(projectDir, unit, options) (src/gate-evaluation-scope.cts) is the single owner of which commits and files a gate or workflow step evaluates (ADR-5057 §4, #5164)`
`SEAM.evaluation-scope.enforced-by=test:tests/evaluation-scope-drift-lint.test.cjs`
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ The design rules:
- **The scope is a union, not a range.** A range keeps every interleaved non-phase commit in its window. Measured on a 9,676-commit repository, the range held 23, 194 and 602 files for three phases where the union of the phases' own commits held 9, 42 and 69. A phase's commits are the `## Task Commits` rows of its SUMMARY files (section-scoped, row-scoped, backtick-anchored: a sha quoted in prose is not a task commit); a merge commit contributes its diff against its first parent.
- **Only commits reachable from the evaluated ref count.** The former `git log --all` let a commit on any branch satisfy a check.
- **An empty union never becomes an empty scope.** A phase with no SUMMARY, no task rows, no reachable task commits, or only planning paths degrades to the phase-directory range and reports `degraded` with the reason. A phase directory git has never seen, or an unreadable repository, is `unresolvable`.
- **Plan commits are matched on the subject.** `<type>(<phase>-<plan>):` is anchored, tolerant of zero padding (`03-01`, `3-1`) and of decimal, N-segment and letter-suffixed phase numbers, and escapes every metacharacter, so `03-01` never matches `03-010`.
- **Plan commits are matched on the subject.** `<type>(<phase>-<plan>):` is anchored, tolerant of zero padding (`03-01`, `3-1`) and of decimal, N-segment and letter-suffixed phase numbers, and escapes every metacharacter, so `03-01` never matches `03-010`. An id carrying the project's CONFIGURED `project_code` (`PRJ-01-01` with `project_code: "PRJ"`, #5271) matches either spelling of the same plan — `feat(PRJ-01-01):` and `feat(01-01):` — because the two init verbs hand out different phase tokens for one plan. The code is stripped only when it is the configured one, matched against the phase-id owner's single grammar `PROJECT_CODE_VALUE_RE` (`^[A-Z][A-Z0-9_]*$`); an id that merely looks coded (`AUTH-01-02`), and any `project_code` value outside that grammar, keeps the literal behaviour. Every spelling is built by `qualifiedPlanSubjectPattern`, the builder the workstream-qualified id proposed in #5177 is meant to share.
- **Every git call is bounded** by the `execGit` seam; a timeout or a missing git is `unresolvable`, never a throw and never an empty scope.

Workflows reach it as `gsd_run check evaluation-scope`:
Expand Down
120 changes: 108 additions & 12 deletions src/gate-evaluation-scope.cts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ import { gateVerdict, gateUnreadable, gateUsageFailure, isGateUsageFailure, GATE
import type { GateResult } from './gate-verdict.cjs';
import { resolveContainedPath, resolvePhaseDir } from './gate-phase-context.cjs';
import { escapeEre } from './pattern.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspaceMod = require('./planning-workspace.cjs');
const { readScopedConfigValue } = planningWorkspaceMod;
import { readPlanScanEvidence } from './gate-evidence.cjs';

// ─── Types ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -172,24 +177,114 @@ function paddedPattern(value: string): string {
return `0*${escapeEre(intPart)}${escapeEre(m[2] ?? '')}`;
}

/**
* The `<phase>-<plan>` fragment of a plan subject pattern, or null when `id` is not that shape.
*
* Zero-padding tolerant in both halves: `03-01` yields `0*3-0*1`, so `feat(03-01):`, `test(3-1):`
* and `fix(03-01)!:` match while `feat(03-010):` does not (#4003, #4619, #4748).
*/
function phasePlanPattern(id: string): string | null {
const dash = id.indexOf('-');
const phasePart = dash > 0 ? id.slice(0, dash) : '';
const planPart = dash > 0 ? id.slice(dash + 1) : '';
if (/^[0-9A-Za-z.]+$/.test(phasePart) && /^[0-9A-Za-z.]+$/.test(planPart)) {
return `${paddedPattern(phasePart)}-${paddedPattern(planPart)}`;
}
return null;
}

/**
* The anchored subject pattern for a `<phase>-<plan>` ERE fragment, optionally behind one scope
* qualifier as the group `(<qualifier>-)?`.
*
* Every spelling of a plan id goes through this one builder: the bare id, the id carrying the
* configured project code (#5271), and the workstream-qualified id `<ws>-<phase>-<plan>` that #5177
* proposes. The qualifier is ERE-escaped, so a workstream name (a path segment, not a project-code
* token) is matched literally. The group is a real capture group, never `(?:…)`: the pattern goes
* to `git log --extended-regexp`, and POSIX ERE has no non-capturing group.
*/
export function qualifiedPlanSubjectPattern(fragment: string, qualifier: string | null = null): string {
const group = qualifier === null ? '' : `(${escapeEre(qualifier)}-)?`;
return `^[a-z]+\\(${group}${fragment}\\)!?:`;
}

/**
* The `<CODE>-` prefix of `planId` when it is EXACTLY the project's configured `project_code`
* followed by a dash — the separator included — else null.
*
* The match is CASE-SENSITIVE and anchored to the configured value. The phase-id module's
* `stripProjectCodePrefix` grammar (case-insensitive, any code before a digit) recognises ANY
* letter prefix, which is deliberately wider than the project: `AUTH-01-02` and `setup-1-2` are
* ids this project never assigned a code to, and stripping their prefixes made them resolve
* another plan's commits (#5271). Only the code the project actually configures may be
* stripped, so an id that merely LOOKS coded keeps its literal-match guarantee.
*/
function configuredCodePrefix(planId: string, projectCode: string | null): string | null {
if (projectCode === null || !phaseIdMod.PROJECT_CODE_VALUE_RE.test(projectCode)) return null;
const prefix = `${projectCode}-`;
return planId.startsWith(prefix) ? prefix : null;
}

/**
* The anchored subject pattern for a plan id, or null when `planId` is empty, over-long or carries a
* control / whitespace character.
*
* A `<phase>-<plan>` id (`03-01`) is zero-padding tolerant: `feat(03-01):`, `test(3-1):` and
* `fix(03-01)!:` match, `feat(03-010):` does not. Any other id (a plan FILE NAME that does not follow
* the numbering) is matched LITERALLY — every ERE metacharacter escaped — so `x.*` or `a[b]` can only
* match a commit that names that exact id, and never widens the pattern.
* `fix(03-01)!:` match, `feat(03-010):` does not. An id carrying the CONFIGURED project code
* (`PRJ-01-01` with `project_code: "PRJ"`, #5271) matches either spelling of the SAME plan —
* `feat(PRJ-01-01):` and `feat(01-01):` — because the two init verbs hand out different phase
* tokens for one plan. The code is stripped ONLY when it is the configured one: an id like
* `AUTH-01-02` or `setup-1-2`, which merely looks coded, is matched LITERALLY — every ERE
* metacharacter escaped — so it can only match a commit naming that exact id and never widens the
* pattern to another plan's commits. Any other id (a plan FILE NAME that does not follow the
* numbering) is matched literally for the same reason.
*
* `projectCode` is the project's configured `project_code`; with none (no config, no `planningDir`),
* every id is matched literally, which is the pre-#5271 behaviour.
*/
export function planSubjectPattern(planId: string): string | null {
export function planSubjectPattern(planId: string, projectCode: string | null = null): string | null {
if (planId.length === 0 || planId.length > 200 || /[\s\x00-\x1f\x7f]/.test(planId)) return null;
const dash = planId.indexOf('-');
const phasePart = dash > 0 ? planId.slice(0, dash) : '';
const planPart = dash > 0 ? planId.slice(dash + 1) : '';
if (/^[0-9A-Za-z.]+$/.test(phasePart) && /^[0-9A-Za-z.]+$/.test(planPart)) {
return `^[a-z]+\\(${paddedPattern(phasePart)}-${paddedPattern(planPart)}\\)!?:`;
const direct = phasePlanPattern(planId);
if (direct) return qualifiedPlanSubjectPattern(direct);
// #5271: `init.execute-phase` returns `phase_number: "PRJ-01"` where `init.plan-phase` returns
// `padded_phase: "01"`, and every gate builds its `--plan` from the former. Splitting `PRJ-01-01`
// at its FIRST dash leaves `01-01` as the plan half, which is not a plan number, so the id fell
// through to the literal pattern that no executor commit can ever carry — an empty scope at exit
// 0, which is the correct answer for a plan that has NOT started and the wrong one for one that
// has. Retry on the CONFIGURED project-code-stripped id, with the code OPTIONAL so the commit the
// executor actually wrote still matches.
//
// The `(PRJ-)?` group comes from `qualifiedPlanSubjectPattern`, and it rides ONLY on a remainder
// that could be a plan number, so an empty or non-numeric remainder keeps the whole id literal —
// never WIDER than the pre-#5271 pattern.
const prefix = configuredCodePrefix(planId, projectCode);
if (prefix !== null) {
const stripped = planId.slice(prefix.length);
const coded = phasePlanPattern(stripped);
const planShaped = coded !== null || /^\d/.test(stripped);
if (!planShaped) return qualifiedPlanSubjectPattern(escapeEre(planId));
return qualifiedPlanSubjectPattern(coded ?? escapeEre(stripped), projectCode);
}
return `^[a-z]+\\(${escapeEre(planId)}\\)!?:`;
return qualifiedPlanSubjectPattern(escapeEre(planId));
}

/**
* The project's configured `project_code`, or null when it is absent, non-string or outside the
* supported grammar.
*
* SUPPORTED GRAMMAR: `PROJECT_CODE_VALUE_RE`, the phase-id owner's grammar (an uppercase letter,
* then uppercase letters, digits or `_`). `config-set` stores `project_code` unvalidated, so a
* lowercase (`prj`) or hyphenated (`AUTH-X`) value is possible; such a value is treated as ABSENT
* rather than matched with a second, wider grammar, which means the coded id falls back to its
* LITERAL spelling and #5271's symptom reappears for that project. That degradation is the safe
* direction — never WIDER than the pre-#5271 pattern, since a literal id can only match a subject
* naming that exact id — but it is a real limit: a project that configures a code outside the
* supported grammar does not get coded-id resolution.
*/
function configuredProjectCode(projectDir: string): string | null {
const { present, value } = readScopedConfigValue(projectDir, ['project_code']);
if (!present || typeof value !== 'string' || value.length === 0) return null;
return phaseIdMod.PROJECT_CODE_VALUE_RE.test(value) ? value : null;
}

// ─── Git plumbing ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -280,7 +375,7 @@ export function resolveEvaluationScope(projectDir: string, unit: ScopeUnit, opti
const repoRoot = git(['rev-parse', '--show-toplevel']);

if (unit.kind === 'phase') resolvePhase(projectDir, unit, scope, git, ref, options, repoRoot);
else resolveBySubject(unit, scope, git, ref, options, repoRoot);
else resolveBySubject(projectDir, unit, scope, git, ref, options, repoRoot);
return scope;
} catch (error) {
const reason = error instanceof ScopeUnreadable ? error.reason : 'resolver-error';
Expand Down Expand Up @@ -467,6 +562,7 @@ function resolvePhase(
}

function resolveBySubject(
projectDir: string,
unit: { kind: 'plan'; planId: string } | { kind: 'quick'; id: string }, scope: EvaluationScope,
git: Git, ref: string, options: ScopeOptions, repoRoot: string,
): void {
Expand All @@ -489,7 +585,7 @@ function resolveBySubject(
const grepArgs: string[] = [];
let anchored: RegExp | null = null;
if (unit.kind === 'plan') {
const pattern = planSubjectPattern(unit.planId);
const pattern = planSubjectPattern(unit.planId, configuredProjectCode(projectDir));
if (pattern === null) throw new ScopeUnreadable('invalid-plan-id');
grepArgs.push('--extended-regexp', `--grep=${pattern}`);
anchored = new RegExp(pattern);
Expand Down
14 changes: 10 additions & 4 deletions src/phase-id.cts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,15 @@ import { escapeRegex } from './pattern.cjs';

// project_code values start with an uppercase letter (e.g. PROJ, APP_CODE);
// leading underscores are not valid project codes per .planning/config.json.
const PROJECT_CODE_PREFIX_STRIP_RE = /^[A-Z][A-Z0-9_]*-(?=\d)/;
const PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i;
const PROJECT_CODE_PREFIX_CAPTURE_RE_I = /^([A-Z][A-Z0-9_]*)-(\d.*)/i;
const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?';
// Every project-code pattern below is built from this one class.
const PROJECT_CODE_CLASS_SOURCE = '[A-Z][A-Z0-9_]*';
const PROJECT_CODE_PREFIX_STRIP_RE = new RegExp(`^${PROJECT_CODE_CLASS_SOURCE}-(?=\\d)`);
const PROJECT_CODE_PREFIX_STRIP_RE_I = new RegExp(PROJECT_CODE_PREFIX_STRIP_RE.source, 'i');
const PROJECT_CODE_PREFIX_CAPTURE_RE_I = new RegExp(`^(${PROJECT_CODE_CLASS_SOURCE})-(\\d.*)`, 'i');
const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = `(?:${PROJECT_CODE_CLASS_SOURCE}-)?`;
// A configured project_code VALUE: the same class, anchored and
// case-sensitive, with no trailing dash (#5271).
const PROJECT_CODE_VALUE_RE = new RegExp(`^${PROJECT_CODE_CLASS_SOURCE}$`);

// #1729: phase headers may carry a parenthetical tag between the number and the
// colon, e.g. `### Phase 26 (Cluster B): Title`. This optional, non-capturing
Expand Down Expand Up @@ -1752,6 +1757,7 @@ function roadmapPhaseLookupSources(phaseNum: unknown): string[] {
}

export = {
PROJECT_CODE_VALUE_RE,
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
OPTIONAL_PHASE_TAG_SOURCE,
PHASE_NUMBER_TOKEN_SOURCE,
Expand Down
Loading
Loading