@codyswann/lisa 4.8.2 → 4.8.4

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 (83) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +212 -3
  2. package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +11 -0
  3. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +232 -67
  4. package/dist/cli/gate-report-cells.d.ts +1 -1
  5. package/dist/cli/gate-report-cells.d.ts.map +1 -1
  6. package/dist/cli/gate-report-cells.js +18 -2
  7. package/dist/cli/gate-report-cells.js.map +1 -1
  8. package/dist/cli/gate-report-joins.d.ts.map +1 -1
  9. package/dist/cli/gate-report-joins.js +36 -4
  10. package/dist/cli/gate-report-joins.js.map +1 -1
  11. package/dist/cli/gate-report-registry.d.ts +30 -0
  12. package/dist/cli/gate-report-registry.d.ts.map +1 -1
  13. package/dist/cli/gate-report-registry.js +25 -0
  14. package/dist/cli/gate-report-registry.js.map +1 -1
  15. package/dist/core/gate-declaration-drift.d.ts +18 -0
  16. package/dist/core/gate-declaration-drift.d.ts.map +1 -1
  17. package/dist/core/gate-declaration-drift.js +30 -1
  18. package/dist/core/gate-declaration-drift.js.map +1 -1
  19. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  20. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  21. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  22. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  23. package/dist/core/upstream-evidence-manifest.js +9 -6
  24. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  25. package/package.json +1 -1
  26. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +41 -0
  29. package/plugins/lisa/skills/lisa-doctor/SKILL.md +41 -0
  30. package/plugins/lisa-agy/plugin.json +1 -1
  31. package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +41 -0
  32. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  35. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +41 -0
  39. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +41 -0
  41. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-expo-agy/plugin.json +1 -1
  44. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  54. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  59. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  64. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-rails-agy/plugin.json +1 -1
  69. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  74. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  79. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/src/base/skills/lisa-doctor/SKILL.md +41 -0
  82. package/scripts/lisa-github-rulesets.sh +15 -5
  83. package/scripts/lisa-ruleset-payload.mjs +30 -0
