@mmerterden/multi-agent-pipeline 20.22.0 → 20.23.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 (49) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/docs/adr/0010-own-code-graph.md +3 -2
  3. package/docs/facts.json +3 -3
  4. package/docs/features.md +2 -2
  5. package/install/_usage-lifecycle.mjs +44 -0
  6. package/install/catalog-history.json +1 -1
  7. package/install/index.mjs +2 -0
  8. package/manifest.json +52 -49
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -1
  11. package/pipeline/commands/multi-agent/setup/SKILL.md +3 -3
  12. package/pipeline/commands/multi-agent/update/SKILL.md +3 -3
  13. package/pipeline/lib/json-file-lock.mjs +24 -2
  14. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  15. package/pipeline/multi-agent-refs/cross-cli-contract.md +1 -0
  16. package/pipeline/multi-agent-refs/features/analysis-jira.md +5 -0
  17. package/pipeline/multi-agent-refs/features/code-graph.md +40 -3
  18. package/pipeline/multi-agent-refs/features/usage-reporting.md +40 -23
  19. package/pipeline/multi-agent-refs/generate-issue.md +11 -1
  20. package/pipeline/multi-agent-refs/knowledge.md +18 -0
  21. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  22. package/pipeline/schemas/code-graph.schema.json +3 -3
  23. package/pipeline/schemas/prefs.schema.json +26 -4
  24. package/pipeline/scripts/_code-graph.mjs +121 -12
  25. package/pipeline/scripts/_diff-impact.mjs +12 -5
  26. package/pipeline/scripts/_supertypes.mjs +133 -0
  27. package/pipeline/scripts/analysis-story-body.mjs +24 -8
  28. package/pipeline/scripts/capture-flush.sh +4 -1
  29. package/pipeline/scripts/code-graph-rules/android.json +1 -0
  30. package/pipeline/scripts/code-graph-rules/ios.json +5 -0
  31. package/pipeline/scripts/code-graph-rules/node.json +1 -0
  32. package/pipeline/scripts/code-graph-rules/python.json +1 -0
  33. package/pipeline/scripts/git-path.mjs +21 -0
  34. package/pipeline/scripts/graph-affected.mjs +2 -1
  35. package/pipeline/scripts/graph-confirm.mjs +140 -0
  36. package/pipeline/scripts/learnings-ledger.mjs +3 -2
  37. package/pipeline/scripts/product-context.mjs +30 -19
  38. package/pipeline/scripts/symbol-existence-gate.mjs +21 -9
  39. package/pipeline/scripts/test-gap-scan.mjs +4 -1
  40. package/pipeline/scripts/test-strength.mjs +7 -8
  41. package/pipeline/scripts/usage-identity.mjs +1 -1
  42. package/pipeline/scripts/usage-register.mjs +181 -15
  43. package/pipeline/scripts/validate-code-graph.mjs +3 -3
  44. package/pipeline/scripts/valven-gate.mjs +9 -1
  45. package/pipeline/scripts/valven-port.mjs +16 -5
  46. package/pipeline/scripts/valven-split.mjs +6 -4
  47. package/pipeline/skills/.skill-manifest.json +3 -3
  48. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +3 -3
  49. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +3 -3
@@ -16,6 +16,11 @@ import { escapeJiraWiki, wikiLink } from "./jira-wiki-escape.mjs";
16
16
  import { redesignSections } from "../lib/analysis-sections.mjs";
17
17
 
18
18
  const MAX_CRITERIA = 8;
19
+
20
+ // Panel border colours of a story section, the ones testers read stories in:
21
+ // the user story, the acceptance criteria, and every other section.
22
+ const PANEL = { story: "#d4d39e", criteria: "#d4a19e", info: "#9eb6d4" };
23
+ const panel = (colour, lines) => [`{panel:borderColor=${colour}}`, ...lines, "{panel}", ""];
19
24
  const MAX_DESIGN_LINKS = 5;
20
25
 
21
26
  // A criterion that describes a refusal, an error or an alternative path. One is
