@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.27.9",
3
+ "version": "4.28.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.27.9",
3
+ "version": "4.28.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.27.9",
3
+ "version": "4.28.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -89,4 +89,27 @@ A non-integration environment bug is fixed, merged, and verified on that
89
89
  environment branch first, then
90
90
  forward cherry-picked down to the integration branch via a linked follow-up.
91
91
 
92
+ ## Env-keyed `done` — promotion completeness
93
+
94
+ A merged PR's base branch names the environment a change **entered**, never the environments it has
95
+ **reached**. Before writing an env-keyed `done`, walk `deploy.order` from its lowest rung and write
96
+ the highest **contiguously reached** rung at or below the resolved env. A rung is reached only when
97
+ the merge commit is an ancestor of its `deploy.branches` branch
98
+ (`git merge-base --is-ancestor <merge-sha> origin/<branch>`, asserted for **every** env branch at or
99
+ below the resolved one, not only the PR's base) **and** that branch's most recent deploy
100
+ **concluded `success`** — read the `conclusion`, never the `status`, because an in-flight deploy has
101
+ a null conclusion and looks identical to a pass. Only a concluded `success` promotes: a null
102
+ conclusion and every other conclusion (`failure`, `cancelled`, `timed_out`, `neutral`, `skipped`,
103
+ `stale`, `action_required`) leave the rung unreached. Where `deploy.order` is absent the ladder is
104
+ the single resolved env; where a branch exposes no deploy surface at all, ancestry alone decides
105
+ that rung.
106
+
107
+ A hotfix straight to `main` that skipped `staging` therefore resolves to the rung below the gap and
108
+ stays open; the terminal value, and provider-native closure with it, is earned only by a
109
+ promotion-complete merge. The recorded reason always carries all three fields —
110
+ `<first unreached env> (<its branch>) — <condition>`, where the condition is `missing ancestry`,
111
+ `deploy unknown: <run URL or "no concluded run">`, or `deploy concluded <conclusion>: <run URL>`. A
112
+ failing run named without its environment and branch is an incomplete reason. An open back-fill PR
113
+ against a skipped environment branch is outstanding delivery, not branch hygiene.
114
+
92
115
  Full reference: [reference/config-resolution.md](../reference/config-resolution.md).
@@ -600,9 +600,73 @@ Skills that transition to `done` MUST resolve the env first:
600
600
  1. **Explicit caller arg** (`target_env=staging`) — always wins.
601
601
  2. **Branch inference** — derive from the PR's base branch via `deploy.branches`. Reverse-lookup: if base branch is `staging`, env is `staging`.
602
602
  3. **Failure** — if neither resolves and `done` is a map, fail loudly. Never pick arbitrarily.
603
+ 4. **Promotion completeness** — cap the resolved env by the gate below before writing anything. Steps 1–2 resolve a *candidate*; the gate decides what is written.
603
604
 
604
605
  If a project's terminal state is the same regardless of env, set `done` to a string instead of a map (lifecycle skills accept either shape).
605
606
 
607
+ #### Promotion completeness: every environment at or below the resolved one
608
+
609
+ A merged PR's base branch says which environment the change **entered**. It never says which
610
+ environments the change has **reached**. A hotfix merged straight to `main` enters production by an
611
+ out-of-order route: the reverse-lookup resolves `production`, `production` is the terminal value,
612
+ and the item closes while `staging` has none of the fix. Reaching the terminal environment is not
613
+ the same as being delivered, and a base branch alone cannot tell those apart.
614
+
615
+ So the resolved env is not the env that gets written. Walk `deploy.order` from its **lowest** rung
616
+ and keep rungs while they are **reached**, stopping at the first that is not. A rung is reached
617
+ when both hold for its `deploy.branches` branch:
618
+
619
+ - **Ancestry** — the merge commit is an ancestor of the remote env branch:
620
+ `git fetch origin <branch> && git merge-base --is-ancestor <merge-sha> origin/<branch>`. Assert
621
+ this for **every** env branch at or below the resolved one — never only the PR's base.
622
+ - **Deploy health** — that branch's most recent deploy **concluded `success`**. Read the
623
+ `conclusion`, never the `status`: an in-flight deploy has a null conclusion and is
624
+ indistinguishable from a pass on the status field alone. Only `success` promotes — a null
625
+ conclusion and every other conclusion (`failure`, `cancelled`, `timed_out`, `neutral`, `skipped`,
626
+ `stale`, `action_required`) leave the rung **unreached**. "Did not fail" is not the test: a
627
+ cancelled or skipped run is not evidence the change deployed.
628
+
629
+ The written env is the highest **contiguously reached** rung at or below the resolved one — never a
630
+ higher rung, even when the higher rung is reached and a lower one is not. A merge present in `dev`
631
+ and `main` but absent from `staging` resolves to `dev` (`On Dev`), not `production`: the item stays
632
+ open at an intermediate waypoint, and the promotion gap is what holds it open. When no rung at all
633
+ is reached, write nothing and leave the item in its current role.
634
+
635
+ Refusals are named, never silent, and one schema covers every case. Whenever the gate caps the env
636
+ below the resolved one, the recorded reason MUST carry **all three** fields — none is optional in
637
+ any case:
638
+
639
+ ```
640
+ <first unreached env> (<its branch>) — <condition>
641
+ ```
642
+
643
+ `<condition>` is exactly one of:
644
+
645
+ - `missing ancestry` — the merge commit is not an ancestor of that branch.
646
+ - `deploy unknown: <run URL, or "no concluded run">` — the branch has a deploy surface, but its most
647
+ recent run has not concluded, or nothing has ever concluded for it.
648
+ - `deploy concluded <conclusion>: <run URL>` — the most recent run concluded as something other than
649
+ `success` (`failure`, `cancelled`, `timed_out`, `neutral`, `skipped`, `stale`, `action_required`).
650
+
651
+ "Merged to `main` and production deployed green" and "not in `staging`" are both facts; a reason that
652
+ carries only the first is the defect. A failing run named without its environment and branch, or an
653
+ environment named without the condition that failed it, is an incomplete reason and not acceptable.
654
+
655
+ Two classifications this gate forbids:
656
+
657
+ - An **open back-fill PR** against a skipped environment branch is *outstanding delivery*, not
658
+ branch hygiene. It is the evidence that a rung was skipped, so it is a reason to hold the item
659
+ open — never a reason to discount the gap and close anyway.
660
+ - An **in-flight deploy** is unknown, not green. When a rung's ancestry holds but its deploy run has
661
+ not concluded, do not write that rung this cycle: leave the item where it is and let a later cycle
662
+ read a concluded run. Only a concluded success promotes. A project that exposes **no** deploy
663
+ surface for a branch is a different case — there, ancestry alone decides the rung; the absence of
664
+ a deploy system is not an unconcluded deploy.
665
+
666
+ Where `deploy.order` is absent the ladder is the single resolved env, and the gate is the ancestry
667
+ and deploy-health check on that one rung. Multi-branch projects MUST set `deploy.order` (see "Env
668
+ order" below), so an absent order can never silently skip the ladder.
669
+
606
670
  ### Env → base branch (forward: the build base and PR base)
607
671
 
608
672
  `deploy.branches` is also read in the **forward** direction by the build flow (`lisa-implement`): the environment a work item targets determines the branch the work is built on and the branch the PR opens against.
@@ -634,6 +698,7 @@ The true terminal `done` value is also the only value that triggers provider-nat
634
698
  - If `done` is a string, that value is terminal.
635
699
  - If `done` is an env-keyed map, the production / final environment's value is terminal. The conventional key is `production`; project-specific final env names must be explicit in deploy config or the lifecycle skill must fail rather than guessing.
636
700
  - Intermediate env values (`dev`, `staging`, or configured equivalents) are deployment waypoints. Applying them must not close / resolve / complete the native tracker item.
701
+ - The terminal value is earned only by a **promotion-complete** merge: every env branch at or below the terminal one carries the merge commit, and none of their most recent concluded deploys failed. A merge that reached the terminal environment out of order has not earned the terminal value — see "Promotion completeness" above.
637
702
 
638
703
  ### Env order (sync-down chain)
639
704
 
@@ -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.