@codyswann/lisa 4.29.6 → 4.29.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +20 -2
  2. package/all/copy-overwrite/scripts/lisa-work-item.mjs +366 -78
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +4 -0
  5. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +5 -3
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/package.json +4 -4
  11. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa/rules/reference/tracked-work.md +19 -0
  14. package/plugins/lisa-agy/plugin.json +1 -1
  15. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  18. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-copilot/rules/reference/tracked-work.md +19 -0
  22. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cursor/rules/tracked-work-reference.mdc +19 -0
  24. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-expo-agy/plugin.json +1 -1
  27. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  37. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  42. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  47. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails-agy/plugin.json +1 -1
  52. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  57. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  62. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/src/base/rules/reference/tracked-work.md +19 -0
@@ -5196,11 +5196,29 @@ export function resolveMoment({
5196
5196
  costly: definition?.costly === true,
5197
5197
  });
5198
5198
  }
5199
- // Rewriters first, then alphabetical within each group. See `mayRewrite`:
5200
- // a verdict reached before a formatter runs describes bytes that never ship.
5199
+ // Rewriters first, then CHEAP before COSTLY, then alphabetical within each
5200
+ // group.
5201
+ //
5202
+ // `mayRewrite` leads because a verdict reached before a formatter runs
5203
+ // describes bytes that never ship.
5204
+ //
5205
+ // Cheap-before-costly is about what a failing run costs to learn. A gate
5206
+ // marked `costly` runs a test suite, a browser, or a load generator — minutes
5207
+ // each — and the ones that are not answer in seconds from files already on
5208
+ // disk. Alphabetical order alone put `traceability` behind every `test-*`
5209
+ // gate purely because `tra` sorts after `tes`, so a push refused for
5210
+ // something knowable from the commit range in milliseconds paid the whole
5211
+ // suite first and was told at the end. Nothing about the alphabet was a
5212
+ // statement about cost; this is.
5213
+ //
5214
+ // Ordering only. Every declared gate still runs, still reports its own
5215
+ // verdict, and `verdictFor` still runs a cheap gate after a failure and
5216
+ // stands only the costly ones down — so a run reports everything it can
5217
+ // afford to learn, sooner.
5201
5218
  return resolved.sort(
5202
5219
  (left, right) =>
5203
5220
  Number(right.mayRewrite) - Number(left.mayRewrite) ||
5221
+ Number(left.costly) - Number(right.costly) ||
5204
5222
  left.id.localeCompare(right.id)
5205
5223
  );
5206
5224
  }
