@codyswann/lisa 4.27.9 → 4.28.1

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 (102) hide show
  1. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  2. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  3. package/dist/core/upstream-evidence-manifest.js +43 -8
  4. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  5. package/package.json +5 -4
  6. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  8. package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +21 -2
  9. package/plugins/lisa/.codex-plugin/skills/lisa-jira-build-intake/SKILL.md +21 -2
  10. package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +21 -2
  11. package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +65 -5
  12. package/plugins/lisa/.codex-plugin/skills/lisa-validate-tracker-mapping/SKILL.md +134 -3
  13. package/plugins/lisa/rules/eager/config-resolution.md +23 -0
  14. package/plugins/lisa/rules/reference/config-resolution.md +65 -0
  15. package/plugins/lisa/scripts/repo-scope-vocabulary-audit.mjs +458 -0
  16. package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +21 -2
  17. package/plugins/lisa/skills/lisa-jira-build-intake/SKILL.md +21 -2
  18. package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +21 -2
  19. package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +65 -5
  20. package/plugins/lisa/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
  21. package/plugins/lisa-agy/plugin.json +1 -1
  22. package/plugins/lisa-agy/scripts/repo-scope-vocabulary-audit.mjs +458 -0
  23. package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +21 -2
  24. package/plugins/lisa-agy/skills/lisa-jira-build-intake/SKILL.md +21 -2
  25. package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +21 -2
  26. package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +65 -5
  27. package/plugins/lisa-agy/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
  28. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  31. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-copilot/rules/eager/config-resolution.md +23 -0
  35. package/plugins/lisa-copilot/rules/reference/config-resolution.md +65 -0
  36. package/plugins/lisa-copilot/scripts/repo-scope-vocabulary-audit.mjs +458 -0
  37. package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +21 -2
  38. package/plugins/lisa-copilot/skills/lisa-jira-build-intake/SKILL.md +21 -2
  39. package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +21 -2
  40. package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +65 -5
  41. package/plugins/lisa-copilot/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
  42. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +65 -0
  44. package/plugins/lisa-cursor/rules/config-resolution.mdc +23 -0
  45. package/plugins/lisa-cursor/scripts/repo-scope-vocabulary-audit.mjs +458 -0
  46. package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +21 -2
  47. package/plugins/lisa-cursor/skills/lisa-jira-build-intake/SKILL.md +21 -2
  48. package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +21 -2
  49. package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +65 -5
  50. package/plugins/lisa-cursor/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
  51. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-expo-agy/plugin.json +1 -1
  54. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  59. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  64. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  69. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  74. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-rails-agy/plugin.json +1 -1
  79. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  83. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  84. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  88. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  89. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  91. package/plugins/src/base/rules/eager/config-resolution.md +23 -0
  92. package/plugins/src/base/rules/reference/config-resolution.md +65 -0
  93. package/plugins/src/base/scripts/repo-scope-vocabulary-audit.mjs +458 -0
  94. package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +21 -2
  95. package/plugins/src/base/skills/lisa-jira-build-intake/SKILL.md +21 -2
  96. package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +21 -2
  97. package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +65 -5
  98. package/plugins/src/base/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
  99. package/scripts/lib/mutation-performance-reporter.mjs +135 -0
  100. package/scripts/mutation-performance-measure.mjs +1794 -0
  101. package/scripts/mutation-performance.schema.json +103 -0
  102. package/typescript/create-only/.github/workflows/review-evidence.yml +18 -10
