@codyswann/lisa 4.27.8 → 4.28.0
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/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +36 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +5 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-validate-tracker-mapping/SKILL.md +134 -3
- package/plugins/lisa/scripts/repo-scope-vocabulary-audit.mjs +458 -0
- package/plugins/lisa/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/repo-scope-vocabulary-audit.mjs +458 -0
- package/plugins/lisa-agy/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
- 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-copilot/scripts/repo-scope-vocabulary-audit.mjs +458 -0
- package/plugins/lisa-copilot/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/scripts/repo-scope-vocabulary-audit.mjs +458 -0
- package/plugins/lisa-cursor/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
- 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/plugins/src/base/scripts/repo-scope-vocabulary-audit.mjs +458 -0
- package/plugins/src/base/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
- package/scripts/lib/mutation-performance-reporter.mjs +135 -0
- package/scripts/mutation-performance-measure.mjs +1794 -0
- package/scripts/mutation-performance.schema.json +103 -0
- 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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lisa-validate-tracker-mapping
|
|
3
|
-
description: "Detect and repair drift between a project's configured Lisa status/label mappings and the live tracker/source workflow. Compares every lifecycle role in `.lisa.config.json` (JIRA `jira.workflow` statuses, GitHub/Linear `labels.{build,prd}`, Notion `notion.values` select options, Confluence `confluence.parents`) against the authoritative live names the access layer reports — catching renames, deletions, and case drift (e.g. config `On Stg` vs live `ON STG`). Read-only by default; `repair=true` rewrites the config to the canonical live names (config is fixed, never the tracker). Also
|
|
3
|
+
description: "Detect and repair drift between a project's configured Lisa status/label mappings and the live tracker/source workflow. Compares every lifecycle role in `.lisa.config.json` (JIRA `jira.workflow` statuses, GitHub/Linear `labels.{build,prd}`, Notion `notion.values` select options, Confluence `confluence.parents`) against the authoritative live names the access layer reports — catching renames, deletions, and case drift (e.g. config `On Stg` vs live `ON STG`). Read-only by default; `repair=true` rewrites the config to the canonical live names (config is fixed, never the tracker). Also audits the vocabularies no config key declares: `repo:` scope, where an item scoped only by an alias is valid yet invisible to every `repo:<name>` scan (DRIFTED), and `type:`/`priority:`/`component:`/`points:` smells (advisory). Neither is auto-repaired. Audits the current repo by default, or sweeps a set of projects via `projects=<glob>` / `workspaces=<file>`. Safe to schedule for continuous drift detection."
|
|
4
4
|
allowed-tools: ["Skill", "Bash", "Read", "Write", "Edit", "AskUserQuestion"]
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -125,8 +125,8 @@ One role carries a further check that name-existence cannot express:
|
|
|
125
125
|
A project's verdict:
|
|
126
126
|
|
|
127
127
|
- **VALID** — every role is VALID.
|
|
128
|
-
- **DRIFTED** — at least one CASE_DRIFT, MISSING, or INVERTED role, none of which is UNRESOLVABLE. An INVERTED `ready` is never VALID, no matter how cleanly it resolves.
|
|
129
|
-
- **UNRESOLVABLE** — the live set couldn't be enumerated (auth mismatch, missing tracker config, access failure). Distinguish this loudly from VALID — an unresolved audit is not a passing audit.
|
|
128
|
+
- **DRIFTED** — at least one CASE_DRIFT, MISSING, or INVERTED role, none of which is UNRESOLVABLE, **or** any Step 4b repo-scope finding. An INVERTED `ready` is never VALID, no matter how cleanly it resolves.
|
|
129
|
+
- **UNRESOLVABLE** — the live set couldn't be enumerated (auth mismatch, missing tracker config, access failure), **or** Step 4b could not derive a repo vocabulary. Distinguish this loudly from VALID — an unresolved audit is not a passing audit.
|
|
130
130
|
|
|
131
131
|
## Step 4b — Scoping-label smells (GitHub, advisory)
|
|
132
132
|
|
|
@@ -211,6 +211,99 @@ Three finding kinds, all `severity: "advisory"`:
|
|
|
211
211
|
Do not re-derive any of these thresholds in prose or in a second script. The
|
|
212
212
|
module is the single definition; this step calls it.
|
|
213
213
|
|
|
214
|
+
## Step 4c — Repo-scope vocabulary (all trackers, counts toward the verdict)
|
|
215
|
+
|
|
216
|
+
Steps 2–4 audit **lifecycle** roles, and they can only ever audit lifecycle
|
|
217
|
+
roles: every pair they compare comes from a `(role, configured-name)` mapping
|
|
218
|
+
declared in `.lisa.config.json`. The **scoping** vocabulary is declared in no
|
|
219
|
+
config key at all, so there is nothing to compare a live name against.
|
|
220
|
+
|
|
221
|
+
`repo:` is the scoping family where that blind spot costs the most, because a
|
|
222
|
+
wrong answer there is not a matter of taste: it decides which work items a
|
|
223
|
+
repository's build queue can see. When the repo vocabulary splits, a scan
|
|
224
|
+
filtering on the canonical spelling returns **fewer items, not an error**, and
|
|
225
|
+
an empty result is indistinguishable from "nothing to do". Work sits unrouted
|
|
226
|
+
and nobody is told. This step is what makes that impossible.
|
|
227
|
+
|
|
228
|
+
> The **open** scoping families — `type:`, `priority:`, `points:`,
|
|
229
|
+
> `component:` — are a separate problem with a separate answer, tracked
|
|
230
|
+
> upstream on #3420. An open vocabulary has no wrong member, only inconsistent
|
|
231
|
+
> ones, so findings about it must stay advisory and must never become a gate.
|
|
232
|
+
> That constraint exists to stop a *guess* becoming a gate; it is not weakened
|
|
233
|
+
> here, because nothing in this step is a guess. Do not extend this step to
|
|
234
|
+
> those families.
|
|
235
|
+
|
|
236
|
+
`assertRepoScope` (`lisa-work-item.mjs`, #1957) accepts **three** spellings as
|
|
237
|
+
valid repo scope, while every build-intake scanner filters on one:
|
|
238
|
+
|
|
239
|
+
| spelling | accepted by validation | found by a `repo:<name>` scan |
|
|
240
|
+
| --- | --- | --- |
|
|
241
|
+
| `repo:<name>` (canonical) | yes | **yes** |
|
|
242
|
+
| bare `<name>` label | yes | **no** |
|
|
243
|
+
| Jira component `<name>` | yes | **no** |
|
|
244
|
+
|
|
245
|
+
The bare branch is deliberate and load-bearing — Sentry-provenance items arrive
|
|
246
|
+
carrying only the bare repo name — so it is **not** to be tightened away. What
|
|
247
|
+
this step reports is the *disagreement* between the two columns: an item that
|
|
248
|
+
passes validation while being invisible to every scan looking for it. That is a
|
|
249
|
+
checkable fact about a specific item, not a heuristic about a name, which is
|
|
250
|
+
why these findings count toward the verdict.
|
|
251
|
+
|
|
252
|
+
### Derive the vocabulary — never declare it in config
|
|
253
|
+
|
|
254
|
+
Do **not** add a config key for this, and do not accept one if you find it. A
|
|
255
|
+
hand-written repo vocabulary is per-project curation of a list that is already
|
|
256
|
+
derivable, and it becomes its own drift surface — the same objection that
|
|
257
|
+
rejected declaring the open vocabularies. A key nothing reads would look like
|
|
258
|
+
an assertion while asserting nothing. Derive it instead:
|
|
259
|
+
|
|
260
|
+
- **Single-project mode**: the current-repo identity ladder from
|
|
261
|
+
`config-resolution` — `.lisa.config.local.json` `repo` → `.lisa.config.json`
|
|
262
|
+
`repo` → `.lisa.config.json` `github.repo` → `basename -s .git` of the git
|
|
263
|
+
remote.
|
|
264
|
+
- **Batch mode** (`projects=<glob>` / `workspaces=<file>`): the union of that
|
|
265
|
+
ladder across every resolved project, which is exactly the set of repos this
|
|
266
|
+
one tracker serves.
|
|
267
|
+
|
|
268
|
+
If the vocabulary resolves **empty**, the verdict is `UNRESOLVABLE`, never
|
|
269
|
+
`VALID`. A check that reports "nothing wrong" having looked at nothing is the
|
|
270
|
+
same failure this step exists to catch.
|
|
271
|
+
|
|
272
|
+
### Collect and classify
|
|
273
|
+
|
|
274
|
+
Feed the derived vocabulary, the live work items with their labels (and Jira
|
|
275
|
+
components), and the tracker's declared label set — the declared set is what
|
|
276
|
+
catches a malformed marker carrying zero items:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/repo-scope-vocabulary-audit.mjs" \
|
|
280
|
+
< /tmp/lisa-repo-scope-input.json
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Input is `{ knownRepos: [...], items: [{ ref, labels, components }], labels: [...] }`.
|
|
284
|
+
It returns `{ verdict, findings, knownAliases, vocabulary }` and always exits
|
|
285
|
+
`0` — the verdict is the answer, and the exit status is not a second, quieter
|
|
286
|
+
channel for it. Three finding kinds, all `severity: "drift"`:
|
|
287
|
+
|
|
288
|
+
- **`unstamped-alias`** — work items carry an accepted alias (bare label or
|
|
289
|
+
Jira component) for a known repo **without** the canonical `repo:<name>`
|
|
290
|
+
beside it. They pass validation and no scan finds them. The finding names the
|
|
291
|
+
alias and every item carrying it.
|
|
292
|
+
- **`malformed-marker`** — a label shaped like a repo marker with the wrong
|
|
293
|
+
separator (`repo-frontend`, `repo_backend`). It scopes nothing at all: no
|
|
294
|
+
scan filters on it and validation does not accept it either.
|
|
295
|
+
- **`undeclared-scope`** — a `repo:<value>` label naming a repo outside the
|
|
296
|
+
derived vocabulary. Either the project set is incomplete or the label is a
|
|
297
|
+
misspelling routing work nowhere.
|
|
298
|
+
|
|
299
|
+
An alias on an item that **also** carries the canonical marker is reported in
|
|
300
|
+
`knownAliases`, **not** as drift — every scan still finds that item, so nothing
|
|
301
|
+
is wrong. That distinction is the whole check: an alias cannot be judged from
|
|
302
|
+
the label set alone, only per item.
|
|
303
|
+
|
|
304
|
+
Fold `DRIFTED` from this step into the project verdict exactly as a
|
|
305
|
+
`CASE_DRIFT` or `MISSING` role does.
|
|
306
|
+
|
|
214
307
|
## Step 5 — Report
|
|
215
308
|
|
|
216
309
|
Per project, print a terminal-first section:
|
|
@@ -236,6 +329,24 @@ An INVERTED `ready` gets a full line rather than a one-word status, because the
|
|
|
236
329
|
via /lisa:setup:linear, then set linear.workflow.ready to it.
|
|
237
330
|
```
|
|
238
331
|
|
|
332
|
+
Print the Step 4c findings **with this mapping table**, not as a separate
|
|
333
|
+
advisory block — they are drift, and a reader must not mistake them for
|
|
334
|
+
smells. Name the items, because "which tickets are invisible" is the operator's
|
|
335
|
+
actual question:
|
|
336
|
+
|
|
337
|
+
```
|
|
338
|
+
repo scope derived vocabulary: backend, frontend, infrastructure
|
|
339
|
+
backend 2 items scoped by the bare label only UNSTAMPED_ALIAS
|
|
340
|
+
SE-9, SE-10 pass repo-scope validation but carry no "repo:backend",
|
|
341
|
+
so every build scan for backend skips them and reports no error.
|
|
342
|
+
Stamping "repo:backend" on those items is a tracker change a human makes.
|
|
343
|
+
repo-frontend 0 items MALFORMED_MARKER
|
|
344
|
+
repo:mobile outside the derived vocabulary UNDECLARED_SCOPE
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
A project whose repo vocabulary is in good order prints
|
|
348
|
+
`repo scope: VALID` and nothing else.
|
|
349
|
+
|
|
239
350
|
Print the Step 4b findings, when there are any, as a clearly separate advisory
|
|
240
351
|
block **below** the mapping table so nobody reads a smell as a mapping failure:
|
|
241
352
|
|
|
@@ -250,7 +361,7 @@ Write those lines for a non-technical operator: say what was seen and what a
|
|
|
250
361
|
person might do about it, never "FAIL". A healthy repository prints
|
|
251
362
|
`scoping labels: none` and nothing else.
|
|
252
363
|
|
|
253
|
-
End with a roll-up: counts of VALID / DRIFTED / UNRESOLVABLE projects and the exact next command (`… repair=true` when drift is auto-repairable; an admin note when a status is genuinely MISSING; `/lisa:setup:linear` when a `ready` is INVERTED). Scoping findings are reported alongside that roll-up as a **separate count**, never folded into DRIFTED.
|
|
364
|
+
End with a roll-up: counts of VALID / DRIFTED / UNRESOLVABLE projects and the exact next command (`… repair=true` when drift is auto-repairable; an admin note when a status is genuinely MISSING; `/lisa:setup:linear` when a `ready` is INVERTED; an admin note naming the items when a repo scope is UNSTAMPED_ALIAS). Scoping-label findings are reported alongside that roll-up as a **separate count**, never folded into DRIFTED.
|
|
254
365
|
|
|
255
366
|
## Step 6 — Repair (only when `repair=true`)
|
|
256
367
|
|
|
@@ -305,6 +416,22 @@ real issue metadata on a coin flip. Report the finding, name the two spellings,
|
|
|
305
416
|
and leave the decision to a human — and note that reconciling them means
|
|
306
417
|
relabelling the issues, which is a tracker mutation this skill never performs.
|
|
307
418
|
|
|
419
|
+
### Repo-scope findings — never repaired, for a different reason
|
|
420
|
+
|
|
421
|
+
Step 4c findings are never auto-repaired either, but not because the right
|
|
422
|
+
answer is unknowable — often it is obvious. It is because **every** available
|
|
423
|
+
fix is a **tracker** mutation, and `repair=true` writes the config only:
|
|
424
|
+
|
|
425
|
+
- an `unstamped-alias` is fixed by adding `repo:<name>` to the work items,
|
|
426
|
+
- a `malformed-marker` by renaming or deleting a label,
|
|
427
|
+
- an `undeclared-scope` by deciding whether the name is a real repository (so
|
|
428
|
+
the project set is incomplete) or a misspelling (so the label is wrong).
|
|
429
|
+
|
|
430
|
+
There is no config edit that resolves any of them, and there is deliberately no
|
|
431
|
+
config key to edit — see Step 4c on why the vocabulary is derived. Report the
|
|
432
|
+
finding, name the items, and leave the tracker to a human.
|
|
433
|
+
|
|
434
|
+
|
|
308
435
|
### Invalidate the verification cache
|
|
309
436
|
|
|
310
437
|
After any JIRA repair, clear the `setup-jira` reachability cache so it re-verifies the new mapping:
|
|
@@ -334,3 +461,7 @@ Re-print the project section showing the post-repair mapping and the new verdict
|
|
|
334
461
|
- Reuse the config-resolution defaults and the vendor access skills (`atlassian-access`, `gh`, Linear/Notion MCP); do not invent a parallel mapping or lifecycle vocabulary.
|
|
335
462
|
- Scoping-label findings (Step 4b) are **advisory**. They never change a project's verdict, never change this skill's exit status, and are never auto-repaired. Do not add a flag that turns them into a gate.
|
|
336
463
|
- Never let Step 4b fail the run. A `gh` error, a non-GitHub tracker, or an empty repository means the scoping section reports "not collected" — the lifecycle verdict is unaffected either way.
|
|
464
|
+
- Step 4c repo-scope findings are **drift**, not smells: they count toward the verdict and are reported with the mapping table. They are still never auto-repaired, because every fix is a tracker mutation.
|
|
465
|
+
- Derive the repo vocabulary from the current-repo identity ladder (and, in batch mode, the resolved project set). Never introduce a config key declaring it, and never trust one — a hand-written vocabulary is a second thing to drift, and a key nothing reads would assert nothing while looking like an assertion.
|
|
466
|
+
- An empty derived repo vocabulary is `UNRESOLVABLE`, never `VALID`. The whole point of Step 4c is that a silent zero is the failure, so a zero-length vocabulary must never read as a pass.
|
|
467
|
+
- Do not extend Step 4c to the open scoping families (`type:`, `priority:`, `points:`, `component:`). Findings about those are heuristic and must stay advisory — see #3420.
|