Commit Graph

6 Commits

Author SHA1 Message Date
gsxdsm
5897d87e95 fix(gate): the lane census judged a call against a signature it never had (the false positive #3013's merge introduced) (#3021)
#3013's merge of same-named declarations fixed a false **negative** and
introduced a false **positive**.

`ModelSelectorTab` declares its own two-parameter
`resolveEffectiveExecutor(task, settings)` — a pass-through with nothing
lane-related — while an unrelated exported function of the same name in
`effective-model-resolution.ts` takes `columnFlags`. Both local calls
were reported unwired against a signature they have never had.

Only **exported** declarations enter the accepting map, so the rule is
exact: if the calling file declares the name itself and the map entry
came from a different file, the call resolves to the local declaration
and is not a lane call here.

## The version I did not ship

My first attempt re-ran the detector over the single calling file and
used that result. It scored *better* on this tree — **19 → 16** instead
of 19 → 17, also clearing `bucket-mapping.ts` — and I threw it away.

A single-file pass cannot resolve an **imported** options interface. A
locally-declared function with an imported context type would quietly
stop being lane-accepting, and every call to it would stop being
checked. That is a false negative, which is the one failure a ratchet
must not have; the better-looking number came from the gate seeing less.
The global pass still does all type resolution here — only the *choice*
of declaration is local.

## Measured

| | |
|---|---|
| new tests | 3 |
| against the old census | **1 of 3 fails** — the positive |
| baseline | **19 → 17**, exactly the two `ModelSelectorTab` sites |

Both negatives pass either way and they are the ones that matter: a file
declaring its **own** exported lane function is not shadowed by itself,
and a file declaring nothing is judged normally. Shadowing must not
become a way to disappear a genuine unwired call.

## Still flagged, honestly

`bucket-mapping.ts:75` stays in the baseline. `bucketForTask(task:
TaskItem)` is only lane-accepting because `TaskItem` *declares*
`columnFlags` — the lane data rides on the domain object, so passing
`task` forwards it inherently. That is a different limitation
(options-bag vs domain-entity parameters) and I have not tried to fix it
here; it accounts for 2 of the remaining 17 along with
`otherBucketSecondaryLabel`.

## Verification

`node --test scripts/__tests__/check-lane-wiring.test.mjs` **19 passed**
· `pnpm test:gate` 13 + 161 + 487 + 71 · lint · lifecycle census
`--strict` · lane-wiring · fnxc-dates (TZ=UTC) · changesets — green.
2026-07-31 01:03:24 -07:00
gsxdsm
ffe9898710 fix(gate): the lane-wiring census could not see three of the shapes it asks for (incl. a false positive it started reporting) (#3013)
**The gate could not see three of the shapes it exists to check** —
including the wiring I landed this week in #2990 and #3004. Found by
using it: the baseline still listed `useBlockerFanout.ts`,
`ExecutorStatusBar.tsx`, and `TaskDetailModal.tsx` as unwired *after*
those PRs merged.

### 1. Conditional shapes

Passing lanes only when they resolved is the **correct** way to write
these — an empty trait index means "not loaded yet", not "nothing is
terminal", so the caller must fall through to the documented legacy
default rather than fabricate one. Both idioms that produces were
invisible:

```ts
computeBlockerFanoutMap(tasks, flags ? { columnFlagsByTaskId: flags } : {})   // ConditionalExpression
computeBlockerFanoutMapCore(tasks, N, { ...(flags ? { classify } : {}) })     // SpreadAssignment, name === undefined
```

Either branch supplying the lane now counts. **Neither branch supplying
it is still unwired** — that negative is tested.

### 2. Vocabulary

`columnFlagsByTaskId`, the per-task trait index the dashboard threads,
was never added. It answers every lane question at once, so a call site
dropping it reverts to the legacy vocabulary wholesale — and the gate
would have stayed silent.

### 3. Name collisions — the false positive

Declarations were `set` by name, so the **last one parsed won**. Core's
`computeBlockerFanoutMap(tasks, n, opts)` and the dashboard wrapper
`computeBlockerFanoutMap(tasks, opts)` put their lane options at
**different argument indices**, so core's callers were checked against
the wrapper's signature: `task-priority.ts:141` passes `terminalColumns`
at index 2 and was reported unwired.

I caught this because adding the vocabulary entry in (2) made it appear.
A ratchet that reports a correctly-wired site is worse than one that
misses it — the first person to open one learns the number is noise.
Both shapes are now merged; a call satisfying either counts.

### Measured

| | |
|---|---|
| new tests | **6** — every positive paired with its negative |
| against the old census | **3 of 6 fail** — exactly the three
positives; the negatives pass either way, which is why they exist |
| baseline | **23 → 19** — four sites recognized as *already* wired; no
site newly excused |
| new flags | none |

### Verification

`node --test scripts/__tests__/check-lane-wiring.test.mjs` **16 passed**
· `pnpm test:gate` 161 + 13 + 487 + 71 · lint · lifecycle census
`--strict` · fnxc-dates (TZ=UTC) · changesets — green.

Seven other `scripts/__tests__` files fail on main independently of this
change (`verify-fast`, `dependency-security-floor`,
`engine-vitest-gate-policy`, `plugin-authoring-docs`,
`release-prompt-gate`, `ci-test-shard-timings`,
`workflow-reliability-release-check`). None are in the merge gate and
none are touched here — noting them because I looked, not because this
PR affects them.
2026-07-31 00:33:13 -07:00
gsxdsm
684c324084 fix(gate): the lane-wiring census counted { reviewColumns: undefined } as wired (#2984)
## What

Follow-up to the finding @gsxdsm left on #2981, taking the direction
offered there.

Both arms of this census asked whether the lane argument was
**present**, not whether it carried anything:

```ts
isThing(task, { reviewColumns: undefined });   // property present -> counted as wired
isThing(task, undefined);                      // arity satisfied  -> counted as wired
```

The callee receives exactly what it received before: nothing. The seam
is still inert, the board still reads the legacy vocabulary — the census
just stops saying so, which is the one failure mode a ratchet must not
have.

Same defect as the positional one #2981 fixes in
`check-inert-flag-seams`, one level in. The two gates are complementary
by design — this one owns the options-object and default-valued shapes
the other is structurally blind to — so the hole had to be closed in
**both**. Neither covered it, confirmed by probing each with a control
shape.

## The direction I took, since the review raised it as a contract
question

> *tightening just relocates the dishonesty into whichever spelling
survives... especially as I have already spent three attempts learning
that heuristic tightening here trades false positives for worse false
negatives.*

Agreed, which is why this is the narrowest possible reading rather than
a heuristic:

**Only a literal `undefined` / `void 0` counts as empty.** Shorthand `{
reviewColumns }` forwards a variable whose value is not knowable from
syntax, and treating it as unwired would flag every correct forwarding
wrapper in the tree — exactly the false-positive wave that trains
readers to skip a gate. Same for a call expression, a conditional, or
anything else with a value at runtime.

That keeps the rule provable from syntax alone. It doesn't relocate the
dishonesty so much as remove the one spelling that is *demonstrably*
empty; anything ambiguous still counts as wired, so the gate stays
conservative in the direction that matters.

## No tests existed for this census

`check-lane-wiring` and `lane-wiring-census.mjs` had no unit coverage on
`main`, so both rules ship with tests rather than resting on the probe
that found them.

## Measured

| check | result |
|---|---|
| clean `main` | exit 0, unchanged — all five gates green |
| now caught | property spelled `undefined` · property spelled `void 0`
|
| correctly **not** flagged | a real value · shorthand forwarding · a
call-expression value · a middle `undefined` with a real argument after
it |
| new suite | **8 tests**; reverting both rules fails **exactly** the 3
positives, negatives hold |

## Not done here, deliberately

The second finding on #2981 — `computeBlockerFanoutMap`'s dashboard
wrapper dropping all four lane options, so the fanout display reads
legacy literals on a renamed board — is **not** in this PR. Confirming
the diagnosis: `useBlockerFanout.ts`'s `UseBlockerFanoutOptions`
declares only `staleHighFanoutAgeThresholdMs` and forwards only that,
and all three dashboard call sites (`Board`, `TaskDetailModal`,
`ExecutorStatusBar`) have the same gap.

One correction to how it's framed, though: core already has the right
seam for it. `classify` and `escalationClassify` are documented there as
*"the only correct option on a multi-workflow board"*, precisely because
the set-shaped options assume a column id means the same thing
everywhere. So the fix should thread **per-task classifiers**, not
resolved column-flag sets — otherwise it reproduces the union read that
this program's own learnings doc lists as the fourth failure shape.

What's genuinely undecided is where a per-task role answer comes from in
a sync render path: `Board` holds `columnDef.flags` for the *selected*
workflow only, and the dashboard has no per-task resolver hook. That's
the design call, and it's dashboard-batch work rather than a mechanical
edit — so I've left it for whoever owns that batch rather than guessing
at it inside a gate PR.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:07 -07:00
gsxdsm
16921fc518 fix(engine,core): role resolution was half-done in two shared lifecycle predicates (surfacing family + file-scope leases) (#2975)
The three surfacing sweeps stopped reporting anything for a card resting
in a board's **second** review or hold column.

A lifecycle role is a **trait**, and any number of columns may carry it.
The shared runner resolved it with `resolveLifecycleColumns()[role]` —
**first match** — then gated on it:

```ts
const roleColumn = lifecycle?.[spec.role];        // FIRST column carrying the trait
if (task.column !== resolved.roleColumn) continue; // everything else dropped
```

A workflow that splits human sign-off from the merge lane has two review
columns; one that parks dependency-blocked cards separately has two hold
columns. Cards in the second got **no stale-paused-todo, no
stale-paused-review, no in-review-stalled** diagnostic — silently, with
no error, on all three sweeps at once.

## The second bug hiding inside the fix for the first

Resolving membership but still reading `roleColumns[0]`'s declared
`recovery` applies the **merge lane's** threshold to a card sitting in
the **sign-off** lane. Each card's policy now comes from its own column,
and one of the new cases fails if it doesn't: the first role column
declares a policy that suppresses the signal, the card's own column
declares one that fires.

## Reverted

| | |
|---|---|
| **6 of 12** new cases fail | `fires for a card in the SECOND column
carrying its role` and `reads the recovery policy of the card's OWN role
column` — × 3 sweeps |
| the other 6 pass either way | non-regression halves: still fires for
the FIRST role column, still does **not** fire for a card outside every
role column. Membership must widen the gate, not move it. |

The pre-existing 45 cases were all green throughout — the
single-role-column fixture could not express the case, which is why the
table-driven file that exists to stop these three sweeps drifting apart
never caught it.

## Verification

`pnpm test:gate` 161 + 13 + 487 + 71 · surfacing family 57 · core
stale-paused 20 · lint · census `--strict` · sql-literals · fnxc-dates ·
lane-wiring · changesets — all green.

## Note

`holdColumns` was missing from the lane-wiring vocabulary, so the gate
could not see that argument dropped. Added in the same commit.

While reviewing, I found and measured **two problems in #2974** (comment
posted there): six of its newly-visible sites are `satisfies`-wrapped
false positives, and baselining them means deleting a real
`reviewColumns` argument keeps the count unchanged and the gate green;
and its baseline predates #2970, re-opening the slot that PR closed.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved stale-card detection across all applicable review and hold
columns.
* Cards are now surfaced using the policies configured for their
specific lifecycle column.
* Cards outside matching lifecycle columns are no longer incorrectly
surfaced.
* Preserved existing fallback behavior when no lifecycle columns are
configured.

* **Tests**
  * Added coverage for workflows with split review and hold columns.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---

## Second commit: the same predicate, half-converted
(`shouldHoldActiveFileScopeLease`)

Folded in here rather than stacked — same file, same class, and a
stacked PR on an unmerged base is not mergeable. Reversible; say the
word and I'll split it.

`shouldHoldActiveFileScopeLease` is the **scheduler's** lease predicate,
shared with the self-healing repair paths deliberately so the two cannot
disagree about who holds a file-scope lease. Its two role answers are
optional parameters defaulting to the legacy ids. The scheduler's own
call sites were converted to pass resolved answers; self-healing's two
were not:

```ts
const isWipColumn    = options?.isWipColumn    ?? task.column === "in-progress";
const isReviewColumn = options?.isReviewColumn ?? task.column === "in-review";
```

On a renamed board neither branch matches, so the predicate returns
`false` for every card. The scheduler kept the lease; self-healing saw
none, cleared `overlapBlockedBy`, and **released a dependent to edit
files another agent still holds** — the outcome `groupOverlappingFiles`
exists to prevent.

Membership comes from the wip/review sets each sweep already resolved a
few lines above, so this adds no reads.

**Reverted:** both new cases fail with `overlapBlockedBy` = `null` — the
release itself, not a proxy. The pre-existing legacy-column case in the
same file passes either way, because `in-progress` satisfies the literal
default; that is exactly why it never caught this.

Lane-wiring baseline re-recorded `9 -> 7` in the same commit (the
ratchet refused a stale allowance, as intended).

**Verification:** gate 161 + 13 + 487 + 71 · surfacing 57 · overlap-seam
+ scheduler-lease + query-blindness 79 · core stale-paused 20 · lint ·
census `--strict` · sql-literals · fnxc-dates · changesets — green.
2026-07-30 23:13:34 -07:00
gsxdsm
41af5e5dbd fix(gate): the lane-wiring census could not see its own motivating case (#2956) (#2974)
#2966 shipped a gate that **cannot detect the defect named first in its
own header.**

`findLaneAcceptingFunctions` matched a lane parameter only when
`param.type` was a `TypeLiteralNode` — an inline `{ reviewColumns?: …
}`. But the real code declares these as interfaces:

```ts
export function getInReviewStallReason(
  task: Pick<Task, …>,
  context: InReviewStallContext = {},   // TypeReference — invisible
): InReviewStallSignal | undefined
```

so the function never entered `accepting` and none of its call sites
were examined.

### Measured, both directions

| | before | after |
|---|---|---|
| lane-accepting functions detected | 20 | **30** |
| `getInReviewStallReason` detected | no | **yes** |
| re-introduce #2956 (drop `reviewColumns` from one call site) | `none
added` — **passes** | **fails**: `reads.ts: 7 unwired now, baseline
allows 6` |

The gate now catches the thing it was built for.

### The baseline moves 10 → 24, and that number needs context

`10 unwired call site(s) across 8 files` → `24 across 15`. **No entry
was removed** — every previously-recorded file kept its count and 14
sites became visible for the first time:

```
core/task-store/reads.ts                        0 -> 6
engine/self-healing.ts                          2 -> 4
core/task-store/branch-and-pr-entities.ts       0 -> 1
core/task-store/task-update.ts                  0 -> 1
engine/scheduler.ts                             0 -> 1
dashboard/routes/register-task-workflow-routes  0 -> 1
cli/commands/dashboard-tui/bucket-mapping.ts    0 -> 1
cli/extension.ts                                0 -> 1
```

**These are newly VISIBLE, not newly broken** — they have been unwired
all along. I have **not** audited them, and recording them in the
baseline is not a claim that they are fine; it is the ratchet doing what
its header describes, since the census's own note says roughly half of
the original hits were legitimately unwired (identity proven by a
stronger means, sentinel columns, dead exports). Someone should walk the
14. Two stand out as worth a look first: **`reads.ts` at 6** is the file
#2956 was about, and **`scheduler.ts`** is a dispatch path.

Flagging rather than fixing, because wiring a call site that should not
be wired is its own defect and each needs the judgement call the census
header describes.

### Regression test

`packages/engine/src/__tests__/lane-wiring-census-named-types.test.ts`
pins the detector's shape — named interface, type alias, inline literal,
positional — against fixtures rather than live counts, so it does not
churn when someone legitimately wires a call site. Plus one anti-vacuity
case asserting the named-type arm is still load-bearing on real source
(`getInReviewStallReason` resolves in the live tree), so the fixtures
cannot pass while the tool has quietly stopped applying here.

**Mutation:** removing the `TypeReference` arm fails **4 of 5**.

### Also worth knowing

`findLaneAcceptingFunctions` still only visits
`ts.isFunctionDeclaration` at top level, so `export const fn = (ctx) =>
…` remains invisible. I checked — no exported arrow function currently
takes a lane argument, so nothing is missed today, and I left it rather
than widen the surface in the same change.

Resolved by **name across the corpus** instead of a type-checker
`Program`: these are plain source scans and a checker would cost a full
type-resolution pass for one lookup. Two same-named types merge, which
only ever widens what counts as wired — safe for a ratchet.

**Verified:** 5/5 new tests, `check-lane-wiring` clean at the new
baseline, lint clean, FNXC gate exit 0. Core suite on main is green
(4923 passed / 0 failed) — unrelated, but I had it running.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Improved lane-wiring analysis to recognize named interfaces and type
aliases.
* Added support for wrapped configuration expressions and positional
parameters when detecting lane information.

* **Tests**
* Added comprehensive coverage for lane-wiring detection, including
named contexts and live-tree validation.

* **Chores**
* Updated baseline counts to reflect newly recognized application areas
and improved self-healing detection.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:54:31 -07:00
gsxdsm
19deb42170 gate: ratchet call sites that never receive the lane answer (#2966)
**This is the gap that let three defects reach `main` in one day.**

`unwired-lane-parameter.mjs` catches a parameter that reaches **no**
caller. It is deliberately satisfied by a mention *anywhere*, so
**partial** wiring is invisible to it:

| | |
| --- | --- |
| #2956 | `getInReviewStallReason` wired at **0 of 4** call sites while
both siblings were wired |
| #2963 | both merge entry points unwired — merging was **impossible**
on a renamed board |
| #2964 | merge-confirmed finalization unwired — **already-landed work
parked `failed`** |

Every one was a fix that added an optional parameter without the
call-site sweep that has to follow it. The existing guard was green
throughout, correctly by its own contract.

## A census, not a guard — and that distinction is the whole design

Auditing the sites this finds showed **four of seven were legitimately
unwired**: `skipColumnIdentityCheck` callers have already proven lane
identity by a stronger means, a sentinel-column caller wants the
identity check satisfied by construction, and a dead export has no
caller to wire at all.

A check that failed on those is ~57% false positives. The sibling
guard's own header says why that is worse than a miss — *"it teaches
people to disable the check"* — and I agree, so this does not do it.

Instead it ratchets like the lifecycle census: **36 known unwired sites
across 20 files**, allowed to shrink and not to grow. A new unwired
caller raises the count and fails; wiring one lowers it and re-records.
The recurrence — adding a caller that forgets the lane answer — is
precisely what gets caught, and the legitimate sites cost one baseline
line each instead of a permanently red gate.

## Detection is AST-based, deliberately

It finds exported functions accepting a lane-named argument — directly
*or* as an options-bag member — then finds call sites passing none of
them.

Not regex: the ad-hoc scan I used during the audit produced false
negatives on multi-line calls, which is exactly how a caller gets missed
in the first place. Using a heuristic to police a defect caused by a
heuristic seemed like a poor trade.

## Verified to fail on the recurrence

A ratchet that cannot fail is worse than none, so this was measured
rather than assumed. Injecting one new unwired caller into
`self-healing.ts`:

```
[check-lane-wiring] call sites not passing a resolved lane argument INCREASED:

  packages/engine/src/self-healing.ts: 9 unwired now, baseline allows 8
```

exit 1, naming the file and the delta.

## Placement

Runs as a named `check:lane-wiring` step in `pr-checks.yml` beside the
lifecycle, SQL, inert-seam and FNXC ratchets — same convention, same
failure ergonomics, ~1s.

Note the baseline records today's state, which still includes the
#2963/#2964 sites because those fixes have not merged yet. When they
land the count drops and the baseline is re-recorded downward — the
ratchet working as intended rather than a conflict.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; `tsc` engine clean; lint,
lifecycle census `--strict`, FNXC gate, and the new check all clean.
2026-07-30 22:14:27 -07:00