@@ -152,19 +157,30 @@ function linksOf(story, lines, ctx) {
152
157
 
153
158
  function render({ userStory, acceptance, currentVsTarget, links }, sourceIds) {
154
159
  const out = [];
155
- if (userStory) out.push("h3. User story", escapeJiraWiki(userStory), "");
160
+ if (userStory) out.push(...panel(PANEL.story, ["h3. User story", escapeJiraWiki(userStory)]));
156
161
  if (acceptance.length) {
157
- out.push("h3. Acceptance criteria", ...acceptance.map((a) => `# ${escapeJiraWiki(a)}`), "");
162
+ out.push(
163
+ ...panel(PANEL.criteria, [
164
+ "h3. Acceptance criteria",
165
+ ...acceptance.map((a) => `# ${escapeJiraWiki(a)}`),
166
+ ]),
167
+ );
158
168
  }
159
169
  if (currentVsTarget.length) {
160
- out.push("h3. Current vs target", "||Id||Status||Where||");
161
- for (const r of currentVsTarget) {
162
- out.push(`|${escapeJiraWiki(r.id)}|${escapeJiraWiki(r.status)}|${escapeJiraWiki(r.where)}|`);
163
- }
164
- out.push("");
170
+ out.push(
171
+ ...panel(PANEL.info, [
172
+ "h3. Current vs target",
173
+ "||Id||Status||Where||",
174
+ ...currentVsTarget.map(
175
+ (r) => `|${escapeJiraWiki(r.id)}|${escapeJiraWiki(r.status)}|${escapeJiraWiki(r.where)}|`,
176
+ ),
177
+ ]),
178
+ );
165
179
  }
166
180
  if (links.length) {
167
- out.push("h3. Links", ...links.map((l) => `* ${wikiLink(l.label, l.url)}`), "");
181
+ out.push(
182
+ ...panel(PANEL.info, ["h3. Links", ...links.map((l) => `* ${wikiLink(l.label, l.url)}`)]),
183
+ );
168
184
  }
169
185
  out.push(
170
186
  `Sources: ${escapeJiraWiki(sourceIds.length ? sourceIds.join(", ") : "derived from the section heading")}`,
@@ -66,7 +66,10 @@ LOGS="${LOGS_ROOT:-$HOME/.claude/logs/multi-agent}"
66
66
  SCRIPTS="$HOME/.claude/scripts"
67
67
  [ -d "$SCRIPTS" ] || SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
68
68
 
69
- say() { [ "$QUIET" -eq 1 ] && return 0; printf '%s\n' "$1"; }
69
+ say() {
70
+ [ "$QUIET" -eq 1 ] && return 0
71
+ if [ "$JSON" -eq 1 ]; then printf '%s\n' "$1" >&2; else printf '%s\n' "$1"; fi
72
+ }
70
73
 
71
74
  # Resolve the run to flush.
72
75
  #
@@ -51,6 +51,7 @@
51
51
  }
52
52
  ],
53
53
  "referenceKinds": ["class", "interface", "object", "enum", "record", "typealias"],
54
+ "supertypes": { "syntax": "colon", "kinds": ["class", "interface", "object", "enum"] },
54
55
  "referenceKindsNote": "fun is deliberately absent, for the reason Swift's rules record for func: a bare lowercase name matched across files is almost never a call to that exact declaration, and on a large app the generic ones (invoke, build, create, get) take the top god-node slots purely because each happened to be declared once. Composables are the tempting exception - they are capitalised and behave like types - but they are declared with fun, so including them means including every other fun with them.",
55
56
  "ignoredIdentifiers": [
56
57
  "this",
@@ -51,6 +51,11 @@
51
51
  }
52
52
  ],
53
53
  "referenceKinds": ["class", "struct", "enum", "protocol", "actor", "typealias"],
54
+ "supertypes": {
55
+ "syntax": "colon",
56
+ "kinds": ["class", "struct", "enum", "protocol", "actor"],
57
+ "extensionPattern": "\\bextension\\s+([A-Z][A-Za-z0-9_.]*)"
58
+ },
54
59
  "referenceKindsNote": "func is deliberately absent. A bare lowercase method name matched across files is almost never a call to that exact declaration: on a large Swift app `with`, `size`, `name`, `image` and `model` took the top god-node slots purely because each happened to be declared in exactly one file. Functions still reach the graph through their `defines` edge, so they stay findable by name.",
