@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/docs/facts.json +1 -1
  3. package/install/claude.mjs +1 -1
  4. package/manifest.json +37 -28
  5. package/package.json +1 -1
  6. package/pipeline/lib/claude-md-links.mjs +328 -0
  7. package/pipeline/lib/owned-path-gate.mjs +699 -0
  8. package/pipeline/lib/repo-profile-derive.mjs +1771 -0
  9. package/pipeline/lib/repo-profile.mjs +780 -0
  10. package/pipeline/lib/stack-detect.sh +59 -19
  11. package/pipeline/lib/unattended.mjs +17 -0
  12. package/pipeline/multi-agent-refs/features/repo-profile.md +96 -0
  13. package/pipeline/multi-agent-refs/features/review-decision.md +18 -13
  14. package/pipeline/multi-agent-refs/features/stack-skill-routing.md +179 -33
  15. package/pipeline/multi-agent-refs/outside-the-pipeline.md +33 -11
  16. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +26 -12
  17. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +24 -13
  18. package/pipeline/multi-agent-refs/phases/phase-3-review.md +16 -4
  19. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +1 -1
  20. package/pipeline/multi-agent-refs/phases/phase-5-report.md +8 -0
  21. package/pipeline/rules/outside-the-pipeline.md +6 -1
  22. package/pipeline/schemas/agent-state.schema.json +66 -2
  23. package/pipeline/schemas/phases.json +4 -4
  24. package/pipeline/schemas/repo-profile.schema.json +1107 -0
  25. package/pipeline/schemas/token-budget.json +4 -4
  26. package/pipeline/scripts/agent-guard.py +30 -0
  27. package/pipeline/scripts/owned-path-gate.mjs +205 -0
  28. package/pipeline/scripts/pre-commit-check.sh +151 -1
  29. package/pipeline/scripts/repo-profile.mjs +244 -0
  30. package/pipeline/scripts/review-decision-gate.mjs +42 -18
  31. package/pipeline/scripts/skill-candidates.mjs +882 -0
  32. package/pipeline/scripts/unattended_policy.py +90 -0
  33. package/pipeline/scripts/usage-report.mjs +36 -6
  34. 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
+ }