@@ -128,6 +128,22 @@ export const MOMENTS = [
128
128
  /** Moment families that take an `:<environment>` suffix. */
129
129
  export const MOMENT_FAMILIES = [PRE_DEPLOY, POST_DEPLOY, CONTINUOUS];
130
130
 
131
+ /**
132
+ * Moments where nothing posts a status a pull request can be held on.
133
+ *
134
+ * These run on the developer's machine or on the agent's, before there is a
135
+ * pull request for a signal to attach to. Two declarations are refused at them
136
+ * for the same reason — `await`, which names a signal that could never fire,
137
+ * and `caller_chain`, which names the shape of a check-run name that is never
138
+ * posted.
139
+ */
140
+ const NO_STATUS_MOMENTS = Object.freeze([
141
+ COMMIT,
142
+ PUSH,
143
+ SESSION_START,
144
+ ...TOOL_MOMENTS,
145
+ ]);
146
+
131
147
  /**
132
148
  * Moments that gate a *state* rather than a change.
133
149
  *
@@ -149,6 +165,25 @@ export const STATE_FAMILIES = [CONTINUOUS];
149
165
  /** Keys on a gate entry that are settings rather than moments. */
150
166
  export const GATE_FIELDS = new Set(["run", "needs", "task"]);
151
167
 
168
+ /**
169
+ * The per-declaration field naming the job chain this gate's prover sits under.
170
+ *
171
+ * A gate whose prover lives OUTSIDE the quality facade cannot state its own
172
+ * context without it. The facade's contexts are derived by prefixing a gate's
173
+ * label with the chain of jobs that reach the facade, and that chain is a fact
174
+ * about the CALLER, not about the gate. When a project proves one property
175
+ * from a workflow of its own, the caller-wide chain describes a route that
176
+ * never reaches that gate's prover, and the derived name is one nothing posts.
177
+ *
178
+ * Declared per MOMENT, never once for the whole gate, because the chain is a
179
+ * property of one moment's wiring: the same job posts
180
+ * `<facade> / <label>` on the pull-request path and
181
+ * `Release / <facade> / <label>` on the release path. A gate-level value would
182
+ * assert one chain for every moment, which is the depth error this field
183
+ * exists to let a project correct.
184
+ */
185
+ export const CALLER_CHAIN_FIELD = "caller_chain";
186
+
152
187
  /**
153
188
  * Registry flag: this gate's task may rewrite the working tree.
154
189
  *
@@ -2792,6 +2827,18 @@ export const POLICY_SCHEMA = Object.freeze({
2792
2827
  review: Object.freeze({
2793
2828
  required_approving_review_count: "number",
2794
2829
  require_code_owner_review: "boolean",
2830
+ // Declarable precisely because GitHub fills it in when a payload omits it,
2831
+ // and what it fills in is not "leave it alone". Measured against the live
2832
+ // API on 2026-08-25: a `pull_request` rule sent without the field came
2833
+ // back with it `true`; flipped to `false` explicitly it came back `false`;
2834
+ // sent again without it, it came back `true`. So an operator who wants it
2835
+ // OFF has no way to hold it off — every apply silently puts it back — and
2836
+ // an operator who wants it ON is relying on a GitHub default that can move
2837
+ // under them without any declaration recording the choice. Undeclared
2838
+ // still means undeclared: the generator omits the field entirely rather
2839
+ // than defaulting it, so a project that says nothing sends exactly the
2840
+ // payload it sent before this existed.
2841
+ require_extra_approval_for_unattributed_changes: "boolean",
2795
2842
  }),
2796
2843
  ruleset: Object.freeze({
2797
2844
  enforcement: ENFORCEMENTS,
@@ -3698,6 +3745,23 @@ function validateGate(id, gate) {
3698
3745
  // `pull_request` with an underscore reads as a configured gate and enables
3699
3746
  // nothing at all. Same shape as a misspelled gate id, one level down.
3700
3747
  if (!isMoment(moment)) {
3748
+ // Named before the generic typo message, because the generic one reads
3749
+ // as "this is not a moment" and sends the author looking for a moment
3750
+ // spelling. The mistake is real but different: the field exists, and it
3751
+ // belongs one level down.
3752
+ if (moment === CALLER_CHAIN_FIELD) {
3753
+ problems.push(
3754
+ `gates."${id}"."${CALLER_CHAIN_FIELD}" is declared for the whole ` +
3755
+ `gate, but the chain of jobs a check run is posted under is a ` +
3756
+ `property of ONE moment's wiring, not of the gate: the same job ` +
3757
+ `posts "<caller> ${CONTEXT_SEPARATOR.trim()} <label>" on the ` +
3758
+ `pull-request path and "Release ${CONTEXT_SEPARATOR.trim()} ` +
3759
+ `<caller> ${CONTEXT_SEPARATOR.trim()} <label>" on the release ` +
3760
+ `path. Declare it inside the moment whose context it names, e.g. ` +
3761
+ `gates."${id}"."${PULL_REQUEST}".${CALLER_CHAIN_FIELD}.`
3762
+ );
3763
+ continue;
3764
+ }
3701
3765
  if (!GATE_FIELDS.has(moment)) {
3702
3766
  problems.push(
3703
3767
  `gates."${id}"."${moment}" ${unknownMomentMessage(moment)} ` +
@@ -3740,7 +3804,7 @@ function validateMoment(id, moment, value, known, interceptor, gateRun) {
3740
3804
  );
3741
3805
  }
3742
3806
  if (entry.await) {
3743
- if ([COMMIT, PUSH, SESSION_START, ...TOOL_MOMENTS].includes(moment)) {
3807
+ if (NO_STATUS_MOMENTS.includes(moment)) {
3744
3808
  problems.push(
3745
3809
  `gates."${id}"."${moment}" awaits "${entry.await}", but there is no ` +
3746
3810
  `pull request yet for a signal to post against. An awaited check ` +
@@ -3756,6 +3820,7 @@ function validateMoment(id, moment, value, known, interceptor, gateRun) {
3756
3820
  problems.push(...validateEvidence(id, moment, entry.evidence ?? {}));
3757
3821
  }
3758
3822
  problems.push(...validatePostedBy(id, moment, entry));
3823
+ problems.push(...validateCallerChain(id, moment, entry));
3759
3824
  if (!known && !interceptor && !entry.await && !entry.run && !gateRun) {
3760
3825
  problems.push(
3761
3826
  `gates."${id}"."${moment}" names no prover and Lisa has no default task ` +
@@ -3804,6 +3869,75 @@ function validatePostedBy(id, moment, entry) {
3804
3869
  return [];
3805
3870
  }
3806
3871
 
3872
+ /**
3873
+ * Validate a declaration's caller-chain override.
3874
+ *
3875
+ * Every arm refuses at DECLARATION time something that would otherwise fail at
3876
+ * merge time, invisibly. A required context nothing posts does not turn a pull
3877
+ * request red — GitHub holds it at "Expected — Waiting for status to be
3878
+ * reported" indefinitely, with no failing check to read and no log to open —
3879
+ * so a chain that derives such a name has to be caught by the only thing that
3880
+ * can still say anything about it, which is the validator.
3881
+ *
3882
+ * Three ways it can derive a name nothing posts, and each is refused:
3883
+ *
3884
+ * 1. On an `await`ing moment it is a no-op. An awaited signal posts under its
3885
+ * own name from wherever its app runs, and no chain prefixes it — so the
3886
+ * author who declared the override to fix a context would keep requiring
3887
+ * the unchanged one while believing it had been fixed.
3888
+ * 2. At a moment where nothing posts a status at all there is no check-run
3889
+ * name for the chain to shape.
3890
+ * 3. A blank, non-string or empty level derives `" / <label>"` or
3891
+ * `"<a> / / <label>"` — strings that read as plausible and that no run has
3892
+ * ever posted. `declaredCallerChain` is what refuses those, and it is the
3893
+ * same refusal `contextsFor` applies, called here so it lands with the
3894
+ * declaration rather than with the derivation.
3895
+ * @param {string} id Gate id.
3896
+ * @param {string} moment Moment key.
3897
+ * @param {object} entry The moment entry.
3898
+ * @returns {string[]} Problems.
3899
+ */
3900
+ function validateCallerChain(id, moment, entry) {
3901
+ const value = entry[CALLER_CHAIN_FIELD];
3902
+ if (value === undefined) return [];
3903
+ const where = `gates."${id}"."${moment}".${CALLER_CHAIN_FIELD}`;
3904
+
3905
+ if (entry.await) {
3906
+ return [
3907
+ `${where} names the chain of jobs a check run is posted under, but ` +
3908
+ `this moment awaits "${entry.await}" — an awaited signal posts under ` +
3909
+ `its own name from wherever its app runs, and no chain prefixes it. ` +
3910
+ `The override would change nothing: the required context would still ` +
3911
+ `be "${entry.await}".`,
3912
+ ];
3913
+ }
3914
+ if (NO_STATUS_MOMENTS.includes(moment)) {
3915
+ return [
3916
+ `${where} names the chain of jobs a check run is posted under, but ` +
3917
+ `nothing posts a check run at "${moment}" — it runs here, before ` +
3918
+ `there is a pull request for a status to attach to. Declare it at ` +
3919
+ `"${PULL_REQUEST}", which is the moment whose context it shapes.`,
3920
+ ];
3921
+ }
3922
+ const levels = Array.isArray(value) ? value : [value];
3923
+ if (levels.some(level => typeof level !== "string")) {
3924
+ return [
3925
+ `${where} is ${JSON.stringify(value)}; expected the job names that ` +
3926
+ `reach this gate's prover, outermost first — an array of strings, or ` +
3927
+ `the same chain as one "${CONTEXT_SEPARATOR.trim()}"-joined string. ` +
3928
+ `Read them off a completed run rather than off the YAML: a check ` +
3929
+ `run's name is the chain of JOB names, and the workflow's own "name:" ` +
3930
+ `never appears in it.`,
3931
+ ];
3932
+ }
3933
+ try {
3934
+ declaredCallerChain(value);
3935
+ } catch (error) {
3936
+ return [`${where} is ${JSON.stringify(value)}: ${error.message}`];
3937
+ }
3938
+ return [];
3939
+ }
3940
+
3807
3941
  /**
3808
3942
  * Validate an awaited signal's evidence block.
3809
3943
  * @param {string} id Gate id.
@@ -4197,6 +4331,7 @@ export function resolveMoment({
4197
4331
  work: null,
4198
4332
  alias: null,
4199
4333
  evidence: null,
4334
+ callerChain: null,
4200
4335
  mayRewrite: false,
4201
4336
  costly: false,
4202
4337
  });
@@ -4240,6 +4375,14 @@ export function resolveMoment({
4240
4375
  work: definition?.work ?? null,
4241
4376
  alias: entry.await || intercepts ? null : alias,
4242
4377
  evidence: entry.await ? mergeEvidence(entry.evidence) : null,
4378
+ // Carried RAW, exactly as declared. Normalising here would move the
4379
+ // blank-level refusal into a function whose caller swallows throws
4380
+ // (`resolveMergeMoment` in the drift comparator turns any throw into
4381
+ // "nothing resolved"), and a chain that fails silently is the failure
4382
+ // this whole area exists to make loud. `validateGates` refuses a
4383
+ // malformed chain at declaration time; `contextsFor` refuses it again at
4384
+ // derivation time.
4385
+ callerChain: entry.await ? null : (entry[CALLER_CHAIN_FIELD] ?? null),
4243
4386
  mayRewrite: definition?.mayRewrite === true,
4244
4387
  costly: definition?.costly === true,
4245
4388
  });
@@ -4383,6 +4526,53 @@ function callerChainFrom({ callerChain, workflowName }) {
4383
4526
  return chain;
4384
4527
  }
4385
4528
 
4529
+ /**
4530
+ * Normalise the chain ONE declaration overrode the caller-wide chain with.
4531
+ *
4532
+ * Deliberately routed through `callerChainFrom` rather than used as written.
4533
+ * An override and a caller chain shape the same string, so they must be
4534
+ * normalised by the same code or the two would eventually disagree about
4535
+ * trimming, about the separator, and about what an empty level means. The
4536
+ * array form is the chain; the string form is the same chain `/`-joined, so
4537
+ * `"A / B"` and `["A", "B"]` are one declaration written two ways.
4538
+ *
4539
+ * It REPLACES the caller's chain rather than extending it. That is the whole
4540
+ * point: a project declares this precisely when the caller's chain does not
4541
+ * reach this gate's prover, so appending would derive a route that does not
4542
+ * exist. The label, the registry's `previousLabels` union and the blank-level
4543
+ * refusal are all still applied on top, so an override cannot smuggle in a raw
4544
+ * string that skips them.
4545
+ * @param {string[]|string} value The declared chain.
4546
+ * @returns {string[]} The validated chain, outermost first.
4547
+ */
4548
+ function declaredCallerChain(value) {
4549
+ return Array.isArray(value)
4550
+ ? callerChainFrom({ callerChain: value })
4551
+ : callerChainFrom({ workflowName: String(value) });
4552
+ }
4553
+
4554
+ /**
4555
+ * The prefix one declaration's derived context carries, from its own chain.
4556
+ *
4557
+ * Exported for the SECOND derivation of the same string. `contextsFor` is not
4558
+ * the only thing that builds `<chain> / <label>` — the declaration-drift
4559
+ * comparator and the gate report each build it too, from the same registry
4560
+ * label, and three copies of the joining rule is three chances for a rename to
4561
+ * land in two of them. They call this instead, so a chain that is refused in
4562
+ * one place is refused in all three.
4563
+ *
4564
+ * Throws rather than falling back for the same reason `callerChainFrom` does:
4565
+ * a prefix nobody can determine is not a prefix to guess at, because the guess
4566
+ * fails silently. A caller that cannot afford to throw must report that it did
4567
+ * not compare, never that the comparison was clean.
4568
+ * @param {string[]|string} chain The declared chain, or one `/`-joined string.
4569
+ * @returns {string} The prefix, ready to join with a label.
4570
+ * @throws {Error} When the chain has an empty level.
4571
+ */
4572
+ export function callerPrefix(chain) {
4573
+ return declaredCallerChain(chain).join(CONTEXT_SEPARATOR);
4574
+ }
4575
+
4386
4576
  /**
4387
4577
  * The branch-protection contexts a gates block implies at one moment.
4388
4578
  *
@@ -4401,6 +4591,16 @@ function callerChainFrom({ callerChain, workflowName }) {
4401
4591
  * the release path posts, and passing the wrong depth is the permanent-pending
4402
4592
  * trap in either direction. `verifyContextsPosted` closes that loop against a
4403
4593
  * real run rather than leaving it to reasoning.
4594
+ *
4595
+ * One declaration may override that chain for itself with `caller_chain`, and
4596
+ * a declaration that does not carries on deriving exactly what it always did.
4597
+ * The override wins over the caller's chain for that gate alone, because it is
4598
+ * declared for precisely the case where the caller's chain does not reach the
4599
+ * gate's prover — a property proved by a workflow of the project's own rather
4600
+ * than through the quality facade. It changes only which chain is joined; the
4601
+ * label, the registry's `previousLabels` union and the blank-level refusal all
4602
+ * still apply, so an override cannot derive a name by a different route than
4603
+ * every other context.
4404
4604
  * @param {object} gates The gates block.
4405
4605
  * @param {object} [options] Context options.
4406
4606
  * @param {string} [options.moment] The moment to derive for.
@@ -4426,6 +4626,15 @@ export function contextsFor(gates, options = {}) {
4426
4626
  // chain of jobs that reach it.
4427
4627
  .flatMap(gate => {
4428
4628
  if (gate.mode === "await") return [gate.awaits];
4629
+ // The caller's chain, UNLESS this declaration named its own. A gate
4630
+ // proved outside the facade is not reached through the facade's callers,
4631
+ // so the caller-wide chain describes a route to it that does not exist.
4632
+ // Everything below this line is identical either way, which is what
4633
+ // keeps an override from becoming a second way to spell a context.
4634
+ const chain =
4635
+ gate.callerChain === null
4636
+ ? prefix
4637
+ : declaredCallerChain(gate.callerChain).join(CONTEXT_SEPARATOR);
4429
4638
  // The gate's OWN record of what it used to be called, unioned in
4430
4639
  // automatically. This was a `--previous` flag the caller had to
4431
4640
  // remember, which meant nothing knew a rename was in flight and a
@@ -4433,8 +4642,8 @@ export function contextsFor(gates, options = {}) {
4433
4642
  // DECLARED once, in the registry, and every derivation sees it.
4434
4643
  const former = REGISTRY[gate.id]?.previousLabels ?? [];
4435
4644
  return [
4436
- `${prefix}${CONTEXT_SEPARATOR}${gate.label}`,
4437
- ...former.map(label => `${prefix}${CONTEXT_SEPARATOR}${label}`),
4645
+ `${chain}${CONTEXT_SEPARATOR}${gate.label}`,
4646
+ ...former.map(label => `${chain}${CONTEXT_SEPARATOR}${label}`),
4438
4647
  ];
4439
4648
  });
4440
4649
 
@@ -342,6 +342,10 @@ export const POLICY_SOURCES = Object.freeze({
342
342
  surface: "ruleset",
343
343
  field: "require_code_owner_review",
344
344
  },
345
+ "review.require_extra_approval_for_unattributed_changes": {
346
+ surface: "ruleset",
347
+ field: "require_extra_approval_for_unattributed_changes",
348
+ },
345
349
  "ruleset.enforcement": { surface: "ruleset", field: "enforcement" },
346
350
  "ruleset.include_refs": { surface: "ruleset", field: "include_refs" },
347
351
  "ruleset.exclude_refs": { surface: "ruleset", field: "exclude_refs" },
@@ -559,6 +563,13 @@ function rulesetShape(rulesets, policyRuleset) {
559
563
  pullRequest?.parameters?.required_approving_review_count,
560
564
  require_code_owner_review:
561
565
  pullRequest?.parameters?.require_code_owner_review,
566
+ // Read as a SHAPE field, not as an OR across rulesets. GitHub fills this
567
+ // in on every ruleset carrying a `pull_request` rule, so ORing would
568
+ // report the strictest ruleset's value as the answer for the one Lisa
569
+ // generates, and a declared `false` on that ruleset would read as matched
570
+ // because some other ruleset had GitHub's default.
571
+ require_extra_approval_for_unattributed_changes:
572
+ pullRequest?.parameters?.require_extra_approval_for_unattributed_changes,
562
573
  };
563
574
  }
564
575