@codyswann/lisa 4.4.13 → 4.4.15

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 (67) hide show
  1. package/all/copy-overwrite/scripts/lib/placeholder-expiry.mjs +79 -7
  2. package/all/copy-overwrite/scripts/lisa-floor-collisions.mjs +46 -9
  3. package/all/copy-overwrite/scripts/lisa-postinstall.mjs +10 -1
  4. package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +260 -41
  5. package/all/copy-overwrite/scripts/lisa-schema-validate.mjs +12 -1
  6. package/cdk/package-lisa/package.lisa.json +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  8. package/dist/core/lisa-owned-hash-ledger.js +17 -0
  9. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  10. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  11. package/dist/core/upstream-evidence-manifest.js +11 -10
  12. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  13. package/expo/package-lisa/package.lisa.json +1 -1
  14. package/nestjs/package-lisa/package.lisa.json +1 -1
  15. package/package.json +2 -2
  16. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa-agy/plugin.json +1 -1
  19. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  22. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo-agy/plugin.json +1 -1
  29. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  49. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails-agy/plugin.json +1 -1
  54. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  59. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  64. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  66. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +75 -25
  67. package/typescript/package-lisa/package.lisa.json +1 -1
@@ -33,20 +33,83 @@
33
33
  /** The marker a provisional value carries, followed by its condition key. */
34
34
  export const PLACEHOLDER_MARKER = "PLACEHOLDER-UNTIL:";
35
35
 
36
- /** Condition keys are plain slugs, so a marker cannot smuggle a regex. */
37
- const KEY = /PLACEHOLDER-UNTIL:\s*([a-z0-9][a-z0-9-]{0,63})/g;
36
+ /**
37
+ * The marker, plus whatever token was written after it on the SAME line.
38
+ *
39
+ * Deliberately not "the marker followed by a WELL-FORMED key". Matching only
40
+ * well-formed keys meant a marker whose key was misspelled matched NOTHING, so
41
+ * a key one capital letter away from correct was neither expired nor
42
+ * unchecked — it passed the gate in silence, which is the precise fail-open
43
+ * shape this module's header says it exists to close. The marker is the
44
+ * commitment; the key's form is judged afterwards, so a misspelling becomes a
45
+ * finding instead of an exemption.
46
+ *
47
+ * The token class is identifier-shaped rather than "any non-space", and that is
48
+ * what separates a COMMITMENT from PROSE. A malformed key is still an attempt
49
+ * at an identifier. A marker followed by punctuation is this file specifying
50
+ * the marker, a regex containing it, or documentation quoting it — none of
51
+ * which is a promise anybody made. Without that distinction the module defining
52
+ * the marker reports itself, and a gate that fires on its own specification is
53
+ * one that gets deleted rather than heeded.
54
+ *
55
+ * The empty alternative catches a marker written with nothing after it on its
56
+ * line, which is a commitment naming no condition at all — reported, not
57
+ * skipped.
58
+ *
59
+ * Horizontal whitespace only. `\s*` crossed a newline, so a bare marker adopted
60
+ * the first token of the NEXT line as its key.
61
+ *
62
+ * The token runs to the next whitespace once it has STARTED as an identifier,
63
+ * and that boundary is what keeps a malformed key malformed. Stopping the
64
+ * capture at the first character outside the slug class read a marker written
65
+ * with the token `ready!` as the well-formed key `ready`: a predicate named
66
+ * `ready` then answered for a marker nobody wrote, and a marker whose predicate
67
+ * answered `false` produced neither `expired` nor `unchecked`. That is the
68
+ * fail-open shape this module exists to close, reintroduced one character at a
69
+ * time. Captured whole, `ready!` fails {@link isWellFormedKey} and is reported
70
+ * as unchecked, which is the honest answer for a commitment nothing evaluates.
71
+ *
72
+ * Note that this comment cannot SHOW the failing line: writing the marker with
73
+ * an identifier after it is a commitment as far as the scanner is concerned,
74
+ * and the sweep over the shipped tree reported this file the moment a draft of
75
+ * it did. That is the guard working, so the example is named rather than
76
+ * quoted.
77
+ *
78
+ * The capture is bounded rather than greedy: it is echoed into a report, and an
79
+ * unbounded match on a minified line would put the rest of that line there. The
80
+ * bound is 65 rather than 64 — one MORE than the longest well-formed key — so
81
+ * an over-long token is captured at a length `WELL_FORMED_KEY` rejects. Cutting
82
+ * at 64 would have turned a 200-character token into a valid-looking key.
83
+ */
84
+ const MARKER_AND_TOKEN =
85
+ /PLACEHOLDER-UNTIL:[^\S\n]*([A-Za-z0-9_-][^\s]{0,64}|(?=\r?\n)|$)/g;
86
+
87
+ /** A condition key is a plain slug, so a marker cannot smuggle a regex. */
88
+ const WELL_FORMED_KEY = /^[a-z0-9][a-z0-9-]{0,63}$/;
89
+
90
+ /**
91
+ * Whether a token read after a marker is a usable condition key.
92
+ * @param {string} key The token written after the marker.
93
+ * @returns {boolean} True when it is a plain slug.
94
+ */
95
+ export function isWellFormedKey(key) {
96
+ return WELL_FORMED_KEY.test(key);
97
+ }
38
98
 
