@codyswann/lisa 4.4.14 → 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.
- package/all/copy-overwrite/scripts/lib/placeholder-expiry.mjs +79 -7
- package/all/copy-overwrite/scripts/lisa-floor-collisions.mjs +46 -9
- package/all/copy-overwrite/scripts/lisa-postinstall.mjs +10 -1
- package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +260 -41
- package/all/copy-overwrite/scripts/lisa-schema-validate.mjs +12 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +17 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +7 -6
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +75 -25
|
@@ -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
|
-
/**
|
|
37
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
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({
|
|
642
|
-
|
|
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
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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 =>
|
|
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
|
-
|
|
871
|
-
|
|
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))
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
|
1268
|
-
// is where the applier writes it, so the two writers
|
|
1269
|
-
// the reconciler needing --ruleset to place a context
|
|
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
|
-
...
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
|
|
1346
|
-
|
|
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: {
|
|
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)",
|
|
@@ -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,
|
|
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"}
|