@codyswann/lisa 4.33.8 → 4.33.10

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 (92) hide show
  1. package/README.md +22 -0
  2. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +44 -1
  3. package/all/copy-overwrite/scripts/lisa-work-item.mjs +361 -10
  4. package/dist/configs/vitest/base.d.ts +76 -11
  5. package/dist/configs/vitest/base.d.ts.map +1 -1
  6. package/dist/configs/vitest/base.js +121 -14
  7. package/dist/configs/vitest/base.js.map +1 -1
  8. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  9. package/dist/core/lisa-owned-hash-ledger.js +12 -0
  10. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  11. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  12. package/dist/core/nightly-e2e-guard-behavior-certificate.js.map +1 -1
  13. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  14. package/dist/core/upstream-evidence-manifest.js +12 -9
  15. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  16. package/dist/migrations/ensure-lisa-postinstall.d.ts +26 -15
  17. package/dist/migrations/ensure-lisa-postinstall.d.ts.map +1 -1
  18. package/dist/migrations/ensure-lisa-postinstall.js +43 -20
  19. package/dist/migrations/ensure-lisa-postinstall.js.map +1 -1
  20. package/dist/opencode/plugin-templates/parity-safety-net.sh +44 -1
  21. package/package.json +4 -4
  22. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +17 -2
  25. package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +1 -1
  26. package/plugins/lisa/hooks/parity-safety-net.sh +44 -1
  27. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +17 -2
  28. package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +1 -1
  29. package/plugins/lisa-agy/hooks/parity-safety-net.sh +44 -1
  30. package/plugins/lisa-agy/plugin.json +1 -1
  31. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +17 -2
  32. package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +1 -1
  33. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  36. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +44 -1
  40. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +17 -2
  41. package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +1 -1
  42. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +44 -1
  44. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +17 -2
  45. package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +1 -1
  46. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-expo-agy/plugin.json +1 -1
  49. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  54. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  59. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  64. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  69. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-rails-agy/plugin.json +1 -1
  74. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  79. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  83. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  84. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  86. package/plugins/src/base/hooks/parity-safety-net.sh +44 -1
  87. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +17 -2
  88. package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +1 -1
  89. package/scripts/install-claude-plugins.sh +22 -4
  90. package/scripts/two-channel-couplings.json +1 -1
  91. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +96 -12
  92. package/typescript/copy-overwrite/scripts/reconcile-nightly-e2e-tracking.mjs +3 -2
@@ -1687,7 +1687,50 @@ fi
1687
1687
 
1688
1688
  # 13. Dropping or truncating a database / schema / table (Lisa-only guard —
1689
1689
  # upstream has no SQL protection; keep).
