@mmerterden/multi-agent-pipeline 20.8.3 → 20.9.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/CHANGELOG.md +37 -0
- package/docs/facts.json +1 -1
- package/install/claude.mjs +1 -1
- package/manifest.json +37 -28
- package/package.json +1 -1
- package/pipeline/lib/claude-md-links.mjs +328 -0
- package/pipeline/lib/owned-path-gate.mjs +699 -0
- package/pipeline/lib/repo-profile-derive.mjs +1771 -0
- package/pipeline/lib/repo-profile.mjs +780 -0
- package/pipeline/lib/stack-detect.sh +59 -19
- package/pipeline/lib/unattended.mjs +17 -0
- package/pipeline/multi-agent-refs/features/repo-profile.md +96 -0
- package/pipeline/multi-agent-refs/features/review-decision.md +18 -13
- package/pipeline/multi-agent-refs/features/stack-skill-routing.md +179 -33
- package/pipeline/multi-agent-refs/outside-the-pipeline.md +33 -11
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +26 -12
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +24 -13
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +16 -4
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +8 -0
- package/pipeline/rules/outside-the-pipeline.md +6 -1
- package/pipeline/schemas/agent-state.schema.json +66 -2
- package/pipeline/schemas/phases.json +4 -4
- package/pipeline/schemas/repo-profile.schema.json +1107 -0
- package/pipeline/schemas/token-budget.json +4 -4
- package/pipeline/scripts/agent-guard.py +30 -0
- package/pipeline/scripts/owned-path-gate.mjs +205 -0
- package/pipeline/scripts/pre-commit-check.sh +151 -1
- package/pipeline/scripts/repo-profile.mjs +244 -0
- package/pipeline/scripts/review-decision-gate.mjs +42 -18
- package/pipeline/scripts/skill-candidates.mjs +882 -0
- package/pipeline/scripts/unattended_policy.py +90 -0
- package/pipeline/scripts/usage-report.mjs +36 -6
- package/pipeline/skills/.skill-manifest.json +1 -1
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* owned-path-gate.mjs - a change to a path an automated account owns is a
|
|
3
|
+
* violation, decided from the repo profile rather than from judgement.
|
|
4
|
+
*
|
|
5
|
+
* The repo profile (repo-profile.mjs) lists `ownedPaths[]`: trees a sync job or
|
|
6
|
+
* generator writes, where a hand edit is reverted on the next sync or rejected
|
|
7
|
+
* by CI. This module takes a set of changed paths, keeps the owned entries the
|
|
8
|
+
* profile's confidence policy allows for the run's mode, and reports every
|
|
9
|
+
* changed path an entry covers, unless one of these applies:
|
|
10
|
+
*
|
|
11
|
+
* except the path matches one of the entry's `except[]` globs;
|
|
12
|
+
* owner every author of the change matches the entry's `owner`;
|
|
13
|
+
* bypass-author every author of the change matches `bypass.author`;
|
|
14
|
+
* bypass-label the PR carries `bypass.label` (given with --pr-labels, or
|
|
15
|
+
* read read-only through `gh` when it is installed).
|
|
16
|
+
*
|
|
17
|
+
* Glob semantics, shared by `glob` and `except`: `**` as a whole segment
|
|
18
|
+
* matches any number of segments (zero included; a trailing one needs at least
|
|
19
|
+
* one), `*`, `?` and a `**` inside a segment stay inside that segment, `[...]`
|
|
20
|
+
* is a character class (`[!...]` negated, `]` first is a member) that never
|
|
21
|
+
* matches `/`. A glob with no wildcard, or one ending in `/`, names a file or a
|
|
22
|
+
* whole directory, so a stored directory covers its subtree. Matching walks
|
|
23
|
+
* segments and characters with two pointers, so its time is linear in the
|
|
24
|
+
* glob times the path, whatever the number of wildcards.
|
|
25
|
+
*
|
|
26
|
+
* Owner and bypass-author values are regular expressions when anchored (`^` or
|
|
27
|
+
* `$`) and exact literal names otherwise.
|
|
28
|
+
*
|
|
29
|
+
* When several entries cover a path, the first one whose `except` does not
|
|
30
|
+
* exempt it decides; an `except` only releases the path from its own entry.
|
|
31
|
+
*
|
|
32
|
+
* @module pipeline/lib/owned-path-gate
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { execFileSync } from "node:child_process";
|
|
36
|
+
import { ensureProfile, locateProfile, policyMode, resolveField } from "./repo-profile.mjs";
|
|
37
|
+
import { runPosture } from "./unattended.mjs";
|
|
38
|
+
|
|
39
|
+
export const FINDING_TAG = "owned_path";
|
|
40
|
+
|
|
41
|
+
const WILDCARD = /[*?[]/;
|
|
42
|
+
|
|
43
|
+
function normalize(p) {
|
|
44
|
+
return String(p)
|
|
45
|
+
.replace(/\\/g, "/")
|
|
46
|
+
.replace(/\/{2,}/g, "/")
|
|
47
|
+
.replace(/^(?:\.\/)+/, "")
|
|
48
|
+
.replace(/^\/+/, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A glob without its trailing slashes, and whether it had any (a directory). */
|
|
52
|
+
function trimGlob(glob) {
|
|
53
|
+
const g = normalize(glob);
|
|
54
|
+
const trimmed = g.replace(/\/+$/, "");
|
|
55
|
+
return { g: trimmed, dir: trimmed !== g };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One segment's tokens: a literal character, `?`, `*`, or a class. A class
|
|
60
|
+
* opens at `[`, takes a leading `!` as negation and a `]` right after that as
|
|
61
|
+
* a member, and closes at the next `]`; an unclosed `[` is a literal.
|
|
62
|
+
*/
|
|
63
|
+
function compileSegment(seg) {
|
|
64
|
+
const tokens = [];
|
|
65
|
+
for (let i = 0; i < seg.length; i += 1) {
|
|
66
|
+
const ch = seg[i];
|
|
67
|
+
if (ch === "*") {
|
|
68
|
+
while (seg[i + 1] === "*") i += 1;
|
|
69
|
+
tokens.push({ star: true });
|
|
70
|
+
} else if (ch === "?") {
|
|
71
|
+
tokens.push({ any: true });
|
|
72
|
+
} else if (ch === "[") {
|
|
73
|
+
let j = i + 1;
|
|
74
|
+
const negate = seg[j] === "!";
|
|
75
|
+
if (negate) j += 1;
|
|
76
|
+
const from = j;
|
|
77
|
+
if (seg[j] === "]") j += 1;
|
|
78
|
+
while (j < seg.length && seg[j] !== "]") j += 1;
|
|
79
|
+
if (j >= seg.length) {
|
|
80
|
+
tokens.push({ lit: ch });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const body = seg.slice(from, j);
|
|
84
|
+
const ranges = [];
|
|
85
|
+
for (let k = 0; k < body.length; k += 1) {
|
|
86
|
+
if (body[k + 1] === "-" && k + 2 < body.length) {
|
|
87
|
+
ranges.push([body[k], body[k + 2]]);
|
|
88
|
+
k += 2;
|
|
89
|
+
} else {
|
|
90
|
+
ranges.push([body[k], body[k]]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
tokens.push({ ranges, negate });
|
|
94
|
+
i = j;
|
|
95
|
+
} else {
|
|
96
|
+
tokens.push({ lit: ch });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return tokens;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tokenMatches(t, ch) {
|
|
103
|
+
if (t.any) return true;
|
|
104
|
+
if (t.lit !== undefined) return t.lit === ch;
|
|
105
|
+
const inside = t.ranges.some(([a, b]) => ch >= a && ch <= b);
|
|
106
|
+
return t.negate ? !inside : inside;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Two-pointer wildcard match: on a mismatch, return to the last `*` and let
|
|
111
|
+
* it take one more item. `step(atom, item)` compares one non-star atom with
|
|
112
|
+
* one item. Time is bounded by atoms times items; nothing backtracks further.
|
|
113
|
+
*/
|
|
114
|
+
function wildMatch(atoms, items, isStar, step) {
|
|
115
|
+
let a = 0;
|
|
116
|
+
let i = 0;
|
|
117
|
+
let starA = -1;
|
|
118
|
+
let starI = 0;
|
|
119
|
+
while (i < items.length) {
|
|
120
|
+
if (a < atoms.length && !isStar(atoms[a]) && step(atoms[a], items[i])) {
|
|
121
|
+
a += 1;
|
|
122
|
+
i += 1;
|
|
123
|
+
} else if (a < atoms.length && isStar(atoms[a])) {
|
|
124
|
+
starA = a;
|
|
125
|
+
starI = i;
|
|
126
|
+
a += 1;
|
|
127
|
+
} else if (starA >= 0) {
|
|
128
|
+
a = starA + 1;
|
|
129
|
+
starI += 1;
|
|
130
|
+
i = starI;
|
|
131
|
+
} else {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
while (a < atoms.length && isStar(atoms[a])) a += 1;
|
|
136
|
+
return a === atoms.length;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const GLOBSTAR = { globstar: true };
|
|
140
|
+
const ANY_SEGMENT = { tokens: [{ star: true }], nonEmpty: true };
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compile a glob to segment atoms. `**` alone in a segment is any number of
|
|
144
|
+
* segments; a trailing `**` needs at least one. Inside a segment `**` is `*`.
|
|
145
|
+
* A glob with no wildcard, or one ending in `/`, also covers its subtree.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} glob
|
|
148
|
+
* @returns {{atoms: object[]}}
|
|
149
|
+
*/
|
|
150
|
+
export function compileGlob(glob) {
|
|
151
|
+
const { g, dir } = trimGlob(glob);
|
|
152
|
+
const parts = g === "" ? [] : g.split("/");
|
|
153
|
+
const atoms = [];
|
|
154
|
+
parts.forEach((part, idx) => {
|
|
155
|
+
if (part === "**" || /^\*{2,}$/.test(part)) {
|
|
156
|
+
if (idx === parts.length - 1) atoms.push(ANY_SEGMENT, GLOBSTAR);
|
|
157
|
+
else if (atoms.at(-1) !== GLOBSTAR) atoms.push(GLOBSTAR);
|
|
158
|
+
} else {
|
|
159
|
+
atoms.push({ tokens: compileSegment(part) });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
if ((dir || !WILDCARD.test(g)) && atoms.at(-1) !== GLOBSTAR) atoms.push(GLOBSTAR);
|
|
163
|
+
return { atoms };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function segmentMatches(atom, seg) {
|
|
167
|
+
if (atom.nonEmpty && seg === "") return false;
|
|
168
|
+
return wildMatch(
|
|
169
|
+
atom.tokens,
|
|
170
|
+
seg,
|
|
171
|
+
(t) => t.star === true,
|
|
172
|
+
(t, ch) => tokenMatches(t, ch),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const compiled = new Map();
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {string} glob
|
|
180
|
+
* @param {string} path repo-relative
|
|
181
|
+
* @returns {boolean}
|
|
182
|
+
*/
|
|
183
|
+
export function matchGlob(glob, path) {
|
|
184
|
+
let c = compiled.get(glob);
|
|
185
|
+
if (!c) {
|
|
186
|
+
c = compileGlob(glob);
|
|
187
|
+
compiled.set(glob, c);
|
|
188
|
+
}
|
|
189
|
+
const p = normalize(path);
|
|
190
|
+
const segs = p === "" ? [] : p.split("/");
|
|
191
|
+
return wildMatch(c.atoms, segs, (a) => a === GLOBSTAR, segmentMatches);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The literal part of a glob a matching path must start with: everything up to
|
|
196
|
+
* the last `/` before the first wildcard, or the whole glob (trailing slashes
|
|
197
|
+
* trimmed) when it has none. The pre-commit hook uses the same rule to skip
|
|
198
|
+
* the gate cheaply; an empty prefix matches every path.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} glob
|
|
201
|
+
* @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
export function ownedPrefix(glob) {
|
|
204
|
+
const { g } = trimGlob(glob);
|
|
205
|
+
const at = g.search(WILDCARD);
|
|
206
|
+
if (at < 0) return g;
|
|
207
|
+
const head = g.slice(0, at);
|
|
208
|
+
const slash = head.lastIndexOf("/");
|
|
209
|
+
return slash < 0 ? "" : head.slice(0, slash + 1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const isAnchored = (pattern) => /^\^|\$$/.test(String(pattern));
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* An owner or bypass author: a regular expression when it is anchored (`^` or
|
|
216
|
+
* `$`), otherwise one literal name compared exactly, so `x[bot]` is itself and
|
|
217
|
+
* `bot` is not `robot`.
|
|
218
|
+
*/
|
|
219
|
+
function compileAuthor(pattern) {
|
|
220
|
+
if (!isAnchored(pattern)) return { test: (s) => s === pattern };
|
|
221
|
+
try {
|
|
222
|
+
return new RegExp(pattern);
|
|
223
|
+
} catch {
|
|
224
|
+
return { test: (s) => s === pattern };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Whether every author of a change matches `pattern`; an empty author list never does. */
|
|
229
|
+
export function allAuthorsMatch(pattern, authors) {
|
|
230
|
+
if (!pattern || !authors || authors.length === 0) return false;
|
|
231
|
+
const re = compileAuthor(pattern);
|
|
232
|
+
return authors.every((a) => [a.name, a.email, `${a.name} <${a.email}>`].some((s) => re.test(s)));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A regex owner such as `^(?:a\[bot\]|b)$` as a reader would write it: `a[bot], b`. */
|
|
236
|
+
export function ownerLabel(owner) {
|
|
237
|
+
if (!isAnchored(owner)) return String(owner);
|
|
238
|
+
return String(owner)
|
|
239
|
+
.replace(/^\^/, "")
|
|
240
|
+
.replace(/\$$/, "")
|
|
241
|
+
.replace(/^\(\?:(.*)\)$/, "$1")
|
|
242
|
+
.split("|")
|
|
243
|
+
.map((s) => s.replace(/\\(.)/g, "$1"))
|
|
244
|
+
.join(", ");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function fixText(path, entry, hints) {
|
|
248
|
+
const who = ownerLabel(entry.owner);
|
|
249
|
+
const where = hints.length ? ` (${hints.join("; ")})` : "";
|
|
250
|
+
let text =
|
|
251
|
+
`${path} is generated or synced by ${who}; change its source instead${where}, ` +
|
|
252
|
+
"or wait for the sync. Never hand-edit it.";
|
|
253
|
+
if (entry.bypass && entry.bypass.label) {
|
|
254
|
+
text += ` A deliberate exception needs the PR label "${entry.bypass.label}".`;
|
|
255
|
+
}
|
|
256
|
+
return text;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function sourceHints(path, generators, authoring) {
|
|
260
|
+
const hints = [];
|
|
261
|
+
for (const g of generators) {
|
|
262
|
+
if (!g || !g.output || !matchGlob(g.output, path)) continue;
|
|
263
|
+
if (g.input) hints.push(`generator input: ${g.input}`);
|
|
264
|
+
if (g.command) hints.push(`regenerate with: ${g.command}`);
|
|
265
|
+
}
|
|
266
|
+
for (const [domain, cmd] of Object.entries(authoring || {})) {
|
|
267
|
+
hints.push(`author ${domain} with: ${cmd}`);
|
|
268
|
+
}
|
|
269
|
+
return [...new Set(hints)];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The decision, as a pure function of its inputs.
|
|
274
|
+
*
|
|
275
|
+
* @param {{paths: string[], entries: object[], authorsFor?: (p: string) => {name: string, email: string}[],
|
|
276
|
+
* labels?: string[], fetchLabels?: () => string[], generators?: object[], authoring?: object}} input
|
|
277
|
+
* @returns {{violations: object[], exempt: object[], labels: string[]}}
|
|
278
|
+
*/
|
|
279
|
+
export function evaluate({
|
|
280
|
+
paths,
|
|
281
|
+
entries,
|
|
282
|
+
authorsFor = () => [],
|
|
283
|
+
labels = [],
|
|
284
|
+
fetchLabels = null,
|
|
285
|
+
generators = [],
|
|
286
|
+
authoring = null,
|
|
287
|
+
}) {
|
|
288
|
+
const violations = [];
|
|
289
|
+
const exempt = [];
|
|
290
|
+
let known = [...labels];
|
|
291
|
+
let fetched = false;
|
|
292
|
+
const hasLabel = (label) => {
|
|
293
|
+
if (known.includes(label)) return true;
|
|
294
|
+
if (!fetched && fetchLabels) {
|
|
295
|
+
fetched = true;
|
|
296
|
+
known = [...new Set([...known, ...fetchLabels()])];
|
|
297
|
+
}
|
|
298
|
+
return known.includes(label);
|
|
299
|
+
};
|
|
300
|
+
for (const raw of paths) {
|
|
301
|
+
const path = normalize(raw);
|
|
302
|
+
let entry = null;
|
|
303
|
+
let excepted = null;
|
|
304
|
+
for (const e of entries) {
|
|
305
|
+
if (!e || !e.glob || !matchGlob(e.glob, path)) continue;
|
|
306
|
+
const except = (e.except || []).find((g) => matchGlob(g, path));
|
|
307
|
+
if (!except) {
|
|
308
|
+
entry = e;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
excepted ??= { path, glob: e.glob, reason: "except", except };
|
|
312
|
+
}
|
|
313
|
+
if (!entry) {
|
|
314
|
+
if (excepted) exempt.push(excepted);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const base = { path, glob: entry.glob };
|
|
318
|
+
const authors = authorsFor(path);
|
|
319
|
+
if (allAuthorsMatch(entry.owner, authors)) {
|
|
320
|
+
exempt.push({ ...base, reason: "owner" });
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (entry.bypass && entry.bypass.author && allAuthorsMatch(entry.bypass.author, authors)) {
|
|
324
|
+
exempt.push({ ...base, reason: "bypass-author", author: entry.bypass.author });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (entry.bypass && entry.bypass.label && hasLabel(entry.bypass.label)) {
|
|
328
|
+
exempt.push({ ...base, reason: "bypass-label", label: entry.bypass.label });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
violations.push({
|
|
332
|
+
...base,
|
|
333
|
+
owner: entry.owner,
|
|
334
|
+
evidence: entry.evidence || [],
|
|
335
|
+
confidence: entry.confidence,
|
|
336
|
+
fix: fixText(path, entry, sourceHints(path, generators, authoring)),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return { violations, exempt, labels: known };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Reviewer-shaped findings, so Phase 3 triage adjudicates a violation that reached it. */
|
|
343
|
+
export function toFindings(violations) {
|
|
344
|
+
return violations.map((v) => ({
|
|
345
|
+
file: v.path,
|
|
346
|
+
line: 0,
|
|
347
|
+
severity: "blocking",
|
|
348
|
+
tag: FINDING_TAG,
|
|
349
|
+
issue: `Edits ${v.path}, which ${ownerLabel(v.owner)} owns (${v.glob}, ${v.confidence} confidence, evidence ${v.evidence.join(", ") || "none"}).`,
|
|
350
|
+
fix: v.fix,
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// reading the change set from git
|
|
356
|
+
|
|
357
|
+
function git(repo, args, { input } = {}) {
|
|
358
|
+
return execFileSync("git", ["-C", repo, "-c", "core.quotePath=false", ...args], {
|
|
359
|
+
encoding: "utf8",
|
|
360
|
+
input,
|
|
361
|
+
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
362
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function tryGit(repo, args) {
|
|
367
|
+
try {
|
|
368
|
+
return git(repo, args).trim();
|
|
369
|
+
} catch {
|
|
370
|
+
return "";
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const splitNul = (s) => s.split("\0").filter(Boolean);
|
|
375
|
+
|
|
376
|
+
/** The identity a commit made now would carry (GIT_AUTHOR_* and config both count). */
|
|
377
|
+
export function localAuthor(repo) {
|
|
378
|
+
const ident = tryGit(repo, ["var", "GIT_AUTHOR_IDENT"]);
|
|
379
|
+
const m = ident.match(/^(.*?) <([^>]*)>/);
|
|
380
|
+
return m ? { name: m[1], email: m[2] } : null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Authors per path over the commits of `range`, renames split into both sides.
|
|
385
|
+
* A merge lists the files its author changed while resolving it (dense
|
|
386
|
+
* combined diff), never what the merge brought in from the other side.
|
|
387
|
+
*/
|
|
388
|
+
function rangeAuthors(repo, range) {
|
|
389
|
+
const out = new Map();
|
|
390
|
+
const log = git(repo, [
|
|
391
|
+
"log",
|
|
392
|
+
"--no-renames",
|
|
393
|
+
"--diff-merges=dense-combined",
|
|
394
|
+
"--name-only",
|
|
395
|
+
"-z",
|
|
396
|
+
"--format=%x01%an%x00%ae%x00",
|
|
397
|
+
range,
|
|
398
|
+
]);
|
|
399
|
+
for (const chunk of log.split("\x01").filter(Boolean)) {
|
|
400
|
+
const [name, email, ...files] = chunk.split("\0");
|
|
401
|
+
for (const f of files.map((s) => s.replace(/^\n/, "")).filter(Boolean)) {
|
|
402
|
+
if (!out.has(f)) out.set(f, []);
|
|
403
|
+
out.get(f).push({ name, email });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function isCommit(repo, ref) {
|
|
410
|
+
return Boolean(tryGit(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function isShallow(repo) {
|
|
414
|
+
return tryGit(repo, ["rev-parse", "--is-shallow-repository"]) === "true";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const UNSHALLOW = "fetch more history (git fetch --unshallow)";
|
|
418
|
+
|
|
419
|
+
/** A git call whose failure is one readable line naming `what`, not git's stderr dump. */
|
|
420
|
+
function gitOr(repo, args, what) {
|
|
421
|
+
try {
|
|
422
|
+
return git(repo, args);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
const said = String(err.stderr || "")
|
|
425
|
+
.split("\n")
|
|
426
|
+
.map((l) => l.replace(/^(?:fatal|error):\s*/, "").trim())
|
|
427
|
+
.find(Boolean);
|
|
428
|
+
throw new Error(`${what}${said ? `: ${said}` : ""}`, { cause: err });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The repository's top level, so a subdirectory `--repo` still sees every path. */
|
|
433
|
+
export function repoTop(repo) {
|
|
434
|
+
const top = tryGit(repo, ["rev-parse", "--show-toplevel"]);
|
|
435
|
+
if (!top) throw new Error(`not a git repository: ${repo}`);
|
|
436
|
+
return top;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function baseCandidates(profile, mode) {
|
|
440
|
+
const refs = [];
|
|
441
|
+
for (const field of ["repo.workBranch", "repo.defaultBranch"]) {
|
|
442
|
+
const r = profile ? resolveField(profile, field, { mode }) : { use: false };
|
|
443
|
+
if (r.use && r.value) refs.push(`origin/${r.value}`, String(r.value));
|
|
444
|
+
}
|
|
445
|
+
refs.push("origin/HEAD", "origin/main", "origin/master", "main", "master");
|
|
446
|
+
return [...new Set(refs)];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The merge-base the default change set starts from: --base when given, else
|
|
451
|
+
* the first of the profile's work and default branch (origin first), origin's
|
|
452
|
+
* HEAD, main and master that shares history with HEAD.
|
|
453
|
+
*
|
|
454
|
+
* @returns {{base: string, ref: string} | {base: null, reason: string}}
|
|
455
|
+
*/
|
|
456
|
+
function resolveBase(repo, profile, mode, explicit) {
|
|
457
|
+
if (explicit) {
|
|
458
|
+
if (!isCommit(repo, explicit)) {
|
|
459
|
+
throw new Error(`--base ${explicit}: not a commit in this repository`);
|
|
460
|
+
}
|
|
461
|
+
const base = tryGit(repo, ["merge-base", "HEAD", explicit]);
|
|
462
|
+
if (base) return { base, ref: explicit };
|
|
463
|
+
if (isShallow(repo)) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
`--base ${explicit}: no merge-base with HEAD in this shallow clone; ${UNSHALLOW}, or pass --range or --staged`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
throw new Error(`--base ${explicit}: no merge-base with HEAD`);
|
|
469
|
+
}
|
|
470
|
+
const present = [];
|
|
471
|
+
for (const ref of baseCandidates(profile, mode)) {
|
|
472
|
+
if (!isCommit(repo, ref)) continue;
|
|
473
|
+
present.push(ref);
|
|
474
|
+
const base = tryGit(repo, ["merge-base", "HEAD", ref]);
|
|
475
|
+
if (base) return { base, ref };
|
|
476
|
+
}
|
|
477
|
+
if (present.length && isShallow(repo)) {
|
|
478
|
+
return {
|
|
479
|
+
base: null,
|
|
480
|
+
reason: `shallow clone: no merge-base between HEAD and ${present.join(", ")}; ${UNSHALLOW}, or pass --base, --range or --staged`,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
base: null,
|
|
485
|
+
reason:
|
|
486
|
+
"no base to diff against (no work or default branch in the profile, no origin/HEAD, main or master sharing history with HEAD); pass --base, --range or --staged",
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The index, the uncommitted tracked changes and the untracked files, repo-root relative. */
|
|
491
|
+
function workingTree(repo) {
|
|
492
|
+
const hasHead = isCommit(repo, "HEAD");
|
|
493
|
+
return [
|
|
494
|
+
...new Set([
|
|
495
|
+
...splitNul(git(repo, ["diff", "--cached", "--name-only", "--no-renames", "-z"])),
|
|
496
|
+
...(hasHead
|
|
497
|
+
? splitNul(git(repo, ["diff", "--name-only", "--no-renames", "-z", "HEAD"]))
|
|
498
|
+
: []),
|
|
499
|
+
...splitNul(git(repo, ["ls-files", "--others", "--exclude-standard", "--full-name", "-z"])),
|
|
500
|
+
]),
|
|
501
|
+
];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Above this many paths a default change set says more about its base than about the change. */
|
|
505
|
+
export const MAX_CHANGED = 5000;
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The changed paths and who changed each one. With no --base and no base to
|
|
509
|
+
* resolve, the result carries `noBase` (the reason) and no paths; the caller
|
|
510
|
+
* decides what that means for its mode.
|
|
511
|
+
*
|
|
512
|
+
* @param {string} repo
|
|
513
|
+
* @param {{staged?: boolean, range?: string|null, paths?: string[]|null, base?: string|null,
|
|
514
|
+
* author?: string|null, profile?: object|null, mode?: string, maxChanged?: number}} opts
|
|
515
|
+
* @returns {{paths: string[], authorsFor: (p: string) => object[], source: string, noBase?: string}}
|
|
516
|
+
*/
|
|
517
|
+
export function changeSet(
|
|
518
|
+
repo,
|
|
519
|
+
{
|
|
520
|
+
staged,
|
|
521
|
+
range,
|
|
522
|
+
paths,
|
|
523
|
+
base = null,
|
|
524
|
+
author,
|
|
525
|
+
profile = null,
|
|
526
|
+
mode = "attended",
|
|
527
|
+
maxChanged = MAX_CHANGED,
|
|
528
|
+
},
|
|
529
|
+
) {
|
|
530
|
+
const local = author ? parseAuthor(author) : localAuthor(repo);
|
|
531
|
+
const localList = local ? [local] : [];
|
|
532
|
+
if (paths) return { paths, authorsFor: () => localList, source: "paths" };
|
|
533
|
+
const top = repoTop(repo);
|
|
534
|
+
if (staged) {
|
|
535
|
+
const list = splitNul(git(top, ["diff", "--cached", "--name-only", "--no-renames", "-z"]));
|
|
536
|
+
return { paths: list, authorsFor: () => localList, source: "staged" };
|
|
537
|
+
}
|
|
538
|
+
if (range) {
|
|
539
|
+
const m = range.match(/^(.+?)\.\.\.?(.+)$/);
|
|
540
|
+
if (!m) throw new Error(`--range needs <base>..<head>, got ${range}`);
|
|
541
|
+
const [, a, b] = m;
|
|
542
|
+
for (const side of [a, b]) {
|
|
543
|
+
if (!isCommit(top, side)) {
|
|
544
|
+
throw new Error(`--range ${range}: ${side} is not a commit in this repository`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const mb = tryGit(top, ["merge-base", a, b]);
|
|
548
|
+
if (!mb && isShallow(top)) {
|
|
549
|
+
throw new Error(`--range ${range}: no merge-base in this shallow clone; ${UNSHALLOW}`);
|
|
550
|
+
}
|
|
551
|
+
const list = splitNul(
|
|
552
|
+
gitOr(top, ["diff", "--name-only", "--no-renames", "-z", `${a}...${b}`], `--range ${range}`),
|
|
553
|
+
);
|
|
554
|
+
const byPath = rangeAuthors(top, `${mb || a}..${b}`);
|
|
555
|
+
return { paths: list, authorsFor: (p) => byPath.get(p) || [], source: `range ${range}` };
|
|
556
|
+
}
|
|
557
|
+
const found = resolveBase(top, profile, mode, base);
|
|
558
|
+
if (!found.base) {
|
|
559
|
+
return { paths: [], authorsFor: () => localList, source: "none", noBase: found.reason };
|
|
560
|
+
}
|
|
561
|
+
const committed = new Set(
|
|
562
|
+
splitNul(git(top, ["diff", "--name-only", "--no-renames", "-z", found.base, "HEAD"])),
|
|
563
|
+
);
|
|
564
|
+
const dirty = new Set([
|
|
565
|
+
...splitNul(git(top, ["diff", "--name-only", "--no-renames", "-z", "HEAD"])),
|
|
566
|
+
...splitNul(git(top, ["ls-files", "--others", "--exclude-standard", "--full-name", "-z"])),
|
|
567
|
+
]);
|
|
568
|
+
const all = [...new Set([...committed, ...dirty])];
|
|
569
|
+
if (!base && all.length > maxChanged) {
|
|
570
|
+
throw new Error(
|
|
571
|
+
`base looks wrong: ${all.length} changed paths since the merge-base with ${found.ref} (more than ${maxChanged}); pass --base or --range`,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
const byPath = rangeAuthors(top, `${found.base}..HEAD`);
|
|
575
|
+
return {
|
|
576
|
+
paths: all,
|
|
577
|
+
authorsFor: (p) => [
|
|
578
|
+
...(committed.has(p) ? byPath.get(p) || [] : []),
|
|
579
|
+
...(dirty.has(p) ? localList : []),
|
|
580
|
+
],
|
|
581
|
+
source: `merge-base with ${found.ref}`,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** The working tree as the change set, when unattended and no base resolves. */
|
|
586
|
+
function workingTreeSet(repo, author) {
|
|
587
|
+
const local = author ? parseAuthor(author) : localAuthor(repo);
|
|
588
|
+
const localList = local ? [local] : [];
|
|
589
|
+
return { paths: workingTree(repoTop(repo)), authorsFor: () => localList };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function parseAuthor(s) {
|
|
593
|
+
const m = String(s).match(/^(.*?)\s*<([^>]*)>$/);
|
|
594
|
+
return m ? { name: m[1], email: m[2] } : { name: String(s), email: "" };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** PR labels for the checked-out branch through `gh`, read-only; nothing on any failure. */
|
|
598
|
+
export function ghLabels(repo) {
|
|
599
|
+
try {
|
|
600
|
+
const out = execFileSync("gh", ["pr", "view", "--json", "labels", "--jq", ".labels[].name"], {
|
|
601
|
+
cwd: repo,
|
|
602
|
+
encoding: "utf8",
|
|
603
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
604
|
+
timeout: 15000,
|
|
605
|
+
});
|
|
606
|
+
return out
|
|
607
|
+
.split("\n")
|
|
608
|
+
.map((s) => s.trim())
|
|
609
|
+
.filter(Boolean);
|
|
610
|
+
} catch {
|
|
611
|
+
return [];
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// the whole gate
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Load (or, unattended, derive) the profile, read the change set, decide.
|
|
620
|
+
*
|
|
621
|
+
* @param {string} repo
|
|
622
|
+
* @param {{staged?: boolean, range?: string|null, paths?: string[]|null, base?: string|null,
|
|
623
|
+
* author?: string|null, labels?: string[], gh?: boolean, env?: object, state?: object|null,
|
|
624
|
+
* home?: string, maxChanged?: number}} [opts]
|
|
625
|
+
* A profile that honours no owned entry for the mode skips before any git
|
|
626
|
+
* call. With no base to diff against, an attended run skips with the reason;
|
|
627
|
+
* an unattended run scans the index and working tree, and skips only when
|
|
628
|
+
* those are clean.
|
|
629
|
+
*
|
|
630
|
+
* @returns {object} the result; `exitCode` is 0 clean or skipped, 1 on violations
|
|
631
|
+
*/
|
|
632
|
+
export function runGate(repo, opts = {}) {
|
|
633
|
+
const { env = process.env, state = null, home, labels = [], gh = true } = opts;
|
|
634
|
+
const { mode: storeMode, gatesActive } = runPosture(state, env);
|
|
635
|
+
const mode = policyMode(env, state);
|
|
636
|
+
const found = locateProfile(repo, { home, env, mode: storeMode });
|
|
637
|
+
let profile = found?.profile ?? null;
|
|
638
|
+
let profileAction = profile ? "loaded" : "none";
|
|
639
|
+
let source = found?.path ?? null;
|
|
640
|
+
if (!profile && gatesActive) {
|
|
641
|
+
const ensured = ensureProfile(repo, { home, env, state });
|
|
642
|
+
profile = ensured.profile;
|
|
643
|
+
profileAction = ensured.action;
|
|
644
|
+
source = ensured.path;
|
|
645
|
+
}
|
|
646
|
+
const empty = { violations: [], exempt: [], findings: [], ignored: [], checked: 0 };
|
|
647
|
+
const skip = (note, extra = {}) => ({
|
|
648
|
+
...empty,
|
|
649
|
+
mode,
|
|
650
|
+
skipped: true,
|
|
651
|
+
note,
|
|
652
|
+
profileSource: source,
|
|
653
|
+
profileAction,
|
|
654
|
+
exitCode: 0,
|
|
655
|
+
...extra,
|
|
656
|
+
});
|
|
657
|
+
if (!profile) return skip("no repo profile, gate skipped");
|
|
658
|
+
const owned = resolveField(profile, "ownedPaths", { mode });
|
|
659
|
+
if (!owned.value.length) {
|
|
660
|
+
return skip(`no owned path the profile honours in ${mode} mode, gate skipped`, {
|
|
661
|
+
ignored: owned.ignored,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
const gens = resolveField(profile, "generators", { mode });
|
|
665
|
+
const rs = resolveField(profile, "resourceSource", { mode });
|
|
666
|
+
let change = changeSet(repo, { ...opts, profile, mode });
|
|
667
|
+
if (change.noBase) {
|
|
668
|
+
const tree = mode === "unattended" ? workingTreeSet(repo, opts.author) : null;
|
|
669
|
+
if (!tree || !tree.paths.length) {
|
|
670
|
+
return skip(
|
|
671
|
+
`${change.noBase}; gate skipped${tree ? " (the working tree has no changes)" : ""}`,
|
|
672
|
+
{ ignored: owned.ignored },
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
change = { ...tree, source: `working tree (${change.noBase})` };
|
|
676
|
+
}
|
|
677
|
+
const decided = evaluate({
|
|
678
|
+
paths: change.paths,
|
|
679
|
+
entries: owned.value,
|
|
680
|
+
authorsFor: change.authorsFor,
|
|
681
|
+
labels,
|
|
682
|
+
fetchLabels: gh ? () => ghLabels(repo) : null,
|
|
683
|
+
generators: gens.value || [],
|
|
684
|
+
authoring: rs.use && rs.value ? rs.value.authoring : null,
|
|
685
|
+
});
|
|
686
|
+
return {
|
|
687
|
+
violations: decided.violations,
|
|
688
|
+
exempt: decided.exempt,
|
|
689
|
+
findings: toFindings(decided.violations),
|
|
690
|
+
ignored: owned.ignored,
|
|
691
|
+
checked: change.paths.length,
|
|
692
|
+
changeSource: change.source,
|
|
693
|
+
mode,
|
|
694
|
+
skipped: false,
|
|
695
|
+
profileSource: source,
|
|
696
|
+
profileAction,
|
|
697
|
+
exitCode: decided.violations.length ? 1 : 0,
|
|
698
|
+
};
|
|
699
|
+
}
|