fix(#3586): warn when .planning/ is gitignored but still tracked (#3598)

* feat(#3586): warn when .planning/ is gitignored but still tracked

git ignore rules have no effect on files git already tracks, so a project
that committed .planning/ before ignoring it keeps staging those files --
while commit_docs correctly resolves to false, which is exactly what makes
the contradiction invisible.

The probe lives in the SNAPSHOT BUILDER, not the rule: Rule.check may perform
no ambient I/O (ADR-3180 8.1 rule 1, enforced by lint-planning-snapshot-bypass).
buildPlanningTrackedField follows buildWorktreeHealthField's precedent --
injected execGit, bounded, degrading to UNREADABLE with a typed reason rather
than throwing. W024 went inline instead only because no snapshot field carried
its fact; that precondition does not apply here.

W029 fires only on COMPLETE scope with ignored and tracked both true, so a
degraded probe yields neither a finding nor a false all-clear, and the default
project (tracked, not ignored) stays silent. The remedy is ADVISE-only --
--repair never untracks anything.

* docs(#3586): document W029 and correct the health rule count

CONFIGURATION.md documented the gitignore auto-detect without the caveat that
ignore rules do not affect already-tracked files -- the very gap W029 exists to
surface. Adds the caveat, the warning, its remedy, and why --repair will not
act on it.

CONTEXT.md's rule count was stale at 31 before this change (actual 32 through
W028); corrected to 33 and pointed at the two other places the count is locked,
so the next editor updates all three together.

* fix(#3586): treat ls-files overflow as tracked, add CLI-level W029 tests

Review findings.

Security (minor, confirmed): execGit sets no maxBuffer, so Node's 1MB default
applies to git ls-files. A .planning/ tree large enough to overflow it failed
into git_list_failed and silenced W029 -- a false negative in exactly the
large-history case most likely to have the real bug. Overflow is now treated
as PROOF of tracking (the output was non-empty by definition) and resolves to
tracked:true, scope COMPLETE, reason ok_truncated.

Spec (major): test-matrix rows C1 and C2 were never implemented -- there was no
CLI-level integration test at all, only rule-level ones. Both now drive the real
validate-health dispatch and confirm W029 is reachable end-to-end.

Known limit documented, not papered over: a deliberate git add -f under an
otherwise-ignored .planning/ raises the same signal as the accidental case.
There is no reliable way to tell them apart, the finding is advisory-only, and
a heuristic that cannot actually distinguish them would be worse than the
honest caveat.

* test(#3586): update frozen health-doc counts and acknowledge health.md growth

The remote matrix caught three gates that lint:ci does not cover.

gen-health-docs.test.cjs froze a 35-row / 32-rule assertion; W029 makes it
36/33. Updated both the assertion and the test NAME, which embeds the counts --
a stale name is a lie even when the assertion passes. The second reported
failure was the same assertion surfacing at describe-rollup granularity, not a
distinct bug.

emitted-attribution's growth arm needed an ack for the generated health.md.
health.md was already named in 3309-health-docs-generated.json, and two ack
sources naming one path is a hard error -- so a new fragment was not an option.
That fragment's own history shows the pattern: #3309 created it, #2873 amended
it in place for W028. Amended again for W029, with a note recording why this
one file is amended rather than joined by a sibling.

* docs(#3586): add the private-planning how-to and fix a wrong link

docs/CONFIGURATION.md pointed 'Configure private planning' at
how-to/configure-model-profiles.md -- an unrelated page -- and no
private-planning how-to existed at all. Found while editing that section.

The how-to test genuinely fires here: going private is four steps and crosses
planning.search_gitignored, a setting owned by another concern, so a reference
table structurally cannot carry it. The new page walks the whole sequence and
leads with the step people miss -- .gitignore does not untrack what git already
tracks -- which is the exact state W029 now detects.

Also corrects 'artefacts' to 'artifacts' (repo house style is American).

* chore(#3586): backfill changeset pr number to 3598

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-17 15:56:46 -04:00
committed by GitHub
parent ec7e49a64c
commit 5f64d999dc
14 changed files with 894 additions and 23 deletions

View File

@@ -62,6 +62,10 @@ type Rule = healthDiagnosticMod.Rule;
import { VALID_PROFILES, VALID_TIERS, VALID_PHASE_TYPES } from '../model-catalog.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('../planning-scope.cjs');
const { SCOPE } = planningScopeMod;
// verify.cts:2141 — inlined literal, not exported from anywhere; same list.
const VALID_BRANCHING_STRATEGIES = ['none', 'phase', 'milestone'];
@@ -273,6 +277,39 @@ function checkW022(snapshot: PlanningSnapshot): Diagnostic[] {
return diagnostics;
}
// ─── W029 — `.planning/` ignored but still tracked (#3586, epic #2292) ─────
//
// Not a config.json-sourced check (unlike every other rule in this file) —
// registered here per the phase brief rather than in a new file, reading
// only `snapshot.planningTracked` (`src/planning-snapshot.cts`'s
// `buildPlanningTrackedField`). Fires ONLY when the probe's scope is
// COMPLETE and both `ignored` and `tracked` are true: `.gitignore` has no
// effect on files git already tracks, so a project that committed
// `.planning/` before ignoring it keeps staging those files forever even
// though `commit_docs` correctly auto-resolves `false`. Any other COMPLETE
// combination (most importantly `ignored: false, tracked: true` — the
// default, healthy state of every normal project) is silent; a degraded
// (non-COMPLETE) scope is ALSO silent — a probe that could not run must
// never manufacture a finding. ADVISE-only: auto-untracking is destructive
// and outside this phase's stated boundary (design doc, "Rejected" #3).
function checkW029(snapshot: PlanningSnapshot): Diagnostic[] {
const field = snapshot.planningTracked;
if (!field || field.scope !== SCOPE.COMPLETE) return [];
if (!field.value.ignored || !field.value.tracked) return [];
return [
{
code: 'W029',
severity: SEVERITY.WARNING,
message:
'.planning/ matches a gitignore rule but is still tracked by git — .gitignore has no effect on already-tracked files, so staging keeps picking them up',
remedy: adviseRemedy(
'Run `git rm -r --cached .planning/` to untrack it (the ignore rule then takes effect); this does not delete the files on disk',
),
},
];
}
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
@@ -329,6 +366,13 @@ const RULES: Rule[] = [
repairable: false,
check: checkW022,
},
{
code: 'W029',
severity: SEVERITY.WARNING,
description: '.planning/ matches a gitignore rule but is still tracked by git (gitignore has no effect on already-tracked files)',
repairable: false,
check: checkW029,
},
];
export = { RULES };

View File

@@ -57,6 +57,9 @@ const { checkAgentsInstalled } = agentInstallCheckMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- worktree-safety.cjs is an export= CommonJS module
import worktreeSafetyMod = require('./worktree-safety.cjs');
const { inspectWorktreeHealth } = worktreeSafetyMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
import configLoaderMod = require('./config-loader.cjs');
const { isGitIgnored } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { PHASE_NUMBER_TOKEN_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, stripProjectCodePrefix, scopeToPhase } = phaseIdMod;
@@ -121,6 +124,11 @@ interface PlanningSnapshot {
config: { value: Record<string, unknown> | null; scope: Scope; exists: boolean };
agentInstall: { value: ReturnType<typeof checkAgentsInstalled>; scope: Scope };
worktreeHealth: { value: ReturnType<typeof inspectWorktreeHealth>['findings']; scope: Scope; reason: string };
// ─── #3586 (Phase 2, epic #2292) addition ──────────────────────────────────
// Backs W029: detects `.planning/` matching an ignore rule while at least
// one path under it is still tracked by git — see `buildPlanningTrackedField`'s
// own doc comment for the full rationale.
planningTracked: { value: { tracked: boolean; ignored: boolean }; scope: Scope; reason: string };
// ─── Phase 11 (#3309) "Rule table organization" additions ─────────────────
// The design doc's own "Rule table organization" table and prose disagree
// on the count: the table lists EIGHT rows (through `planningRootFiles`,
@@ -452,6 +460,104 @@ function buildWorktreeHealthField(cwd: string): { value: ReturnType<typeof inspe
}
}
// #3586 (Phase 2, epic #2292): matches `execGit`'s own default timeout
// (`shell-command-projection.cts:628`, also `10_000`) — kept as an explicit
// named constant here (rather than omitting `timeout` and relying on that
// default silently) so this call site's bound is self-documenting; generous
// enough for a normal repo, bounded enough to degrade rather than stall
// `buildPlanningSnapshot`, which every health path calls.
const PLANNING_TRACKED_GIT_TIMEOUT_MS = 10_000;
/**
* Resolve `planningTracked` — whether `.planning/` matches a gitignore rule
* AND whether at least one path under it is still tracked by git (#3586,
* Phase 2 of epic #2292). `.gitignore` has no effect on files git already
* tracks, so a project that committed `.planning/` before ignoring it keeps
* staging those files forever — while `commit_docs` auto-resolves `false`
* (`isGitIgnored`, reused below, is exactly what that resolution consults),
* which is what makes the contradiction invisible. Backs W029
* (`src/health-diagnostic-rules/config-validation.cts`).
*
* Modeled directly on `buildWorktreeHealthField` above (ADR-3180 §8.1 rule 1:
* a `Rule.check(snapshot)` may perform no ambient I/O, so this `git ls-files`
* probe lives here, in the snapshot builder, not the rule).
*
* `tracked` runs `git ls-files -- .planning` through the module's own
* injected `execGit` seam: non-empty stdout means at least one path under
* `.planning/` is in the INDEX — worktree presence is irrelevant (a path
* tracked in the index but deleted on disk still counts; that is what "the
* index is what matters" means for this probe).
*
* `ignored` reuses `isGitIgnored` (`config-loader.cjs`) — the SAME
* `git check-ignore -q --no-index` resolution `commit_docs` auto-resolution
* already calls (`config-loader.cts:824`) — rather than a second,
* independently-drifting `check-ignore` invocation. Only computed once the
* `ls-files` probe itself succeeded; a probe that could not run has no
* grounds to ask a second question either.
*
* Degradation mirrors `buildWorktreeHealthField` exactly: not a git repo →
* `SCOPE.UNREADABLE` + reason `not_a_git_repo` (silent downstream — matches
* the sibling builder's deliberate treatment of a `.planning/`-only
* fixture/tmp dir with no git repo at all); a timed-out `ls-files` →
* `git_timed_out`; any other non-zero exit → `git_list_failed`; a thrown
* exception → `exception`. Never throws out of the builder.
*
* Repo-presence is determined STRUCTURALLY, not by reading `ls-files`'s
* stderr prose (#3586): git localizes its error text (`LANG`/`LC_ALL`), so a
* regex matching the English "not a git repository" string silently
* misclassifies `not_a_git_repo` as `git_list_failed` under any non-English
* locale — this is exactly the "raw text matching on subprocess output"
* `CONTRIBUTING.md` bans, applied to production code rather than a test.
* Instead, on the `ls-files` failure path ONLY (never on the happy path —
* `buildPlanningSnapshot` runs on every health invocation, and the happy
* path must stay a single subprocess call), this asks git a structural
* yes/no question via `git rev-parse --is-inside-work-tree`: exit 0 means we
* ARE inside a work tree, so the `ls-files` failure was something else →
* `git_list_failed`; a non-zero exit means we are NOT → `not_a_git_repo`.
* If that probe itself times out, `result.timedOut` (the shared
* `isSpawnTimeout` predicate) reports `git_timed_out` — not a hand-rolled
* timeout check.
*
* `ENOBUFS` overflow (#3586 review F2): `execGit` sets no `maxBuffer`, so
* `spawnSync`'s Node-default 1MB cap applies to `ls-files`' stdout. A
* `.planning/` tree with enough tracked paths to exceed 1MB makes
* `spawnSync` report `error.code === 'ENOBUFS'` — exactly the large-tracked-
* history case this probe exists to catch, and exactly the case most likely
* to legitimately overflow the buffer. Falling into the generic
* non-zero-exit path here would misclassify it as `git_list_failed` →
* `SCOPE.UNREADABLE`, silently dropping the finding in precisely the
* scenario where it matters most. Overflow is therefore treated as PROOF OF
* TRACKING, not as a degraded read: `ls-files` only overflows because it had
* non-empty output to begin with, so `tracked` is unconditionally `true` —
* detected BEFORE the generic `exitCode !== 0` branch below, so this case
* never reaches (and never pays for) the `rev-parse` structural probe.
*/
function buildPlanningTrackedField(cwd: string): { value: { tracked: boolean; ignored: boolean }; scope: Scope; reason: string } {
try {
const result = execGit(['ls-files', '--', '.planning'], { cwd, timeout: PLANNING_TRACKED_GIT_TIMEOUT_MS });
if (result.timedOut) {
return { value: { tracked: false, ignored: false }, scope: SCOPE.UNREADABLE, reason: 'git_timed_out' };
}
if ((result.error as NodeJS.ErrnoException | null | undefined)?.code === 'ENOBUFS') {
const ignored = isGitIgnored(cwd, '.planning/');
return { value: { tracked: true, ignored }, scope: SCOPE.COMPLETE, reason: 'ok_truncated' };
}
if (result.exitCode !== 0) {
const probe = execGit(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: PLANNING_TRACKED_GIT_TIMEOUT_MS });
if (probe.timedOut) {
return { value: { tracked: false, ignored: false }, scope: SCOPE.UNREADABLE, reason: 'git_timed_out' };
}
const reason = probe.exitCode === 0 ? 'git_list_failed' : 'not_a_git_repo';
return { value: { tracked: false, ignored: false }, scope: SCOPE.UNREADABLE, reason };
}
const tracked = result.stdout.trim().length > 0;
const ignored = isGitIgnored(cwd, '.planning/');
return { value: { tracked, ignored }, scope: SCOPE.COMPLETE, reason: 'ok' };
} catch {
return { value: { tracked: false, ignored: false }, scope: SCOPE.UNREADABLE, reason: 'exception' };
}
}
// ─── Phase 11 (#3309) "Rule table organization" builders ────────────────────
// Each relocates (not reinvents) an existing `verify.cts` derivation. See the
// design doc's "Rule table organization" table for the exact source lines.
@@ -952,6 +1058,7 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot {
config: buildConfigField(cwd),
agentInstall: buildAgentInstallField(cwd),
worktreeHealth: buildWorktreeHealthField(cwd),
planningTracked: buildPlanningTrackedField(cwd),
projectSections: buildProjectSectionsField(cwd),
statePhaseTokens: stateFields.statePhaseTokens,
stateStatus: stateFields.stateStatus,