1690
- if matches '\b(drop[[:space:]]+(database|schema|table)|truncate[[:space:]]+(table[[:space:]]+)?[[:alnum:]_."`]+)\b'; then
1690
+ #
1691
+ # The two arms used to be asymmetric, and only the lopsided one produced false
1692
+ # positives (issue #3530). The `drop` arm requires a following keyword —
1693
+ # `drop database|schema|table` — so `drop the ball` never matched it. The
1694
+ # `truncate` arm made that keyword OPTIONAL:
1695
+ #
1696
+ # truncate[[:space:]]+(table[[:space:]]+)?[[:alnum:]_."`]+
1697
+ #
1698
+ # which reduces to "`truncate`, whitespace, any identifier-ish word", so
1699
+ # `truncate the board`, `truncate results` and `truncate output` all matched.
1700
+ # A session was refused for a bug-report TITLE describing a connection that
1701
+ # truncates results — and a title has no escape hatch, because the remedy this
1702
+ # hook prints at the wall (write the text to a file, pass it BY PATH) covers a
1703
+ # body, while the title flag of an issue-filing CLI is necessarily inline. The
1704
+ # only exit left was to reword, which is what trains people to route around a
1705
+ # guard.
1706
+ #
1707
+ # The fix keeps the coverage and drops the English, in three arms.
1708
+ #
1709
+ # ARM 1 — `drop`, byte-identical to before. It was already correct, and a
1710
+ # rewrite that unified the arms could only make it permissive.
1711
+ readonly SQL_DROP='\bdrop[[:space:]]+(database|schema|table)\b'
1712
+ # ARM 2 — `truncate` carrying its own evidence that it is a statement rather
1713
+ # than a verb: the TABLE keyword (what the drop arm demands), a
1714
+ # schema-qualified name, a quoted identifier, a trailing `;`, or one of the
1715
+ # tail keywords that only ever follow a TRUNCATE. English has none of these.
1716
+ readonly SQL_TRUNCATE_TAIL='(cascade|restrict|restart[[:space:]]+identity|continue[[:space:]]+identity)([^[:alnum:]_]|$)'
1717
+ readonly SQL_TRUNCATE_EVIDENCE='\btruncate[[:space:]]+(table[[:space:]]+[[:alnum:]_."`]+|(only[[:space:]]+)?([[:alnum:]_]+\.[[:alnum:]_."`]+|["`][[:alnum:]_. ]+["`]|[[:alnum:]_]+[[:space:]]*(;|'"$SQL_TRUNCATE_TAIL"')))'
1718
+ # ARM 3 — the permissive-dialect `TRUNCATE <name>` with none of that evidence.
1719
+ # Deleting this shape is what a lazy fix does: it passes every prose scenario
1720
+ # while silently removing the coverage a regression fixture already asserts
1721
+ # (`mysql -e 'TRUNCATE sessions'`). So it is recognized by CONTEXT instead —
1722
+ # the same command must also invoke a command-line database client. Prose in a
1723
+ # tracker filing or a commit message invokes none, which is exactly the
1724
+ # difference the text scan could not see on its own.
1725
+ readonly SQL_CLIENT='(^|[^[:alnum:]_./-])([[:alnum:]_./-]*/)?(psql|pgcli|mysql|mysqladmin|mysqlsh|mariadb|mycli|sqlite3|sqlcmd|cqlsh|clickhouse-client|cockroach|mongosh|duckdb|usql)([[:space:]]|$)'
1726
+ readonly SQL_TRUNCATE_BARE='\btruncate[[:space:]]+(only[[:space:]]+)?[[:alnum:]_."`]'
1727
+ # Residual, accepted and stated: a bare `TRUNCATE <name>` with no terminator,
1728
+ # no qualification and no recognized client in the same command goes unmatched
1729
+ # — the same trade the `drop` arm has always made for `drop the ball`. No DROP
1730
+ # shape is weakened, and the guard still fails in the safe direction.
1731
+ if matches "$SQL_DROP" \
1732
+ || matches "$SQL_TRUNCATE_EVIDENCE" \
1733
+ || { matches "$SQL_CLIENT" && matches "$SQL_TRUNCATE_BARE"; }; then
1691
1734
  block "destructive SQL (DROP/TRUNCATE) detected"
1692
1735
  fi
1693
1736
 
@@ -652,7 +652,7 @@ result: **27 of 27** open items carrying the claimed role in this repository had
652
652
  a merged pull request — the claimed lane reported 27 things in flight when the
653
653
  real number was one.
654
654
 
655
- Three properties make the command safe to run unconditionally:
655
+ Four properties make the command safe to run unconditionally:
656
656
 
657
657
  1. **The terminal role is RESOLVED, never assumed.** `lifecycleContract` reads
658
658
  `github.labels.build.done` (or the Jira/Linear equivalent), which is
@@ -666,7 +666,22 @@ Three properties make the command safe to run unconditionally:
666
666
  a real one afterwards. Cross-repository references do not count: a downstream
667
667
  consumer's PR mentioning an upstream issue is not evidence the upstream issue
668
668
  shipped.