@@ -0,0 +1,458 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Repo-scope vocabulary audit for `/lisa:validate-tracker-mapping`.
4
+ *
5
+ * ## The gap this closes
6
+ *
7
+ * `/lisa:validate-tracker-mapping` audits LIFECYCLE roles: every pair it
8
+ * compares comes from a `(role, configured-name)` mapping declared in
9
+ * `.lisa.config.json`. The SCOPING vocabulary is declared in no config key, so
10
+ * the audit has nothing to compare it against.
11
+ *
12
+ * `repo:` is the scoping family where that blind spot costs the most, because
13
+ * a wrong answer there is not a matter of taste: it decides which work items a
14
+ * repo's build queue can see. When the repo vocabulary drifts, a scan
15
+ * filtering on the canonical spelling returns FEWER items, not an error, and
16
+ * an empty result is indistinguishable from "nothing to do". Work sits
17
+ * unrouted and nobody is told.
18
+ *
19
+ * The OPEN scoping families — `type:`, `priority:`, `points:`, `component:` —
20
+ * are a separate problem with a separate answer, tracked upstream on #3420.
21
+ * This module deliberately does not touch them, for the reason below.
22
+ *
23
+ * ## Why this family asserts membership when `component:` may not
24
+ *
25
+ * The ruling on #3420 — Lisa asserts no authority over project label
26
+ * vocabularies — is about vocabularies that are OPEN and PROJECT-SPECIFIC. An
27
+ * open vocabulary has no wrong member, only inconsistent ones, and declaring
28
+ * one in config would demand per-project curation that becomes its own drift
29
+ * surface. None of that describes `repo:`:
30
+ *
31
+ * - `config-resolution` already declares `repo:<name>` canonical and uniform
32
+ * across trackers, so the canonical FORM is settled upstream, not per project.
33
+ * - The MEMBERS are derived, never curated: the caller resolves them from the
34
+ * repo-identity ladder (config `repo` → `github.repo` → git remote basename)
35
+ * and, in batch mode, from the sibling projects sharing one tracker. This
36
+ * module takes them as input and adds no config key.
37
+ *
38
+ * So there is nothing here for a human to hand-maintain, and no new place for
39
+ * the declaration itself to drift.
40
+ *
41
+ * ## Why these findings are drift and not smells
42
+ *
43
+ * The open families need a heuristic — is `component:plugin` a typo of
44
+ * `component:plugins`? — and a heuristic is sometimes wrong, which is why
45
+ * #3420 rules that findings about them must stay advisory and must never
46
+ * become a gate. That constraint exists to stop a GUESS becoming a gate, and
47
+ * it is not weakened here, because nothing here is a guess. `assertRepoScope`
48
+ * (`lisa-work-item.mjs`, #1957) accepts THREE spellings as valid repo scope:
49
+ *
50
+ * repo:<name> canonical
51
+ * <name> bare label — Sentry-provenance items arrive carrying only this
52
+ * <name> Jira component equal to the bare name
53
+ *
54
+ * The bare branch is deliberate and load-bearing; removing it would break that
55
+ * ingestion path. But every build-intake scanner FILTERS on `repo:<name>`. So
56
+ * an item carrying only the bare spelling passes validation while being
57
+ * invisible to every scan that looks for it. That is not a smell about which
58
+ * reasonable people differ — it is a checkable disagreement between what
59
+ * validation accepts and what filtering finds, and it is the exact shape of the
60
+ * under-read this audit exists to make loud.
61
+ *
62
+ * ## Why nothing here is ever auto-repaired
63
+ *
64
+ * Every finding is `autoRepairable: false`, including under `repair=true`.
65
+ * `repair=true` may rewrite the CONFIG, never the tracker, and every fix
66
+ * available here is a tracker mutation: stamping the canonical label on an
67
+ * item, renaming a malformed marker, or deciding whether an undeclared scope
68
+ * names a real repo or a typo. A human decides; this module reports.
69
+ *
70
+ * @module plugins/src/base/scripts/repo-scope-vocabulary-audit
71
+ */
72
+
73
+ import { realpathSync } from "node:fs";
74
+ import { fileURLToPath } from "node:url";
75
+
76
+ /** The family this module audits. Canonical marker form is `repo:<name>`. */
77
+ export const REPO_SCOPE_FAMILY = "repo";
78
+
79
+ /** The canonical separator between the family and the repo name. */
80
+ export const CANONICAL_SEPARATOR = ":";
81
+
82
+ /**
83
+ * Separators seen standing in for the canonical `:` on a repo marker.
84
+ *
85
+ * A label like `repo-frontend` reads to a human as a repo marker and is not
86
+ * one: no scanner filters on it, and `assertRepoScope` does not accept it
87
+ * either, so it scopes an item to nothing at all. Observed on a live tracker
88
+ * alongside the canonical form, carrying zero items — a vocabulary that had
89
+ * split without anything noticing.
90
+ */
91
+ export const MALFORMED_SEPARATORS = Object.freeze(["-", "_", "/", "."]);
92
+
93
+ /** Every finding this module can emit. All of them are real drift. */
94
+ export const REPO_SCOPE_FINDING_KINDS = Object.freeze([
95
+ "unstamped-alias",
96
+ "malformed-marker",
97
+ "undeclared-scope",
98
+ ]);
99
+
100
+ /**
101
+ * @typedef {{
102
+ * readonly ref?: string
103
+ * readonly labels?: readonly (string | { readonly name?: string })[]
104
+ * readonly components?: readonly (string | { readonly name?: string })[]
105
+ * }} WorkItem
106
+ *
107
+ * @typedef {{
108
+ * readonly family: "repo"
109
+ * readonly kind: string
110
+ * readonly label: string
111
+ * readonly repo?: string
112
+ * readonly canonical?: string
113
+ * readonly items: readonly string[]
114
+ * readonly severity: "drift"
115
+ * readonly autoRepairable: false
116
+ * readonly detail: string
117
+ * }} RepoScopeFinding
118
+ */
119
+
120
+ /**
121
+ * Normalize a label/component list to lowercase names, as `assertRepoScope`
122
+ * does. Its matching is case-insensitive, so a bare `Infrastructure` label
123
+ * scopes an item to `infrastructure` — and is just as invisible to a
124
+ * `repo:infrastructure` filter as a lowercase one would be.
125
+ *
126
+ * @param {readonly (string | { readonly name?: string })[] | undefined} value
127
+ * @returns {string[]} lowercased names, non-strings dropped
128
+ */
129
+ export function namesFrom(value) {
130
+ if (!Array.isArray(value)) return [];
131
+ return value
132
+ .map(item => (typeof item === "string" ? item : item?.name))
133
+ .filter(name => typeof name === "string")
134
+ .map(name => name.trim().toLowerCase())
135
+ .filter(name => name.length > 0);
136
+ }
137
+
138
+ /**
139
+ * The canonical repo marker for a repo short name.
140
+ *
141
+ * @param {string} repo
142
+ * @returns {string} e.g. `repo:frontend`
143
+ */
144
+ export function canonicalRepoLabel(repo) {
145
+ return `${REPO_SCOPE_FAMILY}${CANONICAL_SEPARATOR}${String(repo).trim().toLowerCase()}`;
146
+ }
147
+
148
+ /**
149
+ * Split a label that is shaped like a repo marker into its separator and value.
150
+ *
151
+ * Returns `null` for anything that does not begin `repo<separator>` — a bare
152
+ * repo name is not a marker (it is an alias, handled per item, because whether
153
+ * it is drift depends on what else that item carries), and `component:frontend`
154
+ * belongs to another family entirely.
155
+ *
156
+ * @param {unknown} name
157
+ * @returns {{ separator: string, value: string } | null}
158
+ */
159
+ export function parseRepoMarker(name) {
160
+ if (typeof name !== "string") return null;
161
+ const trimmed = name.trim().toLowerCase();
162
+ if (!trimmed.startsWith(REPO_SCOPE_FAMILY)) return null;
163
+
164
+ const separator = trimmed.slice(
165
+ REPO_SCOPE_FAMILY.length,
166
+ REPO_SCOPE_FAMILY.length + 1
167
+ );
168
+ if (
169
+ separator !== CANONICAL_SEPARATOR &&
170
+ !MALFORMED_SEPARATORS.includes(separator)
171
+ ) {
172
+ return null;
173
+ }
174
+
175
+ const value = trimmed.slice(REPO_SCOPE_FAMILY.length + 1).trim();
176
+ if (value.length === 0) return null;
177
+ return { separator, value };
178
+ }
179
+
180
+ /**
181
+ * Audit a tracker's repo-scoping vocabulary.
182
+ *
183
+ * Total and read-only: mutates nothing, reaches nothing over the network, and
184
+ * returns every finding rather than the first.
185
+ *
186
+ * @param {{
187
+ * readonly knownRepos?: readonly string[]
188
+ * readonly items?: readonly WorkItem[]
189
+ * readonly labels?: readonly (string | { readonly name?: string })[]
190
+ * }} input
191
+ * `knownRepos` is the derived repo vocabulary — the repo-identity ladder for
192
+ * a single project, or the sibling set in batch mode. `items` are the live
193
+ * work items with their labels (and Jira components). `labels` is the
194
+ * tracker's declared label set, which catches a malformed marker carrying
195
+ * zero items.
196
+ * @returns {{
197
+ * readonly verdict: "VALID" | "DRIFTED" | "UNRESOLVABLE"
198
+ * readonly findings: readonly RepoScopeFinding[]
199
+ * readonly knownAliases: readonly { readonly label: string, readonly canonical: string, readonly items: readonly string[] }[]
200
+ * readonly vocabulary: readonly string[]
201
+ * }}
202
+ */
203
+ export function auditRepoScopeVocabulary(input = {}) {
204
+ const vocabulary = [
205
+ ...new Set(
206
+ (Array.isArray(input.knownRepos) ? input.knownRepos : [])
207
+ .filter(repo => typeof repo === "string")
208
+ .map(repo => repo.trim().toLowerCase())
209
+ .filter(repo => repo.length > 0)
210
+ ),
211
+ ];
212
+
213
+ // An empty vocabulary cannot be audited, and reporting VALID for it would
214
+ // reproduce the very failure this module exists to catch: a check that
215
+ // returns "nothing wrong" when it in fact looked at nothing.
216
+ if (vocabulary.length === 0) {
217
+ return {
218
+ verdict: /** @type {const} */ ("UNRESOLVABLE"),
219
+ findings: [],
220
+ knownAliases: [],
221
+ vocabulary,
222
+ };
223
+ }
224
+
225
+ const items = Array.isArray(input.items) ? input.items : [];
226
+ const { aliasFindings, knownAliases } = aliasUsage(items, vocabulary);
227
+
228
+ const findings = [
229
+ ...aliasFindings,
230
+ ...markerFindings(declaredNames(input.labels, items), vocabulary),
231
+ ];
232
+
233
+ return {
234
+ verdict: /** @type {const} */ (findings.length === 0 ? "VALID" : "DRIFTED"),
235
+ findings,
236
+ knownAliases,
237
+ vocabulary,
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Every label name in play: the tracker's declared set unioned with the labels
243
+ * observed on items.
244
+ *
245
+ * The declared set is what catches a malformed marker nobody has applied yet —
246
+ * a label sitting at zero usage beside the canonical form is a vocabulary that
247
+ * has already split, and the next item to receive it disappears from the queue.
248
+ *
249
+ * @param {readonly (string | { readonly name?: string })[] | undefined} labels
250
+ * @param {readonly WorkItem[]} items
251
+ * @returns {string[]}
252
+ */
253
+ function declaredNames(labels, items) {
254
+ return [
255
+ ...new Set([
256
+ ...namesFrom(labels),
257
+ ...items.flatMap(item => namesFrom(item?.labels)),
258
+ ]),
259
+ ];
260
+ }
261
+
262
+ /**
263
+ * Per item, per known repo: does what `assertRepoScope` accepts agree with what
264
+ * a canonical filter would find?
265
+ *
266
+ * An item carrying an accepted alias — the bare repo name as a label, or as a
267
+ * Jira component — WITHOUT the canonical `repo:<name>` beside it passes
268
+ * validation and is invisible to every scan. Carrying both is a known alias
269
+ * and harmless: the item is still found. That difference is the whole check,
270
+ * and it is why an alias cannot be judged from the label set alone.
271
+ *
272
+ * @param {readonly WorkItem[]} items
273
+ * @param {readonly string[]} vocabulary
274
+ * @returns {{ aliasFindings: RepoScopeFinding[], knownAliases: { label: string, canonical: string, items: string[] }[] }}
275
+ */
276
+ function aliasUsage(items, vocabulary) {
277
+ /** @type {Map<string, { repo: string, items: string[] }>} */
278
+ const unstamped = new Map();
279
+ /** @type {Map<string, { repo: string, items: string[] }>} */
280
+ const stamped = new Map();
281
+
282
+ items.forEach((item, index) => {
283
+ const ref = itemRef(item, index);
284
+ const labelNames = namesFrom(item?.labels);
285
+ const componentNames = namesFrom(item?.components);
286
+
287
+ for (const repo of vocabulary) {
288
+ const aliases = [
289
+ labelNames.includes(repo) ? repo : undefined,
290
+ componentNames.includes(repo) ? `component:${repo}` : undefined,
291
+ ].filter(alias => alias !== undefined);
292
+ if (aliases.length === 0) continue;
293
+
294
+ const bucket = labelNames.includes(canonicalRepoLabel(repo))
295
+ ? stamped
296
+ : unstamped;
297
+ for (const alias of aliases) {
298
+ if (!bucket.has(alias)) bucket.set(alias, { repo, items: [] });
299
+ bucket.get(alias).items.push(ref);
300
+ }
301
+ }
302
+ });
303
+
304
+ return {
305
+ aliasFindings: [...unstamped].map(([label, entry]) => ({
306
+ family: /** @type {const} */ (REPO_SCOPE_FAMILY),
307
+ kind: "unstamped-alias",
308
+ label,
309
+ repo: entry.repo,
310
+ canonical: canonicalRepoLabel(entry.repo),
311
+ items: entry.items,
312
+ severity: /** @type {const} */ ("drift"),
313
+ autoRepairable: /** @type {const} */ (false),
314
+ detail:
315
+ `"${label}" is an accepted alias for repository "${entry.repo}", so ` +
316
+ `${entry.items.length} work item(s) carrying it pass repo-scope ` +
317
+ `validation — but they do not carry "${canonicalRepoLabel(entry.repo)}", ` +
318
+ `which is what every build-intake scan filters on. Those items are ` +
319
+ `invisible to that scan, and the scan reports no error. Stamping the ` +
320
+ `canonical label alongside the alias is a tracker change a human makes.`,
321
+ })),
322
+ knownAliases: [...stamped].map(([label, entry]) => ({
323
+ label,
324
+ canonical: canonicalRepoLabel(entry.repo),
325
+ items: entry.items,
326
+ })),
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Label-set findings: markers that look canonical and are not, and canonical
332
+ * markers naming a repo outside the derived vocabulary.
333
+ *
334
+ * @param {readonly string[]} names
335
+ * @param {readonly string[]} vocabulary
336
+ * @returns {RepoScopeFinding[]}
337
+ */
338
+ function markerFindings(names, vocabulary) {
339
+ return names.flatMap(name => {
340
+ const marker = parseRepoMarker(name);
341
+ if (!marker) return [];
342
+
343
+ if (marker.separator !== CANONICAL_SEPARATOR) {
344
+ // Only a malformed marker naming a KNOWN repo is reportable. `repo-x` for
345
+ // an unknown `x` is just a label whose name happens to start with "repo";
346
+ // calling it drift would report every unrelated label into the noise.
347
+ if (!vocabulary.includes(marker.value)) return [];
348
+ return [
349
+ {
350
+ family: /** @type {const} */ (REPO_SCOPE_FAMILY),
351
+ kind: "malformed-marker",
352
+ label: name,
353
+ repo: marker.value,
354
+ canonical: canonicalRepoLabel(marker.value),
355
+ items: [],
356
+ severity: /** @type {const} */ ("drift"),
357
+ autoRepairable: /** @type {const} */ (false),
358
+ detail:
359
+ `"${name}" is shaped like a repo marker but uses "${marker.separator}" ` +
360
+ `where the canonical form uses "${CANONICAL_SEPARATOR}". It scopes ` +
361
+ `nothing: no scan filters on it, and repo-scope validation does not ` +
362
+ `accept it either. The canonical form is ` +
363
+ `"${canonicalRepoLabel(marker.value)}".`,
364
+ },
365
+ ];
366
+ }
367
+
368
+ if (vocabulary.includes(marker.value)) return [];
369
+ return [
370
+ {
371
+ family: /** @type {const} */ (REPO_SCOPE_FAMILY),
372
+ kind: "undeclared-scope",
373
+ label: name,
374
+ repo: marker.value,
375
+ items: [],
376
+ severity: /** @type {const} */ ("drift"),
377
+ autoRepairable: /** @type {const} */ (false),
378
+ detail:
379
+ `"${name}" scopes work to a repository "${marker.value}" that is not ` +
380
+ `in the derived vocabulary (${vocabulary.join(", ")}). Either it names ` +
381
+ `a repository this tracker serves — in which case the project set is ` +
382
+ `incomplete — or it is a misspelling routing work nowhere. Both are ` +
383
+ `human decisions, so this is never repaired automatically.`,
384
+ },
385
+ ];
386
+ });
387
+ }
388
+
389
+ /**
390
+ * A stable, reportable identifier for a work item.
391
+ *
392
+ * @param {WorkItem} item
393
+ * @param {number} index
394
+ * @returns {string}
395
+ */
396
+ function itemRef(item, index) {
397
+ const ref = item?.ref;
398
+ return typeof ref === "string" && ref.trim().length > 0
399
+ ? ref.trim()
400
+ : `item[${index}]`;
401
+ }
402
+
403
+ /**
404
+ * CLI: read `{ knownRepos, items, labels }` on stdin, print the audit.
405
+ *
406
+ * Exits 0 even when the verdict is DRIFTED. The verdict is what the caller
407
+ * reports; the exit status is not a second, quieter channel for the same
408
+ * answer, and a non-zero exit would make this a gate the first time something
409
+ * ran it under `set -e`.
410
+ *
411
+ * @returns {Promise<void>}
412
+ */
413
+ async function main() {
414
+ const chunks = [];
415
+ for await (const chunk of process.stdin) {
416
+ chunks.push(chunk);
417
+ }
418
+ const payload = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
419
+
420
+ process.stdout.write(
421
+ `${JSON.stringify(
422
+ auditRepoScopeVocabulary({
423
+ knownRepos: payload.knownRepos,
424
+ items: payload.items,
425
+ labels: payload.labels,
426
+ }),
427
+ null,
428
+ 2
429
+ )}\n`
430
+ );
431
+ }
432
+
433
+ /**
434
+ * True when `moduleUrl` names the module node was asked to run.
435
+ *
436
+ * Written out rather than imported: this is a plugin payload, which has no
437
+ * `./lib/` to resolve against. Same rule and same reasoning as
438
+ * `scripts/lib/invoked-as-script.mjs` and `lifecycle-label-trust.mjs`.
439
+ *
440
+ * @param {string} moduleUrl - The caller's own `import.meta.url`.
441
+ * @param {string | undefined} [argv1] - Entry path; defaults to `process.argv[1]`.
442
+ * @returns {boolean} Whether the caller should run its CLI body.
443
+ */
444
+ export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
445
+ if (!argv1) return false;
446
+ try {
447
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
448
+ } catch {
449
+ return false;
450
+ }
451
+ }
452
+
453
+ if (invokedAsScript(import.meta.url)) {
454
+ main().catch(error => {
455
+ process.stderr.write(`${error.message}\n`);
456
+ process.exitCode = 1;
457
+ });
458
+ }
@@ -56,6 +56,25 @@ For env-keyed `done`, resolve the env first, then look up `done[<env>]`:
56
56
  2. Otherwise, infer the env from the PR's base branch via `deploy.branches` (reverse lookup).
57
57
  3. If `done` is a **string** in config, use it directly regardless of env.
58
58
  4. If `done` is a **map** and env cannot be resolved, **fail loudly** — do not pick arbitrarily.
59
+ 5. **Promotion completeness caps the result.** The base branch names the environment the change
60
+ *entered*, never the environments it has *reached*. Walk `deploy.order` from its lowest rung and
61
+ write the highest **contiguously reached** rung at or below the resolved env: a rung is reached
62
+ only when the merge commit is an ancestor of its `deploy.branches` branch
63
+ (`git merge-base --is-ancestor <merge-sha> origin/<branch>`, asserted for **every** env branch at
64
+ or below the resolved one — not only the PR's base) **and** that branch's most recent
65
+ deploy **concluded `success`** (read the `conclusion`, never the `status`; only `success`
66
+ promotes — a null conclusion and every other conclusion, `failure` / `cancelled` / `timed_out` /
67
+ `neutral` / `skipped` / `stale` / `action_required`, leave the rung unreached, and an in-flight
68
+ deploy is unknown, not green). Where `deploy.order` is absent the ladder is the single resolved
69
+ env; where a branch exposes no deploy surface at all, ancestry alone decides that rung. A hotfix
70
+ merged straight to the production branch that skipped `staging` therefore writes the rung below
71
+ the gap and stays open. The recorded reason carries all three fields —
72
+ `<first unreached env> (<its branch>) — <condition>`, the condition being `missing ancestry`,
73
+ `deploy unknown: <run URL, or "no concluded run">`, or
74
+ `deploy concluded <conclusion>: <run URL>`; a failing run named without its environment and
75
+ branch is an incomplete reason. An open back-fill PR against a
76
+ skipped environment branch is outstanding delivery, not branch hygiene. See `config-resolution`
77
+ → "Promotion completeness".
59
78
 
60
79
  ```bash
61
80
  TARGET_ENV="${target_env:-}"
@@ -478,10 +497,10 @@ A `done` env state (`status:on-dev`, `status:on-stg`, or the terminal value) ass
478
497
  If the lifecycle run returned Success:
479
498
 
480
499
  1. **Confirm the PR merged.** Read the live state of the issue's PR — `gh pr view <pr> --json state,mergedAt,mergeStateStatus,url`:
481
- - **Merged** (`state == MERGED`) → proceed to resolve and apply `$DONE` below. Where the env deploy is observable (a deploy workflow run / deployment status keyed to the merged-into branch via `deploy.branches`), confirm it did not fail before relabeling; a still-running deploy is treated like an open PR (leave in `$CLAIMED`), a failed deploy is recorded as an Error.
500
+ - **Merged** (`state == MERGED`) → proceed to resolve and apply `$DONE` below. Where the env deploy is observable (a deploy workflow run / deployment status keyed to the merged-into branch via `deploy.branches`), run the **promotion-completeness gate** (resolution step 5) across **every** env branch at or below the resolved env — not only the merged-into branch. A rung counts only when the merge commit is an ancestor of its branch **and** that branch's most recent deploy concluded `success`; a still-running or otherwise unconcluded deploy is treated like an open PR (leave in `$CLAIMED`), and a rung whose deploy concluded anything but `success` is recorded as an Error. Where a branch exposes no deploy surface at all, ancestry alone decides that rung.
482
501
  - **Open / not yet merged** → do **not** transition. The build is sound but the change has reached no environment yet. Record the issue under **"PR open — awaiting merge"** in the summary (with the PR URL and its `mergeStateStatus`), leave it in `$CLAIMED`, and stop. A later `lisa-repair-intake` cycle drives the open PR to merge — re-syncing a `BEHIND` branch so the already-enabled auto-merge can land, or surfacing a real blocker — and, once merged, applies this same env transition. Do **not** comment "Build complete" or close anything.
483
502
  - **Closed without merging** → record an Error (the PR was abandoned unmerged); leave the issue in `$CLAIMED`.
484
- 2. Resolve `$DONE` for this issue's PR base branch using the Workflow resolution algorithm above. If env can't be resolved and `done` is env-keyed, record an Error and skip this transition — never guess.
503
+ 2. Resolve `$DONE` for this issue's PR base branch using the Workflow resolution algorithm above, **including its promotion-completeness cap** (step 5): the value written is the highest contiguously reached rung at or below the resolved env, never the resolved env by itself. If env can't be resolved and `done` is env-keyed, record an Error and skip this transition — never guess.
485
504
  3. Determine whether `$DONE` is the true terminal done value per the `leaf-only-lifecycle` rule's Terminal native closure section:
486
505
  - If `github.labels.build.done` is a string, that string is terminal.
487
506
  - If `github.labels.build.done` is an object, only the production/final environment value is terminal (default: `status:done`). Intermediate env values such as `status:on-dev` and `status:on-stg` are not terminal and must stay open.
@@ -39,6 +39,25 @@ For env-keyed `done`, resolve the env first, then look up `done[<env>]`:
39
39
  2. Otherwise, infer the env from the PR's base branch via `deploy.branches` (reverse lookup: if base is `staging`, env is `staging`).
40
40
  3. If `done` in config is a **string** (not a map), use it directly regardless of env.
41
41
  4. If `done` is a **map** and env cannot be resolved, **fail loudly** — do not pick arbitrarily.
42
+ 5. **Promotion completeness caps the result.** The base branch names the environment the change
43
+ *entered*, never the environments it has *reached*. Walk `deploy.order` from its lowest rung and
44
+ write the highest **contiguously reached** rung at or below the resolved env: a rung is reached
45
+ only when the merge commit is an ancestor of its `deploy.branches` branch
46
+ (`git merge-base --is-ancestor <merge-sha> origin/<branch>`, asserted for **every** env branch at
47
+ or below the resolved one — not only the PR's base) **and** that branch's most recent
48
+ deploy **concluded `success`** (read the `conclusion`, never the `status`; only `success`
49
+ promotes — a null conclusion and every other conclusion, `failure` / `cancelled` / `timed_out` /
50
+ `neutral` / `skipped` / `stale` / `action_required`, leave the rung unreached, and an in-flight
51
+ deploy is unknown, not green). Where `deploy.order` is absent the ladder is the single resolved
52
+ env; where a branch exposes no deploy surface at all, ancestry alone decides that rung. A hotfix
53
+ merged straight to the production branch that skipped `staging` therefore writes the rung below
54
+ the gap and stays open. The recorded reason carries all three fields —
55
+ `<first unreached env> (<its branch>) — <condition>`, the condition being `missing ancestry`,
56
+ `deploy unknown: <run URL, or "no concluded run">`, or
57
+ `deploy concluded <conclusion>: <run URL>`; a failing run named without its environment and
58
+ branch is an incomplete reason. An open back-fill PR against a
59
+ skipped environment branch is outstanding delivery, not branch hygiene. See `config-resolution`
60
+ → "Promotion completeness".
42
61
 
43
62
  ```bash
44
63
  # Resolve env, then DONE.
@@ -334,10 +353,10 @@ A `done` env status (`On Dev`, `On Stg`, or the terminal value) asserts that the
334
353
 
335
354
  If the lifecycle run returned Success:
336
355
  1. **Confirm the PR merged.** Read the live state of the ticket's PR — `gh pr view <pr> --json state,mergedAt,mergeStateStatus,url`:
337
- - **Merged** (`state == MERGED`) → proceed to resolve and apply `$DONE` below. Where the env deploy is observable (a deploy workflow run / deployment status keyed to the merged-into branch via `deploy.branches`), confirm it did not fail before transitioning; a still-running deploy is treated like an open PR (leave in `$CLAIMED` for a later cycle), a failed deploy is recorded as an Error.
356
+ - **Merged** (`state == MERGED`) → proceed to resolve and apply `$DONE` below. Where the env deploy is observable (a deploy workflow run / deployment status keyed to the merged-into branch via `deploy.branches`), run the **promotion-completeness gate** (resolution step 5) across **every** env branch at or below the resolved env — not only the merged-into branch. A rung counts only when the merge commit is an ancestor of its branch **and** that branch's most recent deploy concluded `success`; a still-running or otherwise unconcluded deploy is treated like an open PR (leave in `$CLAIMED` for a later cycle), and a rung whose deploy concluded anything but `success` is recorded as an Error. Where a branch exposes no deploy surface at all, ancestry alone decides that rung.
338
357
  - **Open / not yet merged** → do **not** transition. The build is sound but the change has reached no environment yet. Record the ticket under **"PR open — awaiting merge"** in the summary (with the PR URL and its `mergeStateStatus`), leave it in `$CLAIMED`, and stop. A later `lisa-repair-intake` cycle drives the open PR to merge — re-syncing a `BEHIND` branch so the already-enabled auto-merge can land, or surfacing a real blocker — and, once it is merged, applies this same env transition. Do **not** comment "Build complete" or file anything: the work is in-flight, not done.
339
358
  - **Closed without merging** → record an Error (the PR was abandoned unmerged); leave the ticket in `$CLAIMED` for human investigation.
340
- 2. Resolve `$DONE` for this ticket's PR base branch using the Workflow resolution algorithm above. If env can't be resolved and `done` is env-keyed, record an Error and skip this transition — never guess.
359
+ 2. Resolve `$DONE` for this ticket's PR base branch using the Workflow resolution algorithm above, **including its promotion-completeness cap** (step 5): the value written is the highest contiguously reached rung at or below the resolved env, never the resolved env by itself. If env can't be resolved and `done` is env-keyed, record an Error and skip this transition — never guess.
341
360
  3. Determine whether `$DONE` is the true terminal done value per the `leaf-only-lifecycle` rule's Terminal native closure section:
342
361
  - If `jira.workflow.done` is a string, that status is terminal.
343
362
  - If `jira.workflow.done` is an object, only the production/final environment value is terminal (default: `Done`). Intermediate env statuses such as `On Dev` and `On Stg` are not terminal and must remain unresolved / open.
@@ -42,6 +42,25 @@ For env-keyed `done`, resolve the env first, then look up `done[<env>]`:
42
42
  2. Otherwise, infer the env from the PR's base branch via `deploy.branches` (reverse lookup).
43
43
  3. If `done` is a **string** in config, use it directly regardless of env.
44
44
  4. If `done` is a **map** and env cannot be resolved, **fail loudly** — do not pick arbitrarily.
45
+ 5. **Promotion completeness caps the result.** The base branch names the environment the change
46
+ *entered*, never the environments it has *reached*. Walk `deploy.order` from its lowest rung and
47
+ write the highest **contiguously reached** rung at or below the resolved env: a rung is reached
48
+ only when the merge commit is an ancestor of its `deploy.branches` branch
49
+ (`git merge-base --is-ancestor <merge-sha> origin/<branch>`, asserted for **every** env branch at
50
+ or below the resolved one — not only the PR's base) **and** that branch's most recent
51
+ deploy **concluded `success`** (read the `conclusion`, never the `status`; only `success`
52
+ promotes — a null conclusion and every other conclusion, `failure` / `cancelled` / `timed_out` /
53
+ `neutral` / `skipped` / `stale` / `action_required`, leave the rung unreached, and an in-flight
54
+ deploy is unknown, not green). Where `deploy.order` is absent the ladder is the single resolved
55
+ env; where a branch exposes no deploy surface at all, ancestry alone decides that rung. A hotfix
56
+ merged straight to the production branch that skipped `staging` therefore writes the rung below
57
+ the gap and stays open. The recorded reason carries all three fields —
58
+ `<first unreached env> (<its branch>) — <condition>`, the condition being `missing ancestry`,
59
+ `deploy unknown: <run URL, or "no concluded run">`, or
60
+ `deploy concluded <conclusion>: <run URL>`; a failing run named without its environment and
61
+ branch is an incomplete reason. An open back-fill PR against a
62
+ skipped environment branch is outstanding delivery, not branch hygiene. See `config-resolution`
63
+ → "Promotion completeness".
45
64
 
46
65
  ```bash
47
66
  TARGET_ENV="${target_env:-}"
@@ -333,10 +352,10 @@ A `done` env state (`On Dev`, `On Stg`, or the terminal value) asserts that the
333
352
 
334
353
  If the lifecycle run returned Success:
335
354
  1. **Confirm the PR merged.** Read the live state of the Issue's PR — `gh pr view <pr> --json state,mergedAt,mergeStateStatus,url`:
336
- - **Merged** (`state == MERGED`) → proceed to resolve and apply `$DONE` below. Where the env deploy is observable (a deploy workflow run / deployment status keyed to the merged-into branch via `deploy.branches`), confirm it did not fail before transitioning; a still-running deploy is treated like an open PR (leave at `$CLAIMED`), a failed deploy is recorded as an Error.
355
+ - **Merged** (`state == MERGED`) → proceed to resolve and apply `$DONE` below. Where the env deploy is observable (a deploy workflow run / deployment status keyed to the merged-into branch via `deploy.branches`), run the **promotion-completeness gate** (resolution step 5) across **every** env branch at or below the resolved env — not only the merged-into branch. A rung counts only when the merge commit is an ancestor of its branch **and** that branch's most recent deploy concluded `success`; a still-running or otherwise unconcluded deploy is treated like an open PR (leave at `$CLAIMED`), and a rung whose deploy concluded anything but `success` is recorded as an Error. Where a branch exposes no deploy surface at all, ancestry alone decides that rung.
337
356
  - **Open / not yet merged** → do **not** transition. The build is sound but the change has reached no environment yet. Record the Issue under **"PR open — awaiting merge"** in the summary (with the PR URL and its `mergeStateStatus`), leave it at `$CLAIMED`, and stop. A later `lisa-repair-intake` cycle drives the open PR to merge — re-syncing a `BEHIND` branch so the already-enabled auto-merge can land, or surfacing a real blocker — and, once merged, applies this same env transition. Do **not** comment "Build complete" or change the native state.
338
357
  - **Closed without merging** → record an Error (the PR was abandoned unmerged); leave the Issue at `$CLAIMED`.
339
- 2. Resolve `$DONE` for this issue's PR base branch using the Workflow resolution algorithm above. If env can't be resolved and `done` is env-keyed, record an Error and skip this transition — never guess.
358
+ 2. Resolve `$DONE` for this issue's PR base branch using the Workflow resolution algorithm above, **including its promotion-completeness cap** (step 5): the value written is the highest contiguously reached rung at or below the resolved env, never the resolved env by itself. If env can't be resolved and `done` is env-keyed, record an Error and skip this transition — never guess.
340
359
  3. Determine whether `$DONE` is the true terminal done value per the `leaf-only-lifecycle` rule's Terminal native closure section:
341
360
  - If `linear.workflow.done` is a string, that state is terminal.
342
361
  - If `linear.workflow.done` is an object, only the production/final environment value is terminal (default: `Done`). Intermediate env states such as `On Dev` and `On Stg` are not terminal — they are typed `started`, so the Issue correctly stays open and on the board.