@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,1771 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* repo-profile-derive.mjs - build a draft repo profile from the repo itself.
|
|
3
|
+
*
|
|
4
|
+
* Every heuristic reads what the repo already says about itself: who commits
|
|
5
|
+
* where (git history), what CI runs (workflow files), which files announce
|
|
6
|
+
* they are generated (file headers), what the hooks do (hook bodies). Nothing
|
|
7
|
+
* here knows a particular company, bot, tool or path; a bot is any author
|
|
8
|
+
* matching `[bot]` or a pattern the caller passes.
|
|
9
|
+
*
|
|
10
|
+
* Cost is bounded so a 50k-file repo derives in a few seconds: one `git log`
|
|
11
|
+
* over a fixed window, one full-history `git log` limited to at most
|
|
12
|
+
* MAX_CANDIDATES candidate owned paths, one `git ls-files`, a 600-byte header
|
|
13
|
+
* read per regular source file, and a handful of `git grep` calls restricted to
|
|
14
|
+
* source extensions. Paths are read NUL-separated with core.quotePath off, so a
|
|
15
|
+
* non-ASCII path arrives as written.
|
|
16
|
+
*
|
|
17
|
+
* @module pipeline/lib/repo-profile-derive
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { closeSync, lstatSync, openSync, readdirSync, readFileSync, readSync } from "node:fs";
|
|
22
|
+
import { join, posix } from "node:path";
|
|
23
|
+
import { followClaudeMdLinks } from "./claude-md-links.mjs";
|
|
24
|
+
import { mainRepoRoot, SCHEMA_VERSION } from "./repo-profile.mjs";
|
|
25
|
+
|
|
26
|
+
const DEFAULT_HISTORY = 2000;
|
|
27
|
+
const MIN_OWNED_COMMITS = 3;
|
|
28
|
+
const MAX_CANDIDATES = 50;
|
|
29
|
+
const COMMIT_SAMPLE = 200;
|
|
30
|
+
const MAX_ENTRIES = 50;
|
|
31
|
+
const HEADER_BYTES = 600;
|
|
32
|
+
const HEADER_LINES = 5;
|
|
33
|
+
|
|
34
|
+
const SOURCE_EXT =
|
|
35
|
+
/\.(swift|kt|kts|java|m|mm|h|c|cc|cpp|cs|dart|go|rs|py|rb|ts|tsx|js|jsx|mjs|cjs|strings|xml|gradle|sh)$/;
|
|
36
|
+
const GREP_PATHSPEC = [
|
|
37
|
+
"*.swift",
|
|
38
|
+
"*.kt",
|
|
39
|
+
"*.java",
|
|
40
|
+
"*.m",
|
|
41
|
+
"*.mm",
|
|
42
|
+
"*.dart",
|
|
43
|
+
"*.cs",
|
|
44
|
+
"*.go",
|
|
45
|
+
"*.rs",
|
|
46
|
+
"*.py",
|
|
47
|
+
"*.ts",
|
|
48
|
+
"*.tsx",
|
|
49
|
+
"*.js",
|
|
50
|
+
"*.jsx",
|
|
51
|
+
];
|
|
52
|
+
const STAMP_RE = /generated by|auto-?generated|do not edit/i;
|
|
53
|
+
// A stamp is a comment line: a docstring or a string that mentions generated
|
|
54
|
+
// code is not one.
|
|
55
|
+
const STAMP_LINE_RE =
|
|
56
|
+
/^\s*(?:\/\/+|#+|\/\*+|\*+|<!--|--|;+)\s*(?:@?[\w.-]+:\s*)?.*(?:generated by|auto-?generated|do not edit)/i;
|
|
57
|
+
const BOT_DEFAULT = /\[bot\]$/;
|
|
58
|
+
const HOOK_NAMES = new Set([
|
|
59
|
+
"pre-commit",
|
|
60
|
+
"prepare-commit-msg",
|
|
61
|
+
"commit-msg",
|
|
62
|
+
"post-commit",
|
|
63
|
+
"pre-push",
|
|
64
|
+
"post-checkout",
|
|
65
|
+
"post-merge",
|
|
66
|
+
"pre-rebase",
|
|
67
|
+
"post-rewrite",
|
|
68
|
+
]);
|
|
69
|
+
const HOOK_DIRS = [".githooks/", "scripts/git-hooks/", "git-hooks/", ".husky/"];
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// small helpers
|
|
73
|
+
|
|
74
|
+
const GIT_PLAIN = ["-c", "core.quotePath=false"];
|
|
75
|
+
|
|
76
|
+
function run(cwd, cmd, args, { maxBuffer = 512 * 1024 * 1024 } = {}) {
|
|
77
|
+
try {
|
|
78
|
+
return execFileSync(cmd, args, {
|
|
79
|
+
cwd,
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
maxBuffer,
|
|
82
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
83
|
+
});
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// git grep exits 1 on no match; its stdout is still the answer.
|
|
86
|
+
return typeof err?.stdout === "string" ? err.stdout : "";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const gitOut = (cwd, args, opts) => run(cwd, "git", [...GIT_PLAIN, ...args], opts);
|
|
91
|
+
|
|
92
|
+
// lstat, never stat: a tracked symlink can point at a FIFO (an open blocks) or
|
|
93
|
+
// outside the repo, and neither is repo content.
|
|
94
|
+
const isRegular = (p) => {
|
|
95
|
+
try {
|
|
96
|
+
return lstatSync(p).isFile();
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function readText(top, rel, max = 256 * 1024) {
|
|
103
|
+
try {
|
|
104
|
+
const p = join(top, rel);
|
|
105
|
+
if (!isRegular(p)) return null;
|
|
106
|
+
const buf = readFileSync(p);
|
|
107
|
+
return buf.subarray(0, max).toString("utf8");
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readHeader(top, rel, buf) {
|
|
114
|
+
let fd;
|
|
115
|
+
try {
|
|
116
|
+
const p = join(top, rel);
|
|
117
|
+
if (!isRegular(p)) return "";
|
|
118
|
+
fd = openSync(p, "r");
|
|
119
|
+
const n = readSync(fd, buf, 0, HEADER_BYTES, 0);
|
|
120
|
+
return buf.toString("utf8", 0, n);
|
|
121
|
+
} catch {
|
|
122
|
+
return "";
|
|
123
|
+
} finally {
|
|
124
|
+
if (fd !== undefined) closeSync(fd);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
129
|
+
const short = (sha) => `commit:${sha.slice(0, 12)}`;
|
|
130
|
+
const unquote = (s) => s.trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
131
|
+
|
|
132
|
+
function role(value, evidence = [], confidence = "low", note) {
|
|
133
|
+
const r = { value, source: "derived", evidence: [...new Set(evidence)].slice(0, 8), confidence };
|
|
134
|
+
if (note) r.note = note;
|
|
135
|
+
return r;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function entry(fields, evidence, confidence, note) {
|
|
139
|
+
const e = {
|
|
140
|
+
...fields,
|
|
141
|
+
source: "derived",
|
|
142
|
+
evidence: [...new Set(evidence)].slice(0, 8),
|
|
143
|
+
confidence,
|
|
144
|
+
};
|
|
145
|
+
if (note) e.note = note;
|
|
146
|
+
return e;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function ancestors(file) {
|
|
150
|
+
const out = [];
|
|
151
|
+
let d = posix.dirname(file);
|
|
152
|
+
while (d !== "." && d !== "/") {
|
|
153
|
+
out.push(d);
|
|
154
|
+
d = posix.dirname(d);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function topCount(map) {
|
|
160
|
+
let best = null;
|
|
161
|
+
for (const [k, v] of map) if (!best || v > best[1]) best = [k, v];
|
|
162
|
+
return best;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function bump(map, key, by = 1) {
|
|
166
|
+
map.set(key, (map.get(key) || 0) + by);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const underPrefix = (file, prefix) => file === prefix || file.startsWith(`${prefix}/`);
|
|
170
|
+
const globPrefix = (glob) => glob.replace(/\/?\*.*$/, "").replace(/\/$/, "");
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// inputs
|
|
174
|
+
|
|
175
|
+
/** `git log --name-only -z`, one record per commit, each opened by \x01. */
|
|
176
|
+
function parseLog(raw) {
|
|
177
|
+
const commits = [];
|
|
178
|
+
for (const chunk of raw.split("\x01")) {
|
|
179
|
+
const nul = chunk.indexOf("\0");
|
|
180
|
+
if (nul < 0) continue;
|
|
181
|
+
const [sha, an, ae, ...rest] = chunk.slice(0, nul).split("\t");
|
|
182
|
+
if (!/^[0-9a-f]{40}$/.test(sha)) continue;
|
|
183
|
+
const files = chunk
|
|
184
|
+
.slice(nul + 1)
|
|
185
|
+
.replace(/^\n/, "")
|
|
186
|
+
.split("\0")
|
|
187
|
+
.filter(Boolean);
|
|
188
|
+
commits.push({ sha, an, ae, subject: rest.join("\t"), files });
|
|
189
|
+
}
|
|
190
|
+
return commits;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const LOG_FORMAT = "--format=%x01%H%x09%an%x09%ae%x09%s";
|
|
194
|
+
|
|
195
|
+
function readHistory(top, window) {
|
|
196
|
+
return parseLog(
|
|
197
|
+
gitOut(top, [
|
|
198
|
+
"log",
|
|
199
|
+
`-n${window}`,
|
|
200
|
+
"--no-merges",
|
|
201
|
+
"--no-renames",
|
|
202
|
+
"-z",
|
|
203
|
+
LOG_FORMAT,
|
|
204
|
+
"--name-only",
|
|
205
|
+
]),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Every commit, whole history, that touches one of `paths`; only those paths are listed. */
|
|
210
|
+
function readPathHistory(top, paths) {
|
|
211
|
+
if (!paths.length) return [];
|
|
212
|
+
const specs = paths.map((p) => `:(literal)${p}`);
|
|
213
|
+
return parseLog(
|
|
214
|
+
gitOut(top, [
|
|
215
|
+
"log",
|
|
216
|
+
"--no-merges",
|
|
217
|
+
"--no-renames",
|
|
218
|
+
"-z",
|
|
219
|
+
LOG_FORMAT,
|
|
220
|
+
"--name-only",
|
|
221
|
+
"--",
|
|
222
|
+
...specs,
|
|
223
|
+
]),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function botMatcher(pattern) {
|
|
228
|
+
if (!pattern) return (c) => BOT_DEFAULT.test(c.an) || /\[bot\]@/.test(c.ae || "");
|
|
229
|
+
const re = new RegExp(pattern);
|
|
230
|
+
return (c) => re.test(c.an) || re.test(c.ae || "");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One line with its `#` comment removed; a `#` inside quotes or glued to a word stays. */
|
|
234
|
+
function stripComment(line) {
|
|
235
|
+
let quote = null;
|
|
236
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
237
|
+
const c = line[i];
|
|
238
|
+
if (quote) {
|
|
239
|
+
if (c === quote) quote = null;
|
|
240
|
+
} else if (c === "'" || c === '"') quote = c;
|
|
241
|
+
else if (c === "#" && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i).trimEnd();
|
|
242
|
+
}
|
|
243
|
+
return line;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Minimal line model of a workflow file: text, lines, the same lines with
|
|
248
|
+
* comments removed (`code`, line numbers kept), and whether a pull request
|
|
249
|
+
* triggers it.
|
|
250
|
+
*/
|
|
251
|
+
function readWorkflows(top, files) {
|
|
252
|
+
return files
|
|
253
|
+
.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f))
|
|
254
|
+
.map((path) => {
|
|
255
|
+
const text = readText(top, path) || "";
|
|
256
|
+
const lines = text.split("\n");
|
|
257
|
+
const code = lines.map(stripComment);
|
|
258
|
+
const on = code.join("\n");
|
|
259
|
+
const onPR =
|
|
260
|
+
/^\s*pull_request(?:_target)?\s*:/m.test(on) ||
|
|
261
|
+
/^on:\s*(?:\[[^\]]*\bpull_request(?:_target)?\b|pull_request(?:_target)?\s*$)/m.test(on);
|
|
262
|
+
return { path, text, lines, code, onPR };
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const indentOf = (line) => line.match(/^\s*/)[0].length;
|
|
267
|
+
|
|
268
|
+
/** `run:` commands, one per logical line, continuations joined, with their line numbers. */
|
|
269
|
+
function runCommands(wf) {
|
|
270
|
+
const out = [];
|
|
271
|
+
const { lines } = wf;
|
|
272
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
273
|
+
const m = lines[i].match(/^(\s*)(?:-\s+)?run:\s*(.*)$/);
|
|
274
|
+
if (!m) continue;
|
|
275
|
+
const base = indentOf(lines[i]);
|
|
276
|
+
const val = m[2].trim();
|
|
277
|
+
const block = [];
|
|
278
|
+
if (/^[|>][-+]?\s*$/.test(val)) {
|
|
279
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
280
|
+
if (lines[j].trim() && indentOf(lines[j]) <= base) break;
|
|
281
|
+
block.push([j + 1, lines[j].trim()]);
|
|
282
|
+
}
|
|
283
|
+
} else if (val) {
|
|
284
|
+
block.push([i + 1, unquote(val)]);
|
|
285
|
+
}
|
|
286
|
+
let pending = null;
|
|
287
|
+
for (const [n, text] of block) {
|
|
288
|
+
if (!text || text.startsWith("#")) continue;
|
|
289
|
+
if (pending) {
|
|
290
|
+
pending.cmd = `${pending.cmd} ${text.replace(/\\$/, "").trim()}`.trim();
|
|
291
|
+
} else {
|
|
292
|
+
pending = { cmd: text.replace(/\\$/, "").trim(), line: n };
|
|
293
|
+
}
|
|
294
|
+
if (!text.endsWith("\\")) {
|
|
295
|
+
out.push({
|
|
296
|
+
...pending,
|
|
297
|
+
cmd: pending.cmd.replace(/\s+/g, " "),
|
|
298
|
+
path: wf.path,
|
|
299
|
+
pr: wf.onPR,
|
|
300
|
+
});
|
|
301
|
+
pending = null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (pending) out.push({ ...pending, path: wf.path, pr: wf.onPR });
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Items of a YAML key given inline (`key: [a, b]`) or as a dash list below it. */
|
|
310
|
+
function yamlList(lines, i) {
|
|
311
|
+
const m = lines[i].match(/^\s*[\w-]+:\s*(.*)$/);
|
|
312
|
+
const inline = m ? m[1].trim() : "";
|
|
313
|
+
if (inline.startsWith("[")) {
|
|
314
|
+
return inline
|
|
315
|
+
.replace(/^\[|\].*$/g, "")
|
|
316
|
+
.split(",")
|
|
317
|
+
.map(unquote)
|
|
318
|
+
.filter(Boolean)
|
|
319
|
+
.map((v) => [v, i + 1]);
|
|
320
|
+
}
|
|
321
|
+
if (inline && !inline.startsWith("#")) return [[unquote(inline), i + 1]];
|
|
322
|
+
const base = indentOf(lines[i]);
|
|
323
|
+
const items = [];
|
|
324
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
325
|
+
if (!lines[j].trim() || lines[j].trim().startsWith("#")) continue;
|
|
326
|
+
if (indentOf(lines[j]) <= base && !lines[j].trim().startsWith("-")) break;
|
|
327
|
+
const d = lines[j].match(/^\s*-\s+(.+?)\s*(#.*)?$/);
|
|
328
|
+
if (!d) break;
|
|
329
|
+
items.push([unquote(d[1]), j + 1]);
|
|
330
|
+
}
|
|
331
|
+
return items;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Keys found inside the block that opens at line `i`. */
|
|
335
|
+
function blockKeys(lines, i, key) {
|
|
336
|
+
const base = indentOf(lines[i]);
|
|
337
|
+
const found = [];
|
|
338
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
339
|
+
if (!lines[j].trim()) continue;
|
|
340
|
+
if (indentOf(lines[j]) <= base) break;
|
|
341
|
+
if (new RegExp(`^\\s*${key}:`).test(lines[j])) found.push(j);
|
|
342
|
+
}
|
|
343
|
+
return found;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// ownedPaths
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Glob semantics of the owned-path gate: `**` spans segments, `*` and `?` stay
|
|
351
|
+
* in one, a glob without a wildcard names a file or a whole directory.
|
|
352
|
+
*/
|
|
353
|
+
function globRegExp(glob) {
|
|
354
|
+
let re = "";
|
|
355
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
356
|
+
const ch = glob[i];
|
|
357
|
+
if (ch === "*" && glob[i + 1] === "*") {
|
|
358
|
+
const atStart = i === 0 || glob[i - 1] === "/";
|
|
359
|
+
while (glob[i + 1] === "*") i += 1;
|
|
360
|
+
if (atStart && glob[i + 1] === "/") {
|
|
361
|
+
re += "(?:[^/]*/)*";
|
|
362
|
+
i += 1;
|
|
363
|
+
} else re += ".*";
|
|
364
|
+
} else if (ch === "*") re += "[^/]*";
|
|
365
|
+
else if (ch === "?") re += "[^/]";
|
|
366
|
+
else re += esc(ch);
|
|
367
|
+
}
|
|
368
|
+
if (!/[*?[]/.test(glob)) re += "(?:/.*)?";
|
|
369
|
+
return new RegExp(`^${re}$`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Per-path author counts over `commits`, for every directory on the way from
|
|
374
|
+
* each file up to (and including) the deepest of `roots` that holds it.
|
|
375
|
+
*/
|
|
376
|
+
function pathStats(commits, isBot, roots) {
|
|
377
|
+
const stats = new Map();
|
|
378
|
+
const rootSet = roots instanceof Set ? roots : new Set(roots);
|
|
379
|
+
const rootOf = (f) => [f, ...ancestors(f)].find((p) => rootSet.has(p));
|
|
380
|
+
for (const c of commits) {
|
|
381
|
+
const bot = isBot(c);
|
|
382
|
+
const touched = new Set();
|
|
383
|
+
for (const f of c.files) {
|
|
384
|
+
const root = rootOf(f);
|
|
385
|
+
if (root === undefined) continue;
|
|
386
|
+
touched.add(f);
|
|
387
|
+
for (const d of ancestors(f)) {
|
|
388
|
+
if (!underPrefix(d, root)) break;
|
|
389
|
+
touched.add(d);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
for (const p of touched) {
|
|
393
|
+
let s = stats.get(p);
|
|
394
|
+
if (!s) stats.set(p, (s = { bot: 0, human: 0, bots: new Set(), shas: [] }));
|
|
395
|
+
if (bot) {
|
|
396
|
+
s.bot += 1;
|
|
397
|
+
s.bots.add(c.an);
|
|
398
|
+
if (s.shas.length < 3) s.shas.push(c.sha);
|
|
399
|
+
} else s.human += 1;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return stats;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const botOnly = (s) => Boolean(s) && s.human === 0 && s.bot >= MIN_OWNED_COMMITS;
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Paths only a bot has ever committed to. The fixed history window only picks
|
|
409
|
+
* candidates; each candidate is then checked against its whole history, since
|
|
410
|
+
* a directory whose recent touches are a bot's version bumps can still hold
|
|
411
|
+
* files people wrote before the window. A candidate with human commits narrows
|
|
412
|
+
* to the subdirectories and files inside it that only the bot touched.
|
|
413
|
+
*/
|
|
414
|
+
function historyOwned(top, history, isBot, trackedDirs, tracked) {
|
|
415
|
+
const window = pathStats(
|
|
416
|
+
history,
|
|
417
|
+
isBot,
|
|
418
|
+
new Set(history.flatMap((c) => c.files.map((f) => f.split("/")[0]))),
|
|
419
|
+
);
|
|
420
|
+
const dirs = [...window]
|
|
421
|
+
.filter(([p, s]) => trackedDirs.has(p) && botOnly(s))
|
|
422
|
+
.filter(([p]) => {
|
|
423
|
+
const parent = posix.dirname(p);
|
|
424
|
+
return parent === "." || !botOnly(window.get(parent));
|
|
425
|
+
})
|
|
426
|
+
.map(([p, s]) => ({ path: p, dir: true, bot: s.bot }));
|
|
427
|
+
const loose = [...window]
|
|
428
|
+
.filter(([p, s]) => tracked.has(p) && s.human === 0 && s.bot >= MIN_OWNED_COMMITS)
|
|
429
|
+
.filter(([p]) => !dirs.some((d) => underPrefix(p, d.path)))
|
|
430
|
+
.map(([p, s]) => ({ path: p, dir: false, bot: s.bot }));
|
|
431
|
+
const candidates = [...dirs, ...loose]
|
|
432
|
+
.sort((a, b) => b.bot - a.bot || a.path.localeCompare(b.path))
|
|
433
|
+
.slice(0, MAX_CANDIDATES);
|
|
434
|
+
if (!candidates.length) return [];
|
|
435
|
+
const roots = candidates.map((c) => c.path);
|
|
436
|
+
const full = pathStats(readPathHistory(top, roots), isBot, roots);
|
|
437
|
+
const units = [];
|
|
438
|
+
const unit = (path, glob) => {
|
|
439
|
+
const s = full.get(path);
|
|
440
|
+
units.push({ glob, prefix: path, count: s.bot, bots: s.bots, shas: s.shas });
|
|
441
|
+
};
|
|
442
|
+
const children = new Map();
|
|
443
|
+
for (const p of full.keys()) {
|
|
444
|
+
const parent = posix.dirname(p);
|
|
445
|
+
if (!children.has(parent)) children.set(parent, []);
|
|
446
|
+
children.get(parent).push(p);
|
|
447
|
+
}
|
|
448
|
+
const narrow = (dir) => {
|
|
449
|
+
for (const child of (children.get(dir) || []).sort()) {
|
|
450
|
+
const s = full.get(child);
|
|
451
|
+
if (trackedDirs.has(child)) {
|
|
452
|
+
if (botOnly(s)) unit(child, `${child}/**`);
|
|
453
|
+
else if (s.bot > 0) narrow(child);
|
|
454
|
+
} else if (tracked.has(child) && botOnly(s)) unit(child, child);
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
for (const c of candidates) {
|
|
458
|
+
const s = full.get(c.path);
|
|
459
|
+
if (!s) continue;
|
|
460
|
+
if (botOnly(s)) unit(c.path, c.dir ? `${c.path}/**` : c.path);
|
|
461
|
+
else if (c.dir && s.bot > 0) narrow(c.path);
|
|
462
|
+
}
|
|
463
|
+
units.sort((a, b) => b.count - a.count || a.glob.localeCompare(b.glob));
|
|
464
|
+
return units.slice(0, MAX_ENTRIES);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function ownerPattern(names) {
|
|
468
|
+
const list = [...names].sort();
|
|
469
|
+
return list.length === 1 ? `^${esc(list[0])}$` : `^(?:${list.map(esc).join("|")})$`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Bot logins a workflow names in its code; a login inside a YAML or shell comment is an example, not a claim. */
|
|
473
|
+
function workflowBotNames(wf, known) {
|
|
474
|
+
const named = new Set();
|
|
475
|
+
const code = wf.code.join("\n");
|
|
476
|
+
for (const n of known) {
|
|
477
|
+
const bare = n.replace(/\[bot\]$/, "");
|
|
478
|
+
if (code.includes(n) || (bare.length >= 4 && code.includes(bare))) named.add(n);
|
|
479
|
+
}
|
|
480
|
+
for (const m of code.matchAll(/[A-Za-z0-9._-]+\[bot\]/g)) named.add(m[0]);
|
|
481
|
+
return named;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Whether a workflow reads a bot identity from configuration (`vars.SYNC_BOT_LOGIN`, `BOT_LOGIN:`). */
|
|
485
|
+
const mentionsBot = (wf) =>
|
|
486
|
+
/\b(?:vars|secrets)\.\w*BOT\w*|\b\w*BOT_(?:LOGIN|NAME|USER|EMAIL|ACTOR)\b/i.test(
|
|
487
|
+
wf.code.join("\n"),
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
// Paths a workflow's steps check: case arms and `git diff -- <pathspec>`. An
|
|
491
|
+
// `on.*.paths` trigger filter only says when the workflow runs, so it is never
|
|
492
|
+
// read as ownership.
|
|
493
|
+
function workflowGlobs(wf) {
|
|
494
|
+
const globs = [];
|
|
495
|
+
for (const g of caseArmGlobs(wf.code)) globs.push(g);
|
|
496
|
+
for (const [p, n] of gitPathspecs(wf.code)) globs.push([p, n, "own"]);
|
|
497
|
+
return globs.map(([p, n, kind]) => [normalizeGlob(p), n, kind]).filter(([p]) => p);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** A repo-relative glob: no leading `./` or `/`, no doubled or trailing slash. */
|
|
501
|
+
export function normalizeGlob(glob) {
|
|
502
|
+
return String(glob)
|
|
503
|
+
.replace(/\/{2,}/g, "/")
|
|
504
|
+
.replace(/^(?:\.\/)+/, "")
|
|
505
|
+
.replace(/^\/+/, "")
|
|
506
|
+
.replace(/\/+$/, "");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* A shell `case` pattern as a gate glob, or null when the gate cannot say the
|
|
511
|
+
* same thing. A case `*` crosses `/`: a segment that is only stars becomes
|
|
512
|
+
* `**` (`A/*` + `/G/*` covers `A/x/y/G/z/w`), and a last segment that starts
|
|
513
|
+
* with a star (`A/*.swift`) becomes `**` + `/` + that segment. Any other star
|
|
514
|
+
* inside a segment has no exact glob: an owned arm widens to its literal
|
|
515
|
+
* directory plus `/**`, which can only block more, and an exception arm is
|
|
516
|
+
* dropped, which can only exempt less.
|
|
517
|
+
*/
|
|
518
|
+
function caseGlob(p, kind = "own") {
|
|
519
|
+
const segs = p.split("/");
|
|
520
|
+
const out = [];
|
|
521
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
522
|
+
const seg = segs[i];
|
|
523
|
+
if (!seg.includes("*")) out.push(seg);
|
|
524
|
+
else if (/^\*+$/.test(seg)) out.push("**");
|
|
525
|
+
else if (i === segs.length - 1 && seg.startsWith("*") && !seg.slice(1).includes("*")) {
|
|
526
|
+
out.push("**", seg);
|
|
527
|
+
} else {
|
|
528
|
+
if (kind !== "own") return null;
|
|
529
|
+
const head = out.join("/");
|
|
530
|
+
return head ? `${head}/**` : "**";
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return out.join("/");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Shell `case` arms that match paths. An arm whose body does what the `*)`
|
|
537
|
+
// default arm does is an exception carved out of a broader arm; any other arm
|
|
538
|
+
// is an owned path. Without a default arm every path arm counts as owned.
|
|
539
|
+
function caseArmGlobs(lines) {
|
|
540
|
+
const out = [];
|
|
541
|
+
let arms = null;
|
|
542
|
+
const flush = () => {
|
|
543
|
+
if (!arms) return;
|
|
544
|
+
const fallback = arms.find((a) => a.patterns.includes("*"));
|
|
545
|
+
for (const a of arms) {
|
|
546
|
+
if (a.patterns.includes("*")) continue;
|
|
547
|
+
const kind = fallback && a.body === fallback.body ? "except" : "own";
|
|
548
|
+
for (const p of a.patterns) {
|
|
549
|
+
if (!p.includes("/") || /\s/.test(p)) continue;
|
|
550
|
+
const glob = caseGlob(p, kind);
|
|
551
|
+
if (glob) out.push([glob, a.line, kind]);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
arms = null;
|
|
555
|
+
};
|
|
556
|
+
lines.forEach((line, i) => {
|
|
557
|
+
if (/\bcase\b.+\bin\s*$/.test(line)) {
|
|
558
|
+
flush();
|
|
559
|
+
arms = [];
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (/^\s*esac\b/.test(line)) {
|
|
563
|
+
flush();
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (!arms) return;
|
|
567
|
+
const m = line.match(/^\s*\(?([^()\s#;][^();]*)\)(.*)$/);
|
|
568
|
+
if (!m) return;
|
|
569
|
+
const body = m[2].replace(/;;\s*$/, "").trim();
|
|
570
|
+
arms.push({ patterns: m[1].split("|").map(unquote), body, line: i + 1 });
|
|
571
|
+
});
|
|
572
|
+
flush();
|
|
573
|
+
return out;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// An owned path under another owned path adds nothing to the gate: the outer
|
|
577
|
+
// one already covers it. Its evidence and bypass move to the outer entry, and
|
|
578
|
+
// the outer entry takes the higher confidence of the two.
|
|
579
|
+
// `dir/**` with no wildcard in `dir`: a plain subtree another path can sit under.
|
|
580
|
+
const isSubtree = (glob) => glob.endsWith("/**") && !/[*?[]/.test(glob.slice(0, -3));
|
|
581
|
+
|
|
582
|
+
const RANK = { low: 0, medium: 1, high: 2 };
|
|
583
|
+
|
|
584
|
+
function joinBasis(a, b) {
|
|
585
|
+
if (a === b) return a;
|
|
586
|
+
return "history+workflow";
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function absorb(host, e) {
|
|
590
|
+
for (const ev of e.evidence) if (!host.evidence.includes(ev)) host.evidence.push(ev);
|
|
591
|
+
if (e.commits !== undefined) host.commits = Math.max(host.commits ?? 0, e.commits);
|
|
592
|
+
host.bypass = host.bypass || e.bypass;
|
|
593
|
+
host.basis = joinBasis(host.basis, e.basis);
|
|
594
|
+
for (const b of e.bots) host.bots.add(b);
|
|
595
|
+
if (host.basis !== "history" && RANK[e.confidence] > RANK[host.confidence]) {
|
|
596
|
+
host.confidence = e.confidence;
|
|
597
|
+
}
|
|
598
|
+
if (host.basis === "history+workflow") host.confidence = "high";
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function foldNested(entries) {
|
|
602
|
+
const outer = [...entries].sort((a, b) => globPrefix(a.glob).length - globPrefix(b.glob).length);
|
|
603
|
+
const kept = [];
|
|
604
|
+
for (const e of outer) {
|
|
605
|
+
const prefix = globPrefix(e.glob);
|
|
606
|
+
const host = kept.find((k) => {
|
|
607
|
+
if (!isSubtree(k.glob)) return false;
|
|
608
|
+
const kp = globPrefix(k.glob);
|
|
609
|
+
return prefix === kp || prefix.startsWith(`${kp}/`);
|
|
610
|
+
});
|
|
611
|
+
if (!host) {
|
|
612
|
+
kept.push({ ...e, evidence: [...e.evidence], bots: new Set(e.bots) });
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
absorb(host, e);
|
|
616
|
+
}
|
|
617
|
+
const order = new Map(entries.map((e, i) => [e.glob, i]));
|
|
618
|
+
return kept.sort((a, b) => order.get(a.glob) - order.get(b.glob));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Paths after ` -- ` in a git diff/log/ls-files command, the form a lock job
|
|
622
|
+
// uses to list the trees it guards. Backslash continuations are joined so a
|
|
623
|
+
// pathspec on its own line still belongs to the command above it. A bare
|
|
624
|
+
// directory becomes `<dir>/**`.
|
|
625
|
+
function gitPathspecs(lines) {
|
|
626
|
+
const out = [];
|
|
627
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
628
|
+
if (!/\bgit\s+(?:diff|log|ls-files)\b/.test(lines[i])) continue;
|
|
629
|
+
let text = lines[i];
|
|
630
|
+
let j = i;
|
|
631
|
+
while (/\\\s*$/.test(lines[j]) && j + 1 < lines.length) {
|
|
632
|
+
j += 1;
|
|
633
|
+
text = `${text.replace(/\\\s*$/, " ")}${lines[j]}`;
|
|
634
|
+
}
|
|
635
|
+
const at = text.search(/\s--\s/);
|
|
636
|
+
if (at < 0) continue;
|
|
637
|
+
for (const tok of text.slice(at + 4).match(/'[^']+'|"[^"]+"|[^\s)|;&]+/g) || []) {
|
|
638
|
+
const p = unquote(tok).replace(/\/+$/, "");
|
|
639
|
+
if (!p.includes("/") || p.includes("$") || /\s/.test(p) || p.startsWith("-")) continue;
|
|
640
|
+
out.push([/[*?[]/.test(p) ? p : `${p}/**`, i + 1]);
|
|
641
|
+
}
|
|
642
|
+
i = j;
|
|
643
|
+
}
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Bots that committed, in the window, to a path `glob` covers. */
|
|
648
|
+
function committersUnder(history, isBot, glob) {
|
|
649
|
+
const re = globRegExp(glob);
|
|
650
|
+
const out = new Map();
|
|
651
|
+
for (const c of history) {
|
|
652
|
+
if (!isBot(c) || !c.files.some((f) => re.test(f))) continue;
|
|
653
|
+
bump(out, c.an);
|
|
654
|
+
}
|
|
655
|
+
return out;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function deriveOwnedPaths(top, history, isBot, trackedDirs, files, workflows) {
|
|
659
|
+
const tracked = new Set(files);
|
|
660
|
+
const botNames = new Set(history.filter(isBot).map((c) => c.an));
|
|
661
|
+
const entries = historyOwned(top, history, isBot, trackedDirs, tracked).map((o) => ({
|
|
662
|
+
glob: o.glob,
|
|
663
|
+
bypass: null,
|
|
664
|
+
commits: o.count,
|
|
665
|
+
evidence: o.shas.map(short),
|
|
666
|
+
confidence: "medium",
|
|
667
|
+
bots: new Set(o.bots),
|
|
668
|
+
basis: "history",
|
|
669
|
+
}));
|
|
670
|
+
const excepts = [];
|
|
671
|
+
const fromWorkflows = [];
|
|
672
|
+
for (const wf of workflows) {
|
|
673
|
+
const named = workflowBotNames(wf, botNames);
|
|
674
|
+
if (!named.size && !mentionsBot(wf)) continue;
|
|
675
|
+
const label = wf.code.join("\n").match(/labels\.\*\.name\s*,\s*['"]([^'"]+)['"]/);
|
|
676
|
+
const bypass = label ? { label: label[1] } : null;
|
|
677
|
+
const found = workflowGlobs(wf);
|
|
678
|
+
for (const [glob, line] of found.filter(([, , kind]) => kind === "except")) {
|
|
679
|
+
excepts.push([glob, `${wf.path}:${line}`]);
|
|
680
|
+
}
|
|
681
|
+
// One lock job guards its paths for one account: the bots that actually
|
|
682
|
+
// committed under any path it guards, else the bots it names in code.
|
|
683
|
+
const own = found
|
|
684
|
+
.filter(([, , kind]) => kind === "own")
|
|
685
|
+
.map(([glob, line]) => ({ glob, line, committed: committersUnder(history, isBot, glob) }));
|
|
686
|
+
const committers = new Set(own.flatMap((o) => [...o.committed.keys()]));
|
|
687
|
+
const bots = committers.size ? committers : named;
|
|
688
|
+
if (!bots.size) continue;
|
|
689
|
+
for (const { glob, line, committed } of own) {
|
|
690
|
+
const commits = [...committed.values()].reduce((a, b) => a + b, 0);
|
|
691
|
+
fromWorkflows.push({
|
|
692
|
+
glob,
|
|
693
|
+
bypass,
|
|
694
|
+
evidence: [`${wf.path}:${line}`],
|
|
695
|
+
confidence: commits >= MIN_OWNED_COMMITS ? "high" : "medium",
|
|
696
|
+
bots: new Set(bots),
|
|
697
|
+
basis: "workflow",
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
for (const w of fromWorkflows) {
|
|
702
|
+
const hit = entries.find(
|
|
703
|
+
(e) => e.glob === w.glob || (isSubtree(w.glob) && globPrefix(e.glob) === globPrefix(w.glob)),
|
|
704
|
+
);
|
|
705
|
+
if (hit) absorb(hit, w);
|
|
706
|
+
else entries.push(w);
|
|
707
|
+
}
|
|
708
|
+
// A CI job that locks only part of a directory history calls bot-owned is
|
|
709
|
+
// the narrower, deliberate rule: the history entry folds into it.
|
|
710
|
+
const locks = entries
|
|
711
|
+
.filter((w) => w.basis !== "history" && !isSubtree(w.glob))
|
|
712
|
+
.map((w) => ({ w, re: globRegExp(w.glob) }));
|
|
713
|
+
const narrowed = entries.filter((h) => {
|
|
714
|
+
if (h.basis !== "history" || !locks.length) return true;
|
|
715
|
+
const root = h.glob.replace(/\/\*\*$/, "");
|
|
716
|
+
const inside = files.filter((f) => underPrefix(f, root));
|
|
717
|
+
const host = locks.find(
|
|
718
|
+
({ w, re }) => [...h.bots].some((b) => w.bots.has(b)) && inside.some((f) => re.test(f)),
|
|
719
|
+
)?.w;
|
|
720
|
+
if (!host) return true;
|
|
721
|
+
absorb(host, h);
|
|
722
|
+
return false;
|
|
723
|
+
});
|
|
724
|
+
const folded = foldNested(narrowed);
|
|
725
|
+
for (const [glob, ev] of excepts) {
|
|
726
|
+
const prefix = globPrefix(glob);
|
|
727
|
+
const host = folded.find((e) => {
|
|
728
|
+
if (!isSubtree(e.glob)) return false;
|
|
729
|
+
const hp = globPrefix(e.glob);
|
|
730
|
+
return prefix === hp || prefix.startsWith(`${hp}/`);
|
|
731
|
+
});
|
|
732
|
+
if (!host) continue;
|
|
733
|
+
host.except = [...new Set([...(host.except || []), glob])];
|
|
734
|
+
if (!host.evidence.includes(ev)) host.evidence.push(ev);
|
|
735
|
+
}
|
|
736
|
+
const kept = folded.filter((e) => !excepts.some(([g]) => g === e.glob && !e.except));
|
|
737
|
+
return kept
|
|
738
|
+
.slice(0, MAX_ENTRIES)
|
|
739
|
+
.map(({ glob, bots, bypass, commits, evidence, confidence, except, basis }) => {
|
|
740
|
+
const fields = { glob, owner: ownerPattern(bots), bypass };
|
|
741
|
+
if (commits !== undefined) fields.commits = commits;
|
|
742
|
+
fields.basis = basis;
|
|
743
|
+
if (except && except.length) fields.except = except;
|
|
744
|
+
return entry(fields, evidence, basis === "history" ? "medium" : confidence);
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
// generators
|
|
750
|
+
|
|
751
|
+
function scanStamps(top, files) {
|
|
752
|
+
const buf = Buffer.alloc(HEADER_BYTES);
|
|
753
|
+
const stamped = new Map();
|
|
754
|
+
const scanned = [];
|
|
755
|
+
for (const f of files) {
|
|
756
|
+
if (!SOURCE_EXT.test(f)) continue;
|
|
757
|
+
scanned.push(f);
|
|
758
|
+
const head = readHeader(top, f, buf);
|
|
759
|
+
if (!STAMP_RE.test(head)) continue;
|
|
760
|
+
const lines = head.split("\n").slice(0, HEADER_LINES);
|
|
761
|
+
const idx = lines.findIndex((l) => STAMP_LINE_RE.test(l));
|
|
762
|
+
if (idx < 0) continue;
|
|
763
|
+
const text = lines[idx]
|
|
764
|
+
.replace(/^[\s/*#<!-]+|[\s*/>-]+$/g, "")
|
|
765
|
+
.trim()
|
|
766
|
+
.slice(0, 120);
|
|
767
|
+
stamped.set(f, { line: idx + 1, text });
|
|
768
|
+
}
|
|
769
|
+
return { stamped, scanned };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function stampedOutputs(stamped, scanned) {
|
|
773
|
+
const total = new Map();
|
|
774
|
+
const hit = new Map();
|
|
775
|
+
for (const f of scanned) {
|
|
776
|
+
for (const d of ancestors(f)) {
|
|
777
|
+
bump(total, d);
|
|
778
|
+
if (stamped.has(f)) bump(hit, d);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const qualifies = (d) => (hit.get(d) || 0) >= 2 && hit.get(d) / total.get(d) >= 0.9;
|
|
782
|
+
const dirs = [...hit.keys()].filter((d) => {
|
|
783
|
+
if (!qualifies(d)) return false;
|
|
784
|
+
const parent = posix.dirname(d);
|
|
785
|
+
return parent === "." || !qualifies(parent);
|
|
786
|
+
});
|
|
787
|
+
const loose = [...stamped.keys()].filter((f) => !dirs.some((d) => underPrefix(f, d)));
|
|
788
|
+
return { dirs, loose };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function coChange(history, prefix, outputs) {
|
|
792
|
+
const touching = history.filter((c) => c.files.some((f) => underPrefix(f, prefix)));
|
|
793
|
+
if (!touching.length) return { input: null, sameCommit: null, commits: 0 };
|
|
794
|
+
const counts = new Map();
|
|
795
|
+
for (const c of touching) {
|
|
796
|
+
const dirs = new Set();
|
|
797
|
+
for (const f of c.files) {
|
|
798
|
+
if (underPrefix(f, prefix)) continue;
|
|
799
|
+
const d = posix.dirname(f);
|
|
800
|
+
if (d === "." || outputs.some((o) => underPrefix(f, o))) continue;
|
|
801
|
+
dirs.add(d);
|
|
802
|
+
}
|
|
803
|
+
for (const d of dirs) bump(counts, d);
|
|
804
|
+
}
|
|
805
|
+
const best = topCount(counts);
|
|
806
|
+
if (!best || best[1] < 2 || best[1] / touching.length < 0.5) {
|
|
807
|
+
return {
|
|
808
|
+
input: null,
|
|
809
|
+
sameCommit: touching.length >= 3 ? false : null,
|
|
810
|
+
commits: touching.length,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
// Inputs often span sibling directories (one spec per domain); the input is
|
|
814
|
+
// their common parent, not whichever sibling happened to count highest.
|
|
815
|
+
// Peers are grouped by top-level directory and the heaviest group wins, so
|
|
816
|
+
// one unrelated directory that changes alongside does not lift the input to
|
|
817
|
+
// the repo root.
|
|
818
|
+
const groups = new Map();
|
|
819
|
+
for (const [d, n] of counts) {
|
|
820
|
+
if (n < 2 || n < best[1] / 2) continue;
|
|
821
|
+
const head = d.split("/")[0];
|
|
822
|
+
if (!groups.has(head)) groups.set(head, { weight: 0, dirs: [] });
|
|
823
|
+
const g = groups.get(head);
|
|
824
|
+
g.weight += n;
|
|
825
|
+
g.dirs.push(d.split("/"));
|
|
826
|
+
}
|
|
827
|
+
const heaviest = [...groups.values()].sort((a, b) => b.weight - a.weight)[0];
|
|
828
|
+
let common = heaviest.dirs[0];
|
|
829
|
+
for (const segs of heaviest.dirs.slice(1)) {
|
|
830
|
+
let k = 0;
|
|
831
|
+
while (k < common.length && k < segs.length && common[k] === segs[k]) k += 1;
|
|
832
|
+
common = common.slice(0, k);
|
|
833
|
+
}
|
|
834
|
+
const input = common.join("/");
|
|
835
|
+
return {
|
|
836
|
+
input: `${input}/**`,
|
|
837
|
+
sameCommit: best[1] / touching.length >= 0.8,
|
|
838
|
+
commits: touching.length,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** A command without the logging plumbing CI wraps it in: `2>&1`, `| tee ...`. */
|
|
843
|
+
function stripPlumbing(cmd) {
|
|
844
|
+
let out = cmd;
|
|
845
|
+
for (let prev = null; prev !== out;) {
|
|
846
|
+
prev = out;
|
|
847
|
+
out = out
|
|
848
|
+
.replace(/\s*\|\s*tee\b[^|]*$/, "")
|
|
849
|
+
.replace(/\s+[12]?>&[12]\s*$/, "")
|
|
850
|
+
.trim();
|
|
851
|
+
}
|
|
852
|
+
return out;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** A run that verifies the output is current and regenerates nothing. */
|
|
856
|
+
const isCheckRun = (cmd) => /(?:^|\s)--(?:check|verify|dry-run)(?:[\s=]|$)/.test(cmd);
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* The CI steps that produce `prefix`: the regenerate command, and separately
|
|
860
|
+
* the step that only checks it (`--check`, `--verify`, `--dry-run`).
|
|
861
|
+
*/
|
|
862
|
+
function generatorCommand(stampText, prefix, commands) {
|
|
863
|
+
const pool = [];
|
|
864
|
+
const tool = stampText && stampText.match(/generated by\s+([A-Za-z0-9_.-]*[A-Za-z0-9_])/i);
|
|
865
|
+
if (tool) {
|
|
866
|
+
const re = new RegExp(`(^|[\\s/])${esc(tool[1])}(\\s|$)`, "i");
|
|
867
|
+
pool.push(...commands.filter((x) => re.test(x.cmd)));
|
|
868
|
+
}
|
|
869
|
+
pool.push(...commands.filter((x) => x.cmd.includes(prefix) && !inspectsOnly(x.cmd)));
|
|
870
|
+
const clean = [...new Set(pool)].map((x) => ({ ...x, cmd: stripPlumbing(x.cmd) }));
|
|
871
|
+
return {
|
|
872
|
+
command: clean.find((x) => !isCheckRun(x.cmd)) || null,
|
|
873
|
+
check: clean.find((x) => isCheckRun(x.cmd)) || null,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// A CI line that reads the tree (a drift or lock check) names the output
|
|
878
|
+
// directory too, but running it regenerates nothing.
|
|
879
|
+
function inspectsOnly(cmd) {
|
|
880
|
+
const body = cmd
|
|
881
|
+
.replace(/^\s*[A-Za-z_][A-Za-z0-9_]*=\$\(/, "")
|
|
882
|
+
.replace(/^\s*(?:test|\[)\s+-[nz]\s+"?\$\(/, "");
|
|
883
|
+
return /^\s*(?:git\s+(?:diff|log|status|ls-files|show|rev-parse)|grep|find|ls|cat|test\b|\[)/.test(
|
|
884
|
+
body,
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function deriveGenerators(top, files, trackedDirs, history, commands) {
|
|
889
|
+
const { stamped, scanned } = scanStamps(top, files);
|
|
890
|
+
const { dirs, loose } = stampedOutputs(stamped, scanned);
|
|
891
|
+
const outputs = [];
|
|
892
|
+
for (const d of dirs) {
|
|
893
|
+
const inside = [...stamped.entries()].filter(([f]) => underPrefix(f, d));
|
|
894
|
+
outputs.push({
|
|
895
|
+
prefix: d,
|
|
896
|
+
glob: `${d}/**`,
|
|
897
|
+
evidence: inside.slice(0, 3).map(([f, s]) => `${f}:${s.line}`),
|
|
898
|
+
stamp: inside[0]?.[1].text || null,
|
|
899
|
+
confidence: inside.length >= 5 ? "high" : "medium",
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
const generatedDirs = [...trackedDirs]
|
|
903
|
+
.filter((d) => /^generated$/i.test(posix.basename(d)))
|
|
904
|
+
.filter(
|
|
905
|
+
(d) =>
|
|
906
|
+
!ancestors(`${d}/x`)
|
|
907
|
+
.slice(1)
|
|
908
|
+
.some((a) => /^generated$/i.test(posix.basename(a))),
|
|
909
|
+
)
|
|
910
|
+
.sort();
|
|
911
|
+
for (const d of generatedDirs) {
|
|
912
|
+
if (outputs.some((o) => underPrefix(d, o.prefix))) continue;
|
|
913
|
+
outputs.push({ prefix: d, glob: `${d}/**`, evidence: [d], stamp: null, confidence: "medium" });
|
|
914
|
+
}
|
|
915
|
+
for (const f of loose) {
|
|
916
|
+
if (outputs.some((o) => underPrefix(f, o.prefix))) continue;
|
|
917
|
+
const s = stamped.get(f);
|
|
918
|
+
outputs.push({
|
|
919
|
+
prefix: f,
|
|
920
|
+
glob: f,
|
|
921
|
+
evidence: [`${f}:${s.line}`],
|
|
922
|
+
stamp: s.text,
|
|
923
|
+
confidence: "medium",
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
const prefixes = outputs.map((o) => o.prefix);
|
|
927
|
+
return outputs.slice(0, MAX_ENTRIES).map((o) => {
|
|
928
|
+
const co = coChange(history, o.prefix, prefixes);
|
|
929
|
+
const { command, check } = generatorCommand(o.stamp, o.prefix, commands);
|
|
930
|
+
const evidence = [...o.evidence];
|
|
931
|
+
for (const c of [command, check]) if (c) evidence.push(`${c.path}:${c.line}`);
|
|
932
|
+
return entry(
|
|
933
|
+
{
|
|
934
|
+
output: o.glob,
|
|
935
|
+
input: co.input,
|
|
936
|
+
command: command ? command.cmd : null,
|
|
937
|
+
check: check ? check.cmd : null,
|
|
938
|
+
sameCommit: co.sameCommit,
|
|
939
|
+
stamp: o.stamp,
|
|
940
|
+
},
|
|
941
|
+
evidence,
|
|
942
|
+
o.confidence,
|
|
943
|
+
co.commits ? `${co.commits} commits touched the output` : undefined,
|
|
944
|
+
);
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ---------------------------------------------------------------------------
|
|
949
|
+
// commit format
|
|
950
|
+
|
|
951
|
+
const TICKET = "(?:[A-Z][A-Z0-9]+-\\d+|#\\d+)";
|
|
952
|
+
const PREFIX_RE = new RegExp(`^\\s*([\\[(]?)\\s*(${TICKET})\\s*([\\])]?)(:?)`);
|
|
953
|
+
const SUFFIX_RE = new RegExp(`([\\[(]?)\\s*(${TICKET})\\s*([\\])]?)\\s*$`);
|
|
954
|
+
const CONV_RE = /^[a-z]+(\([^)]+\))?!?:\s/;
|
|
955
|
+
|
|
956
|
+
function ticketStyle(open, token, close, colon) {
|
|
957
|
+
const ph = token.startsWith("#") ? "#123" : "KEY-123";
|
|
958
|
+
return `${open}${ph}${close}${colon || ""}`;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function classifySubject(subject) {
|
|
962
|
+
const s = subject.replace(/\s*\(#\d+\)\s*$/, "");
|
|
963
|
+
const body = s.replace(CONV_RE, "");
|
|
964
|
+
let m = s.match(PREFIX_RE) || (body !== s ? body.match(PREFIX_RE) : null);
|
|
965
|
+
if (m && (m[1] === "" || m[3] !== "")) {
|
|
966
|
+
const rest = (s.match(PREFIX_RE) ? s : body).slice(m[0].length).trim();
|
|
967
|
+
return {
|
|
968
|
+
position: "prefix",
|
|
969
|
+
style: ticketStyle(m[1], m[2], m[3], m[4]),
|
|
970
|
+
rest: s.match(PREFIX_RE) ? rest : s,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
m = s.match(SUFFIX_RE);
|
|
974
|
+
if (m && (m[1] === "" || m[3] !== "") && m.index > 0) {
|
|
975
|
+
return { position: "suffix", style: ticketStyle(m[1], m[2], m[3], ""), rest: s };
|
|
976
|
+
}
|
|
977
|
+
return { position: "none", style: null, rest: s };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function deriveCommitFormat(history, isBot) {
|
|
981
|
+
const sample = history.filter((c) => !isBot(c)).slice(0, COMMIT_SAMPLE);
|
|
982
|
+
if (!sample.length) return role(null);
|
|
983
|
+
const rows = sample.map((c) => ({ c, ...classifySubject(c.subject) }));
|
|
984
|
+
const byPos = new Map();
|
|
985
|
+
for (const r of rows) bump(byPos, r.position);
|
|
986
|
+
const [position, count] = topCount(byPos);
|
|
987
|
+
const styles = new Map();
|
|
988
|
+
for (const r of rows) if (r.position === position && r.style) bump(styles, r.style);
|
|
989
|
+
const style = position === "none" ? null : topCount(styles)[0];
|
|
990
|
+
const convRows = rows.filter((r) => CONV_RE.test(r.rest));
|
|
991
|
+
const scoped = convRows.filter((r) => /^[a-z]+\([^)]+\)/.test(r.rest));
|
|
992
|
+
const examples = rows.filter((r) => r.position === position && r.style === style);
|
|
993
|
+
const share = count / rows.length;
|
|
994
|
+
const confidence =
|
|
995
|
+
rows.length >= 20 && share >= 0.8
|
|
996
|
+
? "high"
|
|
997
|
+
: rows.length >= 5 && share >= 0.6
|
|
998
|
+
? "medium"
|
|
999
|
+
: "low";
|
|
1000
|
+
return role(
|
|
1001
|
+
{
|
|
1002
|
+
ticketPosition: position,
|
|
1003
|
+
ticketStyle: style,
|
|
1004
|
+
scopeRequired: convRows.length > 0 && scoped.length / convRows.length >= 0.8,
|
|
1005
|
+
conventional: convRows.length / rows.length >= 0.5,
|
|
1006
|
+
example: examples[0]?.c.subject || null,
|
|
1007
|
+
sampleSize: rows.length,
|
|
1008
|
+
},
|
|
1009
|
+
examples.slice(0, 5).map((r) => short(r.c.sha)),
|
|
1010
|
+
confidence,
|
|
1011
|
+
`${count} of ${rows.length} subjects`,
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// ---------------------------------------------------------------------------
|
|
1016
|
+
// branches
|
|
1017
|
+
|
|
1018
|
+
/** Branches named under an `on:` event block (`pull_request:`, `push:`), per workflow once. */
|
|
1019
|
+
function eventBranches(workflows, eventRe) {
|
|
1020
|
+
const targets = new Map();
|
|
1021
|
+
const ev = new Map();
|
|
1022
|
+
for (const wf of workflows) {
|
|
1023
|
+
const seen = new Set();
|
|
1024
|
+
wf.code.forEach((line, i) => {
|
|
1025
|
+
if (!eventRe.test(line)) return;
|
|
1026
|
+
for (const j of blockKeys(wf.code, i, "branches")) {
|
|
1027
|
+
for (const [b, n] of yamlList(wf.code, j)) {
|
|
1028
|
+
if (/[*?[]/.test(b) || seen.has(b)) continue;
|
|
1029
|
+
seen.add(b);
|
|
1030
|
+
bump(targets, b);
|
|
1031
|
+
if (!ev.has(b)) ev.set(b, []);
|
|
1032
|
+
ev.get(b).push(`${wf.path}:${n}`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
return { targets, ev };
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const refExists = (top, ref) =>
|
|
1041
|
+
Boolean(gitOut(top, ["rev-parse", "--verify", "--quiet", ref]).trim());
|
|
1042
|
+
|
|
1043
|
+
function deriveBranches(top, workflows) {
|
|
1044
|
+
const pr = eventBranches(workflows, /^\s*pull_request(_target)?:\s*$/);
|
|
1045
|
+
const push = eventBranches(workflows, /^\s*push:\s*$/);
|
|
1046
|
+
const originHead = gitOut(top, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]).trim();
|
|
1047
|
+
let defaultBranch;
|
|
1048
|
+
if (originHead) {
|
|
1049
|
+
defaultBranch = role(
|
|
1050
|
+
originHead.replace(/^refs\/remotes\/origin\//, ""),
|
|
1051
|
+
["git:refs/remotes/origin/HEAD"],
|
|
1052
|
+
"high",
|
|
1053
|
+
);
|
|
1054
|
+
} else {
|
|
1055
|
+
const cur = gitOut(top, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
1056
|
+
defaultBranch =
|
|
1057
|
+
cur && cur !== "HEAD"
|
|
1058
|
+
? role(cur, ["git:HEAD"], "low", "no origin/HEAD; current branch")
|
|
1059
|
+
: role(null);
|
|
1060
|
+
}
|
|
1061
|
+
const best = topCount(pr.targets);
|
|
1062
|
+
if (best) {
|
|
1063
|
+
return {
|
|
1064
|
+
workBranch: role(best[0], pr.ev.get(best[0]), best[1] >= 2 ? "high" : "medium"),
|
|
1065
|
+
defaultBranch,
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
// No pull request names a target: the branch CI runs on when something is
|
|
1069
|
+
// pushed to it is where work lands. It counts double when the branch exists.
|
|
1070
|
+
const pushed = topCount(push.targets);
|
|
1071
|
+
if (pushed) {
|
|
1072
|
+
const [b, n] = pushed;
|
|
1073
|
+
const local = refExists(top, `refs/heads/${b}`) || refExists(top, `refs/remotes/origin/${b}`);
|
|
1074
|
+
return {
|
|
1075
|
+
workBranch: role(b, push.ev.get(b), n >= 2 || local ? "high" : "medium", "push target"),
|
|
1076
|
+
defaultBranch,
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
const candidates = ["develop", "development", "dev", "main", "master", "trunk"];
|
|
1080
|
+
const activity = new Map();
|
|
1081
|
+
const where = new Map();
|
|
1082
|
+
for (const b of candidates) {
|
|
1083
|
+
for (const ref of [`refs/remotes/origin/${b}`, `refs/heads/${b}`]) {
|
|
1084
|
+
if (!refExists(top, ref)) continue;
|
|
1085
|
+
const n = Number(gitOut(top, ["rev-list", "--count", "--since=90.days", ref]).trim()) || 0;
|
|
1086
|
+
if (!activity.has(b) || n > activity.get(b)) {
|
|
1087
|
+
activity.set(b, n);
|
|
1088
|
+
where.set(b, ref);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
const active = topCount(activity);
|
|
1093
|
+
return {
|
|
1094
|
+
workBranch: active
|
|
1095
|
+
? role(active[0], [`git:${where.get(active[0])}`], "low", "most active integration branch")
|
|
1096
|
+
: role(null),
|
|
1097
|
+
defaultBranch,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// ---------------------------------------------------------------------------
|
|
1102
|
+
// CI commands
|
|
1103
|
+
|
|
1104
|
+
const LINT_RE =
|
|
1105
|
+
/\b(swiftlint|ktlint|detekt|eslint|ruff|flake8|golangci-lint|rubocop)\b|\bswiftformat\b.*--lint|\b(npm|pnpm|yarn)\s+(run\s+)?lint\b|\bgradlew?\b.*\b(lint\w*|detekt|ktlintCheck)\b/;
|
|
1106
|
+
const TEST_RE =
|
|
1107
|
+
/\bxcodebuild\b.*\btest(-without-building)?\b|\bgradlew?\b.*\b(test\w*|check|connected\w*AndroidTest)\b|\b(npm|pnpm|yarn)\s+(run\s+)?test\b|\bswift\s+test\b|\bpytest\b|\bgo\s+test\b|\bcargo\s+test\b|\bmvn\b.*\btest\b/;
|
|
1108
|
+
const BUILD_RE =
|
|
1109
|
+
/\bxcodebuild\b.*\b(build|build-for-testing|archive)\b|\bgradlew?\b.*\b(assemble\w*|build\w*|bundle\w*)\b|\b(npm|pnpm|yarn)\s+(run\s+)?build\b|\bswift\s+build\b|\bgo\s+build\b|\bcargo\s+build\b|\bmvn\b.*\b(package|install|compile)\b/;
|
|
1110
|
+
|
|
1111
|
+
function classifyCommand(cmd) {
|
|
1112
|
+
if (LINT_RE.test(cmd)) return "lint";
|
|
1113
|
+
if (TEST_RE.test(cmd)) return "test";
|
|
1114
|
+
if (BUILD_RE.test(cmd)) return "build";
|
|
1115
|
+
return null;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function pickCommand(list) {
|
|
1119
|
+
if (!list.length) return null;
|
|
1120
|
+
const freq = new Map();
|
|
1121
|
+
for (const c of list) bump(freq, c.cmd);
|
|
1122
|
+
const [cmd, n] = topCount(freq);
|
|
1123
|
+
const hits = list.filter((c) => c.cmd === cmd);
|
|
1124
|
+
return {
|
|
1125
|
+
cmd,
|
|
1126
|
+
first: hits[0],
|
|
1127
|
+
evidence: hits.map((c) => `${c.path}:${c.line}`),
|
|
1128
|
+
n,
|
|
1129
|
+
pr: hits.some((c) => c.pr),
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function packageScripts(top, files) {
|
|
1134
|
+
if (!files.includes("package.json")) return {};
|
|
1135
|
+
try {
|
|
1136
|
+
const scripts = JSON.parse(readText(top, "package.json") || "{}").scripts || {};
|
|
1137
|
+
const pm = files.includes("pnpm-lock.yaml")
|
|
1138
|
+
? "pnpm"
|
|
1139
|
+
: files.includes("yarn.lock")
|
|
1140
|
+
? "yarn"
|
|
1141
|
+
: "npm";
|
|
1142
|
+
const out = {};
|
|
1143
|
+
for (const k of ["build", "test", "lint"]) if (scripts[k]) out[k] = `${pm} run ${k}`;
|
|
1144
|
+
return out;
|
|
1145
|
+
} catch {
|
|
1146
|
+
return {};
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function deriveCommands(top, files, commands) {
|
|
1151
|
+
const kinds = { build: [], test: [], lint: [] };
|
|
1152
|
+
for (const c of commands) {
|
|
1153
|
+
const k = classifyCommand(c.cmd);
|
|
1154
|
+
if (k) kinds[k].push(c);
|
|
1155
|
+
}
|
|
1156
|
+
const pkg = packageScripts(top, files);
|
|
1157
|
+
const pick = (kind, shape) => {
|
|
1158
|
+
const p = pickCommand(kinds[kind]);
|
|
1159
|
+
// A step a pull request runs is what CI holds every change to.
|
|
1160
|
+
if (p) {
|
|
1161
|
+
return role(shape(p.cmd, p.first.path), p.evidence, p.n >= 2 || p.pr ? "high" : "medium");
|
|
1162
|
+
}
|
|
1163
|
+
if (pkg[kind]) return role(shape(pkg[kind], "package.json"), ["package.json"], "low");
|
|
1164
|
+
return role(null);
|
|
1165
|
+
};
|
|
1166
|
+
const plans = new Set();
|
|
1167
|
+
for (const c of kinds.test)
|
|
1168
|
+
for (const m of c.cmd.matchAll(/-testPlan\s+("[^"]+"|'[^']+'|\S+)/g)) plans.add(unquote(m[1]));
|
|
1169
|
+
for (const f of files) if (f.endsWith(".xctestplan")) plans.add(posix.basename(f, ".xctestplan"));
|
|
1170
|
+
const test = pick("test", (command) => ({ command, plans: [...plans] }));
|
|
1171
|
+
if (test.value === null && plans.size) {
|
|
1172
|
+
test.value = { command: null, plans: [...plans] };
|
|
1173
|
+
test.evidence = files.filter((f) => f.endsWith(".xctestplan")).slice(0, 5);
|
|
1174
|
+
}
|
|
1175
|
+
return {
|
|
1176
|
+
build: pick("build", (command, source) => ({ command, source })),
|
|
1177
|
+
test,
|
|
1178
|
+
lint: pick("lint", (command) => ({ command })),
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// ---------------------------------------------------------------------------
|
|
1183
|
+
// accessors, DI, modules, mocks
|
|
1184
|
+
|
|
1185
|
+
const DOMAIN_RE = {
|
|
1186
|
+
localization: /locali[sz]|strings|l10n|i18n|translat/i,
|
|
1187
|
+
testingId: /testing.?ident|accessibility.?ident|ui.?test.?ident|test.?id|test.?tag/i,
|
|
1188
|
+
tokens: /token|colou?r|typograph|spacing|radius|font/i,
|
|
1189
|
+
};
|
|
1190
|
+
const INJECT_RE =
|
|
1191
|
+
/@(Inject|Injected|LazyInjected|WeakLazyInjected|Injection|InjectedObject|Autowired)\b/g;
|
|
1192
|
+
const INJECT_LITERALS = ["@Inject", "@LazyInjected", "@WeakLazyInjected", "@Autowired"];
|
|
1193
|
+
const MOCK_RE =
|
|
1194
|
+
/import (Mockingbird|Cuckoo|MockSwift|SwiftyMocky|Mockolo)\b|\b(io\.mockk|org\.mockito|jest\.mock|unittest\.mock|gomock|sinon)\b/g;
|
|
1195
|
+
const MOCK_LITERALS = [
|
|
1196
|
+
"import Mockingbird",
|
|
1197
|
+
"import Cuckoo",
|
|
1198
|
+
"import MockSwift",
|
|
1199
|
+
"import SwiftyMocky",
|
|
1200
|
+
"import Mockolo",
|
|
1201
|
+
"io.mockk",
|
|
1202
|
+
"org.mockito",
|
|
1203
|
+
"jest.mock",
|
|
1204
|
+
"unittest.mock",
|
|
1205
|
+
"gomock",
|
|
1206
|
+
"sinon",
|
|
1207
|
+
];
|
|
1208
|
+
|
|
1209
|
+
// Only column-0 declarations are candidate roots: nested types are reached
|
|
1210
|
+
// through their root. A top-level `extension` counts, because generators often
|
|
1211
|
+
// hang the whole accessor tree off a root type declared elsewhere.
|
|
1212
|
+
const TOP_LEVEL_TYPE_RE =
|
|
1213
|
+
/^(?:(?:public|internal|open|final|fileprivate|private|data|sealed|@\w+)\s+)*(?:enum|struct|class|object|extension)\s+([A-Z][A-Za-z0-9_]*)/;
|
|
1214
|
+
|
|
1215
|
+
/** Top-level type names the generated files of each domain declare, with where. */
|
|
1216
|
+
function accessorCandidates(top, genFiles) {
|
|
1217
|
+
const out = {};
|
|
1218
|
+
for (const domain of Object.keys(DOMAIN_RE)) {
|
|
1219
|
+
const decl = new Map();
|
|
1220
|
+
for (const f of genFiles.filter((g) => DOMAIN_RE[domain].test(g)).slice(0, 20)) {
|
|
1221
|
+
(readText(top, f, 64 * 1024) || "").split("\n").forEach((line, i) => {
|
|
1222
|
+
const m = line.match(TOP_LEVEL_TYPE_RE);
|
|
1223
|
+
if (m && decl.size < 10 && !decl.has(m[1])) decl.set(m[1], `${f}:${i + 1}`);
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
out[domain] = decl;
|
|
1227
|
+
}
|
|
1228
|
+
return out;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* One fixed-string `git grep` over source files for every literal any usage
|
|
1233
|
+
* heuristic needs, returning the matching lines. Fixed strings keep the scan
|
|
1234
|
+
* linear; the exact patterns (word boundaries, three-segment accessors) are
|
|
1235
|
+
* applied to the returned lines, which POSIX ERE in `git grep -E` could not
|
|
1236
|
+
* express anyway.
|
|
1237
|
+
*/
|
|
1238
|
+
function scanUsage(top, literals) {
|
|
1239
|
+
if (!literals.length) return [];
|
|
1240
|
+
const args = ["grep", "-I", "--no-color", "-n", "-z", "-F"];
|
|
1241
|
+
for (const l of literals) args.push("-e", l);
|
|
1242
|
+
const out = gitOut(top, [...args, "--", ...GREP_PATHSPEC]);
|
|
1243
|
+
const lines = [];
|
|
1244
|
+
for (const raw of out.split("\n")) {
|
|
1245
|
+
const a = raw.indexOf("\0");
|
|
1246
|
+
const b = a < 0 ? -1 : raw.indexOf("\0", a + 1);
|
|
1247
|
+
if (a > 0 && b > a) {
|
|
1248
|
+
lines.push({
|
|
1249
|
+
path: raw.slice(0, a),
|
|
1250
|
+
line: Number(raw.slice(a + 1, b)),
|
|
1251
|
+
text: raw.slice(b + 1),
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return lines;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** Source-file families, so a role read from code comes from the language the repo is written in. */
|
|
1259
|
+
const LANGUAGES = {
|
|
1260
|
+
swift: /\.swift$/,
|
|
1261
|
+
jvm: /\.(kt|kts|java)$/,
|
|
1262
|
+
js: /\.(ts|tsx|js|jsx|mjs|cjs)$/,
|
|
1263
|
+
python: /\.py$/,
|
|
1264
|
+
go: /\.go$/,
|
|
1265
|
+
rust: /\.rs$/,
|
|
1266
|
+
dart: /\.dart$/,
|
|
1267
|
+
dotnet: /\.cs$/,
|
|
1268
|
+
objc: /\.(m|mm)$/,
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
const languageOf = (path) => Object.keys(LANGUAGES).find((k) => LANGUAGES[k].test(path)) || null;
|
|
1272
|
+
|
|
1273
|
+
function dominantLanguage(files) {
|
|
1274
|
+
const counts = new Map();
|
|
1275
|
+
for (const f of files) {
|
|
1276
|
+
const l = languageOf(f);
|
|
1277
|
+
if (l) bump(counts, l);
|
|
1278
|
+
}
|
|
1279
|
+
return topCount(counts)?.[0] ?? null;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function accessorRoots(decl, usage, outPrefixes) {
|
|
1283
|
+
if (!decl.size) return [];
|
|
1284
|
+
const re = new RegExp(
|
|
1285
|
+
`\\b(${[...decl.keys()].map(esc).join("|")})\\.[A-Za-z_]\\w*\\.[A-Za-z_]\\w*`,
|
|
1286
|
+
"g",
|
|
1287
|
+
);
|
|
1288
|
+
const counts = new Map();
|
|
1289
|
+
for (const { path, text } of usage) {
|
|
1290
|
+
if (outPrefixes.some((p) => underPrefix(path, p))) continue;
|
|
1291
|
+
for (const m of text.matchAll(re)) bump(counts, m[1]);
|
|
1292
|
+
}
|
|
1293
|
+
return [...counts]
|
|
1294
|
+
.filter(([, n]) => n >= 3)
|
|
1295
|
+
.sort((a, b) => b[1] - a[1])
|
|
1296
|
+
.map(([name, n]) => ({ name, n, evidence: decl.get(name) }));
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function conventionRole(bucket) {
|
|
1300
|
+
if (!bucket || !bucket.pattern || !["high", "medium", "low"].includes(bucket.confidence))
|
|
1301
|
+
return null;
|
|
1302
|
+
const ev = (bucket.evidenceFiles || []).filter((e) => typeof e === "string");
|
|
1303
|
+
return role(bucket.pattern, ev, bucket.confidence, "from extract-conventions.sh");
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function deriveAccessors(candidates, usage, outPrefixes, conventions) {
|
|
1307
|
+
const one = (domain) => {
|
|
1308
|
+
const roots = accessorRoots(candidates[domain], usage, outPrefixes);
|
|
1309
|
+
if (!roots.length) return role(null);
|
|
1310
|
+
const [r] = roots;
|
|
1311
|
+
return role(r.name, [r.evidence], r.n >= 20 ? "high" : "medium", `${r.n} usages`);
|
|
1312
|
+
};
|
|
1313
|
+
const localization = one("localization");
|
|
1314
|
+
if (localization.value === null && conventions?.localizationKey?.pattern) {
|
|
1315
|
+
localization.note = `key style: ${conventions.localizationKey.pattern}`;
|
|
1316
|
+
}
|
|
1317
|
+
const tokenRoots = accessorRoots(candidates.tokens, usage, outPrefixes).slice(0, 5);
|
|
1318
|
+
return {
|
|
1319
|
+
localization,
|
|
1320
|
+
accessibility: conventionRole(conventions?.accessibilityIdentifier) || role(null),
|
|
1321
|
+
testingId: one("testingId"),
|
|
1322
|
+
tokens: tokenRoots.length
|
|
1323
|
+
? role(
|
|
1324
|
+
tokenRoots.map((r) => r.name),
|
|
1325
|
+
tokenRoots.map((r) => r.evidence),
|
|
1326
|
+
tokenRoots[0].n >= 20 ? "high" : "medium",
|
|
1327
|
+
)
|
|
1328
|
+
: role([]),
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// The words DI wiring types end in across frameworks. Which compound suffix a
|
|
1333
|
+
// repo builds from them (`FeatureAssembly`, `DependencyContainer`) is read from
|
|
1334
|
+
// the file names, never listed here.
|
|
1335
|
+
const REGISTRAR_WORD_RE =
|
|
1336
|
+
/^([A-Z][A-Za-z0-9]*?)(Registrar|Configurator|Assembly|Container|Module)\.(swift|kt|java|ts)$/;
|
|
1337
|
+
|
|
1338
|
+
const camelWords = (name) => name.match(/[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+/g) || [];
|
|
1339
|
+
|
|
1340
|
+
/** The longest trailing run of CamelCase words at least 80% of `names` end in. */
|
|
1341
|
+
function commonSuffix(names) {
|
|
1342
|
+
const words = names.map(camelWords);
|
|
1343
|
+
let best = null;
|
|
1344
|
+
for (let k = 1; k <= Math.max(...words.map((w) => w.length)); k += 1) {
|
|
1345
|
+
const counts = new Map();
|
|
1346
|
+
for (const w of words) if (w.length > k) bump(counts, w.slice(-k).join(""));
|
|
1347
|
+
const top1 = topCount(counts);
|
|
1348
|
+
if (!top1 || top1[1] < names.length * 0.8) break;
|
|
1349
|
+
best = top1[0];
|
|
1350
|
+
}
|
|
1351
|
+
return best;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function deriveDi(top, files, usage, conventions, language) {
|
|
1355
|
+
const families = new Map();
|
|
1356
|
+
for (const f of files) {
|
|
1357
|
+
if (language && languageOf(f) !== language) continue;
|
|
1358
|
+
const m = posix.basename(f).match(REGISTRAR_WORD_RE);
|
|
1359
|
+
if (!m) continue;
|
|
1360
|
+
if (!families.has(m[2])) families.set(m[2], []);
|
|
1361
|
+
families.get(m[2]).push(f);
|
|
1362
|
+
}
|
|
1363
|
+
let family = null;
|
|
1364
|
+
for (const [word, list] of families)
|
|
1365
|
+
if (!family || list.length > family[1].length) family = [word, list];
|
|
1366
|
+
let registrarSuffix = role(null);
|
|
1367
|
+
let registerMethod = role(null);
|
|
1368
|
+
const suffix =
|
|
1369
|
+
family && family[1].length >= 2
|
|
1370
|
+
? commonSuffix(family[1].map((f) => posix.basename(f).replace(/\.\w+$/, "")))
|
|
1371
|
+
: null;
|
|
1372
|
+
const byType = suffix
|
|
1373
|
+
? family[1].filter((f) =>
|
|
1374
|
+
posix
|
|
1375
|
+
.basename(f)
|
|
1376
|
+
.replace(/\.\w+$/, "")
|
|
1377
|
+
.endsWith(suffix),
|
|
1378
|
+
)
|
|
1379
|
+
: [];
|
|
1380
|
+
const best = suffix ? [suffix, byType.length] : null;
|
|
1381
|
+
if (best && best[1] >= 2) {
|
|
1382
|
+
const sample = byType;
|
|
1383
|
+
registrarSuffix = role(
|
|
1384
|
+
best[0],
|
|
1385
|
+
sample.slice(0, 3),
|
|
1386
|
+
best[1] >= 5 ? "high" : "medium",
|
|
1387
|
+
`${best[1]} files`,
|
|
1388
|
+
);
|
|
1389
|
+
const methods = new Map();
|
|
1390
|
+
const where = new Map();
|
|
1391
|
+
for (const f of sample.slice(0, 30)) {
|
|
1392
|
+
(readText(top, f, 64 * 1024) || "").split("\n").forEach((line, i) => {
|
|
1393
|
+
for (const m of line.matchAll(/\b(?:func|fun|function)\s+([a-z]\w*)\s*\(/g)) {
|
|
1394
|
+
bump(methods, m[1]);
|
|
1395
|
+
if (!where.has(m[1])) where.set(m[1], `${f}:${i + 1}`);
|
|
1396
|
+
}
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
const top1 = topCount(methods);
|
|
1400
|
+
if (top1 && top1[1] >= 2) {
|
|
1401
|
+
registerMethod = role(
|
|
1402
|
+
top1[0],
|
|
1403
|
+
[where.get(top1[0])],
|
|
1404
|
+
top1[1] >= 5 ? "high" : "medium",
|
|
1405
|
+
`${top1[1]} registrars`,
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
if (registrarSuffix.value === null && conventions?.diRegistration?.pattern) {
|
|
1410
|
+
registrarSuffix.note = `extract-conventions: ${conventions.diRegistration.pattern}`;
|
|
1411
|
+
}
|
|
1412
|
+
const attrs = new Map();
|
|
1413
|
+
const where = new Map();
|
|
1414
|
+
for (const u of usage) {
|
|
1415
|
+
if (!language || languageOf(u.path) !== language) continue;
|
|
1416
|
+
for (const m of u.text.matchAll(INJECT_RE)) {
|
|
1417
|
+
bump(attrs, m[0]);
|
|
1418
|
+
if (!where.has(m[0])) where.set(m[0], []);
|
|
1419
|
+
if (where.get(m[0]).length < 3) where.get(m[0]).push(`${u.path}:${u.line}`);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
const a = topCount(attrs);
|
|
1423
|
+
const injectAttribute =
|
|
1424
|
+
a && a[1] >= 2
|
|
1425
|
+
? role(a[0], where.get(a[0]), a[1] >= 20 ? "high" : "medium", `${a[1]} uses`)
|
|
1426
|
+
: role(null);
|
|
1427
|
+
return { registrarSuffix, registerMethod, injectAttribute };
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const MANIFESTS = new Set([
|
|
1431
|
+
"Package.swift",
|
|
1432
|
+
"build.gradle",
|
|
1433
|
+
"build.gradle.kts",
|
|
1434
|
+
"package.json",
|
|
1435
|
+
"Cargo.toml",
|
|
1436
|
+
"go.mod",
|
|
1437
|
+
"pom.xml",
|
|
1438
|
+
]);
|
|
1439
|
+
const VALIDATOR_RE =
|
|
1440
|
+
/(^|\/)[^/]*((validate|check|lint|verify)[-_]?modules?|modules?[-_]?(validat|check|lint|verif|structure))[^/]*$/i;
|
|
1441
|
+
|
|
1442
|
+
/** One glob covering the largest group of same-named, same-depth paths. */
|
|
1443
|
+
function generalize(paths) {
|
|
1444
|
+
const groups = new Map();
|
|
1445
|
+
for (const p of paths) {
|
|
1446
|
+
const parts = p.split("/");
|
|
1447
|
+
const key = `${parts.length}:${parts[parts.length - 1]}`;
|
|
1448
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
1449
|
+
groups.get(key).push(parts);
|
|
1450
|
+
}
|
|
1451
|
+
let best = null;
|
|
1452
|
+
for (const g of groups.values()) if (!best || g.length > best.length) best = g;
|
|
1453
|
+
if (!best) return null;
|
|
1454
|
+
const glob = best[0].map((seg, i) => (best.every((p) => p[i] === seg) ? seg : "*")).join("/");
|
|
1455
|
+
return { glob, count: best.length, sample: best.slice(0, 3).map((p) => p.join("/")) };
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function deriveModule(files, commands) {
|
|
1459
|
+
const manifests = files.filter((f) => f.includes("/") && MANIFESTS.has(posix.basename(f)));
|
|
1460
|
+
const g = generalize(manifests);
|
|
1461
|
+
const layout =
|
|
1462
|
+
g && g.count >= 2
|
|
1463
|
+
? role(g.glob, g.sample, g.count >= 5 ? "high" : "medium", `${g.count} modules`)
|
|
1464
|
+
: role(null);
|
|
1465
|
+
const cmd = commands.find((c) =>
|
|
1466
|
+
VALIDATOR_RE.test(c.cmd.split(/\s+/).find((w) => VALIDATOR_RE.test(w)) || ""),
|
|
1467
|
+
);
|
|
1468
|
+
const file = files.find((f) => VALIDATOR_RE.test(f));
|
|
1469
|
+
const validator = cmd
|
|
1470
|
+
? role(cmd.cmd, [`${cmd.path}:${cmd.line}`], "medium")
|
|
1471
|
+
: file
|
|
1472
|
+
? role(file, [file], "low")
|
|
1473
|
+
: role(null);
|
|
1474
|
+
return { layout, validator };
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function deriveMock(usage, trackedDirs, generators, language) {
|
|
1478
|
+
const frameworks = new Map();
|
|
1479
|
+
const where = new Map();
|
|
1480
|
+
for (const u of usage) {
|
|
1481
|
+
if (!language || languageOf(u.path) !== language) continue;
|
|
1482
|
+
for (const m of u.text.matchAll(MOCK_RE)) {
|
|
1483
|
+
const name = m[1] || m[2];
|
|
1484
|
+
bump(frameworks, name);
|
|
1485
|
+
if (!where.has(name)) where.set(name, []);
|
|
1486
|
+
if (where.get(name).length < 3) where.get(name).push(`${u.path}:${u.line}`);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
const fw = topCount(frameworks);
|
|
1490
|
+
const fixtureDirs = [...trackedDirs].filter((d) =>
|
|
1491
|
+
/^(mocks?|fixtures?|stubs?|mockdata)$/i.test(posix.basename(d)),
|
|
1492
|
+
);
|
|
1493
|
+
const system = fw
|
|
1494
|
+
? role(fw[0], where.get(fw[0]), fw[1] >= 10 ? "high" : "medium", `${fw[1]} imports`)
|
|
1495
|
+
: fixtureDirs.length
|
|
1496
|
+
? role("fixture-tree", fixtureDirs.slice(0, 3), "low")
|
|
1497
|
+
: role(null);
|
|
1498
|
+
const outParents = new Set(generators.map((gen) => posix.dirname(globPrefix(gen.output))));
|
|
1499
|
+
const custom = [...trackedDirs].filter(
|
|
1500
|
+
(d) =>
|
|
1501
|
+
/^Custom(Fixtures|Mocks|Stubs|Sources|Scenarios|Data)$/i.test(posix.basename(d)) ||
|
|
1502
|
+
(/^Custom[A-Z]\w*$/.test(posix.basename(d)) && outParents.has(posix.dirname(d))),
|
|
1503
|
+
);
|
|
1504
|
+
const g = generalize(custom);
|
|
1505
|
+
const customDir = g
|
|
1506
|
+
? role(g.glob, g.sample, g.count >= 3 ? "medium" : "low", `${g.count} directories`)
|
|
1507
|
+
: role(null);
|
|
1508
|
+
return { system, customDir };
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// ---------------------------------------------------------------------------
|
|
1512
|
+
// hooks, checks, docs, skills, resource source
|
|
1513
|
+
|
|
1514
|
+
function hookEffects(text) {
|
|
1515
|
+
const effects = [];
|
|
1516
|
+
if (/git\s+commit\b[^\n]*--amend/.test(text)) effects.push("amends-commit");
|
|
1517
|
+
if (/git\s+(add|update-index)\b/.test(text)) effects.push("stages-files");
|
|
1518
|
+
if (
|
|
1519
|
+
/\bsed\s+-i\b/.test(text) ||
|
|
1520
|
+
/\b(swiftformat|prettier\s+--write|eslint\s+--fix|black|gofmt\s+-w|ruff\s+format|clang-format\s+-i)\b/.test(
|
|
1521
|
+
text,
|
|
1522
|
+
) ||
|
|
1523
|
+
/\bswiftlint\s+(--fix|autocorrect)\b|\bktlint\s+(-F|--format)\b/.test(text) ||
|
|
1524
|
+
/(^|[^0-9&>])>{1,2}\s*(?!\/dev\/null|&)[\w.$"'/-]/m.test(text)
|
|
1525
|
+
) {
|
|
1526
|
+
effects.push("writes-files");
|
|
1527
|
+
}
|
|
1528
|
+
if (/\b(curl|wget)\b|\bgit\s+(push|fetch|pull)\b/.test(text)) effects.push("network");
|
|
1529
|
+
return effects;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function deriveHooks(top, files) {
|
|
1533
|
+
const hooks = [];
|
|
1534
|
+
for (const f of files) {
|
|
1535
|
+
const dir = HOOK_DIRS.find((d) => f.startsWith(d));
|
|
1536
|
+
if (!dir || f.startsWith(".husky/_/")) continue;
|
|
1537
|
+
const name = posix.basename(f).replace(/\.(sh|bash)$/, "");
|
|
1538
|
+
if (!HOOK_NAMES.has(name) || f.slice(dir.length).includes("/")) continue;
|
|
1539
|
+
hooks.push({ name, path: f, dir: dir.replace(/\/$/, "") });
|
|
1540
|
+
}
|
|
1541
|
+
for (const [cfg, name] of [
|
|
1542
|
+
[".pre-commit-config.yaml", "pre-commit-framework"],
|
|
1543
|
+
["lefthook.yml", "lefthook"],
|
|
1544
|
+
]) {
|
|
1545
|
+
if (files.includes(cfg)) hooks.push({ name, path: cfg, dir: null });
|
|
1546
|
+
}
|
|
1547
|
+
if (!hooks.length) return [];
|
|
1548
|
+
const setup = gitOut(top, ["grep", "-n", "-z", "-I", "-F", "--max-depth", "3", "core.hooksPath"])
|
|
1549
|
+
.split("\n")
|
|
1550
|
+
.filter(Boolean)
|
|
1551
|
+
.map((l) => l.match(/^([^\0]+)\0(\d+)\0(.*)$/))
|
|
1552
|
+
.filter(Boolean);
|
|
1553
|
+
const configured = gitOut(top, ["config", "--get", "core.hooksPath"]).trim();
|
|
1554
|
+
return hooks.map((h) => {
|
|
1555
|
+
const text = readText(top, h.path) || "";
|
|
1556
|
+
const ev = [h.path];
|
|
1557
|
+
for (const m of setup)
|
|
1558
|
+
if (h.dir && m[3].includes(h.dir) && m[1] !== h.path) ev.push(`${m[1]}:${m[2]}`);
|
|
1559
|
+
if (h.dir && configured.replace(/\/$/, "") === h.dir) ev.push("git:config core.hooksPath");
|
|
1560
|
+
return entry(
|
|
1561
|
+
{ name: h.name, path: h.path, sideEffects: hookEffects(text) },
|
|
1562
|
+
ev,
|
|
1563
|
+
ev.length > 1 ? "high" : "medium",
|
|
1564
|
+
);
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function deriveRequiredChecks(top, files, workflows) {
|
|
1569
|
+
const found = new Map();
|
|
1570
|
+
const add = (name, ev) => {
|
|
1571
|
+
const n = unquote(name).trim();
|
|
1572
|
+
if (!n || found.has(n)) return;
|
|
1573
|
+
found.set(n, entry({ name: n }, [ev], "low"));
|
|
1574
|
+
};
|
|
1575
|
+
for (const wf of workflows) {
|
|
1576
|
+
const { lines } = wf;
|
|
1577
|
+
const jobsAt = lines.findIndex((l) => /^jobs:\s*$/.test(l));
|
|
1578
|
+
if (jobsAt < 0) continue;
|
|
1579
|
+
for (let i = jobsAt + 1; i < lines.length; i += 1) {
|
|
1580
|
+
if (!/#.*\brequired\b/i.test(lines[i])) continue;
|
|
1581
|
+
let name = null;
|
|
1582
|
+
for (let j = i + 1; j <= i + 4 && j < lines.length; j += 1) {
|
|
1583
|
+
const m = lines[j].match(/^\s*name:\s*(.+?)\s*$/);
|
|
1584
|
+
if (m) {
|
|
1585
|
+
name = m[1];
|
|
1586
|
+
break;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
if (!name) {
|
|
1590
|
+
for (let j = i; j > jobsAt; j -= 1) {
|
|
1591
|
+
const m = lines[j].match(/^ {2}([A-Za-z0-9_-]+):\s*$/);
|
|
1592
|
+
if (m) {
|
|
1593
|
+
name = m[1];
|
|
1594
|
+
break;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
if (name) add(name, `${wf.path}:${i + 1}`);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
for (const doc of ["CONTRIBUTING.md", ".github/pull_request_template.md", "CLAUDE.md"]) {
|
|
1602
|
+
if (!files.includes(doc)) continue;
|
|
1603
|
+
(readText(top, doc) || "").split("\n").forEach((line, i) => {
|
|
1604
|
+
if (!/required (status )?checks?/i.test(line)) return;
|
|
1605
|
+
for (const m of line.matchAll(/`([^`]+)`/g)) add(m[1], `${doc}:${i + 1}`);
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
return [...found.values()];
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/** The in-repo documents the repo's CLAUDE.md files point at, through the shared bounded follower. */
|
|
1612
|
+
function deriveDocs(top) {
|
|
1613
|
+
const { files } = followClaudeMdLinks(top, { content: false });
|
|
1614
|
+
const out = new Map();
|
|
1615
|
+
for (const f of files) {
|
|
1616
|
+
if (!out.has(f.path)) out.set(f.path, entry({ path: f.path }, [f.from], "high"));
|
|
1617
|
+
}
|
|
1618
|
+
return [...out.values()].slice(0, MAX_ENTRIES);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
function frontmatter(text) {
|
|
1622
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
1623
|
+
if (!m) return {};
|
|
1624
|
+
const out = {};
|
|
1625
|
+
const lines = m[1].split("\n");
|
|
1626
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1627
|
+
const kv = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
1628
|
+
if (!kv) continue;
|
|
1629
|
+
let val = kv[2].trim();
|
|
1630
|
+
if (/^[>|][-+]?$/.test(val)) {
|
|
1631
|
+
const parts = [];
|
|
1632
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1]))
|
|
1633
|
+
parts.push(lines[(i += 1)].trim());
|
|
1634
|
+
val = parts.join(" ");
|
|
1635
|
+
}
|
|
1636
|
+
out[kv[1]] = unquote(val);
|
|
1637
|
+
}
|
|
1638
|
+
return out;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function deriveSkills(top) {
|
|
1642
|
+
const dir = join(top, ".claude", "skills");
|
|
1643
|
+
let names;
|
|
1644
|
+
try {
|
|
1645
|
+
names = readdirSync(dir, { withFileTypes: true })
|
|
1646
|
+
.filter((d) => d.isDirectory())
|
|
1647
|
+
.map((d) => d.name);
|
|
1648
|
+
} catch {
|
|
1649
|
+
return [];
|
|
1650
|
+
}
|
|
1651
|
+
const out = [];
|
|
1652
|
+
for (const n of names.sort()) {
|
|
1653
|
+
const rel = `.claude/skills/${n}/SKILL.md`;
|
|
1654
|
+
const text = readText(top, rel);
|
|
1655
|
+
if (text === null) continue;
|
|
1656
|
+
const fm = frontmatter(text);
|
|
1657
|
+
const e = entry(
|
|
1658
|
+
{
|
|
1659
|
+
name: fm.name || n,
|
|
1660
|
+
path: rel,
|
|
1661
|
+
trigger:
|
|
1662
|
+
String(fm["disable-model-invocation"]).toLowerCase() === "true" ? "explicit" : "auto",
|
|
1663
|
+
},
|
|
1664
|
+
[`${rel}:1`],
|
|
1665
|
+
"high",
|
|
1666
|
+
);
|
|
1667
|
+
if (fm.description) e.description = fm.description.slice(0, 300);
|
|
1668
|
+
out.push(e);
|
|
1669
|
+
}
|
|
1670
|
+
return out;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function deriveResourceSource(workflows, history, isBot) {
|
|
1674
|
+
const botNames = new Set(history.filter(isBot).map((c) => c.an));
|
|
1675
|
+
for (const wf of workflows) {
|
|
1676
|
+
if (!workflowBotNames(wf, botNames).size) continue;
|
|
1677
|
+
const i = wf.lines.findIndex((l) => /^\s*repository:\s*[\w.-]+\/[\w.-]+\s*$/.test(l));
|
|
1678
|
+
if (i < 0) continue;
|
|
1679
|
+
const repo = unquote(wf.lines[i].split(":").slice(1).join(":"));
|
|
1680
|
+
return role(
|
|
1681
|
+
{ repo, authoring: {} },
|
|
1682
|
+
[`${wf.path}:${i + 1}`],
|
|
1683
|
+
"low",
|
|
1684
|
+
"checkout in a workflow that names a bot",
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
return role(null);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// ---------------------------------------------------------------------------
|
|
1691
|
+
// entry point
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* Derive a draft profile. Reads the repo, writes nothing.
|
|
1695
|
+
*
|
|
1696
|
+
* @param {string} repo path inside the repo (a worktree is fine)
|
|
1697
|
+
* @param {{botPattern?: string, historyWindow?: number, conventions?: object|null, now?: Date}} [opts]
|
|
1698
|
+
* @returns {object} a profile that satisfies repo-profile.schema.json
|
|
1699
|
+
*/
|
|
1700
|
+
export function deriveProfile(repo, opts = {}) {
|
|
1701
|
+
const started = Date.now();
|
|
1702
|
+
const {
|
|
1703
|
+
botPattern,
|
|
1704
|
+
historyWindow = DEFAULT_HISTORY,
|
|
1705
|
+
conventions = null,
|
|
1706
|
+
now = new Date(),
|
|
1707
|
+
} = opts;
|
|
1708
|
+
const repoRoot = mainRepoRoot(repo);
|
|
1709
|
+
const top = gitOut(repo, ["rev-parse", "--show-toplevel"]).trim() || repoRoot;
|
|
1710
|
+
const files = [];
|
|
1711
|
+
const contentFiles = [];
|
|
1712
|
+
for (const rec of gitOut(top, ["ls-files", "-s", "-z"]).split("\0")) {
|
|
1713
|
+
const tab = rec.indexOf("\t");
|
|
1714
|
+
if (tab < 0) continue;
|
|
1715
|
+
const path = rec.slice(tab + 1);
|
|
1716
|
+
files.push(path);
|
|
1717
|
+
// 120000 is a symlink, 160000 a submodule: neither is content to read.
|
|
1718
|
+
if (!/^1[26]0000 /.test(rec)) contentFiles.push(path);
|
|
1719
|
+
}
|
|
1720
|
+
const trackedDirs = new Set();
|
|
1721
|
+
for (const f of files) for (const d of ancestors(f)) trackedDirs.add(d);
|
|
1722
|
+
const history = readHistory(top, historyWindow);
|
|
1723
|
+
const isBot = botMatcher(botPattern);
|
|
1724
|
+
const workflows = readWorkflows(top, files);
|
|
1725
|
+
const commands = workflows.flatMap(runCommands);
|
|
1726
|
+
const head = gitOut(top, ["rev-parse", "--verify", "--quiet", "HEAD"]).trim();
|
|
1727
|
+
|
|
1728
|
+
const language = dominantLanguage(contentFiles);
|
|
1729
|
+
const generators = deriveGenerators(top, contentFiles, trackedDirs, history, commands);
|
|
1730
|
+
const outPrefixes = generators.map((g) => globPrefix(g.output)).filter(Boolean);
|
|
1731
|
+
const genFiles = files.filter(
|
|
1732
|
+
(f) => SOURCE_EXT.test(f) && outPrefixes.some((p) => underPrefix(f, p)),
|
|
1733
|
+
);
|
|
1734
|
+
const candidates = accessorCandidates(top, genFiles);
|
|
1735
|
+
const rootLiterals = Object.values(candidates).flatMap((d) => [...d.keys()].map((n) => `${n}.`));
|
|
1736
|
+
const usage = scanUsage(top, [
|
|
1737
|
+
...new Set([...rootLiterals, ...INJECT_LITERALS, ...MOCK_LITERALS]),
|
|
1738
|
+
]);
|
|
1739
|
+
const { build, test, lint } = deriveCommands(top, files, commands);
|
|
1740
|
+
const profile = {
|
|
1741
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1742
|
+
repoRoot,
|
|
1743
|
+
repoHead: /^[0-9a-f]{40}$/.test(head) ? head : null,
|
|
1744
|
+
derivedAt: now.toISOString(),
|
|
1745
|
+
confirmedAt: null,
|
|
1746
|
+
derivation: {
|
|
1747
|
+
botPattern: botPattern || BOT_DEFAULT.source,
|
|
1748
|
+
historyWindow,
|
|
1749
|
+
durationMs: 0,
|
|
1750
|
+
conventions: Boolean(conventions),
|
|
1751
|
+
},
|
|
1752
|
+
repo: deriveBranches(top, workflows),
|
|
1753
|
+
commit: { format: deriveCommitFormat(history, isBot) },
|
|
1754
|
+
build,
|
|
1755
|
+
test,
|
|
1756
|
+
lint,
|
|
1757
|
+
ownedPaths: deriveOwnedPaths(top, history, isBot, trackedDirs, files, workflows),
|
|
1758
|
+
generators,
|
|
1759
|
+
resourceSource: deriveResourceSource(workflows, history, isBot),
|
|
1760
|
+
accessors: deriveAccessors(candidates, usage, outPrefixes, conventions),
|
|
1761
|
+
di: deriveDi(top, contentFiles, usage, conventions, language),
|
|
1762
|
+
module: deriveModule(files, commands),
|
|
1763
|
+
mock: deriveMock(usage, trackedDirs, generators, language),
|
|
1764
|
+
hooks: deriveHooks(top, files),
|
|
1765
|
+
requiredChecks: deriveRequiredChecks(top, files, workflows),
|
|
1766
|
+
docs: { authoritative: deriveDocs(top) },
|
|
1767
|
+
inRepoSkills: deriveSkills(top),
|
|
1768
|
+
};
|
|
1769
|
+
profile.derivation.durationMs = Math.max(0, Date.now() - started);
|
|
1770
|
+
return profile;
|
|
1771
|
+
}
|