669
- 3. **It is idempotent**, so re-running after a retry converges rather than
669
+ 3. **It weighs the merged pull request's BASE BRANCH**, the same way step (c)
670
+ above does, and for the same reason. A merged pull request is not evidence
671
+ on its own: the writer resolves `<baseRefName>` through `.lisa.config.json`
672
+ `deploy.branches` and applies only the role that base earned. A merge into
673
+ the production deploy branch is terminal and closes the item; a merge into a
674
+ branch mapping to a lower env records that env's role and leaves the item
675
+ open; a merge into a branch the project does not deploy at all — an
676
+ integration/stack branch — records nothing, closes nothing, and reports the
677
+ base it observed. **Measured**: on a stacked-PR queue the previous writer
678
+ stamped the production terminal role and closed every ticket in the batch at
679
+ the moment its PR landed on the stacking branch, and one of those false
680
+ completions then made push gate 1 refuse a follow-up commit on the same
681
+ ticket, because there was no longer an open work item to bind. When the
682
+ command reports `work-item NOT completed`, that is the correct answer for a
683
+ stacked merge — re-run it after the stack reaches the deploy branch.
684
+ 4. **It is idempotent**, so re-running after a retry converges rather than
670
685
  accumulating.
671
686
 
672
687
  It also removes the claimed role rather than only adding the terminal one.
@@ -539,7 +539,7 @@ gh issue comment <number> --repo <org>/<repo> --body "[claude-build-intake] Buil
539
539
 
540
540
  The ordering is the point. Announcing before verifying leaves a public "Build complete" on an item whose lifecycle state was never confirmed, and a reader has no way to tell that claim apart from a verified one — the same indistinguishability the readback exists to prevent. On a failed readback, comment the Error instead.
541
541
 
542
- `node scripts/lisa-work-item.mjs complete --ref <org>/<repo>#<number>` performs this whole terminal sequence — merged-PR evidence check, full role reconciliation, close, independent readback — in one step, and is the preferred path whenever `$DONE` is the terminal value. Use the manual commands above for intermediate env transitions, which must not close the issue.
542
+ `node scripts/lisa-work-item.mjs complete --ref <org>/<repo>#<number>` performs this whole terminal sequence — merged-PR evidence check, full role reconciliation, close, independent readback — in one step, and is the preferred path whenever `$DONE` is the terminal value. It also decides for itself whether `$DONE` is terminal here: it resolves each merged PR's base branch through `.lisa.config.json` `deploy.branches`, so a merge into an intermediate env branch records that env's role WITHOUT closing the issue, and a merge into a branch the project does not deploy records nothing and says which base it saw. The manual commands above remain available for an intermediate env transition performed without merge evidence.
543
543
 
544
544
  For any non-Success outcome, do NOT transition. The issue sits in `$CLAIMED` (or wherever the lifecycle left it) — humans take it from there.
545
545
 
@@ -178,15 +178,24 @@ HOST_HAS_LISA_POSTINSTALL="$(node -e "
178
178
  process.stdout.write(postinstall.includes('node_modules/@codyswann/lisa/dist/index.js') ? 'true' : 'false');
179
179
  " 2>/dev/null || echo false)"
180
180
 
181
+ # Set when the package-side apply below fails. Read at the very end of this
182
+ # script, which exits non-zero. See the exit block for why it is deferred.
183
+ APPLY_FAILED=false
184
+
181
185
  if [ "$IS_LISA_SELF" != "true" ] && [ -z "${CI:-}" ]; then
182
186
  if [ "$HOST_HAS_LISA_POSTINSTALL" = "true" ]; then
183
187
  echo "Lisa apply deferred to the host project's own postinstall script."
184
188
  elif ! LISA_BOOTSTRAP=1 LISA_POSTINSTALL=1 node "$LISA_DIR/dist/index.js" --yes --skip-git-check "$PROJECT_ROOT"; then
185
- # Loud but non-fatal, for the same reason as the host-side bootstrap: a
186
- # postinstall that exits non-zero aborts the whole install. The durable
187
- # signal is the apply receipt this failed run did NOT write, which
188
- # `lisa doctor` reports (CodySwannGT/lisa#2467).
189
+ # Loud AND fatal, matching the host-side bootstrap in
190
+ # src/migrations/ensure-lisa-postinstall.ts. This used to warn and exit 0,
191
+ # reasoning that the apply receipt the failed run did NOT write was signal
192
+ # enough for `lisa doctor` (CodySwannGT/lisa#2467) — but a missing receipt
193
+ # cannot distinguish a failed apply from one that never ran or was skipped,
194
+ # so the failure was reported as a successful install
195
+ # (CodySwannGT/lisa#3466). The `[ -z "${CI:-}" ]` guard above keeps the
196
+ # blast radius to local installs, and `CI=1` remains the escape hatch.
189
197
  echo "⚠️ lisa: TEMPLATE APPLY FAILED (error above) - this project is NOT receiving Lisa template or guardrail updates. Diagnose with: node $LISA_DIR/dist/index.js doctor" >&2
