* 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:
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user