55
60
  "ignoredIdentifiers": [
56
61
  "self",
@@ -46,6 +46,7 @@
46
46
  }
47
47
  ],
48
48
  "referenceKinds": ["class", "interface", "type", "enum", "const"],
49
+ "supertypes": { "syntax": "extends", "kinds": ["class", "interface"] },
49
50
  "referenceKindsNote": "function is deliberately absent, for the reason Swift's rules record for func: a bare name matched across files is almost never a call to that exact declaration, and the generic ones (run, main, get, parse, handler) would take the top god-node slots purely because each happened to be declared once. Functions still reach the graph through their defines edge, so they stay findable by name. const is present but narrowed to an uppercase-initial declaration at column 0, which is what a component, a singleton or an exported table looks like; a lowercase const is a local and is not a declaration anyone references across files. Measured on this repo (126 sources): including function put `ok` at degree 52, `f` at 38, `tokens` at 15 and `arg` at 12 - local helper names that happen to be declared exactly once, so the ambiguity rule does not catch them either. Excluding it leaves a thin symbol layer, and that is the honest shape of this stack: a JavaScript module's exported unit is usually a function, so the useful graph here is the import graph between files. `graph-affected \"<file>.mjs\"` answers who imports a module, which is the question this stack actually gets asked.",
50
51
  "ignoredIdentifiers": [
51
52
  "this",
@@ -30,6 +30,7 @@
30
30
  }
31
31
  ],
32
32
  "referenceKinds": ["class"],
33
+ "supertypes": { "syntax": "parens", "kinds": ["class"] },
33
34
  "referenceKindsNote": "def is deliberately absent, for the reason Swift's rules record for func and the node rules record with measurements: a bare lowercase name matched across files is almost never a call to that exact declaration, and Python's convention of short module-level helpers (main, run, load, parse, setup) makes it worse than most. Functions still reach the graph through their defines edge, so they stay findable by name.",
34
35
  "ignoredIdentifiers": [
35
36
  "self",
@@ -61,3 +61,24 @@ export function unquotePathIfNeeded(s) {
61
61
  }
62
62
  return s;
63
63
  }
64
+
65
+ /**
66
+ * The path a `---` or `+++` diff header names (the text after that marker): a
67
+ * quoted path ends at its closing quote, which git may follow with a tab, and
68
+ * an unquoted one ends at a tab.
69
+ *
70
+ * @param {string} text
71
+ * @returns {string}
72
+ */
73
+ export function diffHeaderPath(text) {
74
+ if (text.startsWith('"')) {
75
+ for (let i = 1; i < text.length; i++) {
76
+ if (text[i] === "\\") {
77
+ i++;
78
+ continue;
79
+ }
80
+ if (text[i] === '"') return unquoteGitPath(text.slice(1, i));
81
+ }
82
+ }
83
+ return text.replace(/\t.*$/, "");
84
+ }
@@ -171,7 +171,8 @@ function runDiff(flags, graph, depth, kinds) {
171
171
  text = diffFor(root, flags.diff);
172
172
  }
173
173
  const freshness = graphFreshness(graph, { root });
174
- const side = graphSide(graph, { root });
174
+ const range = flags["diff-file"] && flags["diff-file"] !== true ? undefined : flags.diff;
175
+ const side = graphSide(graph, { root, range });
175
176
  const mapped = changedSymbols(graph, parseHunkRanges(text, side));
176
177
  const hits = affected({ graph, seeds: mapped.seeds, depth, kinds });