198
+ APPLY_FAILED=true
190
199
  fi
191
200
  fi
192
201
 
@@ -599,3 +608,12 @@ if [ -n "$LISA_VERSION" ]; then
599
608
  && printf '%s' "$LISA_VERSION" > "$PLUGIN_SYNC_MARKER" 2>/dev/null \
600
609
  || true
601
610
  fi
611
+
612
+ # A failed template apply fails this lifecycle script (CodySwannGT/lisa#3466).
613
+ # Deferred to here rather than exiting at the failure itself: plugin sync is
614
+ # independent work that did succeed, and the sync marker above must record it
615
+ # so the next install does not redo it. What must NOT happen is reporting
616
+ # success — that is the whole defect.
617
+ if [ "$APPLY_FAILED" = "true" ]; then
618
+ exit 1
619
+ fi
@@ -5,7 +5,7 @@
5
5
  },
6
6
  "inspected": {
7
7
  "workflows": 22,
8
- "steps": 677,
8
+ "steps": 678,
9
9
  "couplings": 33,
10
10
  "inventory": 69
11
11
  },
@@ -197,6 +197,87 @@ export const OUTCOMES = Object.freeze({
197
197
  noFloorApplied: "mutation-gate: no-floor-applied",
198
198
  });
199
199
 
200
+ /**
201
+ * The outcomes under which Stryker generated mutants and produced a score.
202
+ *
203
+ * THE POINT OF #3668. Every other outcome in {@link OUTCOMES} ends a run that
204
+ * MEASURED NOTHING, and until this set existed the exit-0 members of that group
205
+ * were indistinguishable from a real pass at the only layer a merge decision
206
+ * consults: `🔍 Quality Checks / 🧬 Mutation Testing Gate` printed `pass` for a
207
+ * pull request no mutant had touched. Measured on #3664 — a 400-line change to
208
+ * a shipped guard script, `nothing-to-mutate`, `pass`.
209
+ *
210
+ * The file said so itself, above {@link OUTCOMES}, and had for months: "both
211
+ * exit 0, and only the marker says which one happened." The marker lives in a
212
+ * log; a log is not a control. This set is what lets the workflow render the
213
+ * difference.
214
+ *
215
+ * MEMBERSHIP IS THE STRYKER RUN, not the verdict. `scoped` and `wholeList` are
216
+ * the two outcomes that carry a `reportRun`, so they are the two under which
217
+ * mutants existed — whether the score then cleared the floor or not is a
218
+ * different question, answered by the exit code. A run that measured and FAILED
219
+ * is still a run that measured.
220
+ */
221
+ export const MEASURED_OUTCOMES = Object.freeze([
222
+ OUTCOMES.scoped,
223
+ OUTCOMES.wholeList,
224
+ ]);
225
+
226
+ /**
227
+ * Whether an outcome means mutants were generated against this change.
228
+ *
229
+ * An unknown marker answers `false`, and that direction is deliberate: a new
230
+ * outcome added without being classified must not inherit "measured". Failing
231
+ * closed here costs a `skipping` row on a run that did measure; failing open
232
+ * costs exactly the false green this issue is about.
233
+ *
234
+ * @param {string} outcome - One {@link OUTCOMES} marker
235
+ * @returns {boolean} True when Stryker produced mutants
236
+ */
237
+ export const measuredAnything = outcome =>
238
+ MEASURED_OUTCOMES.includes(String(outcome));
239
+
240
+ /**
241
+ * Publishes the outcome where the workflow can render it, then returns the code.
242
+ *
243
+ * Every exit in {@link runGate} goes through here, and that is the property
244
+ * worth more than the plumbing: an exit that forgets to name its outcome is now
245
+ * impossible to write, rather than merely discouraged by a convention. It is
246
+ * the same shape the automation-runbook contract asks of a loop — end in
247
+ * exactly one named outcome — applied to a gate.
248
+ *
249
+ * `$GITHUB_OUTPUT` rather than a check-run publish, and the difference is
250
+ * forced rather than chosen: `test_mutation` in `quality.yml` declares
251
+ * `permissions: contents: read`, and `quality.yml` is `workflow_call`-only. A
252
+ * called workflow may only DOWNGRADE its caller's grant — asking for `checks:
253
+ * write` is a `startup_failure` for the entire run (#2049), which is why
254
+ * `review-evidence.yml` is a standalone workflow rather than a job. So the
255
+ * verdict leaves as a job output and a downstream job renders it.
256
+ *
257
+ * Best-effort, for the reason every reporting path in this repository is: a
258
+ * lost output line must never change the exit code the gate worked to earn.
259
+ *
260
+ * @param {string} outcome - The {@link OUTCOMES} marker this run ended on
261
+ * @param {number} code - The exit code to return unchanged
262
+ * @param {NodeJS.ProcessEnv} [env] - Environment, injectable for tests
263
+ * @returns {number} `code`, unchanged
264
+ */
265
+ export const finish = (outcome, code, env = process.env) => {
266
+ const target = env.GITHUB_OUTPUT;
267
+ if (target === undefined || target === "") return code;
268
+ try {
269
+ fs.appendFileSync(
270
+ target,
271
+ `mutation_outcome=${outcome}\nmutation_measured=${measuredAnything(outcome)}\n`
272
+ );
273
+ } catch (error) {
274
+ console.error(
275
+ `[mutation-gate] outcome outputs not written: ${error.message}`
276
+ );
277
+ }
278
+ return code;
279
+ };
280
+
200
281
  /**
201
282
  * Stryker config file names, in the order Stryker itself resolves them.
202
283
  *
@@ -2080,7 +2161,7 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2080
2161
  `⚪ ${OUTCOMES.disabled} — mutation.gate.json says "enabled": false. Skipping.\n` +
2081
2162
  ' Flip "enabled": true (and tune thresholds.break in stryker.conf.json) to turn it on.'
2082
2163
  );
2083
- return 0;
2164
+ return finish(OUTCOMES.disabled, 0);
2084
2165
  }
2085
2166
 
2086
2167
  const declaration = resolveMutateDeclaration(cwd);
@@ -2093,7 +2174,7 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2093
2174
  ` ${error instanceof Error ? error.message : String(error)}\n` +
2094
2175
  ` Fix the \`mutate\` patterns in ${declaration.source} before re-running.`
2095
2176
  );
2096
- return 1;
2177
+ return finish(OUTCOMES.invalidMutatePattern, 1);
2097
2178
  }
2098
2179
 
2099
2180
  if (countMutateTargetsInRepo(cwd, patterns) === 0) {
@@ -2105,7 +2186,7 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2105
2186
  " that is switched on and wired to nothing.\n" +
2106
2187
  " Fix the `mutate` patterns in your Stryker config, or turn the gate off."
2107
2188
  );
2108
- return 1;
2189
+ return finish(OUTCOMES.inertConfig, 1);
2109
2190
  }
2110
2191
 
2111
2192
  const unparseable = selectUninstrumentableMutateTargets(cwd, patterns);
@@ -2127,7 +2208,7 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2127
2208
  " script against a payload table and asserts the blocked/allowed\n" +
2128
2209
  " verdict — never by this gate."
2129
2210
  );
2130
- return 1;
2211
+ return finish(OUTCOMES.uninstrumentableTarget, 1);
2131
2212
  }
2132
2213
 
2133
2214
  if (argv.includes(WHOLE_LIST_FLAG)) {
@@ -2135,7 +2216,7 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2135
2216
  `🧬 ${OUTCOMES.wholeList} — Stryker over every pattern in ` +
2136
2217
  `${declaration.source}, with no diff scoping.`
2137
2218
  );
2138
- return reportRun(cwd, runStryker(cwd, []));
2219
+ return finish(OUTCOMES.wholeList, reportRun(cwd, runStryker(cwd, [])));
2139
2220
  }
2140
2221
 
2141
2222
  const base = resolveDiffBase(cwd, since);
@@ -2145,7 +2226,7 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2145
2226
  " unknown ref). Skipping rather than mutating the whole repository.\n" +
2146
2227
  " Nothing was measured; this is not a mutation score."
2147
2228
  );
2148
- return 0;
2229
+ return finish(OUTCOMES.noBase, 0);
2149
2230
  }
2150
2231
 
2151
2232
  let scope;
@@ -2168,7 +2249,7 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2168
2249
  " NO mutant was generated and NO score was computed. Nothing was measured,\n" +
2169
2250
  " so nothing passed."
2170
2251
  );
2171
- return 1;
2252
+ return finish(OUTCOMES.diffFailed, 1);
2172
2253
  }
2173
2254
 
2174
2255
  if (scope.uninstrumentable.length > 0) {
@@ -2191,7 +2272,7 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2191
2272
  " on both sides. Check that one exists; nothing here did."
2192
2273
  );
2193
2274
  if (scope.selected.length === 0 && scope.noCurrentLines.length === 0)
2194
- return 0;
2275
+ return finish(OUTCOMES.uninstrumentableLanguage, 0);
2195
2276
  }
2196
2277
 
2197
2278
  if (scope.selected.length === 0) {
@@ -2207,7 +2288,7 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2207
2288
  " Stryker can place mutants only on current lines. NO mutant was generated\n" +
2208
2289
  " and NO score was computed; this is not a measured pass."
2209
2290
  );
2210
- return 0;
2291
+ return finish(OUTCOMES.noCurrentLines, 0);
2211
2292
  }
2212
2293
  console.log(
2213
2294
  `⚪ ${OUTCOMES.nothingToMutate}\n` +
@@ -2216,7 +2297,7 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2216
2297
  " NO mutant was generated and NO score was computed. Nothing was measured,\n" +
2217
2298
  " so nothing passed — do not read this as evidence about your tests."
2218
2299
  );
2219
- return 0;
2300
+ return finish(OUTCOMES.nothingToMutate, 0);
2220
2301
  }
2221
2302
 
2222
2303
  // `--mutate` is one comma-separated argument, so a path containing a comma
@@ -2234,7 +2315,7 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2234
2315
  `${listed}\n` +
2235
2316
  " Rename them, or exclude them in your Stryker config."
2236
2317
  );
2237
- return 1;
2318
+ return finish(OUTCOMES.unrepresentablePath, 1);
2238
2319
  }
2239
2320
 
2240
2321
  console.log(
@@ -2243,7 +2324,10 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2243
2324
  );
2244
2325
  for (const file of scope.selected) console.log(` • ${file}`);
2245
2326
 
2246
- return reportRun(cwd, runStryker(cwd, scope.selected));
2327
+ return finish(
2328
+ OUTCOMES.scoped,
2329
+ reportRun(cwd, runStryker(cwd, scope.selected))
2330
+ );
2247
2331
  };
2248
2332
 
2249
2333
  /**
@@ -36,8 +36,9 @@ function validateAuthority(findings, trackers) {
36
36
  if (!Array.isArray(findings) || findings.length !== 2) {
37
37
  refuse(exactSuiteMessage);
38
38
  }
39
- const labels = findings.map(finding => finding?.label).sort();
40
- const expected = [...TRACKED_SUITE_LABELS].sort();
39
+ const byLabel = (left, right) => String(left).localeCompare(String(right));
40
+ const labels = findings.map(finding => finding?.label).sort(byLabel);
41
+ const expected = [...TRACKED_SUITE_LABELS].sort(byLabel);
41
42
  if (JSON.stringify(labels) !== JSON.stringify(expected)) {
42
43
  refuse(exactSuiteMessage);
43
44
  }