jev-agent-tools 0.1.3
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 +66 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/docs/design.md +187 -0
- package/docs/tools/jev_ask.md +92 -0
- package/docs/tools/jev_ask_files.md +80 -0
- package/docs/tools/jev_check_diff.md +77 -0
- package/docs/tools/jev_find_files.md +59 -0
- package/docs/tools/jev_locate_in_file.md +51 -0
- package/docs/tools/jev_select_tests.md +59 -0
- package/package.json +85 -0
- package/rules/jev-ask.md +4 -0
- package/src/adapters/analysis-context.ts +100 -0
- package/src/adapters/ask-files.ts +236 -0
- package/src/adapters/ask-proof.ts +202 -0
- package/src/adapters/ask-syntax.ts +463 -0
- package/src/adapters/command.ts +240 -0
- package/src/adapters/docs.ts +222 -0
- package/src/adapters/files.ts +411 -0
- package/src/adapters/find.ts +151 -0
- package/src/adapters/git-base.ts +32 -0
- package/src/adapters/git-inventory.ts +94 -0
- package/src/adapters/git.ts +525 -0
- package/src/adapters/locate-file.ts +197 -0
- package/src/adapters/output-lines.ts +50 -0
- package/src/adapters/risk-callers.ts +525 -0
- package/src/adapters/runner-version.ts +102 -0
- package/src/adapters/syntax.ts +229 -0
- package/src/adapters/test-inventory.ts +168 -0
- package/src/adapters/usage.ts +21 -0
- package/src/adapters/utf8.ts +57 -0
- package/src/constants.ts +109 -0
- package/src/core/ask-closure.ts +419 -0
- package/src/core/ask-proof.ts +32 -0
- package/src/core/ask-references.ts +249 -0
- package/src/core/asks.ts +616 -0
- package/src/core/batches.ts +83 -0
- package/src/core/command-output.ts +249 -0
- package/src/core/diff.ts +226 -0
- package/src/core/docs.ts +399 -0
- package/src/core/find.ts +157 -0
- package/src/core/git.ts +5 -0
- package/src/core/import-boundaries.ts +102 -0
- package/src/core/imports.ts +691 -0
- package/src/core/integrity.ts +64 -0
- package/src/core/lexical.ts +130 -0
- package/src/core/locate.ts +213 -0
- package/src/core/output.ts +264 -0
- package/src/core/pointer.ts +51 -0
- package/src/core/risk-callers.ts +1270 -0
- package/src/core/runner-version.ts +66 -0
- package/src/core/sections.ts +269 -0
- package/src/core/state.ts +53 -0
- package/src/core/syntax.ts +8 -0
- package/src/core/test-commands.ts +430 -0
- package/src/core/test-coverage.ts +103 -0
- package/src/core/test-discovery.ts +1695 -0
- package/src/core/test-evidence.ts +649 -0
- package/src/core/test-state.ts +99 -0
- package/src/core/truncate.ts +14 -0
- package/src/core/units.ts +531 -0
- package/src/describe.ts +26 -0
- package/src/guide.ts +42 -0
- package/src/host.ts +22 -0
- package/src/index.ts +40 -0
- package/src/jev/client.ts +505 -0
- package/src/jev/pool.ts +60 -0
- package/src/jev/types.ts +60 -0
- package/src/presets/docs.ts +85 -0
- package/src/presets/risk.ts +263 -0
- package/src/presets/spec.ts +111 -0
- package/src/presets/witnesses.ts +313 -0
- package/src/render.ts +45 -0
- package/src/result.ts +4 -0
- package/src/run-end.ts +157 -0
- package/src/runtime.ts +16 -0
- package/src/session.ts +106 -0
- package/src/texts/ask-files.ts +2 -0
- package/src/texts/ask.ts +4 -0
- package/src/texts/check-diff.ts +24 -0
- package/src/texts/configuration.ts +2 -0
- package/src/texts/find.ts +14 -0
- package/src/texts/guide.ts +16 -0
- package/src/texts/locate.ts +10 -0
- package/src/texts/run-end.ts +13 -0
- package/src/texts/select-tests.ts +3 -0
- package/src/tools/ask-files.ts +263 -0
- package/src/tools/ask-schema.ts +116 -0
- package/src/tools/ask.ts +925 -0
- package/src/tools/check-diff.ts +510 -0
- package/src/tools/docs-check.ts +399 -0
- package/src/tools/find.ts +529 -0
- package/src/tools/locate.ts +369 -0
- package/src/tools/select-tests.ts +746 -0
- package/src/tools/spec-check.ts +210 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { STATE_MAX_CHARS } from "../constants.ts";
|
|
2
|
+
import type { State } from "../jev/types.ts";
|
|
3
|
+
import type { TestScenario } from "./test-discovery.ts";
|
|
4
|
+
|
|
5
|
+
export interface TestStateBatch {
|
|
6
|
+
state: State;
|
|
7
|
+
scenarios: readonly TestScenario[];
|
|
8
|
+
}
|
|
9
|
+
export interface PreparedTestStates {
|
|
10
|
+
batches: readonly TestStateBatch[];
|
|
11
|
+
unjudged: readonly { scenario: TestScenario; reason: string }[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Partition complete bodies with a single setup mask and additive serialized sizes. */
|
|
15
|
+
export function prepareTestStates(
|
|
16
|
+
state: State,
|
|
17
|
+
scenarios: readonly TestScenario[],
|
|
18
|
+
maxChars = STATE_MAX_CHARS,
|
|
19
|
+
): PreparedTestStates {
|
|
20
|
+
if (JSON.stringify(state).length <= maxChars)
|
|
21
|
+
return { batches: [{ state, scenarios }], unjudged: [] };
|
|
22
|
+
const file = state.testFile;
|
|
23
|
+
if (
|
|
24
|
+
!file ||
|
|
25
|
+
typeof file !== "object" ||
|
|
26
|
+
Array.isArray(file) ||
|
|
27
|
+
typeof file.text !== "string"
|
|
28
|
+
)
|
|
29
|
+
return {
|
|
30
|
+
batches: [],
|
|
31
|
+
unjudged: scenarios.map((scenario) => ({
|
|
32
|
+
scenario,
|
|
33
|
+
reason: "required test file absent from state",
|
|
34
|
+
})),
|
|
35
|
+
};
|
|
36
|
+
const source = file.text.split("\n");
|
|
37
|
+
const mask = new Int32Array(source.length + 1);
|
|
38
|
+
for (const scenario of scenarios)
|
|
39
|
+
if (scenario.range) {
|
|
40
|
+
const start = Math.max(0, scenario.range.start - 1);
|
|
41
|
+
const end = Math.min(source.length, scenario.range.end);
|
|
42
|
+
mask[start] = (mask[start] ?? 0) + 1;
|
|
43
|
+
mask[end] = (mask[end] ?? 0) - 1;
|
|
44
|
+
}
|
|
45
|
+
let active = 0;
|
|
46
|
+
const setup = `${source
|
|
47
|
+
.map((line, index) => {
|
|
48
|
+
active += mask[index] ?? 0;
|
|
49
|
+
return active ? "" : line;
|
|
50
|
+
})
|
|
51
|
+
.join("\n")}\n`;
|
|
52
|
+
const base: State = { ...state, testFile: { ...file, text: setup } };
|
|
53
|
+
const baseSize = JSON.stringify(base).length;
|
|
54
|
+
const batches: TestStateBatch[] = [];
|
|
55
|
+
const unjudged: { scenario: TestScenario; reason: string }[] = [];
|
|
56
|
+
let group: TestScenario[] = [];
|
|
57
|
+
let bodies: string[] = [];
|
|
58
|
+
let size = baseSize;
|
|
59
|
+
const flush = () => {
|
|
60
|
+
if (group.length)
|
|
61
|
+
batches.push({
|
|
62
|
+
state: {
|
|
63
|
+
...base,
|
|
64
|
+
testFile: { ...file, text: setup + bodies.join("\n") },
|
|
65
|
+
},
|
|
66
|
+
scenarios: group,
|
|
67
|
+
});
|
|
68
|
+
group = [];
|
|
69
|
+
bodies = [];
|
|
70
|
+
size = baseSize;
|
|
71
|
+
};
|
|
72
|
+
for (const scenario of scenarios) {
|
|
73
|
+
if (!scenario.range) {
|
|
74
|
+
unjudged.push({
|
|
75
|
+
scenario,
|
|
76
|
+
reason:
|
|
77
|
+
"test body range unresolved; complete file exceeds state budget",
|
|
78
|
+
});
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const body = source
|
|
82
|
+
.slice(scenario.range.start - 1, scenario.range.end)
|
|
83
|
+
.join("\n");
|
|
84
|
+
const bodySize = JSON.stringify(body).length - 2;
|
|
85
|
+
if (baseSize + bodySize > maxChars) {
|
|
86
|
+
unjudged.push({
|
|
87
|
+
scenario,
|
|
88
|
+
reason: `complete test body and required evidence exceed state budget ${maxChars}`,
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (group.length && size + 2 + bodySize > maxChars) flush();
|
|
93
|
+
size += bodySize + (group.length ? 2 : 0);
|
|
94
|
+
group.push(scenario);
|
|
95
|
+
bodies.push(body);
|
|
96
|
+
}
|
|
97
|
+
flush();
|
|
98
|
+
return { batches, unjudged };
|
|
99
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Keep a UTF-16 prefix without splitting a valid surrogate pair. */
|
|
2
|
+
export function truncate(text: string, maxChars: number): string {
|
|
3
|
+
let end = Math.max(0, Math.min(text.length, Math.floor(maxChars)));
|
|
4
|
+
if (
|
|
5
|
+
end > 0 &&
|
|
6
|
+
end < text.length &&
|
|
7
|
+
text.charCodeAt(end - 1) >= 0xd800 &&
|
|
8
|
+
text.charCodeAt(end - 1) <= 0xdbff &&
|
|
9
|
+
text.charCodeAt(end) >= 0xdc00 &&
|
|
10
|
+
text.charCodeAt(end) <= 0xdfff
|
|
11
|
+
)
|
|
12
|
+
end--;
|
|
13
|
+
return text.slice(0, end);
|
|
14
|
+
}
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import { UNIT_CONTEXT_LINES, UNIT_MAX_CHARS } from "../constants.ts";
|
|
2
|
+
import { type DiffFile, isTestFile } from "./diff.ts";
|
|
3
|
+
import { truncate } from "./truncate.ts";
|
|
4
|
+
|
|
5
|
+
export interface SourceFile extends DiffFile {
|
|
6
|
+
before: string | null;
|
|
7
|
+
after: string | null;
|
|
8
|
+
limitation?: "too_large" | "unreadable" | "not UTF-8 text";
|
|
9
|
+
}
|
|
10
|
+
export interface Declaration {
|
|
11
|
+
kind: "function" | "declaration";
|
|
12
|
+
name: string;
|
|
13
|
+
exported: boolean;
|
|
14
|
+
start: number;
|
|
15
|
+
end: number;
|
|
16
|
+
/** Syntax type disambiguates declarations sharing a qualified name. */
|
|
17
|
+
type?: string;
|
|
18
|
+
signatureEnd?: number;
|
|
19
|
+
/** Token-normalized body; literal contents are preserved. */
|
|
20
|
+
body?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface DeclarationResult {
|
|
23
|
+
declarations: Declaration[];
|
|
24
|
+
limits: (
|
|
25
|
+
| { kind: "parse_failed" }
|
|
26
|
+
| { kind: "parse_partial"; declaration?: string }
|
|
27
|
+
)[];
|
|
28
|
+
}
|
|
29
|
+
export interface SyntaxParser {
|
|
30
|
+
declarations(
|
|
31
|
+
path: string,
|
|
32
|
+
text: string,
|
|
33
|
+
options?: { descendAbove?: number },
|
|
34
|
+
): DeclarationResult;
|
|
35
|
+
supports(path: string): boolean;
|
|
36
|
+
}
|
|
37
|
+
export type UnitLimit =
|
|
38
|
+
| { kind: "grammar_absent"; file: string; language: string }
|
|
39
|
+
| {
|
|
40
|
+
kind:
|
|
41
|
+
| "parse_failed"
|
|
42
|
+
| "binary_ignored"
|
|
43
|
+
| "too_large"
|
|
44
|
+
| "unreadable"
|
|
45
|
+
| "not UTF-8 text"
|
|
46
|
+
| "rename_unpaired";
|
|
47
|
+
file: string;
|
|
48
|
+
}
|
|
49
|
+
| { kind: "truncated"; file: string; unit: string }
|
|
50
|
+
| { kind: "parse_partial"; file: string; declaration?: string };
|
|
51
|
+
export interface EvidenceUnit {
|
|
52
|
+
id: string;
|
|
53
|
+
kind: "function" | "slice" | "declaration" | "file" | "hunk";
|
|
54
|
+
file: string;
|
|
55
|
+
name: string;
|
|
56
|
+
exported: boolean;
|
|
57
|
+
before: string | null;
|
|
58
|
+
after: string | null;
|
|
59
|
+
beforeRange: { start: number; end: number } | null;
|
|
60
|
+
afterRange: { start: number; end: number } | null;
|
|
61
|
+
}
|
|
62
|
+
const code = /\.(?:[cm]?[jt]sx?|py)$/;
|
|
63
|
+
function lines(text: string | null): string[] {
|
|
64
|
+
return text === null ? [] : text.split("\n");
|
|
65
|
+
}
|
|
66
|
+
function overlaps(
|
|
67
|
+
start: number,
|
|
68
|
+
end: number,
|
|
69
|
+
point: number,
|
|
70
|
+
count: number,
|
|
71
|
+
): boolean {
|
|
72
|
+
return count
|
|
73
|
+
? point <= end && point + count - 1 >= start
|
|
74
|
+
: point > start && point <= end;
|
|
75
|
+
}
|
|
76
|
+
/** Conservative fallback: unknown regions remain hunks, never disappear. */
|
|
77
|
+
export function heuristicDeclarations(
|
|
78
|
+
path: string,
|
|
79
|
+
text: string,
|
|
80
|
+
): Declaration[] {
|
|
81
|
+
const source = lines(text);
|
|
82
|
+
const result: Declaration[] = [];
|
|
83
|
+
const python = path.endsWith(".py");
|
|
84
|
+
for (let i = 0; i < source.length; i++) {
|
|
85
|
+
const line = source[i] ?? "";
|
|
86
|
+
const match = python
|
|
87
|
+
? /^(\s*)(?:async\s+)?def\s+(\w+)/.exec(line)
|
|
88
|
+
: /^\s*(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+(\w+)/.exec(
|
|
89
|
+
line,
|
|
90
|
+
);
|
|
91
|
+
const declaration = python
|
|
92
|
+
? /^(\w+)\s*(?::[^=]+)?=/.exec(line)
|
|
93
|
+
: /^\s*(?:export\s+)?(?:const|let|var|type|interface|enum)\s+(\w+)/.exec(
|
|
94
|
+
line,
|
|
95
|
+
);
|
|
96
|
+
if (!match && !declaration) continue;
|
|
97
|
+
let start = i;
|
|
98
|
+
let end = i;
|
|
99
|
+
if (python && match) {
|
|
100
|
+
const indent = (match[1] ?? "").length;
|
|
101
|
+
while (start > 0 && (source[start - 1] ?? "").trim().startsWith("@"))
|
|
102
|
+
start--;
|
|
103
|
+
while (
|
|
104
|
+
end + 1 < source.length &&
|
|
105
|
+
(!(source[end + 1] ?? "").trim() ||
|
|
106
|
+
(source[end + 1] ?? "").search(/\S/) > indent)
|
|
107
|
+
)
|
|
108
|
+
end++;
|
|
109
|
+
} else if (!python) {
|
|
110
|
+
let depth = 0;
|
|
111
|
+
let opened = false;
|
|
112
|
+
for (let j = i; j < source.length; j++) {
|
|
113
|
+
for (const char of source[j] ?? "") {
|
|
114
|
+
if (char === "{") {
|
|
115
|
+
depth++;
|
|
116
|
+
opened = true;
|
|
117
|
+
} else if (char === "}") depth--;
|
|
118
|
+
}
|
|
119
|
+
end = j;
|
|
120
|
+
if (
|
|
121
|
+
(opened && depth <= 0) ||
|
|
122
|
+
(!opened && /;\s*$/.test(source[j] ?? ""))
|
|
123
|
+
)
|
|
124
|
+
break;
|
|
125
|
+
if (!opened && j === i && !match) break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
result.push({
|
|
129
|
+
kind: match ? "function" : "declaration",
|
|
130
|
+
name: match
|
|
131
|
+
? (match[python ? 2 : 1] ?? "function")
|
|
132
|
+
: (declaration?.[1] ?? "declaration"),
|
|
133
|
+
exported: python
|
|
134
|
+
? !line.trim().startsWith("def _")
|
|
135
|
+
: /\bexport\b/.test(line),
|
|
136
|
+
start: start + 1,
|
|
137
|
+
end: end + 1,
|
|
138
|
+
});
|
|
139
|
+
i = end;
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function buildUnits(
|
|
145
|
+
files: readonly SourceFile[],
|
|
146
|
+
parser?: SyntaxParser,
|
|
147
|
+
): { units: EvidenceUnit[]; limits: UnitLimit[] } {
|
|
148
|
+
const units: EvidenceUnit[] = [];
|
|
149
|
+
const limits: UnitLimit[] = [];
|
|
150
|
+
for (const file of files) {
|
|
151
|
+
if (isTestFile(file.path)) continue;
|
|
152
|
+
if (file.binary) {
|
|
153
|
+
limits.push({ kind: "binary_ignored", file: file.path });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (file.limitation) {
|
|
157
|
+
limits.push({ kind: file.limitation, file: file.path });
|
|
158
|
+
if (
|
|
159
|
+
file.limitation === "too_large" ||
|
|
160
|
+
file.limitation === "not UTF-8 text"
|
|
161
|
+
) {
|
|
162
|
+
for (const hunk of file.hunks) {
|
|
163
|
+
const id = `u${String(units.length + 1).padStart(3, "0")}`;
|
|
164
|
+
const before = /^[A?]/.test(file.status)
|
|
165
|
+
? null
|
|
166
|
+
: (hunk.beforeText ?? null);
|
|
167
|
+
const after = file.status === "D" ? null : (hunk.afterText ?? null);
|
|
168
|
+
const budget =
|
|
169
|
+
before === null
|
|
170
|
+
? 0
|
|
171
|
+
: after === null
|
|
172
|
+
? UNIT_MAX_CHARS
|
|
173
|
+
: Math.floor(UNIT_MAX_CHARS / 2);
|
|
174
|
+
units.push({
|
|
175
|
+
id,
|
|
176
|
+
kind: "hunk",
|
|
177
|
+
file: file.path,
|
|
178
|
+
name: `${file.path}:${hunk.afterStart}`,
|
|
179
|
+
exported: false,
|
|
180
|
+
before: before === null ? null : truncate(before, budget),
|
|
181
|
+
after:
|
|
182
|
+
after === null
|
|
183
|
+
? null
|
|
184
|
+
: truncate(
|
|
185
|
+
after,
|
|
186
|
+
UNIT_MAX_CHARS - Math.min(before?.length ?? 0, budget),
|
|
187
|
+
),
|
|
188
|
+
beforeRange:
|
|
189
|
+
before === null
|
|
190
|
+
? null
|
|
191
|
+
: {
|
|
192
|
+
start: hunk.beforeStart,
|
|
193
|
+
end: hunk.beforeStart + hunk.beforeCount - 1,
|
|
194
|
+
},
|
|
195
|
+
afterRange:
|
|
196
|
+
after === null
|
|
197
|
+
? null
|
|
198
|
+
: {
|
|
199
|
+
start: hunk.afterStart,
|
|
200
|
+
end: hunk.afterStart + hunk.afterCount - 1,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
if (
|
|
204
|
+
hunk.truncated ||
|
|
205
|
+
(before?.length ?? 0) + (after?.length ?? 0) > UNIT_MAX_CHARS
|
|
206
|
+
)
|
|
207
|
+
limits.push({ kind: "truncated", file: file.path, unit: id });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const oldLines = lines(file.before);
|
|
213
|
+
const newLines = lines(file.after);
|
|
214
|
+
const changes = file.hunks.flatMap((hunk) => hunk.changes);
|
|
215
|
+
if (
|
|
216
|
+
!changes.length &&
|
|
217
|
+
(file.before !== file.after || /^[RC]/.test(file.status))
|
|
218
|
+
)
|
|
219
|
+
changes.push({
|
|
220
|
+
beforeStart: 1,
|
|
221
|
+
beforeCount: oldLines.length,
|
|
222
|
+
afterStart: 1,
|
|
223
|
+
afterCount: newLines.length,
|
|
224
|
+
});
|
|
225
|
+
if (!changes.length) continue;
|
|
226
|
+
const add = (
|
|
227
|
+
kind: EvidenceUnit["kind"],
|
|
228
|
+
name: string,
|
|
229
|
+
exported: boolean,
|
|
230
|
+
oldRange: EvidenceUnit["beforeRange"],
|
|
231
|
+
newRange: EvidenceUnit["afterRange"],
|
|
232
|
+
) => {
|
|
233
|
+
const excerpt = (
|
|
234
|
+
source: string[],
|
|
235
|
+
range: EvidenceUnit["beforeRange"],
|
|
236
|
+
): string | null => {
|
|
237
|
+
if (!range) return null;
|
|
238
|
+
return source.slice(range.start - 1, range.end).join("\n");
|
|
239
|
+
};
|
|
240
|
+
let before = excerpt(oldLines, oldRange);
|
|
241
|
+
let after = excerpt(newLines, newRange);
|
|
242
|
+
const id = `u${String(units.length + 1).padStart(3, "0")}`;
|
|
243
|
+
const total = (before?.length ?? 0) + (after?.length ?? 0);
|
|
244
|
+
if (total > UNIT_MAX_CHARS) {
|
|
245
|
+
const beforeBudget =
|
|
246
|
+
before === null
|
|
247
|
+
? 0
|
|
248
|
+
: after === null
|
|
249
|
+
? UNIT_MAX_CHARS
|
|
250
|
+
: Math.floor(UNIT_MAX_CHARS / 2);
|
|
251
|
+
if (before !== null) before = truncate(before, beforeBudget);
|
|
252
|
+
if (after !== null)
|
|
253
|
+
after = truncate(after, UNIT_MAX_CHARS - (before?.length ?? 0));
|
|
254
|
+
limits.push({ kind: "truncated", file: file.path, unit: id });
|
|
255
|
+
}
|
|
256
|
+
units.push({
|
|
257
|
+
id,
|
|
258
|
+
kind,
|
|
259
|
+
file: file.path,
|
|
260
|
+
name,
|
|
261
|
+
exported,
|
|
262
|
+
before,
|
|
263
|
+
after,
|
|
264
|
+
beforeRange: oldRange,
|
|
265
|
+
afterRange: newRange,
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
if (
|
|
269
|
+
!code.test(file.path) &&
|
|
270
|
+
(file.before?.length ?? 0) + (file.after?.length ?? 0) <= UNIT_MAX_CHARS
|
|
271
|
+
) {
|
|
272
|
+
add(
|
|
273
|
+
"file",
|
|
274
|
+
file.path,
|
|
275
|
+
false,
|
|
276
|
+
file.before === null ? null : { start: 1, end: oldLines.length },
|
|
277
|
+
file.after === null ? null : { start: 1, end: newLines.length },
|
|
278
|
+
);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const declarations = (text: string | null, path: string) => {
|
|
282
|
+
if (text === null || !code.test(path)) return [];
|
|
283
|
+
if (parser?.supports(path)) {
|
|
284
|
+
const result = parser.declarations(path, text);
|
|
285
|
+
for (const limit of result.limits) {
|
|
286
|
+
if (
|
|
287
|
+
!limits.some(
|
|
288
|
+
(existing) =>
|
|
289
|
+
existing.kind === limit.kind &&
|
|
290
|
+
existing.file === path &&
|
|
291
|
+
(limit.kind !== "parse_partial" ||
|
|
292
|
+
(existing.kind === "parse_partial" &&
|
|
293
|
+
existing.declaration === limit.declaration)),
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
limits.push({ ...limit, file: path });
|
|
297
|
+
}
|
|
298
|
+
if (!result.limits.some((limit) => limit.kind === "parse_failed"))
|
|
299
|
+
return result.declarations;
|
|
300
|
+
} else if (
|
|
301
|
+
!limits.some(
|
|
302
|
+
(limit) =>
|
|
303
|
+
limit.kind === "grammar_absent" && limit.file === file.path,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
limits.push({
|
|
307
|
+
kind: "grammar_absent",
|
|
308
|
+
file: file.path,
|
|
309
|
+
language: path.endsWith(".py") ? "Python" : "JavaScript/TypeScript",
|
|
310
|
+
});
|
|
311
|
+
return heuristicDeclarations(path, text);
|
|
312
|
+
};
|
|
313
|
+
const beforeDeclarations = declarations(file.before, file.oldPath);
|
|
314
|
+
const afterDeclarations = declarations(file.after, file.path);
|
|
315
|
+
const key = (d: Declaration) => `${d.type ?? d.kind}:${d.name}`;
|
|
316
|
+
const pairedAfter = new Set<Declaration>();
|
|
317
|
+
const selected: { before?: Declaration; after?: Declaration }[] = [];
|
|
318
|
+
for (const before of beforeDeclarations) {
|
|
319
|
+
const after = afterDeclarations.find(
|
|
320
|
+
(d) => !pairedAfter.has(d) && key(d) === key(before),
|
|
321
|
+
);
|
|
322
|
+
if (after) pairedAfter.add(after);
|
|
323
|
+
selected.push({ before, after });
|
|
324
|
+
}
|
|
325
|
+
for (const pair of selected) {
|
|
326
|
+
const before = pair.before;
|
|
327
|
+
if (
|
|
328
|
+
pair.after ||
|
|
329
|
+
before?.kind !== "function" ||
|
|
330
|
+
before.body === undefined
|
|
331
|
+
)
|
|
332
|
+
continue;
|
|
333
|
+
const after = afterDeclarations.find(
|
|
334
|
+
(d) =>
|
|
335
|
+
!pairedAfter.has(d) &&
|
|
336
|
+
d.kind === "function" &&
|
|
337
|
+
d.type === before.type &&
|
|
338
|
+
d.body === before.body,
|
|
339
|
+
);
|
|
340
|
+
if (after) {
|
|
341
|
+
pair.after = after;
|
|
342
|
+
pairedAfter.add(after);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (const after of afterDeclarations)
|
|
346
|
+
if (!pairedAfter.has(after)) selected.push({ after });
|
|
347
|
+
const touched = (d: Declaration | undefined, side: "before" | "after") =>
|
|
348
|
+
d !== undefined &&
|
|
349
|
+
changes.some((c) =>
|
|
350
|
+
overlaps(d.start, d.end, c[`${side}Start`], c[`${side}Count`]),
|
|
351
|
+
);
|
|
352
|
+
const changed = selected.filter(
|
|
353
|
+
(pair) => touched(pair.before, "before") || touched(pair.after, "after"),
|
|
354
|
+
);
|
|
355
|
+
if (
|
|
356
|
+
changed.some((p) => p.before?.kind === "function" && !p.after) &&
|
|
357
|
+
changed.some((p) => p.after?.kind === "function" && !p.before)
|
|
358
|
+
)
|
|
359
|
+
limits.push({ kind: "rename_unpaired", file: file.path });
|
|
360
|
+
for (const pair of changed) {
|
|
361
|
+
const declaration = pair.after ?? pair.before;
|
|
362
|
+
if (!declaration) continue;
|
|
363
|
+
const name = declaration.name;
|
|
364
|
+
const size =
|
|
365
|
+
(pair.before
|
|
366
|
+
? oldLines.slice(pair.before.start - 1, pair.before.end).join("\n")
|
|
367
|
+
.length
|
|
368
|
+
: 0) +
|
|
369
|
+
(pair.after
|
|
370
|
+
? newLines.slice(pair.after.start - 1, pair.after.end).join("\n")
|
|
371
|
+
.length
|
|
372
|
+
: 0);
|
|
373
|
+
const large = size > UNIT_MAX_CHARS;
|
|
374
|
+
const relevant = changes.filter(
|
|
375
|
+
(c) =>
|
|
376
|
+
(pair.before &&
|
|
377
|
+
overlaps(
|
|
378
|
+
pair.before.start,
|
|
379
|
+
pair.before.end,
|
|
380
|
+
c.beforeStart,
|
|
381
|
+
c.beforeCount,
|
|
382
|
+
)) ||
|
|
383
|
+
(pair.after &&
|
|
384
|
+
overlaps(
|
|
385
|
+
pair.after.start,
|
|
386
|
+
pair.after.end,
|
|
387
|
+
c.afterStart,
|
|
388
|
+
c.afterCount,
|
|
389
|
+
)),
|
|
390
|
+
);
|
|
391
|
+
const zones: (typeof changes)[] = [];
|
|
392
|
+
for (const change of relevant) {
|
|
393
|
+
const previous = zones[zones.length - 1];
|
|
394
|
+
const last = previous?.[previous.length - 1];
|
|
395
|
+
if (
|
|
396
|
+
last &&
|
|
397
|
+
change.beforeStart <=
|
|
398
|
+
last.beforeStart + last.beforeCount + 2 * UNIT_CONTEXT_LINES &&
|
|
399
|
+
change.afterStart <=
|
|
400
|
+
last.afterStart + last.afterCount + 2 * UNIT_CONTEXT_LINES
|
|
401
|
+
)
|
|
402
|
+
previous?.push(change);
|
|
403
|
+
else zones.push([change]);
|
|
404
|
+
}
|
|
405
|
+
for (const zone of large ? zones : [relevant]) {
|
|
406
|
+
const range = (
|
|
407
|
+
d: Declaration | undefined,
|
|
408
|
+
side: "before" | "after",
|
|
409
|
+
) => {
|
|
410
|
+
if (!d) return null;
|
|
411
|
+
if (!large) return { start: d.start, end: d.end };
|
|
412
|
+
return {
|
|
413
|
+
start: Math.max(
|
|
414
|
+
d.start,
|
|
415
|
+
Math.min(...zone.map((c) => c[`${side}Start`])) -
|
|
416
|
+
UNIT_CONTEXT_LINES,
|
|
417
|
+
),
|
|
418
|
+
end: Math.min(
|
|
419
|
+
d.end,
|
|
420
|
+
Math.max(
|
|
421
|
+
...zone.map(
|
|
422
|
+
(c) => c[`${side}Start`] + Math.max(1, c[`${side}Count`]) - 1,
|
|
423
|
+
),
|
|
424
|
+
) + UNIT_CONTEXT_LINES,
|
|
425
|
+
),
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
add(
|
|
429
|
+
large ? "slice" : declaration.kind,
|
|
430
|
+
name,
|
|
431
|
+
declaration.exported,
|
|
432
|
+
range(pair.before, "before"),
|
|
433
|
+
range(pair.after, "after"),
|
|
434
|
+
);
|
|
435
|
+
if (!large) continue;
|
|
436
|
+
const unit = units[units.length - 1];
|
|
437
|
+
if (!unit) continue;
|
|
438
|
+
const omission = file.path.endsWith(".py")
|
|
439
|
+
? "# … omitted …"
|
|
440
|
+
: "// … omitted …";
|
|
441
|
+
for (const side of ["before", "after"] as const) {
|
|
442
|
+
const d = pair[side];
|
|
443
|
+
const r = unit[`${side}Range`];
|
|
444
|
+
const source = side === "before" ? oldLines : newLines;
|
|
445
|
+
if (!d || !r || unit[side] === null) continue;
|
|
446
|
+
const signatureEnd = d.signatureEnd ?? d.start;
|
|
447
|
+
if (r.start > d.start) {
|
|
448
|
+
const signature = source
|
|
449
|
+
.slice(d.start - 1, Math.min(signatureEnd, r.start - 1))
|
|
450
|
+
.join("\n");
|
|
451
|
+
unit[side] =
|
|
452
|
+
`${signature}\n${r.start > signatureEnd + 1 ? `${omission}\n` : ""}${unit[side]}`;
|
|
453
|
+
}
|
|
454
|
+
if (r.end < d.end) unit[side] += `\n${omission}`;
|
|
455
|
+
}
|
|
456
|
+
if (
|
|
457
|
+
(unit.before?.length ?? 0) + (unit.after?.length ?? 0) >
|
|
458
|
+
UNIT_MAX_CHARS
|
|
459
|
+
) {
|
|
460
|
+
const budget =
|
|
461
|
+
unit.after === null
|
|
462
|
+
? UNIT_MAX_CHARS
|
|
463
|
+
: unit.before === null
|
|
464
|
+
? 0
|
|
465
|
+
: Math.floor(UNIT_MAX_CHARS / 2);
|
|
466
|
+
if (unit.before !== null) unit.before = truncate(unit.before, budget);
|
|
467
|
+
if (unit.after !== null)
|
|
468
|
+
unit.after = truncate(
|
|
469
|
+
unit.after,
|
|
470
|
+
UNIT_MAX_CHARS - (unit.before?.length ?? 0),
|
|
471
|
+
);
|
|
472
|
+
if (!limits.some((l) => l.kind === "truncated" && l.unit === unit.id))
|
|
473
|
+
limits.push({ kind: "truncated", file: file.path, unit: unit.id });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
for (const change of changes) {
|
|
478
|
+
const contains = (
|
|
479
|
+
declarations: Declaration[],
|
|
480
|
+
source: string[],
|
|
481
|
+
start: number,
|
|
482
|
+
count: number,
|
|
483
|
+
) =>
|
|
484
|
+
count === 0 ||
|
|
485
|
+
source
|
|
486
|
+
.slice(Math.max(0, start - 1), start + count - 1)
|
|
487
|
+
.every(
|
|
488
|
+
(line, offset) =>
|
|
489
|
+
!line.trim() ||
|
|
490
|
+
declarations.some(
|
|
491
|
+
(d) => d.start <= start + offset && d.end >= start + offset,
|
|
492
|
+
),
|
|
493
|
+
);
|
|
494
|
+
const covered =
|
|
495
|
+
(file.before === null ||
|
|
496
|
+
contains(
|
|
497
|
+
beforeDeclarations,
|
|
498
|
+
oldLines,
|
|
499
|
+
change.beforeStart,
|
|
500
|
+
change.beforeCount,
|
|
501
|
+
)) &&
|
|
502
|
+
(file.after === null ||
|
|
503
|
+
contains(
|
|
504
|
+
afterDeclarations,
|
|
505
|
+
newLines,
|
|
506
|
+
change.afterStart,
|
|
507
|
+
change.afterCount,
|
|
508
|
+
));
|
|
509
|
+
if (covered) continue;
|
|
510
|
+
const range = (start: number, count: number, length: number) => ({
|
|
511
|
+
start: Math.max(1, start - UNIT_CONTEXT_LINES),
|
|
512
|
+
end: Math.min(
|
|
513
|
+
length,
|
|
514
|
+
start + Math.max(1, count) - 1 + UNIT_CONTEXT_LINES,
|
|
515
|
+
),
|
|
516
|
+
});
|
|
517
|
+
add(
|
|
518
|
+
"hunk",
|
|
519
|
+
`${file.path}:${change.afterStart}`,
|
|
520
|
+
false,
|
|
521
|
+
file.before === null
|
|
522
|
+
? null
|
|
523
|
+
: range(change.beforeStart, change.beforeCount, oldLines.length),
|
|
524
|
+
file.after === null
|
|
525
|
+
? null
|
|
526
|
+
: range(change.afterStart, change.afterCount, newLines.length),
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return { units, limits };
|
|
531
|
+
}
|
package/src/describe.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as constants from "./constants.ts";
|
|
2
|
+
import type { Names } from "./host.ts";
|
|
3
|
+
import { ASK_DESCRIPTION } from "./texts/ask.ts";
|
|
4
|
+
export function interpolate(template: string, names: Names): string {
|
|
5
|
+
const values: Record<string, unknown> = {
|
|
6
|
+
...constants,
|
|
7
|
+
BAND_BOOL_GRAY_A: constants.BAND_BOOL_GRAY_A.toFixed(2),
|
|
8
|
+
BAND_BOOL_YES_MIN: constants.BAND_BOOL_YES_MIN.toFixed(2),
|
|
9
|
+
BAND_CHOICE_VERDICT_MIN: constants.BAND_CHOICE_VERDICT_MIN.toFixed(2),
|
|
10
|
+
...Object.fromEntries(
|
|
11
|
+
Object.entries(names).map(([key, value]) => [`names.${key}`, value]),
|
|
12
|
+
),
|
|
13
|
+
};
|
|
14
|
+
const rendered = template.replace(/{{([^}]+)}}/g, (_match, key: string) => {
|
|
15
|
+
const value = values[key];
|
|
16
|
+
if (typeof value !== "string" && typeof value !== "number")
|
|
17
|
+
throw new Error(`Unknown description interpolation: ${key}`);
|
|
18
|
+
return String(value);
|
|
19
|
+
});
|
|
20
|
+
if (rendered.includes("{{"))
|
|
21
|
+
throw new Error("Unresolved description interpolation.");
|
|
22
|
+
return rendered;
|
|
23
|
+
}
|
|
24
|
+
export function describeAsk(names: Names): string {
|
|
25
|
+
return interpolate(ASK_DESCRIPTION, names);
|
|
26
|
+
}
|