39
99
  /**
40
100
  * The condition keys one source file declares.
101
+ *
102
+ * Malformed tokens are RETURNED, not filtered. They carry no predicate, so the
103
+ * caller reports them as unchecked — which is the honest answer for a
104
+ * commitment nothing can evaluate, and the one that keeps a typo from reading
105
+ * as an absence.
41
106
  * @param {string} source The file's contents.
42
107
  * @returns {string[]} The keys, in order of appearance, deduplicated.
43
108
  */
44
109
  export function placeholderKeys(source) {
45
110
  return [
46
111
  ...new Set(
47
- [...String(source).matchAll(KEY)].flatMap(hit =>
48
- hit[1] === undefined ? [] : [hit[1]]
49
- )
112
+ [...String(source).matchAll(MARKER_AND_TOKEN)].map(hit => hit[1] ?? "")
50
113
  ),
51
114
  ];
52
115
  }
@@ -60,7 +123,7 @@ export function placeholderKeys(source) {
60
123
  * first's noise.
61
124
  * @param {object} options Inputs.
62
125
  * @param {Array<{file: string, source: string}>} options.files The sources to scan.
63
- * @param {Record<string, () => boolean>} options.conditions Predicate per key, true when the condition has arrived.
126
+ * @param {Record<string, () => boolean>} options.conditions Own-property predicate per key, true when the condition has arrived.
64
127
  * @returns {{expired: Array<{file: string, key: string}>, unchecked: Array<{file: string, key: string}>}} What must be corrected.
65
128
  */
66
129
  export function expiredPlaceholders({ files, conditions }) {
@@ -68,7 +131,16 @@ export function expiredPlaceholders({ files, conditions }) {
68
131
  const unchecked = [];
69
132
  for (const { file, source } of files) {
70
133
  for (const key of placeholderKeys(source)) {
71
- const predicate = conditions[key];
134
+ // `Object.hasOwn`, not a plain read. `constructor` is a legal slug under
135
+ // the key pattern, so `conditions[key]` found `Object.prototype.constructor`
136
+ // on the prototype chain, passed the `typeof` check, was CALLED, returned
137
+ // a truthy object, and reported the placeholder expired. Same for
138
+ // `valueOf` and `toString`. A predicate nobody declared is not a
139
+ // predicate. A malformed key lands here too and has no own entry, so it
140
+ // becomes `unchecked` rather than silently passing.
141
+ const predicate = Object.hasOwn(conditions, key)
142
+ ? conditions[key]
143
+ : undefined;
72
144
  if (typeof predicate !== "function") {
73
145
  unchecked.push({ file, key });
74
146
  continue;
@@ -63,8 +63,33 @@ const DEPENDENCY_SECTIONS = [
63
63
  * The prefix it skips is the comparator, which is the only thing that ever
64
64
  * precedes the number in a branch: `>=1.2.3`, `^1.2.3`, `~1.2.3`, `1.2.3`,
65
65
  * `>=1.2.3 <2`.
66
+ *
67
+ * The prefix is a CLOSED class of comparator characters rather than "anything
68
+ * that is not a digit", because `\D*` made every digit-bearing string look like
69
+ * a range. `file:../pkg2` parsed as a floor of `2.0.0`, and `collisions()`
70
+ * applies {@link isIncomparable} to the DEPENDENCY line only — so a protocol
71
+ * override carrying a digit invented a floor and reported a collision that does
72
+ * not exist. A false alarm in this check is the failure that gets it switched
73
+ * off, which costs the false negatives it was written to prevent.
74
+ *
75
+ * Minor and patch are OPTIONAL, and that is load-bearing rather than tidy.
76
+ * Demanding a full `x.y.z` made `^8` and `~1.2` read as carrying no floor at
77
+ * all, which is not a near-miss — it is the opposite of the truth. `^8` permits
78
+ * `>=8.0.0`, and calling that floorless broke this check in both directions at
79
+ * once: an override written `^8` was skipped on the reasoning that it had
80
+ * nothing to lose, and a dependency written `~1.2` was compared as `0.0.0` and
81
+ * lost to everything. The first is a false negative in a security check, which
82
+ * this file's own comments name as the failure mode it is built to avoid.
83
+ *
84
+ * A partial version's absent parts are zero because that is what the range
85
+ * means: `^8` is `>=8.0.0 <9.0.0` and `1.x` is `>=1.0.0 <2.0.0`. Only the lower
86
+ * bound is read here, so the upper half costs nothing to ignore.
87
+ *
88
+ * Still one left-to-right pass: the optional groups each have a literal `.`
89
+ * ahead of them, so there is no position at which the engine has two viable
90
+ * ways to match and nothing to backtrack over (S5852).
66
91
  */
67
- const BRANCH_VERSION = /^\D*(\d+)\.(\d+)\.(\d+)/;
92
+ const BRANCH_VERSION = /^[\sv=><~^]*(\d+)(?:\.(\d+))?(?:\.(\d+))?/;
68
93
 
69
94
  /**
70
95
  * Lowest version a single disjunction branch permits.
@@ -81,7 +106,11 @@ function branchLowerBound(branch) {
81
106
  if (trimmed === "") return null;
82
107
  if (/^<=?\s*\d/.test(trimmed)) return [0, 0, 0];
83
108
  const match = BRANCH_VERSION.exec(trimmed);
84
- return match ? match.slice(1, 4).map(Number) : null;
109
+ if (!match) return null;
110
+ // An absent minor or patch is zero, not missing: `^8` is `>=8.0.0`. Number()
111
+ // of undefined is NaN, which would compare as neither higher nor lower than
112
+ // anything and quietly disable the comparison it feeds.
113
+ return [match[1], match[2] ?? "0", match[3] ?? "0"].map(Number);
85
114
  }
86
115
 
87
116
  /**
@@ -95,8 +124,12 @@ function branchLowerBound(branch) {
95
124
  *
96
125
  * An alias spec (`npm:other-pkg@^1.2.3`) versions a different package
97
126
  * entirely; its number cannot be compared against a floor for this name, so it
98
- * returns null rather than a misleading answer. A spec carrying no version
99
- * `*`, `latest`, `^8` — has no floor either.
127
+ * returns null rather than a misleading answer. A spec carrying no version at
128
+ * all — `*`, `latest` — has no floor either.
129
+ *
130
+ * A PARTIAL version is not one of those. `^8` and `~1.2` carry floors of
131
+ * `8.0.0` and `1.2.0`; this list used to name `^8` as floorless and the code
132
+ * agreed with it, which is how an override written that way went unchecked.
100
133
  *
101
134
  * `null` therefore means only "no floor was read here", and callers must not
102
135
  * read it as "nothing to check". Those are opposite answers: a range with no
@@ -108,11 +141,15 @@ function branchLowerBound(branch) {
108
141
  export function lowestPermitted(spec) {
109
142
  const raw = spec ?? "";
110
143
  if (/^\s*npm:/i.test(raw)) return null;
111
- const bounds = raw
112
- .split("||")
113
- .map(branchLowerBound)
114
- .filter(bound => bound !== null);
115
- if (bounds.length === 0) return null;
144
+ // A floorless branch is not a branch to skip — it decides the answer. A
145
+ // disjunction permits the union of its branches, so `latest || ^8` permits
146
+ // everything `latest` does, and reading the range as `8.0.0` reports a floor
147
+ // the range does not have. Filtering the null out made the strongest branch
148
+ // speak for the weakest one, which is a floor read TOO HIGH: the caller's
149
+ // `compare(target, floor) >= 0` then skips a genuine collision, and a false
150
+ // negative in a security check is the one direction this file must not fail.
151
+ const bounds = raw.split("||").map(branchLowerBound);
152
+ if (bounds.some(bound => bound === null)) return null;
116
153
  return bounds.reduce((lowest, bound) =>
117
154
  compare(bound, lowest) < 0 ? bound : lowest
118
155
  );
@@ -203,5 +203,14 @@ if (invokedAsScript(import.meta.url)) {
203
203
  // Unconditionally 0. See the module header: a non-zero postinstall aborts the
204
204
  // dependency install outright, which is strictly worse than a stale template
205
205
  // and would strand the project with no way to install the fix.
206
- process.exit(0);
206
+ //
207
+ // `exitCode`, NOT `process.exit(0)`. Under a package manager stdout is a
208
+ // PIPE, and writes to a pipe are asynchronous — `process.exit` tears the
209
+ // process down without flushing them, so the banner this module exists to
210
+ // print could be truncated or lost entirely. The comment a few lines above
211
+ // calls a silent postinstall "the worst possible silence, since it is
212
+ // indistinguishable from the apply having succeeded", and the next statement
213
+ // was capable of causing exactly that. Setting `exitCode` makes the same
214
+ // unconditional-zero promise and lets the event loop drain first.
215
+ process.exitCode = 0;
207
216
  }
@@ -148,9 +148,14 @@ import {
148
148
  /**
149
149
  * The three context sets. Reached only when the read succeeded.
150
150
  * @typedef {object} ContextDrift
151
- * @property {string[]} missing Declared, not required on GitHub.
151
+ * @property {string[]} missing Declared, not required on GitHub. A name appears
152
+ * here when ANY of its declarations is unsatisfied.
153
+ * @property {Array<{context: string, ruleset?: string, integration_id?: number}>} missingRecords
154
+ * The unsatisfied (ruleset, context) pairs behind `missing`. What a repair
155
+ * acts on: `missing` says which names to report, this says where each goes.
152
156
  * @property {LiveContext[]} extra Required on GitHub, not declared.
153
- * @property {string[]} matched Present on both sides.
157
+ * @property {string[]} matched Present on both sides, in EVERY ruleset that
158
+ * declared them.
154
159
  */
155
160
 
156
161
  /**
@@ -633,25 +638,103 @@ export function readLivePolicy({ repo, gh }) {
633
638
  * beside a not-required `🔐 Credential Leakage` proving the same property,
634
639
  * `🧹 Lint` beside `🐢 Slow Lint Rules` — and a fuzzy match raises a false
635
640
  * alarm whose obvious fix is deleting the guard.
641
+ * ## Why a name alone is not the comparison
642
+ *
643
+ * A context declared in `github.rulesets.requiredChecks` carries structure —
644
+ * the ruleset that owns it, and often an `integration_id` pinning WHICH app may
645
+ * satisfy it. Reducing the declaration to a bare name threw that away, so a
646
+ * context declared pinned in one ruleset was reported as matched by an
647
+ * UNPINNED context of the same name in a different ruleset. No repair was
648
+ * produced, and the requirement stayed satisfiable by a writer the project
649
+ * never named — which is the whole property the pin exists to establish.
650
+ *
651
+ * The constraint applies only where the project actually declared one.
652
+ * A gate-derived context names no ruleset and no app, so it keeps matching on
653
+ * name against any active ruleset; nothing is tightened that was never stated.
636
654
  * @param {object} options Comparison inputs.
637
655
  * @param {string[]} options.declared Contexts `contextsFor` derived.
638
656
  * @param {LiveContext[]} options.live Contexts read from the rulesets.
639
- * @returns {ContextDrift} Three sets.
657
+ * @param {Record<string, string>} [options.homes] Declared ruleset per context,
658
+ * from `declaredChecks`. A context absent here declares no owner.
659
+ * @param {Record<string, number>} [options.pins] Declared app id per context,
660
+ * from `declaredChecks`. A context absent here declares no writer.
661
+ * @param {Array<{context: string, ruleset?: string, integration_id?: number}>} [options.records]
662
+ * One record per declared (ruleset, context) pair, from `declaredChecks`.
663
+ * Supplied, it is what the comparison is made of; absent, the name-keyed
664
+ * `homes`/`pins` projection is reconstituted into equivalent records.
665
+ * @returns {ContextDrift} Three sets, plus the unsatisfied records.
640
666
  */
641
- export function reconcileContexts({ declared, live }) {
642
- const liveNames = new Set((live ?? []).map(entry => entry.context));
667
+ export function reconcileContexts({
668
+ declared,
669
+ live,
670
+ homes = {},
671
+ pins = {},
672
+ records,
673
+ }) {
674
+ const liveContexts = live ?? [];
643
675
  const declaredNames = new Set(declared ?? []);
644
676
  const byName = (a, b) => a.localeCompare(b);
645
677
 
678
+ // The unit of declaration is a (ruleset, context) PAIR, not a name. A name
679
+ // set cannot hold `build` required by both `base` and `release`, so a live
680
+ // `build` in `release` alone answered for both and the `base` requirement was
681
+ // reported matched — a requirement that does not exist, reported satisfied.
682
+ const supplied = Array.isArray(records) ? records : null;
683
+ const reconstituted = [...declaredNames]
684
+ .filter(name => Object.hasOwn(homes, name))
685
+ .map(name => ({
686
+ context: name,
687
+ ruleset: homes[name],
688
+ // `Object.hasOwn`, not a truthiness test: an entry's absence and an entry
689
+ // holding a falsy value are different declarations, and the prototype
690
+ // chain is not a source of declared policy.
691
+ ...(Object.hasOwn(pins, name) ? { integration_id: pins[name] } : {}),
692
+ }));
693
+ const byContext = new Map();
694
+ for (const record of supplied ?? reconstituted) {
695
+ if (!declaredNames.has(record?.context)) continue;
696
+ if (!byContext.has(record.context)) byContext.set(record.context, []);
697
+ byContext.get(record.context).push(record);
698
+ }
699
+ // A gate-derived context names no ruleset and no app, so it keeps matching on
700
+ // name against any active ruleset; nothing is tightened that was never stated.
701
+ const declarations = [...declaredNames]
702
+ .sort(byName)
703
+ .flatMap(name => byContext.get(name) ?? [{ context: name }]);
704
+
705
+ const satisfies = (entry, record) => {
706
+ if (entry.context !== record.context) return false;
707
+ if (record.ruleset !== undefined && entry.ruleset !== record.ruleset) {
708
+ return false;
709
+ }
710
+ return !(
711
+ record.integration_id !== undefined &&
712
+ entry.integration_id !== record.integration_id
713
+ );
714
+ };
715
+ const unsatisfied = declarations.filter(
716
+ record => !liveContexts.some(entry => satisfies(entry, record))
717
+ );
718
+ const missingNames = new Set(unsatisfied.map(record => record.context));
719
+
646
720
  return {
647
- missing: [...declaredNames]
648
- .filter(name => !liveNames.has(name))
649
- .sort(byName),
650
- extra: (live ?? [])
721
+ // A NAME is missing when ANY of its declarations is unsatisfied, and
722
+ // matched only when every one of them is. The two stay mutually exclusive,
723
+ // and a partially-satisfied context can no longer report as done.
724
+ missing: [...missingNames].sort(byName),
725
+ // The specific pairs still to be written. `missing` says which names to
726
+ // report; this says where each one has to go — a distinction a name-keyed
727
+ // `homes` lookup cannot make once two rulesets want the same context.
728
+ missingRecords: unsatisfied,
729
+ // EXTRA stays name-based. A live context whose name IS declared is not
730
+ // unexpected — it is the same requirement in the wrong shape, already
731
+ // reported through `missing`. Listing it here too would tell `--prune` to
732
+ // delete the thing the repair is about to add.
733
+ extra: liveContexts
651
734
  .filter(entry => !declaredNames.has(entry.context))
652
735
  .sort((a, b) => a.context.localeCompare(b.context)),
653
736
  matched: [...declaredNames]
654
- .filter(name => liveNames.has(name))
737
+ .filter(name => !missingNames.has(name))
655
738
  .sort(byName),
656
739
  };
657
740
  }
@@ -860,6 +943,15 @@ export function declaredChecks(requiredChecks = {}) {
860
943
  const contexts = [];
861
944
  const homes = {};
862
945
  const pins = {};
946
+ // The RECORD is the declaration; `homes` and `pins` are a name-keyed
947
+ // projection of it that cannot represent a context required by two rulesets.
948
+ // `homes[context] = ruleset` is last-write-wins, so `build` required in both
949
+ // `base` and `release` kept only `release` — and a live `build` in `release`
950
+ // alone marked the declaration matched while the `base` requirement stayed
951
+ // missing and unrepaired. The projections remain because callers use them to
952
+ // ROUTE a repair for a context that has exactly one home; they are no longer
953
+ // what the comparison is made of.
954
+ const records = [];
863
955
  for (const [ruleset, entries] of Object.entries(requiredChecks ?? {})) {
864
956
  if (!Array.isArray(entries)) continue;
865
957
  for (const entry of entries) {
@@ -867,12 +959,48 @@ export function declaredChecks(requiredChecks = {}) {
867
959
  if (typeof entry.context !== "string") continue;
868
960
  contexts.push(entry.context);
869
961
  homes[entry.context] = ruleset;
870
- if (Number.isInteger(entry.integration_id)) {
871
- pins[entry.context] = entry.integration_id;
872
- }
962
+ const pinned = Number.isInteger(entry.integration_id);
963
+ if (pinned) pins[entry.context] = entry.integration_id;
964
+ records.push({
965
+ context: entry.context,
966
+ ruleset,
967
+ ...(pinned ? { integration_id: entry.integration_id } : {}),
968
+ });
873
969
  }
874
970
  }
875
- return { contexts, homes, pins };
971
+ return { contexts, homes, pins, records };
972
+ }
973
+
974
+ /**
975
+ * Where an awaited context should be written.
976
+ *
977
+ * An awaited context is declared on a GATE, not in `requiredChecks`, so it
978
+ * names no ruleset of its own. `POLICY_RULESET_NAME` is its DEFAULT home
979
+ * because that is where Lisa's own generator writes it, which is what lets the
980
+ * two writers agree without anyone passing `--ruleset`.
981
+ *
982
+ * A default, though, and not an override — which is what it had become. The
983
+ * name was assigned directly, so an explicit `--ruleset` was silently ignored
984
+ * for exactly these contexts, and a repository with no ruleset called `base`
985
+ * was told to go and seed one instead of falling back to the single carrier it
986
+ * already had. Both are answers to a question the caller had already answered.
987
+ *
988
+ * `null` means "no declared home", which sends the context down the normal
989
+ * fallback path in `planContextRepairs` and surfaces that path's real
990
+ * diagnosis — ambiguous carriers, or none at all — rather than a message about
991
+ * a ruleset the project never mentioned.
992
+ * @param {LivePolicy} live Result of `readLivePolicy`.
993
+ * @param {string|null} [rulesetName] An explicit `--ruleset` name.
994
+ * @returns {string|null} Ruleset name to write to, or null for the fallback.
995
+ */
996
+ export function awaitedHome(live, rulesetName = null) {
997
+ const rulesets = live?.rulesets ?? [];
998
+ if (rulesetName)
999
+ return repairTarget(rulesets, rulesetName).ruleset?.name ?? null;
1000
+ if (rulesets.some(entry => entry?.name === POLICY_RULESET_NAME)) {
1001
+ return POLICY_RULESET_NAME;
1002
+ }
1003
+ return repairTarget(rulesets, null).ruleset?.name ?? null;
876
1004
  }
877
1005
 
878
1006
  /**
@@ -923,16 +1051,21 @@ export function rulesetPayload(
923
1051
  return payload;
924
1052
  }
925
1053
 
1054
+ // An ADDITION REPLACES a same-named check in this ruleset rather than losing
1055
+ // to it. Only a context the comparison reported unsatisfied HERE reaches
1056
+ // `add`, so an existing check of that name in this ruleset is by construction
1057
+ // the wrong shape — the unpinned form of a pinned declaration, or one pinned
1058
+ // to an app the project did not name. Keeping it and de-duplicating the
1059
+ // addition away wrote a PUT that preserved the wrong pin, so the repair
1060
+ // reported success and converged on nothing: the next run found the same
1061
+ // drift, planned the same repair, and reported the same success forever.
1062
+ const added = new Map(additions.map(check => [check.context, check]));
926
1063
  const kept = (rule.parameters?.required_status_checks ?? []).filter(
927
- check => !remove.includes(check?.context)
1064
+ check => !remove.includes(check?.context) && !added.has(check?.context)
928
1065
  );
929
- const present = new Set(kept.map(check => check?.context));
930
1066
  rule.parameters = {
931
1067
  ...rule.parameters,
932
- required_status_checks: [
933
- ...kept,
934
- ...additions.filter(check => !present.has(check.context)),
935
- ],
1068
+ required_status_checks: [...kept, ...added.values()],
936
1069
  };
937
1070
  payload.rules = rules;
938
1071
  return payload;
@@ -1021,6 +1154,26 @@ export function planRepairs({
1021
1154
  return plan;
1022
1155
  }
1023
1156
 
1157
+ /**
1158
+ * The pins that apply to ONE ruleset's additions.
1159
+ *
1160
+ * Three sources, in increasing authority: the name-keyed map (awaited-context
1161
+ * pins, which are declared per context and belong to no ruleset), this group's
1162
+ * own declared pins, and this group's declarations that state NO pin. The last
1163
+ * is why a merge is not enough — an unpinned declaration has to remove an
1164
+ * inherited value, and spreading objects can only add.
1165
+ * @param {object} options Inputs.
1166
+ * @param {Record<string, number>} options.pins Name-keyed declared and awaited pins.
1167
+ * @param {Record<string, number>} options.groupPins Pins declared on this ruleset.
1168
+ * @param {Set<string>} options.unpinned Contexts this ruleset declares without a pin.
1169
+ * @returns {Record<string, number>} Pins to apply to this ruleset's additions.
1170
+ */
1171
+ function pinsFor({ pins, groupPins, unpinned }) {
1172
+ const effective = { ...pins, ...groupPins };
1173
+ for (const context of unpinned) delete effective[context];
1174
+ return effective;
1175
+ }
1176
+
1024
1177
  /**
1025
1178
  * Group the context repairs by the ruleset each one belongs to.
1026
1179
  *
@@ -1053,10 +1206,17 @@ function planContextRepairs({
1053
1206
  pins,
1054
1207
  homes,
1055
1208
  }) {
1056
- /** @type {Map<string, {add: string[], remove: string[]}>} */
1209
+ /** @type {Map<string, {add: string[], remove: string[], pins: Record<string, number>, unpinned: Set<string>}>} */
1057
1210
  const groups = new Map();
1058
1211
  const group = name => {
1059
- if (!groups.has(name)) groups.set(name, { add: [], remove: [] });
1212
+ if (!groups.has(name)) {
1213
+ groups.set(name, {
1214
+ add: [],
1215
+ remove: [],
1216
+ pins: {},
1217
+ unpinned: new Set(),
1218
+ });
1219
+ }
1060
1220
  return groups.get(name);
1061
1221
  };
1062
1222
  const problems = [];
@@ -1069,10 +1229,36 @@ function planContextRepairs({
1069
1229
  }
1070
1230
 
1071
1231
  let fallback;
1072
- for (const context of contexts.missing) {
1073
- const home = homes[context];
1074
- if (home) {
1075
- group(home).add.push(context);
1232
+ // Planned per RECORD, not per name. A context required by two rulesets needs
1233
+ // an addition in each one that lacks it, and `homes[context]` can only name
1234
+ // one of them — so routing by name repaired one requirement and silently
1235
+ // abandoned the other. `missingRecords` is the unsatisfied (ruleset, context)
1236
+ // pairs; a record without a ruleset is gate-derived and still routes through
1237
+ // `homes` and then the fallback, exactly as before.
1238
+ const missingRecords =
1239
+ contexts.missingRecords ??
1240
+ (contexts.missing ?? []).map(context => ({ context }));
1241
+ for (const record of missingRecords) {
1242
+ const context = record.context;
1243
+ const home = record.ruleset ?? homes[context];
1244
+ const target = home ? group(home) : null;
1245
+ if (target) {
1246
+ target.add.push(context);
1247
+ // A declared record is authoritative about its own pin — INCLUDING the
1248
+ // absence of one. `pins` is name-keyed and last-write-wins, so if `base`
1249
+ // declares a context unpinned and `release` pins it to app 99, the
1250
+ // name-keyed value is 99 and the `base` addition inherited it: a pin the
1251
+ // project never declared for that ruleset, written by the repair itself.
1252
+ // Silently narrowing WHO may satisfy a required check is the same class
1253
+ // of harm as silently widening it — both replace a stated policy with an
1254
+ // invented one — so the record's silence is recorded, not defaulted over.
1255
+ if (record.ruleset !== undefined) {
1256
+ if (Number.isInteger(record.integration_id)) {
1257
+ target.pins[context] = record.integration_id;
1258
+ } else {
1259
+ target.unpinned.add(context);
1260
+ }
1261
+ }
1076
1262
  continue;
1077
1263
  }
1078
1264
  fallback ??= repairTarget(live.rulesets, rulesetName);
@@ -1084,7 +1270,7 @@ function planContextRepairs({
1084
1270
  }
1085
1271
 
1086
1272
  const actions = [];
1087
- for (const [name, { add, remove }] of groups) {
1273
+ for (const [name, { add, remove, pins: groupPins, unpinned }] of groups) {
1088
1274
  const ruleset = (live.rulesets ?? []).find(entry => entry?.name === name);
1089
1275
  if (!ruleset) {
1090
1276
  actions.push({
@@ -1103,7 +1289,16 @@ function planContextRepairs({
1103
1289
  rulesetId: ruleset.id,
1104
1290
  add,
1105
1291
  remove,
1106
- payload: rulesetPayload(ruleset, { add, remove, awaited, pins }),
1292
+ payload: rulesetPayload(ruleset, {
1293
+ add,
1294
+ remove,
1295
+ awaited,
1296
+ // The name-keyed map still carries AWAITED pins, which are declared per
1297
+ // context and have no ruleset of their own, so it stays the base. A
1298
+ // declared record then overrides it — with its pin, or by deleting the
1299
+ // inherited one when the declaration states none.
1300
+ pins: pinsFor({ pins, groupPins, unpinned }),
1301
+ }),
1107
1302
  });
1108
1303
  }
1109
1304
  for (const problem of [...new Set(problems)]) {
@@ -1241,7 +1436,19 @@ export function reconcile({
1241
1436
  };
1242
1437
  }
1243
1438
 
1244
- const contexts = reconcileContexts({ declared, live: live.contexts });
1439
+ // The structured declaration travels through the comparison, so a pinned
1440
+ // context is not reported as satisfied by an unpinned one somewhere else.
1441
+ // Only `configured` is passed: it is what the project actually declared. The
1442
+ // awaited-context homes below are a routing hint for repairs, not a stated
1443
+ // expectation, and treating them as one would report drift nobody declared.
1444
+ const contexts = reconcileContexts({
1445
+ declared,
1446
+ live: live.contexts,
1447
+ homes: configured.homes,
1448
+ pins: configured.pins,
1449
+ records: configured.records,
1450
+ });
1451
+ const resolvedAwaitedHome = awaitedHome(live, rulesetName);
1245
1452
  const settings = reconcileSettings({ policy, live });
1246
1453
  const drifted =
1247
1454
  contexts.missing.length > 0 ||
@@ -1264,17 +1471,21 @@ export function reconcile({
1264
1471
  awaited: awaitedContexts(gates, moment),
1265
1472
  pins: { ...configured.pins, ...awaitedPins(gates, moment) },
1266
1473
  // An awaited context has no ruleset in its declaration — it is declared
1267
- // on a GATE. Its home is the ruleset Lisa generates from config, which
1268
- // is where the applier writes it, so the two writers agree instead of
1269
- // the reconciler needing --ruleset to place a context the generator
1270
- // already placed.
1474
+ // on a GATE. Its home defaults to the ruleset Lisa generates from
1475
+ // config, which is where the applier writes it, so the two writers
1476
+ // agree instead of the reconciler needing --ruleset to place a context
1477
+ // the generator already placed. `awaitedHome` keeps that default while
1478
+ // letting an explicit --ruleset win and falling back when the default
1479
+ // ruleset does not exist here.
1271
1480
  homes: {
1272
- ...Object.fromEntries(
1273
- awaitedContexts(gates, moment).map(context => [
1274
- context,
1275
- POLICY_RULESET_NAME,
1276
- ])
1277
- ),
1481
+ ...(resolvedAwaitedHome === null
1482
+ ? {}
1483
+ : Object.fromEntries(
1484
+ awaitedContexts(gates, moment).map(context => [
1485
+ context,
1486
+ resolvedAwaitedHome,
1487
+ ])
1488
+ )),
1278
1489
  ...configured.homes,
1279
1490
  },
1280
1491
  })
@@ -1342,8 +1553,16 @@ export function render(result) {
1342
1553
  `${result.verdict.toUpperCase()} — ${result.repo} at ${result.moment}`,
1343
1554
  ` matched: ${result.contexts.matched.length} context(s)`,
1344
1555
  ];
1345
- for (const name of result.contexts.missing) {
1346
- lines.push(` MISSING ${name} (declared, not required on GitHub)`);
1556
+ // Reported per unsatisfied PAIR where there is one, so a context required by
1557
+ // two rulesets and present in one names the ruleset still lacking it. A
1558
+ // reader who is told only the name looks at the ruleset that already has it.
1559
+ for (const record of result.contexts.missingRecords ??
1560
+ result.contexts.missing.map(context => ({ context }))) {
1561
+ lines.push(
1562
+ record.ruleset
1563
+ ? ` MISSING ${record.context} (declared on "${record.ruleset}", not required there on GitHub)`
1564
+ : ` MISSING ${record.context} (declared, not required on GitHub)`
1565
+ );
1347
1566
  }
1348
1567
  for (const entry of result.contexts.extra) {
1349
1568
  lines.push(
@@ -87,7 +87,18 @@ const KEYWORD_FORMS = Object.freeze({
87
87
  Array.isArray(value) && value.every(entry => typeof entry === "string"),
88
88
  form: "an array of property names",
89
89
  },
90
- properties: { accepts: isSchemaObject, form: "an object of subschemas" },
90
+ properties: {
91
+ // The VALUES, not just the container. Checking only the container accepted
92
+ // `{"properties": {"a": true}}` — a form the rest of this file explicitly
93
+ // does not implement, as `items` and `additionalProperties` both say in so
94
+ // many words. The boolean then reached `validateNode` and threw, so a
95
+ // malformed schema crashed the validator instead of producing the
96
+ // validation finding this allowlist exists to produce. The failure was one
97
+ // step later than the check that was supposed to catch it.
98
+ accepts: value =>
99
+ isSchemaObject(value) && Object.values(value).every(isSchemaObject),
100
+ form: "an object whose values are subschema objects (a boolean is not implemented)",
101
+ },
91
102
  additionalProperties: {
92
103
  accepts: value => typeof value === "boolean",
93
104
  form: "a boolean (a subschema is not implemented)",
@@ -29,7 +29,7 @@
29
29
  "@vitest/coverage-v8": "^4.1.0",
30
30
  "eslint-plugin-oxlint": "^1.62.0",
31
31
  "oxlint": "^1.62.0",
32
- "oxlint-tsgolint": "^0.22.1",
32
+ "oxlint-tsgolint": "^0.24.0",
33
33
  "@stryker-mutator/core": "^9.0.0",
34
34
  "@stryker-mutator/vitest-runner": "^9.0.0"
35
35
  },
@@ -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,CA8kBjC,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,CA+lBjC,CAAC"}