177
178
  const result = {
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file graph-confirm.mjs - a name-based blast radius, checked by a language server.
5
+ *
6
+ * The code graph joins files to declarations by name, so a dependent it lists
7
+ * may only share a name with the changed type, and a dependent behind an
8
+ * ambiguous name is missing. The toolkit's `code_references` answers the same
9
+ * question semantically when the project has an index. This merges the two
10
+ * without trusting either blindly:
11
+ *
12
+ * confirmed a direct dependent the server also found
13
+ * name-only a direct dependent a ready semantic index did not find
14
+ * unchecked a deeper ring the server did not name (it depends on an
15
+ * intermediate declaration), or no answer could check it
16
+ *
17
+ * A deeper ring the server names directly is confirmed too.
18
+ *
19
+ * `lspOnly` lists files the server found that the graph did not. Only a result
20
+ * with `semantic: true` and `indexReady: true` confirms or demotes anything; a
21
+ * fallback answer or an index still building leaves every file unchecked,
22
+ * because "not found" from an unready index is not evidence.
23
+ *
24
+ * Inputs:
25
+ * --affected <path> graph-affected --json output
26
+ * --references <path> JSON array of code_references results, one per changed type
27
+ * --root <path> repository root the server's absolute paths are under
28
+ *
29
+ * Exit codes: 0 answered, 64 usage error.
30
+ *
31
+ * Zero runtime dependencies (ADR-0004).
32
+ *
33
+ * @module pipeline/scripts/graph-confirm
34
+ */
35
+
36
+ import { readFileSync, realpathSync } from "node:fs";
37
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
38
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
39
+
40
+ /**
41
+ * @param {{affected: Array<{path: string|null, dist: number}>}} affectedDoc
42
+ * @param {Array<{semantic?: boolean, indexReady?: boolean, results?: Array<{file: string}>}>} references
43
+ * @param {{root: string}} opts
44
+ */
45
+ export function confirmImpact(affectedDoc, references, { root }) {
46
+ const usable = (references || []).filter(
47
+ (r) => r && r.semantic === true && r.indexReady === true,
48
+ );
49
+ const checked = usable.length > 0 && usable.length === (references || []).length;
50
+ const real = (p) => {
51
+ const abs = resolve(p);
52
+ try {
53
+ return realpathSync(abs);
54
+ } catch {
55
+ const parent = dirname(abs);
56
+ return parent === abs ? abs : join(real(parent), basename(abs));
57
+ }
58
+ };
59
+ const repo = real(root);
60
+ // A server answers with paths relative to its own workspace root (the
61
+ // nearest package), absolute ones through whatever spelling of a symlinked
62
+ // directory it was given, and files outside the repository (SDKs).
63
+ const rel = (file, serverRoot) => {
64
+ const abs = isAbsolute(file) ? file : resolve(serverRoot || root, file);
65
+ const r = relative(repo, real(abs));
66
+ return r && !r.startsWith("..") && !isAbsolute(r) ? r : null;
67
+ };
68
+ const found = new Set();
69
+ for (const r of usable)
70
+ for (const loc of r.results || []) {
71
+ const f = loc?.file ? rel(loc.file, r.root) : null;
72
+ if (f) found.add(f);
73
+ }
74
+
75
+ const seedPaths = new Set((affectedDoc.changedSymbols || []).map((c) => c.path).filter(Boolean));
76
+ for (const id of affectedDoc.seeds || []) {
77
+ const s = String(id);
78
+ if (s.startsWith("sym:")) seedPaths.add(s.slice(4, s.lastIndexOf("#")));
79
+ else if (s.startsWith("file:")) seedPaths.add(s.slice(5));
80
+ }
81
+ const graphPaths = new Set();
82
+ const affected = (affectedDoc.affected || []).map((row) => {
83
+ if (row.path) graphPaths.add(row.path);
84
+ let confirmation = "unchecked";
85
+ if (checked && row.path && found.has(row.path)) confirmation = "confirmed";
86
+ else if (checked && row.dist === 1 && row.path) confirmation = "name-only";
87
+ return { ...row, confirmation };
88
+ });
89
+
90
+ const lspOnly = checked
91
+ ? [...found].filter((f) => !graphPaths.has(f) && !seedPaths.has(f)).sort()
92
+ : [];
93
+ const byPath = new Map();
94
+ for (const a of affected) if (a.path && !byPath.has(a.path)) byPath.set(a.path, a.confirmation);
95
+ const count = (c) => [...byPath.values()].filter((v) => v === c).length;
96
+ return {
97
+ affected,
98
+ lspOnly,
99
+ summary: {
100
+ confirmed: count("confirmed"),
101
+ nameOnly: count("name-only"),
102
+ unchecked: count("unchecked"),
103
+ lspOnly: lspOnly.length,
104
+ checked,
105
+ },
106
+ };
107
+ }
108
+
109
+ function flag(argv, name) {
110
+ const i = argv.indexOf(name);
111
+ const v = i >= 0 ? argv[i + 1] : undefined;
112
+ return v && !v.startsWith("--") ? v : null;
113
+ }
114
+
115
+ function main(argv) {
116
+ const affectedPath = flag(argv, "--affected");
117
+ const referencesPath = flag(argv, "--references");
118
+ const root = flag(argv, "--root");
119
+ if (!affectedPath || !referencesPath || !root) {
120
+ process.stderr.write(
121
+ "usage: graph-confirm.mjs --affected <json> --references <json> --root <path>\n",
122
+ );
123
+ process.exitCode = 64;
124
+ return;
125
+ }
126
+ let affectedDoc;
127
+ let references;
128
+ try {
129
+ affectedDoc = JSON.parse(readFileSync(affectedPath, "utf8"));
130
+ references = JSON.parse(readFileSync(referencesPath, "utf8"));
131
+ } catch (e) {
132
+ process.stderr.write(`graph-confirm: ${e.message}\n`);
133
+ process.exitCode = 64;
134
+ return;
135
+ }
136
+ if (!Array.isArray(references)) references = [references];
137
+ process.stdout.write(`${JSON.stringify(confirmImpact(affectedDoc, references, { root }))}\n`);
138
+ }
139
+
140
+ if (invokedDirectly(import.meta.url)) main(process.argv.slice(2));
@@ -41,7 +41,8 @@
41
41
  * Distill the triage's rejected[] findings into rejected-preference
42
42
  * entries so reviewers stop re-raising them. SAFETY: blocking-
43
43
  * severity rejections are NOT distilled - a wrong rejection of a
44
- * blocking issue must never permanently suppress that class.
44
+ * blocking issue must never permanently suppress that class. Exit 2
45
+ * when nothing new was distilled (a repeat of the same triage).
45
46
  * forget [--statement "..."] [--kind <kind>] [--repo-slug <slug>]
46
47
  * Remove ledger entries matching the statement substring and/or
47
48
  * kind (rewrites the file). Use to clear a bad/stale learning.
@@ -311,7 +312,7 @@ function cmdFromTriage() {
311
312
  }
312
313
  }