@@ -100,21 +100,44 @@ class TrackingError extends Error {
100
100
  /** What `soleWorkItem` is reading when it reads a commit. */
101
101
  const COMMIT_SUBJECT = "commit message";
102
102
 
103
- /**
104
- * A refusal a commit rewrite clears, tagged as such.
105
- *
106
- * Used where the refusal is raised about the COMMITS in a range rather than
107
- * about one message — `exactWorkItem` tags its own by catching instead, since
108
- * the body it calls is shared with gate 4.
109
- * @param {string} message What is wrong.
110
- * @returns {TrackingError} The tagged error.
111
- */
112
- function commitTrailerError(message) {
113
- const error = new TrackingError(message);
114
- error.commitRewritable = true;
103
+ // WHICH RULE FIRED. `🔗 Work-Item Traceability` is one check name over several
104
+ // independent requirements, and a refusal that does not say which of them it is
105
+ // costs the reader a diagnosis step every single time: the same red check means
106
+ // "rewrite a commit", "edit the body", "post a backlink", or "the tracker says
107
+ // no", and those have nothing in common but the name. Measured on the defect
108
+ // this naming was added for — four misdiagnoses of one refusal in a single day,
109
+ // each one an edit that could not have cleared it.
110
+ //
111
+ // The numbers are the ones `gateSummary` prints, so a refusal and the checklist
112
+ // underneath it refer to the same thing by the same name.
113
+ /** The trailer on each commit in the range. */
114
+ const GATE_COMMIT = "gate 3 (commit trailer)";
115
+ /** The tracker's own answer about an item the range names. */
116
+ const GATE_LIVENESS = "gate 1 (live tracker item)";
117
+ /** What the pull-request body declares, and whether it matches the commits. */
118
+ const GATE_MAPPING = "gate 4 (pull-request declaration)";
119
+ /** The managed backlink comment on the tracker item. */
120
+ const GATE_BACKLINK = "gate 5 (tracker backlink)";
121
+
122
+ /**
123
+ * Record which rule a refusal belongs to, without overwriting a narrower tag.
124
+ * @param {Error} error The refusal.
125
+ * @param {string} gate The rule that raised it.
126
+ * @returns {Error} The same error, tagged.
127
+ */
128
+ function taggedGate(error, gate) {
129
+ if (error instanceof TrackingError && !error.gate) error.gate = gate;
115
130
  return error;
116
131
  }
117
132
 
133
+ // A HELPER FOR RANGE-WIDE COMMIT REFUSALS USED TO LIVE HERE, and it is gone
134
+ // because the only refusal that used it is gone. `validateCommits` raised
135
+ // "mixed Work-Item references" about the RANGE rather than about any one
136
+ // message; that rule is now the pull-request mapping check in `reportMapping`,
137
+ // where it is about the body and a body edit clears it. Every remaining
138
+ // commit-side refusal comes from `exactWorkItem`, which tags its own by
139
+ // catching.
140
+
118
141
  /**
119
142
  * The tracker could not be ASKED — a missing binary, a refused credential, a
120
143
  * call that never completed. Distinct from a TrackingError, which carries the
@@ -893,7 +916,7 @@ function exactWorkItem(message, contract = trackerContract()) {
893
916
  // than inside it keeps the flag set exactly where a commit rewrite is the
894
917
  // answer — never on the body refusal, which a rewrite would not touch.
895
918
  if (error instanceof TrackingError) error.commitRewritable = true;
896
- throw error;
919
+ throw taggedGate(error, GATE_COMMIT);
897
920
  }
898
921
  }
899
922
 
@@ -1958,6 +1981,40 @@ function assertStateMatches(ref, contract) {
1958
1981
  }
1959
1982
  }
1960
1983
 
1984
+ /**
1985
+ * The binding check for a range that names SEVERAL work items.
1986
+ *
1987
+ * `assertStateMatches` asks whether the binding IS the range's one item, which
1988
+ * a multi-item range has no answer to — picking any single reference out of the
1989
+ * set and comparing against that is a coin toss dressed as a check, and it
1990
+ * would refuse or accept the same push depending on commit order.
1991
+ *
1992
+ * The question that still has an answer is containment: the worktree is bound
1993
+ * to an item, and that item must be one the range actually carries. A branch
1994
+ * bound to something the range never touches is the same mistake the single-ref
1995
+ * check catches — work pushed from a worktree tracking something else — and it
1996
+ * is still refused here. Fails open on an unbound worktree, exactly as
1997
+ * `assertStateMatches` does.
1998
+ * @param {string[]} refs Canonical references the range names, in order.
1999
+ * @param {object} contract Resolved tracker contract.
2000
+ */
2001
+ function assertStateAmong(refs, contract) {
2002
+ const state = readState(true);
2003
+ if (!state) return;
2004
+ assertStateBranch(state);
2005
+ if (state.provider !== contract.provider) {
2006
+ throw new TrackingError(
2007
+ `Work-item binding provider ${state.provider} does not match configured tracker ${contract.provider}`
2008
+ );
2009
+ }
2010
+ const bound = canonicalizeRef(state.ref, contract);
2011
+ if (refs.includes(bound)) return;
2012
+ throw new TrackingError(
2013
+ `This worktree is bound to ${state.ref}, which none of this range's ` +
2014
+ `work items (${refs.join(", ")}) name`
2015
+ );
2016
+ }
2017
+
1961
2018
  /**
1962
2019
  * The work item a branch name encodes, or undefined when it encodes none.
1963
2020
  *
@@ -2046,8 +2103,8 @@ function assertBranchMatches(ref, contract) {
2046
2103
  * existed, down to the refusal wording.
2047
2104
  *
2048
2105
  * Scoped to the commit path deliberately. `validateCommits` (push and
2049
- * pull-request validation) keeps calling `assertStateMatches` directly: it
2050
- * already refuses mixed references across commits, and in CI the head is
2106
+ * pull-request validation) keeps checking the binding directly — against the
2107
+ * range's one reference, or against the set it names — and in CI the head is
2051
2108
  * frequently detached or on a synthetic merge ref, where a branch name is not
2052
2109
  * a statement about anything.
2053
2110
  * @param {string} ref Canonical reference taken from the trailer.
@@ -2108,18 +2165,43 @@ function validateCommits(commits, configRef, remote) {
2108
2165
  relevant += 1;
2109
2166
  const ref = exactWorkItem(commitMessage(sha), contract);
2110
2167
  refs.add(ref);
2111
- if (!issues.has(ref)) issues.set(ref, validateLive(ref, contract));
2168
+ if (!issues.has(ref)) {
2169
+ try {
2170
+ issues.set(ref, validateLive(ref, contract));
2171
+ } catch (error) {
2172
+ // The tracker's own "no" — closed, missing, out of scope, not a leaf.
2173
+ // Distinct from a badly written trailer, and a rewrite clears neither.
2174
+ throw taggedGate(error, GATE_LIVENESS);
2175
+ }
2176
+ }
2112
2177
  }
2113
- if (refs.size > 1)
2114
- throw commitTrailerError(
2115
- `Push/PR contains mixed Work-Item references: ${[...refs].join(", ")}`
2116
- );
2117
- const [ref] = refs;
2118
- if (ref) assertStateMatches(ref, contract);
2178
+ // A range spanning SEVERAL work items is no longer refused here, and the
2179
+ // reason is that the refusal was unsatisfiable rather than strict.
2180
+ //
2181
+ // Every commit above has already proved gate 3 on its own terms: one trailer,
2182
+ // canonical, naming an item the tracker says is live, open, repo-scoped and a
2183
+ // leaf. Nothing about that is weakened by the range naming two of them. What
2184
+ // the old refusal enforced was a MAPPING rule — this pull request is about
2185
+ // exactly one item — and a mapping is a property of the pull request, not of
2186
+ // the commits. It is enforced in `validatePrData`, against the pull-request
2187
+ // BODY, which is the only surface where an author can express the answer and
2188
+ // the only one a reviewer can audit.
2189
+ //
2190
+ // Raising it here made the rule impossible to satisfy for a whole class of
2191
+ // legitimate pushes: an integration branch that gathers several finished
2192
+ // items before one pull request has no edit — to any commit, body, or config
2193
+ // — that makes the range name one item, so the only remedies left were to
2194
+ // abandon the shape or to bypass the gate. Neither is a remedy.
2195
+ const list = [...refs];
2196
+ if (list.length === 1) assertStateMatches(list[0], contract);
2197
+ else if (list.length > 1) assertStateAmong(list, contract);
2198
+ const [ref] = list;
2119
2199
  return {
2120
2200
  contract,
2121
2201
  ref,
2202
+ refs: list,
2122
2203
  issue: ref ? issues.get(ref) : undefined,
2204
+ issues,
2123
2205
  mergeExempt,
2124
2206
  protectedExempt,
2125
2207
  releaseExempt,
@@ -2151,14 +2233,35 @@ function remoteDefaultRef(remote) {
2151
2233
  return exists.status === 0 ? target : undefined;
2152
2234
  }
2153
2235
 
2154
- function parsePushLines(input, remote) {
2155
- const commits = [];
2236
+ /**
2237
+ * One pushed ref and the commits it introduces, per line git sent.
2238
+ *
2239
+ * PER REF, not pooled, and that is the correction rather than a refactor.
2240
+ * `git push` may carry several ref updates in one invocation — three rebased
2241
+ * branches pushed together to pay one slow pre-push gate instead of three — and
2242
+ * pooling their ranges into a single commit list made the pushed refs
2243
+ * indistinguishable from one branch that had somehow gathered all of their
2244
+ * work. Three branches, each perfectly traced to one work item, were refused
2245
+ * for "mixed Work-Item references" that no branch contained: an artefact of the
2246
+ * pooling, not a property of anything anyone pushed. Batching was therefore
2247
+ * impossible by construction, and N work items cost N full gate runs.
2248
+ *
2249
+ * Each group is validated on its own below, which is exactly how the same
2250
+ * branches would be validated if pushed one at a time. Nothing is exempted:
2251
+ * the rule is unchanged and now applied to the unit it was always about.
2252
+ * @param {string} input The pre-push stdin stream.
2253
+ * @param {string} remote Remote being pushed to.
2254
+ * @returns {{localRef: string|undefined, commits: string[]}[]} One entry per
2255
+ * ref update that introduces commits.
2256
+ */
2257
+ function parsePushGroups(input, remote) {
2258
+ const groups = [];
2156
2259
  // Commits already reachable from the remote default branch are the base's
2157
2260
  // history (a merge-sync brings them along); excluding them keeps validation
2158
2261
  // scoped to branch-authored commits (issue #1956).
2159
2262
  const defaultRef = remoteDefaultRef(remote);
2160
2263
  for (const line of input.trim().split(/\r?\n/).filter(Boolean)) {
2161
- const [, localOid, , remoteOid] = line.trim().split(/\s+/);
2264
+ const [localRef, localOid, , remoteOid] = line.trim().split(/\s+/);
2162
2265
  if (!localOid || ZERO_OID.test(localOid)) continue;
2163
2266
  const args =
2164
2267
  remoteOid && !ZERO_OID.test(remoteOid)
@@ -2173,16 +2276,20 @@ function parsePushLines(input, remote) {
2173
2276
  // The existing-branch lane above must NOT use it (its tracking ref is
2174
2277
  // pusher-controlled); it excludes only the remote default branch.
2175
2278
  ["rev-list", localOid, "--not", `--remotes=${remote}`];
2176
- commits.push(...git(args).split("\n").filter(Boolean));
2279
+ groups.push({
2280
+ localRef,
2281
+ commits: git(args).split("\n").filter(Boolean),
2282
+ });
2177
2283
  }
2178
- if (commits.length === 0 && input.trim() === "") {
2179
- commits.push(
2180
- ...git(["rev-list", "HEAD", "--not", `--remotes=${remote}`])
2284
+ if (groups.length === 0 && input.trim() === "") {
2285
+ groups.push({
2286
+ localRef: undefined,
2287
+ commits: git(["rev-list", "HEAD", "--not", `--remotes=${remote}`])
2181
2288
  .split("\n")
2182
- .filter(Boolean)
2183
- );
2289
+ .filter(Boolean),
2290
+ });
2184
2291
  }
2185
- return commits;
2292
+ return groups;
2186
2293
  }
2187
2294
 
2188
2295
  /** The `refs/heads/` prefix, spelled once because three parsers below strip it. */
@@ -2345,16 +2452,34 @@ function validatePushDestination(args) {
2345
2452
  * @returns {string} The canonical work-item reference.
2346
2453
  */
2347
2454
  /**
2348
- * The one work item a PULL-REQUEST BODY names.
2455
+ * Every work item a PULL-REQUEST BODY names, deduplicated, in order.
2456
+ *
2457
+ * The body is where a pull request DECLARES what it is about, and that is why
2458
+ * this reads a set where the commit side reads exactly one. A commit is one
2459
+ * unit of work and can only honestly name one item; a pull request may
2460
+ * legitimately gather several — an integration branch, a stack of finished
2461
+ * items shipped together — and the body is the surface on which saying so is
2462
+ * explicit, reviewable, and visible in the forge without cloning anything.
2463
+ *
2464
+ * Writing N `Work-Item:` trailers into a body is not something that happens by
2465
+ * accident, which is what makes it a declaration rather than a loophole. What
2466
+ * keeps it honest is that `validatePrData` requires the declared set to equal
2467
+ * the set the commits actually carry — so it can neither omit an item the range
2468
+ * contains nor claim one it does not.
2349
2469
  *
2350
- * Same rule as the commit message, through the same function. See
2351
- * `soleWorkItem` for why the two used to disagree and why they no longer do.
2470
+ * An EMPTY body is still refused, with the same message as before: a pull
2471
+ * request naming no work item at all declares nothing.
2352
2472
  * @param {string} body Pull-request body.
2353
2473
  * @param {object} contract Resolved tracker contract.
2354
- * @returns {string} The canonical work-item reference.
2474
+ * @returns {string[]} The canonical work-item references, in order of appearance.
2355
2475
  */
2356
- function prWorkItem(body, contract) {
2357
- return soleWorkItem(body, contract, "pull request body");
2476
+ function prWorkItems(body, contract) {
2477
+ const values = workItemLines(body);
2478
+ if (values.length === 0)
2479
+ throw new TrackingError(
2480
+ "No Work-Item trailer anywhere in the pull request body"
2481
+ );
2482
+ return [...new Set(values.map(value => canonicalizeRef(value, contract)))];
2358
2483
  }
2359
2484
 
2360
2485
  /**
@@ -2427,15 +2552,16 @@ function commitOutcome(commits, configRef, remote) {
2427
2552
  * Run one check, recording its refusal as a finding instead of throwing.
2428
2553
  * @param {object[]} findings Accumulator.
2429
2554
  * @param {string} scope Whether an edit to this pull request can fix it.
2555
+ * @param {string} gate Which rule this check proves.
2430
2556
  * @param {Function} check The check to run.
2431
2557
  * @returns {unknown} The check's value, or undefined when it refused.
2432
2558
  */
2433
- function collect(findings, scope, check) {
2559
+ function collect(findings, scope, gate, check) {
2434
2560
  try {
2435
2561
  return check();
2436
2562
  } catch (error) {
2437
2563
  if (!(error instanceof TrackingError)) throw error;
2438
- findings.push({ message: error.message, scope });
2564
+ findings.push({ gate: error.gate ?? gate, message: error.message, scope });
2439
2565
  return undefined;
2440
2566
  }
2441
2567
  }
@@ -2520,7 +2646,7 @@ function gateSummary(contract) {
2520
2646
  ` 1. the item carries the ready role "${contract.lifecycle.ready}" — required before the work may be created or claimed`,
2521
2647
  ` 2. the item carries the claimed role "${contract.lifecycle.claimed}" — set when intake dispatches the work; NOT checked here, and no commit is ever refused for it`,
2522
2648
  " 3. every commit message carries ONE matching `Work-Item:` trailer — required by the commit-msg hook, on every single commit; exempt are merges, `chore(release)` commits, and commits already on a `deploy.branches` branch (a back-merge or promote re-asserts nothing)",
2523
- " 4. the pull-request BODY carries that same `Work-Item:` trailer — a SEPARATE check from gate 3, run at push once a pull request exists and again at CI time; `Closes owner/repo#N` does NOT satisfy it",
2649
+ " 4. the pull-request BODY declares EXACTLY the work items its commits carry — one `Work-Item:` line per item, usually one line; a SEPARATE check from gate 3, run at push once a pull request exists and again at CI time; `Closes owner/repo#N` does NOT satisfy it. A range spanning several items is allowed and must name all of them here; naming one the commits do not carry is refused too",
2524
2650
  ` ${backlink}`,
2525
2651
  ].join("\n");
2526
2652
  }
@@ -2550,8 +2676,12 @@ function gateSummary(contract) {
2550
2676
  * @returns {Error} The refusal, with the checklist appended.
2551
2677
  */
2552
2678
  function withGateSummary(error) {
2679
+ // The rule's name goes in FRONT of its own message, not only in the checklist
2680
+ // below it: the checklist says what all five gates are, and the reader still
2681
+ // has to work out which one they are looking at. See `GATE_COMMIT`.
2682
+ const rule = error.gate ? `Work-Item Traceability ${error.gate}: ` : "";
2553
2683
  return new TrackingError(
2554
- `${error.message}\n${gateSummary(trackerContract())}`
2684
+ `${rule}${error.message}\n${gateSummary(trackerContract())}`
2555
2685
  );
2556
2686
  }
2557
2687
 
@@ -2571,12 +2701,87 @@ function requirementReport(findings, contract) {
2571
2701
  : `${ordered.length} work-item traceability requirements are unmet. Every one of them is listed here, hardest first, so a single pass can clear them:`;
2572
2702
  const body = ordered
2573
2703
  .map(
2574
- (finding, index) => `${index + 1}. ${finding.scope} ${finding.message}`
2704
+ (finding, index) =>
2705
+ `${index + 1}. ${finding.gate ? `${finding.gate} ` : ""}${finding.scope} ${finding.message}`
2575
2706
  )
2576
2707
  .join("\n\n");
2577
2708
  return `${head}\n\n${body}\n${gateSummary(contract)}`;
2578
2709
  }
2579
2710
 
2711
+ /**
2712
+ * What the pull-request body must say when the range spans several items.
2713
+ *
2714
+ * Named in the refusal, because "declare it in the body" is a remedy a reader
2715
+ * has to be able to act on without going looking for the syntax.
2716
+ * @param {string[]} refs The references the body is missing.
2717
+ * @returns {string} Remediation sentence.
2718
+ */
2719
+ function declarationAdvice(refs) {
2720
+ const lines = refs.map(ref => ` Work-Item: ${ref}`).join("\n");
2721
+ return (
2722
+ `A pull request may gather several work items, but it has to SAY so: add ` +
2723
+ `one \`Work-Item:\` line per item to the pull-request body, so the body ` +
2724
+ `names exactly the set its commits carry. Add:\n\n${lines}\n\n` +
2725
+ `If this range was not meant to span several items, the answer is the ` +
2726
+ `other one: take the foreign commits out of it.`
2727
+ );
2728
+ }
2729
+
2730
+ /**
2731
+ * The mapping rule — the pull request declares exactly the items it carries.
2732
+ *
2733
+ * This is the rule that used to be enforced on the commit range as "no mixed
2734
+ * references", where it was unsatisfiable for an integration branch. Moving it
2735
+ * here does not retire it; it gives it the one surface on which it has an
2736
+ * answer. Set EQUALITY, in both directions, is what keeps it a check:
2737
+ *
2738
+ * - an item in the commits but not the body is undeclared work, which is the
2739
+ * accidental mix the old rule was aimed at, and it is still refused;
2740
+ * - an item in the body but not the commits is a claim the range does not
2741
+ * support — padding the declaration to make a refusal go away — and it is
2742
+ * refused too, which is what stops "list everything" from being a bypass.
2743
+ *
2744
+ * Silent when either side is empty: those are gate-3 and gate-4 absences, and
2745
+ * they are already reported by their own findings. Saying it twice would make
2746
+ * one missing trailer read as two unmet requirements.
2747
+ * @param {object[]} findings Accumulator.
2748
+ * @param {string[]} commitRefs References the range's commits carry.
2749
+ * @param {string[]} bodyRefs References the pull-request body declares.
2750
+ */
2751
+ function reportMapping(findings, commitRefs, bodyRefs) {
2752
+ if (commitRefs.length === 0 || bodyRefs.length === 0) return;
2753
+ const undeclared = commitRefs.filter(ref => !bodyRefs.includes(ref));
2754
+ const unsupported = bodyRefs.filter(ref => !commitRefs.includes(ref));
2755
+ // The 1:1 case keeps its own sentence. "Body declares X, commits carry Y" is
2756
+ // one disagreement, and splitting it into a missing item plus a spurious one
2757
+ // describes a single typo as two faults.
2758
+ if (commitRefs.length === 1 && bodyRefs.length === 1 && undeclared.length) {
2759
+ findings.push({
2760
+ gate: GATE_MAPPING,
2761
+ message: `Pull request Work-Item ${bodyRefs[0]} does not match commit Work-Item ${commitRefs[0]}`,
2762
+ scope: IN_THIS_PR,
2763
+ });
2764
+ return;
2765
+ }
2766
+ if (undeclared.length > 0)
2767
+ findings.push({
2768
+ gate: GATE_MAPPING,
2769
+ message:
2770
+ `Pull request body does not declare ${undeclared.join(", ")}, which ` +
2771
+ `this range's commits carry. ${declarationAdvice(commitRefs)}`,
2772
+ scope: IN_THIS_PR,
2773
+ });
2774
+ if (unsupported.length > 0)
2775
+ findings.push({
2776
+ gate: GATE_MAPPING,
2777
+ message:
2778
+ `Pull request body declares ${unsupported.join(", ")}, which no ` +
2779
+ `commit in this range carries. The body must name exactly the items ` +
2780
+ `the commits do — no more, or the declaration stops meaning anything.`,
2781
+ scope: IN_THIS_PR,
2782
+ });
2783
+ }
2784
+
2580
2785
  /**
2581
2786
  * Check every pull-request requirement and report all of the unmet ones.
2582
2787
  * @param {{result?: object, error?: Error}} outcome Commit-side outcome.
@@ -2612,6 +2817,7 @@ function validatePrData(outcome, prUrl, prBody) {
2612
2817
  // advice is what makes the difference legible at the point of reading.
2613
2818
  if (outcome.error)
2614
2819
  findings.push({
2820
+ gate: outcome.error.gate ?? GATE_COMMIT,
2615
2821
  message: outcome.error.commitRewritable
2616
2822
  ? `${outcome.error.message}. ${COMMIT_REWRITE_ADVICE}`
2617
2823
  : outcome.error.message,
@@ -2619,35 +2825,42 @@ function validatePrData(outcome, prUrl, prBody) {
2619
2825
  });
2620
2826
  else if (!tracedWhereAuthored && result.relevant === 0)
2621
2827
  findings.push({
2828
+ gate: GATE_COMMIT,
2622
2829
  message: `Pull request has no non-merge commit linked to a work item. ${COMMIT_REWRITE_ADVICE}`,
2623
2830
  scope: IN_THIS_PR,
2624
2831
  });
2625
2832
  else if (!tracedWhereAuthored && !result.ref)
2626
2833
  findings.push({
2834
+ gate: GATE_COMMIT,
2627
2835
  message: `Pull request commits are not linked to a work item. ${COMMIT_REWRITE_ADVICE}`,
2628
2836
  scope: IN_THIS_PR,
2629
2837
  });
2630
- const commitRef = result?.ref;
2631
- const bodyRef = collect(findings, IN_THIS_PR, () =>
2632
- prWorkItem(prBody, contract)
2633
- );
2634
- if (commitRef && bodyRef && bodyRef !== commitRef)
2635
- findings.push({
2636
- message: `Pull request Work-Item ${bodyRef} does not match commit Work-Item ${commitRef}`,
2637
- scope: IN_THIS_PR,
2638
- });
2639
- const ref = commitRef ?? bodyRef;
2838
+ const commitRefs = result?.refs ?? [];
2839
+ const bodyRefs =
2840
+ collect(findings, IN_THIS_PR, GATE_MAPPING, () =>
2841
+ prWorkItems(prBody, contract)
2842
+ ) ?? [];
2843
+ reportMapping(findings, commitRefs, bodyRefs);
2844
+ const refs = commitRefs.length > 0 ? commitRefs : bodyRefs;
2640
2845
  // Requirement 4 belongs to `full` alone: it needs tracker WRITE access, and
2641
2846
  // a project that keeps no tracker credentials cannot ever satisfy it. The
2642
2847
  // reference checks above are what stays, and they still refuse everything
2643
2848
  // that is genuinely untraceable.
2644
- if (ref && contract.verify === "full") {
2645
- const before = findings.length;
2646
- collect(findings, OUTSIDE_THIS_PR, () =>
2647
- assertBacklink(ref, prUrl, contract, commitRef ? result.issue : undefined)
2648
- );
2649
- if (findings.length > before)
2650
- findings[before].message += `. ${backlinkAdvice(ref, prUrl, contract)}`;
2849
+ //
2850
+ // EVERY item the range names needs its own backlink, not just the first one.
2851
+ // A pull request that gathers three items and links one of them leaves the
2852
+ // other two with no route back from the tracker, which is the whole property
2853
+ // this gate exists to keep — and a multi-item range that proved less than a
2854
+ // single-item one would make the declaration a way of buying weaker checks.
2855
+ if (contract.verify === "full") {
2856
+ for (const ref of refs) {
2857
+ const before = findings.length;
2858
+ collect(findings, OUTSIDE_THIS_PR, GATE_BACKLINK, () =>
2859
+ assertBacklink(ref, prUrl, contract, result?.issues?.get(ref))
2860
+ );
2861
+ if (findings.length > before)
2862
+ findings[before].message += `. ${backlinkAdvice(ref, prUrl, contract)}`;
2863
+ }
2651
2864
  }
2652
2865
  if (findings.length > 0)
2653
2866
  throw new TrackingError(requirementReport(findings, contract));
@@ -3494,33 +3707,108 @@ function validateCommit(args) {
3494
3707
  console.log(`WORK_ITEM_TRACKING_OK ${result.exempt ?? result.ref}`);
3495
3708
  }
3496
3709
 
3710
+ /**
3711
+ * The pushed refs worth validating, with the empty push still one answer.
3712
+ *
3713
+ * A push whose refs introduce no commits is not nothing to report: this command
3714
+ * has always answered `0 commit(s)` there, and the pull-request checks still
3715
+ * run against it. Dropping every empty group without a replacement would turn
3716
+ * that answer into silence.
3717
+ * @param {string} input The pre-push stdin stream.
3718
+ * @param {string} remote Remote being pushed to.
3719
+ * @returns {{localRef: string|undefined, commits: string[]}[]} Groups to check.
3720
+ */
3721
+ function pushGroups(input, remote) {
3722
+ const parsed = parsePushGroups(input, remote);
3723
+ const carrying = parsed.filter(group => group.commits.length > 0);
3724
+ return carrying.length > 0
3725
+ ? carrying
3726
+ : [{ localRef: parsed[0]?.localRef, commits: [] }];
3727
+ }
3728
+
3729
+ /**
3730
+ * Which pushed ref the pull request in hand is actually about.
3731
+ *
3732
+ * `gh pr view` answers for the CURRENT BRANCH, so its body and backlink are
3733
+ * evidence about that branch and nothing else. Checking a second branch's range
3734
+ * against it would report another branch's work item as missing from a body
3735
+ * that was never supposed to name it.
3736
+ * @param {object[]} groups The pushed refs.
3737
+ * @param {object|undefined} pr The pull request, when one exists.
3738
+ * @returns {object|undefined} The group the pull request describes.
3739
+ */
3740
+ function prTargetGroup(groups, pr) {
3741
+ if (!pr) return undefined;
3742
+ if (groups.length === 1) return groups[0];
3743
+ const branch = activeBranch();
3744
+ return groups.find(
3745
+ group => branch && pushedBranchName(group.localRef ?? "") === branch
3746
+ );
3747
+ }
3748
+
3749
+ /**
3750
+ * What a push could not prove here, named rather than left to CI to reveal.
3751
+ *
3752
+ * The multi-item sentence is the one that matters. A range naming several work
3753
+ * items is legitimate, and it is also the shape most likely to be an accident —
3754
+ * so the moment it is observed, the operator is told what CI will require of
3755
+ * it: a body declaring every one of them. Refusing here instead would be a
3756
+ * refusal with no remedy, because the body it asks for belongs to a pull
3757
+ * request that cannot exist until this push lands.
3758
+ * @param {object} result Commit-side result.
3759
+ * @returns {string} The clause after the commit count.
3760
+ */
3761
+ function pushDeferralNote(result) {
3762
+ const refs = result.refs ?? [];
3763
+ const spanning =
3764
+ refs.length > 1
3765
+ ? ` This range names ${refs.length} work items (${refs.join(", ")}): allowed, but the pull-request body must then declare every one of them, and ${GATE_MAPPING} refuses it at CI time if it does not.`
3766
+ : "";
3767
+ return `no pull request exists yet, so gates 4 and 5 could not be checked here — CI will verify both.${spanning}`;
3768
+ }
3769
+
3770
+ /**
3771
+ * Report one pushed ref's outcome, refusing when it did not prove out.
3772
+ * @param {{result?: object, error?: Error}} outcome Commit-side outcome.
3773
+ * @param {object|undefined} pr The pull request this ref is about, if any.
3774
+ * @param {string} label Prefix naming the ref, empty for a single-ref push.
3775
+ */
3776
+ function reportPushGroup(outcome, pr, label) {
3777
+ if (pr) {
3778
+ validatePrData(outcome, pr.url, pr.body);
3779
+ console.log(
3780
+ `WORK_ITEM_TRACKING_OK ${label}${outcome.result.relevant} commit(s)${alreadyTraced(outcome.result)}, ${provedHere(outcome.result.contract)}`
3781
+ );
3782
+ return;
3783
+ }
3784
+ // No pull request means gates 4 and 5 cannot be CHECKED here. They are
3785
+ // still perfectly well KNOWN here, and this is the last local moment before
3786
+ // CI — so the checklist goes out either way. Saying nothing is precisely
3787
+ // what made those two gates separate CI-cycle surprises (#2681).
3788
+ if (outcome.error) throw withGateSummary(outcome.error);
3789
+ console.log(
3790
+ `WORK_ITEM_TRACKING_OK ${label}${outcome.result.relevant} commit(s); ${pushDeferralNote(outcome.result)}${gateSummary(outcome.result.contract)}`
3791
+ );
3792
+ }
3793
+
3497
3794
  function validatePush(args) {
3498
3795
  const remote = args[0] || "origin";
3499
3796
  const input = readFileSync(0, "utf8");
3500
3797
  // The deploy chain is read from the remote default branch, the same ref this
3501
3798
  // path already trusts to bound the range. A local working tree could declare
3502
3799
  // anything, but so could a `--no-verify`; CI is the enforcing copy.
3503
- const outcome = commitOutcome(
3504
- parsePushLines(input, remote),
3505
- remoteDefaultRef(remote),
3506
- remote
3507
- );
3800
+ const configRef = remoteDefaultRef(remote);
3801
+ const groups = pushGroups(input, remote);
3508
3802
  const pr = currentPullRequest();
3509
- if (!pr) {
3510
- // No pull request means gates 4 and 5 cannot be CHECKED here. They are
3511
- // still perfectly well KNOWN here, and this is the last local moment before
3512
- // CI — so the checklist goes out either way. Saying nothing is precisely
3513
- // what made those two gates separate CI-cycle surprises (#2681).
3514
- if (outcome.error) throw withGateSummary(outcome.error);
3515
- console.log(
3516
- `WORK_ITEM_TRACKING_OK ${outcome.result.relevant} commit(s); no pull request exists yet, so gates 4 and 5 could not be checked here — CI will verify both${gateSummary(outcome.result.contract)}`
3803
+ const target = prTargetGroup(groups, pr);
3804
+ for (const group of groups) {
3805
+ const outcome = commitOutcome(group.commits, configRef, remote);
3806
+ reportPushGroup(
3807
+ outcome,
3808
+ group === target ? pr : undefined,
3809
+ groups.length > 1 ? `${group.localRef ?? "(stdin)"}: ` : ""
3517
3810
  );
3518
- return;
3519
3811
  }
3520
- validatePrData(outcome, pr.url, pr.body);
3521
- console.log(
3522
- `WORK_ITEM_TRACKING_OK ${outcome.result.relevant} commit(s)${alreadyTraced(outcome.result)}, ${provedHere(outcome.result.contract)}`
3523
- );
3524
3812
  }
3525
3813
 
3526
3814
  function validatePr(args) {
@@ -1 +1 @@
1
- {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA03BjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA6xBjC,CAAC"}
1
+ {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA43BjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA+xBjC,CAAC"}