balladeer 1.0.14 → 1.0.16

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 (77) hide show
  1. package/README.md +2 -0
  2. package/dist/behavior-map-schema.d.ts +107 -0
  3. package/dist/behavior-map-schema.js +232 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +77 -2
  6. package/dist/commands/guidance.d.ts +1 -1
  7. package/dist/commands/guidance.js +6 -0
  8. package/dist/commands/judge.d.ts +271 -0
  9. package/dist/commands/judge.js +1170 -0
  10. package/dist/commands/map.d.ts +120 -0
  11. package/dist/commands/map.js +601 -0
  12. package/dist/commands/mcp.d.ts +6 -0
  13. package/dist/commands/mcp.js +53 -0
  14. package/dist/commands/offers.d.ts +265 -0
  15. package/dist/commands/offers.js +801 -0
  16. package/dist/commands/risk.d.ts +29 -0
  17. package/dist/commands/risk.js +133 -0
  18. package/dist/copy.d.ts +24 -1
  19. package/dist/copy.js +91 -0
  20. package/dist/guidance-hook.mjs +230 -74
  21. package/dist/guidance.d.ts +2 -0
  22. package/dist/guidance.js +7 -1
  23. package/dist/headless-agent.d.ts +166 -0
  24. package/dist/headless-agent.js +416 -0
  25. package/dist/hook-trust.d.ts +31 -0
  26. package/dist/hook-trust.js +63 -0
  27. package/dist/judge-brief.d.ts +36 -0
  28. package/dist/judge-brief.js +89 -0
  29. package/dist/judge-hook.d.ts +84 -0
  30. package/dist/judge-hook.js +420 -0
  31. package/dist/map-brief.d.ts +16 -0
  32. package/dist/map-brief.js +41 -0
  33. package/dist/offer-brief.d.ts +95 -0
  34. package/dist/offer-brief.js +217 -0
  35. package/dist/owned-process.d.ts +65 -0
  36. package/dist/owned-process.js +146 -0
  37. package/dist/promise-meaning.d.ts +126 -0
  38. package/dist/promise-meaning.js +292 -0
  39. package/dist/risk/contract.d.ts +145 -0
  40. package/dist/risk/contract.js +74 -0
  41. package/dist/risk/describe.d.ts +7 -0
  42. package/dist/risk/describe.js +45 -0
  43. package/dist/risk/diff.d.ts +26 -0
  44. package/dist/risk/diff.js +174 -0
  45. package/dist/risk/extract.d.ts +43 -0
  46. package/dist/risk/extract.js +336 -0
  47. package/dist/risk/git.d.ts +30 -0
  48. package/dist/risk/git.js +118 -0
  49. package/dist/risk/import-graph.d.ts +41 -0
  50. package/dist/risk/import-graph.js +487 -0
  51. package/dist/risk/index.d.ts +20 -0
  52. package/dist/risk/index.js +20 -0
  53. package/dist/risk/paths.d.ts +16 -0
  54. package/dist/risk/paths.js +73 -0
  55. package/dist/risk/pipeline.d.ts +47 -0
  56. package/dist/risk/pipeline.js +121 -0
  57. package/dist/risk/priors.d.ts +18 -0
  58. package/dist/risk/priors.js +86 -0
  59. package/dist/risk/resources.d.ts +56 -0
  60. package/dist/risk/resources.js +374 -0
  61. package/dist/risk/score.d.ts +85 -0
  62. package/dist/risk/score.js +543 -0
  63. package/dist/risk/symbols.d.ts +35 -0
  64. package/dist/risk/symbols.js +348 -0
  65. package/dist/risk/text.d.ts +43 -0
  66. package/dist/risk/text.js +277 -0
  67. package/dist/risk/validate.d.ts +19 -0
  68. package/dist/risk/validate.js +154 -0
  69. package/dist/scratch-worktree.d.ts +51 -0
  70. package/dist/scratch-worktree.js +153 -0
  71. package/dist/self-update.d.ts +29 -2
  72. package/dist/self-update.js +96 -11
  73. package/dist/user-scope.d.ts +2 -0
  74. package/dist/user-scope.js +26 -2
  75. package/dist/wire.d.ts +55 -2
  76. package/dist/wire.js +5 -1
  77. package/package.json +1 -1