313
314
  process.stdout.write(JSON.stringify({ ok: true, slug, added: written, skippedBlocking }) + "\n");
314
- process.exitCode = 0;
315
+ process.exitCode = written ? 0 : 2;
315
316
  return;
316
317
  }
317
318
 
@@ -40,19 +40,11 @@
40
40
  */
41
41
 
42
42
  import { execFileSync } from "node:child_process";
43
- import {
44
- appendFileSync,
45
- chmodSync,
46
- existsSync,
47
- mkdirSync,
48
- readFileSync,
49
- renameSync,
50
- statSync,
51
- writeFileSync,
52
- } from "node:fs";
43
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
53
44
  import { homedir } from "node:os";
54
45
  import { dirname, join } from "node:path";
55
46
  import { fileURLToPath } from "node:url";
47
+ import { lockTimeoutFrom, withFileLockSync, writeJsonAtomicSync } from "../lib/json-file-lock.mjs";
56
48
 
57
49
  export const STACKS = ["ios", "android", "web", "backend", "resources"];
58
50
  const AUTH_SCHEMES = ["basic", "bearer"];
@@ -429,13 +421,26 @@ function defaultPrefsPath() {
429
421
  );
430
422
  }
431
423
 
432
- function writePrefsAtomic(file, prefs) {
424
+ /**
425
+ * Read, change and replace the preferences file under the lock write-state and
426
+ * migrate-prefs take, so a setup or a second run writing at the same moment is
427
+ * never overwritten. A file that cannot be parsed stops the write instead of
428
+ * being replaced; a symlinked file is written through to its target, at its
429
+ * own mode.
430
+ */
431
+ function updatePrefs(file, mutate) {
433
432
  mkdirSync(dirname(file), { recursive: true });
434
- const tmp = `${file}.tmp.${process.pid}`;
435
- const mode = existsSync(file) ? statSync(file).mode & 0o777 : 0o600;
436
- writeFileSync(tmp, `${JSON.stringify(prefs, null, 2)}\n`, { mode });
437
- chmodSync(tmp, mode);
438
- renameSync(tmp, file);
433
+ return withFileLockSync(
434
+ file,
435
+ () => {
436
+ const current = existsSync(file) ? readJson(file) : { global: {}, projects: {} };
437
+ const next = mutate(current);
438
+ const mode = existsSync(file) ? statSync(file).mode & 0o777 : 0o600;
439
+ writeJsonAtomicSync(file, next, { mode, followSymlink: true });
440
+ return next;
441
+ },
442
+ { timeoutMs: lockTimeoutFrom(process.env) },
443
+ );
439
444
  }
440
445
 
441
446
  function readJson(file) {
@@ -491,11 +496,13 @@ function main() {
491
496
  fail("learn needs --project, --product and --repos");
492
497
  let next;
493
498
  try {
494
- next = learnProduct(loadPrefs(), args.project, args.product, readJson(args.repos));
499
+ const repos = readJson(args.repos);
500
+ next = updatePrefs(prefsFile, (prefs) =>
501
+ learnProduct(prefs, args.project, args.product, repos),
502
+ );
495
503
  } catch (e) {
496
504
  fail(e.message);
497
505
  }
498
- writePrefsAtomic(prefsFile, next);
499
506
  process.stdout.write(
500
507
  `${JSON.stringify({ product: args.product, repos: next.global.products[args.product].repos.length })}\n`,
501
508
  );
@@ -510,7 +517,11 @@ function main() {
510
517
  }
511
518
  case "decline": {
512
519
  if (!args.project) fail("decline needs --project");
513
- writePrefsAtomic(prefsFile, declineProduct(loadPrefs(), args.project));
520
+ try {
521
+ updatePrefs(prefsFile, (prefs) => declineProduct(prefs, args.project));
522
+ } catch (e) {
523
+ fail(e.message);
524
+ }
514
525
  process.stdout.write(`${JSON.stringify({ project: args.project, product: "" })}\n`);
515
526
  break;
516
527
  }
@@ -46,6 +46,7 @@ import { invokedDirectly } from "../lib/invoked-directly.mjs";
46
46
  import { attendedVerdict } from "../lib/unattended.mjs";
47
47
  import { getAdapter, resolveStack, AdapterError } from "./_stack-adapter.mjs";
48
48
  import { globToRegExp } from "./glob-match.mjs";
49
+ import { diffHeaderPath } from "./git-path.mjs";
49
50
  import { gateContext, recordQuietly } from "./gate-ledger.mjs";
50
51
 
51
52
  const GATE = "symbol-existence";
@@ -208,13 +209,13 @@ function keyExists(ref, { keys, idents }) {
208
209
 
209
210
  function listFiles(dir, head) {
210
211
  if (head) {
211
- const r = git(dir, ["ls-tree", "-r", "--name-only", head]);
212
+ const r = git(dir, ["ls-tree", "-r", "-z", "--name-only", head]);
212
213
  if (!r.ok) throw new UsageError(`cannot list ${head}: ${r.err}`);
213
- return r.out.split("\n").filter(Boolean);
214
+ return r.out.split("\0").filter(Boolean);
214
215
  }
215
- const tracked = git(dir, ["ls-files", "--cached"]);
216
- const untracked = git(dir, ["ls-files", "--others", "--exclude-standard"]);
217
- return [...new Set([...tracked.out.split("\n"), ...untracked.out.split("\n")].filter(Boolean))];
216
+ const tracked = git(dir, ["ls-files", "-z", "--cached"]);
217
+ const untracked = git(dir, ["ls-files", "-z", "--others", "--exclude-standard"]);
218
+ return [...new Set([...tracked.out.split("\0"), ...untracked.out.split("\0")].filter(Boolean))];
218
219
  }
219
220
 
220
221
  function readAt(dir, head, file) {
@@ -236,14 +237,25 @@ function readAt(dir, head, file) {
236
237
  */
237
238
  export function addedLines(dir, base, head) {
238
239
  const range = head ? [base, head] : [base];
239
- const d = git(dir, ["diff", "-U0", "--no-color", "--no-ext-diff", "--diff-filter=AMR", ...range]);
240
+ const d = git(dir, [
241
+ "diff",
242
+ "-U0",
243
+ "--no-color",
244
+ "--no-ext-diff",
245
+ "--src-prefix=a/",
246
+ "--dst-prefix=b/",
247
+ "--diff-filter=AMR",
248
+ "--end-of-options",
249
+ ...range,
250
+ ]);
240
251
  if (!d.ok) throw new UsageError(`git diff failed: ${d.err}`);
241
252
  const out = [];
242
253
  let file = null;
243
254
  let line = 0;
244
255
  for (const raw of d.out.split("\n")) {
245
256
  if (raw.startsWith("+++ ")) {
246
- file = raw === "+++ /dev/null" ? null : raw.slice(4).replace(/^b\//, "");
257
+ const path = diffHeaderPath(raw.slice(4));
258
+ file = path === "/dev/null" ? null : path.replace(/^b\//, "");
247
259
  continue;
248
260
  }
249
261
  const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
@@ -258,8 +270,8 @@ export function addedLines(dir, base, head) {
258
270
  }
259
271
  }
260
272
  if (!head) {
261
- const untracked = git(dir, ["ls-files", "--others", "--exclude-standard"]);
262
- for (const f of untracked.out.split("\n").filter(Boolean)) {
273
+ const untracked = git(dir, ["ls-files", "-z", "--others", "--exclude-standard"]);
274
+ for (const f of untracked.out.split("\0").filter(Boolean)) {
263
275
  const text = readAt(dir, null, f);
264
276
  if (text === null || text.includes("\u0000")) continue;
265
277
  text.split("\n").forEach((t, i) => out.push({ file: f, line: i + 1, text: t }));
@@ -207,7 +207,10 @@ function isTestPath(path) {
207
207
  rules.testPathPrefixes.some((p) => basename(path).startsWith(p))
208
208
  )
209
209
  return true;
210
- if (Array.isArray(rules.testPathContains) && rules.testPathContains.some((c) => path.includes(c)))
210
+ if (
211
+ Array.isArray(rules.testPathContains) &&
212
+ rules.testPathContains.some((c) => `/${path}`.includes(c))
213
+ )
211
214
  return true;
212
215
  return false;
213
216
  }
@@ -236,15 +236,14 @@ function prepare(opts) {
236
236
  }
237
237
 
238
238
  function changedFiles(root, base, head) {
239
- const r = git(root, ["diff", "--no-renames", "--name-status", base, head]);
239
+ const r = git(root, ["diff", "--no-renames", "--name-status", "-z", base, head]);
240
240
  if (!r.ok) throw new UsageError(`git diff failed: ${r.err}`);
241
- return r.out
242
- .split("\n")
243
- .filter(Boolean)
244
- .map((line) => {
245
- const [status, ...rest] = line.split("\t");
246
- return { status: status[0], path: rest.join("\t") };
247
- });
241
+ const fields = r.out.split("\0");
242
+ const out = [];
243
+ for (let i = 0; i + 1 < fields.length; i += 2) {
244
+ if (fields[i]) out.push({ status: fields[i][0], path: fields[i + 1] });
245
+ }
246
+ return out;
248
247
  }
249
248
 
250
249
  function testCommandFor(opts, adapter, worktree, file) {
@@ -67,7 +67,7 @@ const HOST = "github.com";
67
67
  const API_DEFAULT = "https://api.github.com";
68
68
  const DEFAULT_KEYS = ["id_ed25519", "id_ecdsa", "id_rsa"];
69
69
  export const LOGIN_METHODS = ["gh", "credential", "ssh"];
70
- const LOGIN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/;
70
+ export const LOGIN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/;
71
71
  const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
72
72
 
73
73
  export function defaultPrefsPath(env = process.env) {