@@ -0,0 +1,45 @@
1
+ /**
2
+ * One reason as a person reads it: what in the change, and why it matters to
3
+ * the promise. Evidence only, never a verdict: a reason says where to look, and
4
+ * a judge says whether the promise still holds.
5
+ */
6
+ export function describeReason(reason) {
7
+ const { file, symbol, resource, delta, caseId, detail } = reason.evidence;
8
+ const distance = detail?.startsWith("distance:") ? Number(detail.slice("distance:".length)) : NaN;
9
+ const steps = (count) => (count === 1 ? "1 step" : `${count} steps`);
10
+ const quoted = (text) => `"${text.length > 90 ? `${text.slice(0, 87)}...` : text}"`;
11
+ switch (reason.signal) {
12
+ case "symbol_hit":
13
+ return detail === "call_site"
14
+ ? `${file} changes a line that calls ${symbol}`
15
+ : `${symbol} ${detail === "added" ? "added" : detail === "deleted" ? "deleted" : "changed"} in ${file}`;
16
+ case "linked_test_changed":
17
+ if (detail === "deleted")
18
+ return `its test ${file} was deleted`;
19
+ if (detail === "assertion_removed")
20
+ return `its test ${file} lost an assertion`;
21
+ if (detail === "renamed")
22
+ return `its test moved to ${file}`;
23
+ return `its test ${file} changed`;
24
+ case "sink_reach":
25
+ return `${file} newly reaches ${resource}${caseId ? `, the effect ${caseId} forbids` : ""}`;
26
+ case "resource":
27
+ return `${file} ${detail === "added" ? "starts using" : detail === "removed" ? "stops using" : "touches"} ${resource}`;
28
+ case "graph_forward":
29
+ return distance === 0
30
+ ? `${file} is one of its mapped files`
31
+ : `its code imports ${file} (${steps(distance)} away)`;
32
+ case "graph_reverse":
33
+ return `${file} imports its code (${steps(distance)} away)`;
34
+ case "intent_clash":
35
+ return detail === "reverses_outcome"
36
+ ? `the change says it undoes the promised outcome: ${quoted(delta)}`
37
+ : `the change's stated intent meets a non-goal: ${quoted(delta)}`;
38
+ case "delta_similarity":
39
+ return `the change reads like ${caseId ?? "a failing case"}: ${quoted(delta)}`;
40
+ case "prior_break":
41
+ return "it has broken before";
42
+ case "churn":
43
+ return "its code changes often";
44
+ }
45
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A zero-context unified diff, as `git diff -U0` prints it, read into files,
3
+ * hunks and the exact lines each hunk removed and added.
4
+ */
5
+ export type DiffLine = Readonly<{
6
+ line: number;
7
+ text: string;
8
+ }>;
9
+ export type DiffHunk = Readonly<{
10
+ oldStart: number;
11
+ oldLines: number;
12
+ newStart: number;
13
+ newLines: number;
14
+ removed: readonly DiffLine[];
15
+ added: readonly DiffLine[];
16
+ }>;
17
+ export type DiffFile = Readonly<{
18
+ path: string;
19
+ oldPath: string | null;
20
+ status: "added" | "modified" | "deleted" | "renamed";
21
+ binary: boolean;
22
+ hunks: readonly DiffHunk[];
23
+ }>;
24
+ /** Undoes git's C-style quoting of a path: `"a\303\251.ts"` → `aé.ts`. */
25
+ export declare function unquotePath(raw: string): string;
26
+ export declare function parseUnifiedDiff(text: string): DiffFile[];
@@ -0,0 +1,174 @@
1
+ /**
2
+ * A zero-context unified diff, as `git diff -U0` prints it, read into files,
3
+ * hunks and the exact lines each hunk removed and added.
4
+ */
5
+ /** Undoes git's C-style quoting of a path: `"a\303\251.ts"` → `aé.ts`. */
6
+ export function unquotePath(raw) {
7
+ if (!raw.startsWith('"') || !raw.endsWith('"'))
8
+ return raw;
9
+ const bytes = [];
10
+ const body = raw.slice(1, -1);
11
+ for (let i = 0; i < body.length; i += 1) {
12
+ const char = body[i];
13
+ if (char !== "\\") {
14
+ bytes.push(...Buffer.from(char, "utf8"));
15
+ continue;
16
+ }
17
+ const next = body[i + 1] ?? "";
18
+ if (/[0-7]/.test(next)) {
19
+ bytes.push(parseInt(body.slice(i + 1, i + 4), 8));
20
+ i += 3;
21
+ continue;
22
+ }
23
+ const escapes = {
24
+ n: 10,
25
+ t: 9,
26
+ r: 13,
27
+ '"': 34,
28
+ "\\": 92,
29
+ a: 7,
30
+ b: 8,
31
+ f: 12,
32
+ v: 11,
33
+ };
34
+ bytes.push(escapes[next] ?? next.charCodeAt(0));
35
+ i += 1;
36
+ }
37
+ return Buffer.from(bytes).toString("utf8");
38
+ }
39
+ function stripPrefix(path, prefix) {
40
+ const unquoted = unquotePath(path.trim());
41
+ return unquoted.startsWith(prefix) ? unquoted.slice(prefix.length) : unquoted;
42
+ }
43
+ /** `diff --git a/x b/x` with an unchanged path, which is the only form this line carries alone. */
44
+ function pathFromHeader(header) {
45
+ const rest = header.slice("diff --git ".length);
46
+ if (rest.startsWith('"')) {
47
+ const end = rest.indexOf('" ', 1);
48
+ return end < 0 ? undefined : stripPrefix(rest.slice(0, end + 1), "a/");
49
+ }
50
+ for (let at = rest.indexOf(" b/"); at >= 0; at = rest.indexOf(" b/", at + 1)) {
51
+ const left = rest.slice(0, at);
52
+ const right = rest.slice(at + 1);
53
+ if (left.slice(2) === right.slice(2))
54
+ return left.slice(2);
55
+ }
56
+ return undefined;
57
+ }
58
+ export function parseUnifiedDiff(text) {
59
+ const files = [];
60
+ let current;
61
+ let hunk;
62
+ const finishHunk = () => {
63
+ if (current !== undefined && hunk !== undefined) {
64
+ current.hunks.push({
65
+ oldStart: hunk.oldStart,
66
+ oldLines: hunk.oldLines,
67
+ newStart: hunk.newStart,
68
+ newLines: hunk.newLines,
69
+ removed: hunk.removed,
70
+ added: hunk.added,
71
+ });
72
+ }
73
+ hunk = undefined;
74
+ };
75
+ const finishFile = () => {
76
+ finishHunk();
77
+ if (current === undefined)
78
+ return;
79
+ let path = current.path;
80
+ let oldPath = current.oldPath;
81
+ if (current.minus !== undefined &&
82
+ current.minus !== "/dev/null" &&
83
+ oldPath === null &&
84
+ current.status !== "added")
85
+ oldPath = current.minus;
86
+ if (current.plus !== undefined && current.plus !== "/dev/null")
87
+ path = current.plus;
88
+ else if (current.status === "deleted" && current.minus !== undefined)
89
+ path = current.minus;
90
+ if (current.status !== "renamed")
91
+ oldPath = null;
92
+ files.push({
93
+ path,
94
+ oldPath,
95
+ status: current.status,
96
+ binary: current.binary,
97
+ hunks: current.hunks,
98
+ });
99
+ current = undefined;
100
+ };
101
+ for (const line of text.split("\n")) {
102
+ if (line.startsWith("diff --git ")) {
103
+ finishFile();
104
+ current = {
105
+ path: pathFromHeader(line) ?? "",
106
+ oldPath: null,
107
+ status: "modified",
108
+ binary: false,
109
+ hunks: [],
110
+ };
111
+ continue;
112
+ }
113
+ if (current === undefined)
114
+ continue;
115
+ if (hunk !== undefined) {
116
+ if (line.startsWith("-")) {
117
+ hunk.removed.push({ line: hunk.oldAt, text: line.slice(1) });
118
+ hunk.oldAt += 1;
119
+ continue;
120
+ }
121
+ if (line.startsWith("+")) {
122
+ hunk.added.push({ line: hunk.newAt, text: line.slice(1) });
123
+ hunk.newAt += 1;
124
+ continue;
125
+ }
126
+ if (line.startsWith("\\"))
127
+ continue;
128
+ if (line.startsWith(" ")) {
129
+ hunk.oldAt += 1;
130
+ hunk.newAt += 1;
131
+ continue;
132
+ }
133
+ }
134
+ const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
135
+ if (header) {
136
+ finishHunk();
137
+ const oldStart = Number(header[1]);
138
+ const newStart = Number(header[3]);
139
+ hunk = {
140
+ oldStart,
141
+ oldLines: header[2] === undefined ? 1 : Number(header[2]),
142
+ newStart,
143
+ newLines: header[4] === undefined ? 1 : Number(header[4]),
144
+ removed: [],
145
+ added: [],
146
+ oldAt: oldStart,
147
+ newAt: newStart,
148
+ };
149
+ continue;
150
+ }
151
+ if (line.startsWith("new file mode"))
152
+ current.status = "added";
153
+ else if (line.startsWith("deleted file mode"))
154
+ current.status = "deleted";
155
+ else if (line.startsWith("rename from ")) {
156
+ current.status = "renamed";
157
+ current.oldPath = unquotePath(line.slice("rename from ".length));
158
+ }
159
+ else if (line.startsWith("rename to "))
160
+ current.path = unquotePath(line.slice("rename to ".length));
161
+ else if (line.startsWith("copy to ")) {
162
+ current.status = "added";
163
+ current.path = unquotePath(line.slice("copy to ".length));
164
+ }
165
+ else if (line.startsWith("--- "))
166
+ current.minus = line === "--- /dev/null" ? "/dev/null" : stripPrefix(line.slice(4), "a/");
167
+ else if (line.startsWith("+++ "))
168
+ current.plus = line === "+++ /dev/null" ? "/dev/null" : stripPrefix(line.slice(4), "b/");
169
+ else if (line.startsWith("Binary files ") || line.startsWith("GIT binary patch"))
170
+ current.binary = true;
171
+ }
172
+ finishFile();
173
+ return files.filter((file) => file.path.length > 0);
174
+ }
@@ -0,0 +1,43 @@
1
+ import { type DiffFile } from "./diff.js";
2
+ import { type LineContext, type ResourceRef } from "./resources.js";
3
+ import { type ChangedFile, type ChangeExtraction, type Intent } from "./contract.js";
4
+ /** Per line of a SQL file: the table the surrounding statement names, and whether it is a CREATE body. */
5
+ export declare function sqlLineContexts(path: string, text: string): LineContext[];
6
+ export type FileChangeInput = Readonly<{
7
+ diff: DiffFile;
8
+ /** The file at the merge base (its old path for a rename), absent for an added file. */
9
+ baseText: string | undefined;
10
+ /** The file at head, absent for a deleted file. */
11
+ headText: string | undefined;
12
+ /** Resources the behavior maps name, looked for directly. */
13
+ watch?: readonly ResourceRef[];
14
+ /** Where the repository is, so its own TypeScript compiler can be found. */
15
+ root?: string;
16
+ }>;
17
+ /** The contract's per-file reading of one changed file. Pure: no git, no disk. */
18
+ export declare function analyzeFileChange(input: FileChangeInput): ChangedFile;
19
+ export type ExtractChangeOptions = Readonly<{
20
+ root: string;
21
+ /** Any ref or full sha. The diff is `base...head`: against their merge base. */
22
+ base: string;
23
+ head: string;
24
+ /** Resources the behavior maps name, looked for directly in the change. */
25
+ watch?: readonly ResourceRef[];
26
+ }>;
27
+ /**
28
+ * Runs `git diff base...head` and reads each changed file at head and at the
29
+ * merge base, then reads each one with `analyzeFileChange`. The extraction's
30
+ * `base` is the merge base, because that is what the diff and any judge compare
31
+ * against.
32
+ */
33
+ export declare function extractChange(options: ExtractChangeOptions): Promise<ChangeExtraction>;
34
+ /**
35
+ * The change's stated intent from its commit messages, with trailers removed:
36
+ * the model-free `intent` (`source: "commits"`). Absent when there are no
37
+ * commits between the two.
38
+ */
39
+ export declare function readCommitIntent(options: Readonly<{
40
+ root: string;
41
+ base: string;
42
+ head: string;
43
+ }>): Intent | undefined;
@@ -0,0 +1,336 @@
1
+ import { parseUnifiedDiff } from "./diff.js";
2
+ import { commitMessages, git, mergeBase, readBlobs, resolveCommit } from "./git.js";
3
+ import { isTestPath, languageOf } from "./paths.js";
4
+ import { isSqlPath, mentionsResource, resourceKey, resourcesInLine, routeFromPath, } from "./resources.js";
5
+ import { CHANGE_EXTRACTION_SCHEMA_VERSION, } from "./contract.js";
6
+ import { declaredSymbols, symbolsAtLines } from "./symbols.js";
7
+ import { identifierTokens } from "./text.js";
8
+ /**
9
+ * One structural reading of a change, shared by every promise it is scored
10
+ * against. No model is involved: symbols come from the TypeScript compiler or a
11
+ * declaration regex, resources and guards from regular expressions, constants
12
+ * from pairing removed and added lines. The optional `deltas` and `intent` are
13
+ * left for a model to add; `readCommitIntent` supplies an intent from commit
14
+ * messages without one.
15
+ */
16
+ const MAX_IDENTIFIERS = 500;
17
+ const MAX_SYMBOLS = 200;
18
+ const MAX_ANALYZED_BYTES = 1_500_000;
19
+ const MAX_BODY_LINES = 300;
20
+ const GUARD = /\b(?:if|elif|unless|throw|raise|return|assert\w*|guard|invariant|panic|abort|require|ensure|precondition|check\w*|validate\w*|verify\w*|reject|deny|forbid\w*)\b|\bexpect\s*\(|\.should\b|\?\?\s*throw/;
21
+ const TEST_CASE = /\b(?:it|test|describe|specify|scenario)(?:\.\w+)?\s*\(|^\s*def\s+test_|^\s*func\s+Test\w*\(/;
22
+ /** Per line of a SQL file: the table the surrounding statement names, and whether it is a CREATE body. */
23
+ export function sqlLineContexts(path, text) {
24
+ const contexts = [];
25
+ let table;
26
+ let creating = false;
27
+ let depth = 0;
28
+ for (const line of text.split("\n")) {
29
+ const statement = /\b(create|alter)\s+table\s+(?:if\s+(?:not\s+)?exists\s+)?(?:only\s+)?[`"]?([A-Za-z_][\w.]*)[`"]?/i.exec(line);
30
+ if (statement) {
31
+ table = (statement[2] ?? "").toLowerCase();
32
+ creating = (statement[1] ?? "").toLowerCase() === "create";
33
+ depth = 0;
34
+ contexts.push({ path, currentTable: table, inCreateTable: false });
35
+ }
36
+ else
37
+ contexts.push({ path, currentTable: table, inCreateTable: creating && depth > 0 });
38
+ if (creating) {
39
+ for (const char of line) {
40
+ if (char === "(")
41
+ depth += 1;
42
+ else if (char === ")")
43
+ depth -= 1;
44
+ }
45
+ }
46
+ if (line.includes(";")) {
47
+ table = undefined;
48
+ creating = false;
49
+ depth = 0;
50
+ }
51
+ }
52
+ return contexts;
53
+ }
54
+ function lineContexts(path, text) {
55
+ if (text === undefined || !isSqlPath(path))
56
+ return () => ({ path });
57
+ const contexts = sqlLineContexts(path, text);
58
+ return (line) => contexts[line - 1] ?? { path };
59
+ }
60
+ function mentionsIn(lines, context, watch) {
61
+ const resources = new Map();
62
+ for (const { line, text } of lines) {
63
+ const lineContext = context(line);
64
+ for (const ref of resourcesInLine(text, lineContext))
65
+ resources.set(resourceKey(ref), ref);
66
+ for (const ref of watch) {
67
+ if (mentionsResource(text, ref, lineContext))
68
+ resources.set(resourceKey(ref), ref);
69
+ }
70
+ }
71
+ return { resources, keys: new Set(resources.keys()) };
72
+ }
73
+ function numbered(text) {
74
+ if (text === undefined)
75
+ return [];
76
+ return text.split("\n").map((line, index) => ({ line: index + 1, text: line }));
77
+ }
78
+ /** Whether a symbol encloses the gap between two adjacent lines, where a pure deletion or insertion sits. */
79
+ function enclosingGap(symbols, after) {
80
+ return symbols.filter((symbol) => symbol.startLine <= after && symbol.endLine >= after + 1);
81
+ }
82
+ /** Literal values on a line, and the line with each literal replaced by a placeholder. */
83
+ function literalShape(line) {
84
+ const literals = [];
85
+ const shape = line
86
+ .trim()
87
+ .replace(/(["'`])(?:\\.|(?!\1)[^\\])*\1|\b0x[0-9a-fA-F]+\b|(?<![\w.])-?\d+(?:\.\d+)?(?:e[+-]?\d+)?(?:_\d+)*[nLfd]?\b|\b(?:true|false|True|False|null|None|nil)\b/g, (literal) => {
88
+ literals.push(literal);
89
+ return "\u0000";
90
+ });
91
+ return { shape, literals };
92
+ }
93
+ /**
94
+ * What a changed literal belongs to: the key or name it is assigned to, else
95
+ * the function around it, else the call it is passed to, else the line itself.
96
+ */
97
+ function constantName(line, enclosing) {
98
+ const quotedKey = /^\s*["']([^"']+)["']\s*:/.exec(line);
99
+ if (quotedKey?.[1] !== undefined)
100
+ return quotedKey[1];
101
+ const assignment = /(?:^|[\s,{(])([A-Za-z_$][\w$.]*)\s*(?::\s*[\w<>[\]|, ]+)?\s*(?:=(?!=)|:(?!:)|:=)/.exec(line.trim());
102
+ if (assignment?.[1] !== undefined)
103
+ return assignment[1];
104
+ if (enclosing !== undefined)
105
+ return enclosing;
106
+ const call = /([A-Za-z_$][\w$.]*)\s*\(/.exec(line.trim());
107
+ if (call?.[1] !== undefined)
108
+ return call[1];
109
+ return line.trim().slice(0, 80);
110
+ }
111
+ /** The narrowest symbol around a line. */
112
+ function innermost(symbols, line) {
113
+ let best;
114
+ for (const symbol of symbols) {
115
+ if (line < symbol.startLine || line > symbol.endLine)
116
+ continue;
117
+ if (best === undefined || symbol.endLine - symbol.startLine < best.endLine - best.startLine)
118
+ best = symbol;
119
+ }
120
+ return best?.name;
121
+ }
122
+ /** The contract's per-file reading of one changed file. Pure: no git, no disk. */
123
+ export function analyzeFileChange(input) {
124
+ const { diff } = input;
125
+ const watch = input.watch ?? [];
126
+ const path = diff.path;
127
+ const oldPath = diff.status === "renamed" ? diff.oldPath : null;
128
+ const basePath = oldPath ?? path;
129
+ const tooLarge = (text) => text !== undefined && text.length > MAX_ANALYZED_BYTES;
130
+ const baseText = diff.binary || tooLarge(input.baseText) ? undefined : input.baseText;
131
+ const headText = diff.binary || tooLarge(input.headText) ? undefined : input.headText;
132
+ const removed = diff.hunks.flatMap((hunk) => hunk.removed);
133
+ const added = diff.hunks.flatMap((hunk) => hunk.added);
134
+ // Symbols, from both sides.
135
+ const headRead = headText === undefined ? undefined : declaredSymbols(path, headText, input.root);
136
+ const baseRead = baseText === undefined ? undefined : declaredSymbols(basePath, baseText, input.root);
137
+ const headSymbols = headRead?.symbols ?? [];
138
+ const baseSymbols = baseRead?.symbols ?? [];
139
+ const symbolExtractor = (headRead ?? baseRead)?.extractor;
140
+ const headTouched = new Set(symbolsAtLines(headSymbols, new Set(added.map((line) => line.line))));
141
+ const baseTouched = new Set(symbolsAtLines(baseSymbols, new Set(removed.map((line) => line.line))));
142
+ for (const hunk of diff.hunks) {
143
+ if (hunk.newLines === 0)
144
+ for (const s of enclosingGap(headSymbols, hunk.newStart))
145
+ headTouched.add(s);
146
+ if (hunk.oldLines === 0)
147
+ for (const s of enclosingGap(baseSymbols, hunk.oldStart))
148
+ baseTouched.add(s);
149
+ }
150
+ const headNames = new Set(headSymbols.map((symbol) => symbol.name));
151
+ const baseNames = new Set(baseSymbols.map((symbol) => symbol.name));
152
+ const symbolsChanged = new Map();
153
+ for (const symbol of [...headTouched, ...baseTouched]) {
154
+ if (symbolsChanged.has(symbol.name))
155
+ continue;
156
+ const change = headNames.has(symbol.name) && !baseNames.has(symbol.name)
157
+ ? "added"
158
+ : baseNames.has(symbol.name) && !headNames.has(symbol.name)
159
+ ? "deleted"
160
+ : "modified";
161
+ symbolsChanged.set(symbol.name, { name: symbol.name, kind: symbol.kind, change });
162
+ if (symbolsChanged.size >= MAX_SYMBOLS)
163
+ break;
164
+ }
165
+ // Identifiers.
166
+ const identifiersAdded = identifierTokens(added.map((line) => line.text).join("\n")).slice(0, MAX_IDENTIFIERS);
167
+ const identifiersRemoved = identifierTokens(removed.map((line) => line.text).join("\n")).slice(0, MAX_IDENTIFIERS);
168
+ // Resources: changed lines, the bodies of changed functions, and each side whole.
169
+ const headContext = lineContexts(path, headText);
170
+ const baseContext = lineContexts(basePath, baseText);
171
+ const inAdded = mentionsIn(added, headContext, watch);
172
+ const inRemoved = mentionsIn(removed, baseContext, watch);
173
+ const bodyLines = (text, symbols) => {
174
+ const lines = numbered(text);
175
+ const picked = [];
176
+ for (const symbol of symbols) {
177
+ if (symbol.kind !== "function" && symbol.kind !== "method")
178
+ continue;
179
+ if (symbol.endLine - symbol.startLine > MAX_BODY_LINES)
180
+ continue;
181
+ picked.push(...lines.slice(symbol.startLine - 1, symbol.endLine));
182
+ }
183
+ return picked;
184
+ };
185
+ const inBodies = mentionsIn([...bodyLines(headText, headTouched), ...bodyLines(baseText, baseTouched)], headContext, watch);
186
+ // Whether each side reaches a resource anywhere, asked only of the resources found above.
187
+ const found = new Set([...inAdded.keys, ...inRemoved.keys, ...inBodies.keys]);
188
+ const watched = watch.filter((ref) => found.has(resourceKey(ref)));
189
+ const everywhereHead = mentionsIn(numbered(headText), headContext, watched).keys;
190
+ const everywhereBase = mentionsIn(numbered(baseText), baseContext, watched).keys;
191
+ const resources = new Map();
192
+ const classify = (key) => {
193
+ const onAddedSide = inAdded.keys.has(key) || (headText !== undefined && inBodies.keys.has(key));
194
+ if (onAddedSide && !everywhereBase.has(key))
195
+ return "added";
196
+ if (inRemoved.keys.has(key) && !everywhereHead.has(key))
197
+ return "removed";
198
+ return "touched";
199
+ };
200
+ for (const source of [inAdded, inRemoved, inBodies]) {
201
+ for (const [key, ref] of source.resources) {
202
+ if (!resources.has(key))
203
+ resources.set(key, { ...ref, change: classify(key) });
204
+ }
205
+ }
206
+ const served = routeFromPath(path) ?? (oldPath === null ? undefined : routeFromPath(oldPath));
207
+ if (served !== undefined && !resources.has(`route:${served}`)) {
208
+ resources.set(`route:${served}`, {
209
+ kind: "route",
210
+ name: served,
211
+ change: diff.status === "added" ? "added" : diff.status === "deleted" ? "removed" : "touched",
212
+ });
213
+ }
214
+ // Guards and test cases taken out: removed lines that do not come back verbatim.
215
+ const readded = new Set(added.map((line) => line.text.trim()));
216
+ const test = isTestPath(path) || (oldPath !== null && isTestPath(oldPath));
217
+ const guardDeletions = removed
218
+ .filter((line) => {
219
+ const text = line.text.trim();
220
+ if (text.length === 0 || readded.has(text))
221
+ return false;
222
+ if (/^(?:\/\/|#|\*|\/\*)/.test(text))
223
+ return false;
224
+ return GUARD.test(text) || (test && TEST_CASE.test(text));
225
+ })
226
+ .map((line) => ({ line: line.line, text: line.text.trim().slice(0, 200) }));
227
+ // Constants: a removed and an added line with the same shape and different literals.
228
+ const constantChanges = [];
229
+ for (const hunk of diff.hunks) {
230
+ if (hunk.removed.length === 0 || hunk.added.length === 0)
231
+ continue;
232
+ if (hunk.removed.length > 40 || hunk.added.length > 40)
233
+ continue;
234
+ const used = new Set();
235
+ for (const before of hunk.removed) {
236
+ const a = literalShape(before.text);
237
+ if (a.literals.length === 0)
238
+ continue;
239
+ for (let index = 0; index < hunk.added.length; index += 1) {
240
+ if (used.has(index))
241
+ continue;
242
+ const after = hunk.added[index];
243
+ const b = literalShape(after.text);
244
+ if (a.shape !== b.shape || a.literals.length !== b.literals.length)
245
+ continue;
246
+ const differing = a.literals
247
+ .map((literal, position) => [literal, b.literals[position] ?? ""])
248
+ .filter(([from, to]) => from !== to);
249
+ if (differing.length === 0)
250
+ continue;
251
+ used.add(index);
252
+ constantChanges.push({
253
+ name: constantName(after.text, innermost(headSymbols, after.line)),
254
+ from: differing.map(([from]) => from).join(", "),
255
+ to: differing.map(([, to]) => to).join(", "),
256
+ });
257
+ break;
258
+ }
259
+ }
260
+ }
261
+ return {
262
+ path,
263
+ status: diff.status,
264
+ oldPath,
265
+ language: languageOf(path),
266
+ isTest: test,
267
+ hunks: diff.hunks.map((hunk) => ({
268
+ oldStart: hunk.oldStart,
269
+ oldLines: hunk.oldLines,
270
+ newStart: hunk.newStart,
271
+ newLines: hunk.newLines,
272
+ })),
273
+ symbolsChanged: [...symbolsChanged.values()],
274
+ identifiersAdded,
275
+ identifiersRemoved,
276
+ resources: [...resources.values()].sort((a, b) => resourceKey(a).localeCompare(resourceKey(b))),
277
+ guardDeletions,
278
+ constantChanges,
279
+ ...(symbolExtractor === undefined ? {} : { symbolExtractor }),
280
+ };
281
+ }
282
+ /**
283
+ * Runs `git diff base...head` and reads each changed file at head and at the
284
+ * merge base, then reads each one with `analyzeFileChange`. The extraction's
285
+ * `base` is the merge base, because that is what the diff and any judge compare
286
+ * against.
287
+ */
288
+ export async function extractChange(options) {
289
+ const { root } = options;
290
+ const head = resolveCommit(root, options.head);
291
+ const base = mergeBase(root, resolveCommit(root, options.base), head);
292
+ const text = git(root, [
293
+ "diff",
294
+ "--no-color",
295
+ "--no-ext-diff",
296
+ "--no-textconv",
297
+ "-M",
298
+ "-U0",
299
+ "--src-prefix=a/",
300
+ "--dst-prefix=b/",
301
+ `${base}...${head}`,
302
+ ]);
303
+ const diffs = parseUnifiedDiff(text);
304
+ const headBlobs = readBlobs(root, head, diffs.filter((diff) => diff.status !== "deleted" && !diff.binary).map((diff) => diff.path));
305
+ const baseBlobs = readBlobs(root, base, diffs
306
+ .filter((diff) => diff.status !== "added" && !diff.binary)
307
+ .map((diff) => diff.oldPath ?? diff.path));
308
+ const files = diffs.map((diff) => analyzeFileChange({
309
+ diff,
310
+ baseText: diff.status === "added" ? undefined : baseBlobs.get(diff.oldPath ?? diff.path),
311
+ headText: diff.status === "deleted" ? undefined : headBlobs.get(diff.path),
312
+ root,
313
+ ...(options.watch === undefined ? {} : { watch: options.watch }),
314
+ }));
315
+ return { schemaVersion: CHANGE_EXTRACTION_SCHEMA_VERSION, base, head, files };
316
+ }
317
+ const TRAILER = /^(?:[A-Z][\w-]*-by|Balladeer-Session|Change-Id|Signed-off-by|Co-authored-by):/i;
318
+ /**
319
+ * The change's stated intent from its commit messages, with trailers removed:
320
+ * the model-free `intent` (`source: "commits"`). Absent when there are no
321
+ * commits between the two.
322
+ */
323
+ export function readCommitIntent(options) {
324
+ const head = resolveCommit(options.root, options.head);
325
+ const base = mergeBase(options.root, resolveCommit(options.root, options.base), head);
326
+ const messages = commitMessages(options.root, base, head)
327
+ .map((message) => message
328
+ .split("\n")
329
+ .filter((line) => !TRAILER.test(line.trim()))
330
+ .join("\n")
331
+ .trim())
332
+ .filter((message) => message.length > 0);
333
+ if (messages.length === 0)
334
+ return undefined;
335
+ return { summary: messages.join("\n\n").slice(0, 20_000), source: "commits" };
336
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The few git reads the extraction needs, each one process with an argument
3
+ * array and never a shell. Paths are read unquoted and without the reader's own
4
+ * diff settings (external diff tools, text conversion, no-prefix), so the
5
+ * output parses the same on every machine.
6
+ */
7
+ export declare class GitReadError extends Error {
8
+ readonly args: readonly string[];
9
+ constructor(args: readonly string[], message: string);
10
+ }
11
+ export declare function git(root: string, args: readonly string[]): string;
12
+ /** A git answer, or undefined when git refuses. */
13
+ export declare function tryGit(root: string, args: readonly string[]): string | undefined;
14
+ /** The full commit sha a ref names. Throws a GitReadError when it names no commit. */
15
+ export declare function resolveCommit(root: string, ref: string): string;
16
+ /** The merge base `git diff base...head` compares against. */
17
+ export declare function mergeBase(root: string, base: string, head: string): string;
18
+ export type TreeEntry = Readonly<{
19
+ path: string;
20
+ size: number;
21
+ }>;
22
+ /** Every blob in a commit's tree, with its size. */
23
+ export declare function listTree(root: string, rev: string): TreeEntry[];
24
+ /**
25
+ * The contents of many files at one commit, in one `git cat-file --batch`.
26
+ * A path missing at that commit maps to undefined.
27
+ */
28
+ export declare function readBlobs(root: string, rev: string, paths: readonly string[]): Map<string, string | undefined>;
29
+ /** The commit messages from base (exclusive) to head, oldest first. */
30
+ export declare function commitMessages(root: string, base: string, head: string): string[];