balladeer 1.0.17 → 1.0.18
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/dist/anchor-budget.d.ts +40 -0
- package/dist/anchor-budget.js +110 -0
- package/dist/anchoring.d.ts +241 -0
- package/dist/anchoring.js +766 -0
- package/dist/anchors-client.d.ts +62 -0
- package/dist/anchors-client.js +197 -0
- package/dist/anchors-schema.d.ts +85 -0
- package/dist/anchors-schema.js +246 -0
- package/dist/cli.js +5 -1
- package/dist/commands/anchor-usage.d.ts +6 -0
- package/dist/commands/anchor-usage.js +19 -0
- package/dist/commands/anchor.d.ts +127 -0
- package/dist/commands/anchor.js +303 -0
- package/dist/commands/judge.d.ts +69 -21
- package/dist/commands/judge.js +354 -89
- package/dist/commands/map.js +2 -0
- package/dist/guidance-hook.mjs +5 -2
- package/dist/guidance.d.ts +7 -0
- package/dist/guidance.js +10 -1
- package/dist/headless-agent.d.ts +19 -1
- package/dist/headless-agent.js +22 -5
- package/dist/judge-brief.d.ts +20 -2
- package/dist/judge-brief.js +26 -2
- package/dist/promise-meaning.d.ts +17 -0
- package/dist/promise-meaning.js +15 -6
- package/dist/risk/git.d.ts +3 -1
- package/dist/risk/git.js +3 -3
- package/dist/risk/graph-cache.d.ts +83 -0
- package/dist/risk/graph-cache.js +291 -0
- package/dist/risk/import-graph.d.ts +43 -0
- package/dist/risk/import-graph.js +88 -34
- package/dist/risk/pipeline.d.ts +13 -0
- package/dist/risk/pipeline.js +15 -4
- package/dist/wire.d.ts +18 -2
- package/dist/wire.js +1 -1
- package/package.json +7 -2
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ANCHOR_BRIEF_SOURCE, ANCHOR_LIMITS, BEHAVIOR_MAP_SOURCE, PROMISE_ANCHORS_SCHEMA_VERSION, anchorFiles, checkPromiseAnchors, isAnchorPath, isAnchorResourceName, isAnchorSymbol, } from "./anchors-schema.js";
|
|
5
|
+
import { ENTRY_POINT_KINDS } from "./behavior-map-schema.js";
|
|
6
|
+
import { runHeadlessAgent, } from "./headless-agent.js";
|
|
7
|
+
import { isIndexSource } from "./risk/graph-cache.js";
|
|
8
|
+
import { isTestPath } from "./risk/paths.js";
|
|
9
|
+
import { buildIdf, identifierTokens, words } from "./risk/text.js";
|
|
10
|
+
/**
|
|
11
|
+
* Promise anchors on the laptop: where an agreed promise's behavior lives,
|
|
12
|
+
* written by one small call on the person's own Claude Code or Codex, or cut
|
|
13
|
+
* from a behavior map, and shared through the server with every teammate.
|
|
14
|
+
*
|
|
15
|
+
* The call is `anchor-brief/v3`, frozen on 1 October 2026 after the
|
|
16
|
+
* measurement in `docs/agentic-testing/evidence/20261001/anchors-measurement.md`.
|
|
17
|
+
* The index, the shortlist and the brief below are
|
|
18
|
+
* `scripts/agentic-eval/anchors-fresh.ts` at v3, word for word. They were tuned
|
|
19
|
+
* on the seventeen in-window cases, so any change to them is a new revision
|
|
20
|
+
* with a label of its own, measured before it ships; never edit v3 in place.
|
|
21
|
+
*/
|
|
22
|
+
export const ANCHOR_BRIEF_REVISION = ANCHOR_BRIEF_SOURCE;
|
|
23
|
+
const FILES = 150;
|
|
24
|
+
const NAMES_PER_FILE = 12;
|
|
25
|
+
const PER_FOLDER = 6;
|
|
26
|
+
const RARE_WORDS = 10;
|
|
27
|
+
const PER_RARE_WORD = 3;
|
|
28
|
+
/**
|
|
29
|
+
* Each anchoring call's caps. The measured calls wrote at most 1,157 output
|
|
30
|
+
* tokens and took at most 9.3 seconds; one runaway call in 291 wrote 128,690.
|
|
31
|
+
* A call that reaches either cap leaves its promise unanchored.
|
|
32
|
+
*/
|
|
33
|
+
export const ANCHOR_CALL_CAPS = { outputTokens: 8000, wallMs: 60_000 };
|
|
34
|
+
/** A call is not started with less time than this left: the measured calls took up to 9.3 seconds. */
|
|
35
|
+
const MIN_CALL_TIME_MS = 15_000;
|
|
36
|
+
/** Every non-test source file the graph knows, with the top-level names it exports. */
|
|
37
|
+
export function anchorIndex(repository) {
|
|
38
|
+
const sources = repository.graph.files.filter(isIndexSource);
|
|
39
|
+
repository.prepareNames(sources);
|
|
40
|
+
const entries = [];
|
|
41
|
+
for (const file of sources) {
|
|
42
|
+
const names = repository.exportedNames(file);
|
|
43
|
+
if (names === undefined)
|
|
44
|
+
continue;
|
|
45
|
+
const pathWords = file
|
|
46
|
+
.replace(/\.[^.]+$/, "")
|
|
47
|
+
.split(/[/._-]+/)
|
|
48
|
+
.flatMap((part) => identifierTokens(part));
|
|
49
|
+
const nameWords = names.flatMap((entry) => identifierTokens(entry.name));
|
|
50
|
+
entries.push({ file, names, words: words([...pathWords, ...nameWords].join(" ")) });
|
|
51
|
+
}
|
|
52
|
+
return { sha: repository.sha, entries, idf: buildIdf(entries.map((entry) => entry.words)) };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The promise as the measured brief showed it: title, outcome, the cases'
|
|
56
|
+
* label, setup and outcome, and the non-goals. Nothing else of the meaning is
|
|
57
|
+
* read, so what the model sees is what was measured.
|
|
58
|
+
*/
|
|
59
|
+
export function briefMeaning(meaning) {
|
|
60
|
+
const example = (entry) => ({
|
|
61
|
+
...(entry.label ? { label: entry.label } : {}),
|
|
62
|
+
setup: entry.setup,
|
|
63
|
+
expectedOutcome: entry.expectedOutcome,
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
title: meaning.title,
|
|
67
|
+
observableOutcome: meaning.observableOutcome,
|
|
68
|
+
passingExamples: meaning.passingExamples.map(example),
|
|
69
|
+
failingExamples: meaning.failingExamples.map(example),
|
|
70
|
+
nonGoals: [...meaning.nonGoals],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function promiseText(meaning) {
|
|
74
|
+
const examples = [...(meaning.passingExamples ?? []), ...(meaning.failingExamples ?? [])]
|
|
75
|
+
.map((example) => [example.label, example.setup, example.expectedOutcome].filter(Boolean).join(" "))
|
|
76
|
+
.join(" ");
|
|
77
|
+
return [meaning.title, meaning.observableOutcome, examples, ...(meaning.nonGoals ?? [])].join(" ");
|
|
78
|
+
}
|
|
79
|
+
/** The files whose path and names share the most weighted words with the promise. */
|
|
80
|
+
export function shortlist(index, meaning) {
|
|
81
|
+
const promiseWords = words(promiseText(meaning));
|
|
82
|
+
const scored = index.entries.map((entry) => {
|
|
83
|
+
let score = 0;
|
|
84
|
+
for (const word of promiseWords)
|
|
85
|
+
if (entry.words.has(word))
|
|
86
|
+
score += index.idf.weight(word);
|
|
87
|
+
return { entry, score };
|
|
88
|
+
});
|
|
89
|
+
const ordered = scored
|
|
90
|
+
.filter((item) => item.score > 0)
|
|
91
|
+
.sort((a, b) => b.score - a.score || a.entry.file.localeCompare(b.entry.file));
|
|
92
|
+
// At most a few files per folder, so a promise whose words also name generic UI (table,
|
|
93
|
+
// dialog, row) still reaches the feature folders where it lives.
|
|
94
|
+
const perFolder = new Map();
|
|
95
|
+
const out = [];
|
|
96
|
+
// The promise's rarest words that name anything at all each get a few places first. A
|
|
97
|
+
// summed score favours files that match many common words (table, row, dialog) over the one
|
|
98
|
+
// file named by a rare word the promise uses for a specific place (SSO, PIN, verified domain).
|
|
99
|
+
const rare = [...promiseWords]
|
|
100
|
+
.map((word) => ({ word, weight: index.idf.weight(word) }))
|
|
101
|
+
.filter(({ word }) => index.entries.some((entry) => entry.words.has(word)))
|
|
102
|
+
.sort((a, b) => b.weight - a.weight)
|
|
103
|
+
.slice(0, RARE_WORDS);
|
|
104
|
+
const byScore = new Map(ordered.map((item) => [item.entry.file, item.score]));
|
|
105
|
+
for (const { word } of rare) {
|
|
106
|
+
const matches = index.entries
|
|
107
|
+
.filter((entry) => entry.words.has(word) && !out.includes(entry))
|
|
108
|
+
.sort((a, b) => (byScore.get(b.file) ?? 0) - (byScore.get(a.file) ?? 0))
|
|
109
|
+
.slice(0, PER_RARE_WORD);
|
|
110
|
+
out.push(...matches);
|
|
111
|
+
}
|
|
112
|
+
for (const entry of out) {
|
|
113
|
+
const folder = entry.file.slice(0, entry.file.lastIndexOf("/"));
|
|
114
|
+
perFolder.set(folder, (perFolder.get(folder) ?? 0) + 1);
|
|
115
|
+
}
|
|
116
|
+
for (const item of ordered) {
|
|
117
|
+
if (out.includes(item.entry))
|
|
118
|
+
continue;
|
|
119
|
+
if (out.length >= FILES)
|
|
120
|
+
break;
|
|
121
|
+
const folder = item.entry.file.slice(0, item.entry.file.lastIndexOf("/"));
|
|
122
|
+
const count = perFolder.get(folder) ?? 0;
|
|
123
|
+
if (count >= PER_FOLDER)
|
|
124
|
+
continue;
|
|
125
|
+
perFolder.set(folder, count + 1);
|
|
126
|
+
out.push(item.entry);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
/** `anchor-brief/v3`, word for word. */
|
|
131
|
+
export function anchorBrief(meaning, candidates) {
|
|
132
|
+
const listing = candidates
|
|
133
|
+
.map((entry) => `${entry.file}: ${entry.names
|
|
134
|
+
.slice(0, NAMES_PER_FILE)
|
|
135
|
+
.map((name) => name.name)
|
|
136
|
+
.join(", ")}`)
|
|
137
|
+
.join("\n");
|
|
138
|
+
return [
|
|
139
|
+
"You are anchoring a product promise to the code that realizes it.",
|
|
140
|
+
"",
|
|
141
|
+
"The promise, in its owner's words:",
|
|
142
|
+
JSON.stringify({
|
|
143
|
+
title: meaning.title,
|
|
144
|
+
observableOutcome: meaning.observableOutcome,
|
|
145
|
+
passingExamples: meaning.passingExamples ?? [],
|
|
146
|
+
failingExamples: meaning.failingExamples ?? [],
|
|
147
|
+
nonGoals: meaning.nonGoals ?? [],
|
|
148
|
+
}, null, 1),
|
|
149
|
+
"",
|
|
150
|
+
"Source files of the repository, each with the names it exports (a pre-selected list, not the whole repository):",
|
|
151
|
+
listing,
|
|
152
|
+
"",
|
|
153
|
+
"Pick, from this list only:",
|
|
154
|
+
"- entryPoints: where the promised behavior starts (a page, route handler, API endpoint, command, job or exported function a caller uses), at most 6;",
|
|
155
|
+
"- symbols: the functions, components or classes whose code decides whether the promise holds, at most 12.",
|
|
156
|
+
"Prefer the code that decides the outcome over generic helpers. Use exact file paths and names from the list. If nothing in the list realizes the promise, answer empty lists.",
|
|
157
|
+
"Cover every layer the behavior passes through, as far as the list shows them: the page or route where a person meets it, the components that render it, the hooks or state that drive it, and the server or API code it calls. When the promise names several places (several pages or features), anchor each of them. That is usually four to ten files.",
|
|
158
|
+
'Answer with JSON only: {"entryPoints":[{"file":"...","symbol":"...","kind":"route|command|ui|job|event|function"}],"symbols":[{"file":"...","name":"..."}]}',
|
|
159
|
+
].join("\n");
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The first complete JSON value in a model's answer, fenced or bare, as the
|
|
163
|
+
* measurement read it. Undefined when there is none.
|
|
164
|
+
*/
|
|
165
|
+
export function firstJsonValue(answer) {
|
|
166
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(answer);
|
|
167
|
+
for (const body of fenced ? [fenced[1], answer] : [answer]) {
|
|
168
|
+
const start = body.search(/[[{]/);
|
|
169
|
+
if (start === -1)
|
|
170
|
+
continue;
|
|
171
|
+
const opener = body[start];
|
|
172
|
+
const closer = opener === "{" ? "}" : "]";
|
|
173
|
+
let depth = 0;
|
|
174
|
+
let inString = false;
|
|
175
|
+
let escaped = false;
|
|
176
|
+
for (let index = start; index < body.length; index += 1) {
|
|
177
|
+
const character = body[index];
|
|
178
|
+
if (escaped) {
|
|
179
|
+
escaped = false;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (character === "\\") {
|
|
183
|
+
escaped = true;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (character === '"')
|
|
187
|
+
inString = !inString;
|
|
188
|
+
if (inString)
|
|
189
|
+
continue;
|
|
190
|
+
if (character === opener)
|
|
191
|
+
depth += 1;
|
|
192
|
+
if (character === closer) {
|
|
193
|
+
depth -= 1;
|
|
194
|
+
if (depth === 0) {
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(body.slice(start, index + 1));
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
const ENTRY_KINDS = new Set(ENTRY_POINT_KINDS);
|
|
208
|
+
const SYMBOL_KIND = {
|
|
209
|
+
function: "function",
|
|
210
|
+
class: "class",
|
|
211
|
+
method: "method",
|
|
212
|
+
const: "const",
|
|
213
|
+
type: "type",
|
|
214
|
+
interface: "type",
|
|
215
|
+
enum: "type",
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* What the model picked, kept only when its file is on the shortlist and its
|
|
219
|
+
* name is declared or exported in that file, and only when both pass the data
|
|
220
|
+
* boundary. Anything else is dropped. The measurement kept any name in a
|
|
221
|
+
* listed file; a name the file does not declare would read as gone on the next
|
|
222
|
+
* push, so it is dropped here instead (2 of the 1,268 measured picks).
|
|
223
|
+
*/
|
|
224
|
+
export function readPicks(raw, candidates, declared) {
|
|
225
|
+
const answer = (raw ?? {});
|
|
226
|
+
const byFile = new Map(candidates.map((entry) => [entry.file, entry]));
|
|
227
|
+
const dropped = [];
|
|
228
|
+
const keep = (file, name) => {
|
|
229
|
+
if (typeof file !== "string" || typeof name !== "string" || name === "")
|
|
230
|
+
return undefined;
|
|
231
|
+
const known = byFile.get(file);
|
|
232
|
+
if (known === undefined)
|
|
233
|
+
return undefined;
|
|
234
|
+
if (!isAnchorPath(known.file) || !isAnchorSymbol(name))
|
|
235
|
+
return undefined;
|
|
236
|
+
return declared(known.file)?.has(name) ? known : undefined;
|
|
237
|
+
};
|
|
238
|
+
const entryPoints = [];
|
|
239
|
+
for (const entry of Array.isArray(answer.entryPoints) ? answer.entryPoints : []) {
|
|
240
|
+
const row = (entry ?? {});
|
|
241
|
+
const known = keep(row.file, row.symbol);
|
|
242
|
+
if (known === undefined || entryPoints.length >= ANCHOR_LIMITS.entryPoints) {
|
|
243
|
+
dropped.push(`entry ${String(row.file)}#${String(row.symbol)}`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const symbol = row.symbol;
|
|
247
|
+
if (entryPoints.some((kept) => kept.file === known.file && kept.symbol === symbol))
|
|
248
|
+
continue;
|
|
249
|
+
entryPoints.push({
|
|
250
|
+
file: known.file,
|
|
251
|
+
symbol,
|
|
252
|
+
kind: (typeof row.kind === "string" && ENTRY_KINDS.has(row.kind)
|
|
253
|
+
? row.kind
|
|
254
|
+
: "function"),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const symbols = [];
|
|
258
|
+
for (const entry of Array.isArray(answer.symbols) ? answer.symbols : []) {
|
|
259
|
+
const row = (entry ?? {});
|
|
260
|
+
const known = keep(row.file, row.name);
|
|
261
|
+
if (known === undefined || symbols.length >= ANCHOR_LIMITS.symbols) {
|
|
262
|
+
dropped.push(`symbol ${String(row.file)}#${String(row.name)}`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const name = row.name;
|
|
266
|
+
if (symbols.some((kept) => kept.file === known.file && kept.name === name))
|
|
267
|
+
continue;
|
|
268
|
+
const declaredKind = known.names.find((exported) => exported.name === name)?.kind;
|
|
269
|
+
symbols.push({
|
|
270
|
+
file: known.file,
|
|
271
|
+
name,
|
|
272
|
+
kind: SYMBOL_KIND[declaredKind ?? "function"] ?? "function",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return { entryPoints, symbols, dropped };
|
|
276
|
+
}
|
|
277
|
+
/** Linked tests, from the graph: test files that import an anchor file directly. */
|
|
278
|
+
export function linkedTestsFor(graph, files) {
|
|
279
|
+
const tests = new Set();
|
|
280
|
+
for (const file of files)
|
|
281
|
+
for (const importer of graph.importedBy[file] ?? [])
|
|
282
|
+
if (isTestPath(importer))
|
|
283
|
+
tests.add(importer);
|
|
284
|
+
return [...tests]
|
|
285
|
+
.filter((file) => isAnchorPath(file))
|
|
286
|
+
.slice(0, ANCHOR_LIMITS.linkedTests)
|
|
287
|
+
.map((file) => ({ file }));
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Files one import step from the anchor files, either way: what they import
|
|
291
|
+
* and what imports them, tests left out (linked tests are listed apart). This
|
|
292
|
+
* is the judge's neighborhood: where to look next if the behavior reaches
|
|
293
|
+
* further than the anchors.
|
|
294
|
+
*/
|
|
295
|
+
export function neighborhood(graph, files, limit = 30) {
|
|
296
|
+
const anchors = new Set(files);
|
|
297
|
+
const found = new Set();
|
|
298
|
+
for (const file of files) {
|
|
299
|
+
for (const next of graph.imports[file] ?? [])
|
|
300
|
+
if (!anchors.has(next))
|
|
301
|
+
found.add(next);
|
|
302
|
+
for (const next of graph.importedBy[file] ?? [])
|
|
303
|
+
if (!anchors.has(next))
|
|
304
|
+
found.add(next);
|
|
305
|
+
}
|
|
306
|
+
return [...found]
|
|
307
|
+
.filter((file) => !isTestPath(file))
|
|
308
|
+
.sort()
|
|
309
|
+
.slice(0, limit);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Anchors cut from a behavior map, with no model: its entry points, its core
|
|
313
|
+
* symbols, its resources and its linked tests, each kept only when it passes
|
|
314
|
+
* the data boundary, up to the contract's limits. A map's notes are not
|
|
315
|
+
* carried. Undefined when nothing anchorable is left.
|
|
316
|
+
*/
|
|
317
|
+
export function seedFromMap(map, at) {
|
|
318
|
+
const entryPoints = map.entryPoints
|
|
319
|
+
.filter((entry) => isAnchorPath(entry.file) && isAnchorSymbol(entry.symbol))
|
|
320
|
+
.slice(0, ANCHOR_LIMITS.entryPoints)
|
|
321
|
+
.map((entry) => ({ file: entry.file, symbol: entry.symbol, kind: entry.kind }));
|
|
322
|
+
const symbols = map.symbols
|
|
323
|
+
.filter((symbol) => symbol.role === "core")
|
|
324
|
+
.filter((symbol) => isAnchorPath(symbol.file) && isAnchorSymbol(symbol.name))
|
|
325
|
+
.slice(0, ANCHOR_LIMITS.symbols)
|
|
326
|
+
.map((symbol) => ({ file: symbol.file, name: symbol.name, kind: symbol.kind }));
|
|
327
|
+
if (entryPoints.length + symbols.length === 0)
|
|
328
|
+
return undefined;
|
|
329
|
+
const anchors = {
|
|
330
|
+
schemaVersion: PROMISE_ANCHORS_SCHEMA_VERSION,
|
|
331
|
+
promiseId: map.promiseId,
|
|
332
|
+
semanticDigest: map.semanticDigest,
|
|
333
|
+
anchoredAt: {
|
|
334
|
+
sha: map.mappedAt.sha,
|
|
335
|
+
at: at.toISOString(),
|
|
336
|
+
source: BEHAVIOR_MAP_SOURCE,
|
|
337
|
+
client: null,
|
|
338
|
+
model: null,
|
|
339
|
+
},
|
|
340
|
+
entryPoints,
|
|
341
|
+
symbols,
|
|
342
|
+
resources: map.resources
|
|
343
|
+
.filter((resource) => isAnchorResourceName(resource.name))
|
|
344
|
+
.slice(0, ANCHOR_LIMITS.resources)
|
|
345
|
+
.map((resource) => ({ kind: resource.kind, name: resource.name, access: resource.access })),
|
|
346
|
+
linkedTests: map.linkedTests
|
|
347
|
+
.filter((test) => isAnchorPath(test.file))
|
|
348
|
+
.slice(0, ANCHOR_LIMITS.linkedTests)
|
|
349
|
+
.map((test) => ({ file: test.file })),
|
|
350
|
+
};
|
|
351
|
+
return checkPromiseAnchors(anchors).ok ? anchors : undefined;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Which anchors have gone at a commit, read off the graph with no model: an
|
|
355
|
+
* entry point or symbol whose file is not in the tree, or whose name its file
|
|
356
|
+
* no longer declares. A file the graph does not read (another language, from a
|
|
357
|
+
* map) is checked for being there only.
|
|
358
|
+
*/
|
|
359
|
+
export function goneAnchors(anchors, repository) {
|
|
360
|
+
const gone = [];
|
|
361
|
+
const present = (file, name) => {
|
|
362
|
+
if (!repository.tree.has(file))
|
|
363
|
+
return false;
|
|
364
|
+
const names = repository.declaredNames(file);
|
|
365
|
+
return names === undefined || names.has(name);
|
|
366
|
+
};
|
|
367
|
+
const entryPoints = anchors.entryPoints.filter((entry) => {
|
|
368
|
+
const kept = present(entry.file, entry.symbol);
|
|
369
|
+
if (!kept)
|
|
370
|
+
gone.push(`${entry.file}#${entry.symbol}`);
|
|
371
|
+
return kept;
|
|
372
|
+
});
|
|
373
|
+
const symbols = anchors.symbols.filter((symbol) => {
|
|
374
|
+
const kept = present(symbol.file, symbol.name);
|
|
375
|
+
if (!kept)
|
|
376
|
+
gone.push(`${symbol.file}#${symbol.name}`);
|
|
377
|
+
return kept;
|
|
378
|
+
});
|
|
379
|
+
const linkedTests = anchors.linkedTests.filter((test) => repository.tree.has(test.file));
|
|
380
|
+
return { surviving: { ...anchors, entryPoints, symbols, linkedTests }, gone };
|
|
381
|
+
}
|
|
382
|
+
/** How many tokens a run spent: input, cache reads and writes included, and output. */
|
|
383
|
+
function spentTokens(agent) {
|
|
384
|
+
return (agent.inputTokens ?? 0) + (agent.outputTokens ?? 0);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* One promise anchored by one headless call: no tools, in an empty folder,
|
|
388
|
+
* with an output-token cap and a time limit. Only picks from the shortlist are
|
|
389
|
+
* kept, linked tests come from the graph, and the result is checked against
|
|
390
|
+
* the data boundary before anyone sees it.
|
|
391
|
+
*/
|
|
392
|
+
export async function anchorWithCall(input) {
|
|
393
|
+
const meaning = briefMeaning(input.meaning);
|
|
394
|
+
const candidates = shortlist(input.index, meaning);
|
|
395
|
+
const prompt = anchorBrief(meaning, candidates);
|
|
396
|
+
const cwd = mkdtempSync(join(input.scratch ?? tmpdir(), "balladeer-anchor-"));
|
|
397
|
+
const started = Date.now();
|
|
398
|
+
// Shorter than the cap only when the push check has less time than that left; running
|
|
399
|
+
// into that is the push's time running out, not the call running away.
|
|
400
|
+
const limit = Math.min(input.timeoutMs ?? ANCHOR_CALL_CAPS.wallMs, ANCHOR_CALL_CAPS.wallMs);
|
|
401
|
+
let agent;
|
|
402
|
+
try {
|
|
403
|
+
agent = await (input.runAgent ?? runHeadlessAgent)({
|
|
404
|
+
client: input.login.client,
|
|
405
|
+
login: input.login,
|
|
406
|
+
cwd,
|
|
407
|
+
prompt,
|
|
408
|
+
allowedTools: [],
|
|
409
|
+
tools: [],
|
|
410
|
+
sandbox: "read-only",
|
|
411
|
+
outsideRepository: true,
|
|
412
|
+
timeoutMs: limit,
|
|
413
|
+
maxOutputTokens: ANCHOR_CALL_CAPS.outputTokens,
|
|
414
|
+
env: input.environment,
|
|
415
|
+
jsonOutput: false,
|
|
416
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
417
|
+
...(input.scratch === undefined ? {} : { scratch: input.scratch }),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
422
|
+
}
|
|
423
|
+
const tokens = spentTokens(agent);
|
|
424
|
+
if (agent.aborted)
|
|
425
|
+
return { status: "stopped", reason: "the push check's time ran out", tokens };
|
|
426
|
+
if (agent.reason === "output_capped" || agent.overOutputCap === true)
|
|
427
|
+
return {
|
|
428
|
+
status: "capped",
|
|
429
|
+
reason: `its call went past the ${ANCHOR_CALL_CAPS.outputTokens / 1000} thousand output-token cap and was stopped`,
|
|
430
|
+
tokens,
|
|
431
|
+
};
|
|
432
|
+
if (agent.reason === "timed_out")
|
|
433
|
+
return limit < ANCHOR_CALL_CAPS.wallMs
|
|
434
|
+
? { status: "stopped", reason: "the push check's time ran out", tokens }
|
|
435
|
+
: {
|
|
436
|
+
status: "capped",
|
|
437
|
+
reason: `its call ran past ${ANCHOR_CALL_CAPS.wallMs / 1000} seconds and was stopped`,
|
|
438
|
+
tokens,
|
|
439
|
+
};
|
|
440
|
+
if (!agent.ok)
|
|
441
|
+
return {
|
|
442
|
+
status: "failed",
|
|
443
|
+
reason: `its call did not answer (${agent.reason ?? "no answer"})`,
|
|
444
|
+
tokens,
|
|
445
|
+
};
|
|
446
|
+
const raw = firstJsonValue(agent.text);
|
|
447
|
+
if (raw === undefined || raw === null || typeof raw !== "object")
|
|
448
|
+
return { status: "failed", reason: "its call's answer held no JSON", tokens };
|
|
449
|
+
const picks = readPicks(raw, candidates, (file) => input.repository.declaredNames(file));
|
|
450
|
+
if (picks.entryPoints.length + picks.symbols.length === 0)
|
|
451
|
+
return { status: "empty", tokens, dropped: picks.dropped };
|
|
452
|
+
const anchors = {
|
|
453
|
+
schemaVersion: PROMISE_ANCHORS_SCHEMA_VERSION,
|
|
454
|
+
promiseId: input.promiseId,
|
|
455
|
+
semanticDigest: input.semanticDigest,
|
|
456
|
+
anchoredAt: {
|
|
457
|
+
sha: input.repository.sha,
|
|
458
|
+
at: input.now().toISOString(),
|
|
459
|
+
source: ANCHOR_BRIEF_SOURCE,
|
|
460
|
+
client: input.login.client,
|
|
461
|
+
model: anchorModelName(agent.model),
|
|
462
|
+
},
|
|
463
|
+
entryPoints: picks.entryPoints,
|
|
464
|
+
symbols: picks.symbols,
|
|
465
|
+
resources: [],
|
|
466
|
+
linkedTests: linkedTestsFor(input.repository.graph, new Set([
|
|
467
|
+
...picks.entryPoints.map((entry) => entry.file),
|
|
468
|
+
...picks.symbols.map((symbol) => symbol.file),
|
|
469
|
+
])),
|
|
470
|
+
};
|
|
471
|
+
const checked = checkPromiseAnchors(anchors);
|
|
472
|
+
if (!checked.ok)
|
|
473
|
+
return { status: "failed", reason: "its anchors failed the data boundary", tokens };
|
|
474
|
+
return {
|
|
475
|
+
status: "anchored",
|
|
476
|
+
anchors: checked.value,
|
|
477
|
+
tokens,
|
|
478
|
+
dropped: picks.dropped,
|
|
479
|
+
ms: Date.now() - started,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/** The model name as the contract carries it, or `unknown` when the run did not say. */
|
|
483
|
+
export function anchorModelName(model) {
|
|
484
|
+
const name = (model ?? "").trim();
|
|
485
|
+
return /^[A-Za-z0-9][A-Za-z0-9._:/@+[\]-]{0,127}$/.test(name) ? name : "unknown";
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Every agreed promise brought to current anchors as far as today's budget
|
|
489
|
+
* and the time allow: kept when current, cut from a current map with no model,
|
|
490
|
+
* else anchored by a call; anchors whose code has gone are anchored again, and
|
|
491
|
+
* judged on what is left of them meanwhile. A promise left unanchored says why.
|
|
492
|
+
*/
|
|
493
|
+
export async function bringAnchorsCurrent(input) {
|
|
494
|
+
const run = await anchorEach(input);
|
|
495
|
+
const selectBy = new Map();
|
|
496
|
+
for (const promise of input.promises) {
|
|
497
|
+
const files = new Set();
|
|
498
|
+
if (promise.anchors !== null)
|
|
499
|
+
for (const file of anchorFiles(promise.anchors))
|
|
500
|
+
files.add(file);
|
|
501
|
+
const now = run.anchors.get(promise.promiseId);
|
|
502
|
+
if (now !== undefined)
|
|
503
|
+
for (const file of anchorFiles(now))
|
|
504
|
+
files.add(file);
|
|
505
|
+
if (files.size > 0)
|
|
506
|
+
selectBy.set(promise.promiseId, [...files].sort());
|
|
507
|
+
}
|
|
508
|
+
return { ...run, selectBy };
|
|
509
|
+
}
|
|
510
|
+
async function anchorEach(input) {
|
|
511
|
+
const anchors = new Map();
|
|
512
|
+
const outcomes = [];
|
|
513
|
+
let tokens = 0;
|
|
514
|
+
const needs = [];
|
|
515
|
+
for (const promise of input.promises) {
|
|
516
|
+
const forced = input.force?.has(promise.promiseId) === true;
|
|
517
|
+
const inScope = input.only === undefined || input.only.has(promise.promiseId) || forced;
|
|
518
|
+
const existing = promise.anchors;
|
|
519
|
+
if (existing !== null) {
|
|
520
|
+
const check = goneAnchors(existing, input.repository);
|
|
521
|
+
const usable = check.surviving.entryPoints.length + check.surviving.symbols.length > 0;
|
|
522
|
+
if (usable)
|
|
523
|
+
anchors.set(promise.promiseId, check.surviving);
|
|
524
|
+
if (!forced && (check.gone.length === 0 || !inScope)) {
|
|
525
|
+
if (check.gone.length === 0)
|
|
526
|
+
outcomes.push({ promiseId: promise.promiseId, title: promise.title, status: "current" });
|
|
527
|
+
else
|
|
528
|
+
outcomes.push({ promiseId: promise.promiseId, title: promise.title, status: "stale" });
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
needs.push({ promise, again: check.gone.length > 0 });
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (inScope)
|
|
535
|
+
needs.push({ promise, again: false });
|
|
536
|
+
}
|
|
537
|
+
const record = async (promise, fresh) => {
|
|
538
|
+
const sent = await input.client.record(fresh);
|
|
539
|
+
return sent.ok ? { recorded: true } : { recorded: false, recordReason: sent.reason };
|
|
540
|
+
};
|
|
541
|
+
// Seeds first: they cost nothing and need no login.
|
|
542
|
+
const calls = [];
|
|
543
|
+
for (const need of needs) {
|
|
544
|
+
const map = input.currentMaps.get(need.promise.promiseId);
|
|
545
|
+
const seeded = map === undefined ? undefined : seedFromMap(map, input.now());
|
|
546
|
+
if (seeded === undefined) {
|
|
547
|
+
calls.push(need);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
anchors.set(need.promise.promiseId, goneAnchors(seeded, input.repository).surviving);
|
|
551
|
+
outcomes.push({
|
|
552
|
+
promiseId: need.promise.promiseId,
|
|
553
|
+
title: need.promise.title,
|
|
554
|
+
status: "seeded",
|
|
555
|
+
...(need.again ? { again: true } : {}),
|
|
556
|
+
...(await record(need.promise, seeded)),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
if (calls.length === 0)
|
|
560
|
+
return { anchors, outcomes, tokens, budget: input.budget };
|
|
561
|
+
const settledKey = (promise) => `${input.repositoryKey}:${promise.promiseId}:${promise.semanticDigest}`;
|
|
562
|
+
const open = [];
|
|
563
|
+
for (const need of calls) {
|
|
564
|
+
const settled = input.budget.settledToday(settledKey(need.promise));
|
|
565
|
+
if (settled !== undefined && input.force?.has(need.promise.promiseId) !== true)
|
|
566
|
+
outcomes.push({
|
|
567
|
+
promiseId: need.promise.promiseId,
|
|
568
|
+
title: need.promise.title,
|
|
569
|
+
status: need.promise.anchors !== null && anchors.has(need.promise.promiseId)
|
|
570
|
+
? "stale"
|
|
571
|
+
: "settled",
|
|
572
|
+
reason: settled === "empty"
|
|
573
|
+
? "its call came back empty earlier today"
|
|
574
|
+
: "its call went past its cap earlier today",
|
|
575
|
+
});
|
|
576
|
+
else
|
|
577
|
+
open.push(need);
|
|
578
|
+
}
|
|
579
|
+
if (open.length === 0)
|
|
580
|
+
return { anchors, outcomes, tokens, budget: input.budget };
|
|
581
|
+
const login = await input.login();
|
|
582
|
+
if (login === undefined) {
|
|
583
|
+
for (const need of open)
|
|
584
|
+
outcomes.push({
|
|
585
|
+
promiseId: need.promise.promiseId,
|
|
586
|
+
title: need.promise.title,
|
|
587
|
+
status: anchors.has(need.promise.promiseId) ? "stale" : "no_login",
|
|
588
|
+
reason: "no Claude Code signed in through claude.ai, and no Codex signed in with ChatGPT, is on this machine",
|
|
589
|
+
});
|
|
590
|
+
return { anchors, outcomes, tokens, budget: input.budget };
|
|
591
|
+
}
|
|
592
|
+
const client = login.client;
|
|
593
|
+
const repositoryIndex = anchorIndex(input.repository);
|
|
594
|
+
const queue = [...open];
|
|
595
|
+
const worker = async () => {
|
|
596
|
+
for (;;) {
|
|
597
|
+
const need = queue.shift();
|
|
598
|
+
if (need === undefined)
|
|
599
|
+
return;
|
|
600
|
+
const { promise } = need;
|
|
601
|
+
const left = (status, reason, spent) => outcomes.push({
|
|
602
|
+
promiseId: promise.promiseId,
|
|
603
|
+
title: promise.title,
|
|
604
|
+
// A promise with anchors left over is still judged on them.
|
|
605
|
+
status: anchors.has(promise.promiseId) ? "stale" : status,
|
|
606
|
+
...(reason === undefined ? {} : { reason }),
|
|
607
|
+
...(spent === undefined ? {} : { tokens: spent }),
|
|
608
|
+
});
|
|
609
|
+
if (input.signal?.aborted ||
|
|
610
|
+
(input.deadline !== undefined && input.deadline - Date.now() < MIN_CALL_TIME_MS)) {
|
|
611
|
+
left("time", "the push check's time ran out before its turn");
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const read = input.reader === undefined ? undefined : await input.reader.get(promise.promiseId);
|
|
615
|
+
if (read === undefined || !read.ok) {
|
|
616
|
+
left("unreadable", read?.ok === false ? read.reason : "its meaning could not be read here");
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (read.promise.semanticDigest !== promise.semanticDigest) {
|
|
620
|
+
left("unreadable", "its meaning changed while this push was being checked");
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (!input.budget.take()) {
|
|
624
|
+
left("budget", budgetSpent(input.budget.limit));
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const remaining = input.deadline === undefined
|
|
628
|
+
? ANCHOR_CALL_CAPS.wallMs
|
|
629
|
+
: Math.max(1000, input.deadline - Date.now());
|
|
630
|
+
const call = await anchorWithCall({
|
|
631
|
+
promiseId: promise.promiseId,
|
|
632
|
+
semanticDigest: promise.semanticDigest,
|
|
633
|
+
meaning: read.promise.meaning,
|
|
634
|
+
repository: input.repository,
|
|
635
|
+
index: repositoryIndex,
|
|
636
|
+
login,
|
|
637
|
+
environment: input.environment,
|
|
638
|
+
now: input.now,
|
|
639
|
+
timeoutMs: Math.min(ANCHOR_CALL_CAPS.wallMs, remaining),
|
|
640
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
641
|
+
...(input.scratch === undefined ? {} : { scratch: input.scratch }),
|
|
642
|
+
...(input.runAgent === undefined ? {} : { runAgent: input.runAgent }),
|
|
643
|
+
});
|
|
644
|
+
tokens += call.tokens;
|
|
645
|
+
if (call.status === "anchored") {
|
|
646
|
+
anchors.set(promise.promiseId, call.anchors);
|
|
647
|
+
outcomes.push({
|
|
648
|
+
promiseId: promise.promiseId,
|
|
649
|
+
title: promise.title,
|
|
650
|
+
status: "anchored",
|
|
651
|
+
tokens: call.tokens,
|
|
652
|
+
...(need.again ? { again: true } : {}),
|
|
653
|
+
...(await record(promise, call.anchors)),
|
|
654
|
+
});
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (call.status === "empty") {
|
|
658
|
+
input.budget.settle(settledKey(promise), "empty");
|
|
659
|
+
left("empty", "nothing on its shortlist realizes it", call.tokens);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
if (call.status === "capped")
|
|
663
|
+
input.budget.settle(settledKey(promise), "capped");
|
|
664
|
+
left(call.status === "stopped" ? "time" : call.status, call.reason, call.tokens);
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(input.concurrency ?? 3, open.length)) }, worker));
|
|
668
|
+
return {
|
|
669
|
+
anchors,
|
|
670
|
+
outcomes,
|
|
671
|
+
tokens,
|
|
672
|
+
client,
|
|
673
|
+
budget: input.budget,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
function plural(count, one, many) {
|
|
677
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
678
|
+
}
|
|
679
|
+
function budgetSpent(limit) {
|
|
680
|
+
return `this laptop's ${plural(limit, "anchoring", "anchorings")} for today ${limit === 1 ? "is" : "are"} used up`;
|
|
681
|
+
}
|
|
682
|
+
function aboutTokens(tokens) {
|
|
683
|
+
if (tokens < 1000)
|
|
684
|
+
return `about ${tokens} tokens`;
|
|
685
|
+
return `about ${Math.round(tokens / 1000)} thousand tokens`;
|
|
686
|
+
}
|
|
687
|
+
function subscription(client) {
|
|
688
|
+
return client === "codex" ? "your Codex subscription" : "your Claude subscription";
|
|
689
|
+
}
|
|
690
|
+
function named(outcomes, max = 3) {
|
|
691
|
+
const shown = outcomes.slice(0, max).map((outcome) => `${outcome.title} (${outcome.promiseId})`);
|
|
692
|
+
const more = outcomes.length > max ? ` and ${outcomes.length - max} more` : "";
|
|
693
|
+
return `${shown.join("; ")}${more}`;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* What anchoring did, in at most four lines a person reads in one go: what
|
|
697
|
+
* was anchored and what it cost with what is left of today's budget; what was
|
|
698
|
+
* left unanchored, so not checked, and why; what is checked on part of its
|
|
699
|
+
* anchors; and anchors not shared with the team. Nothing when anchoring had
|
|
700
|
+
* nothing to do.
|
|
701
|
+
*
|
|
702
|
+
* From the push check (`push`), a promise left unanchored that the push
|
|
703
|
+
* selected anyway (by an anchor file it moved or deleted) is not listed as
|
|
704
|
+
* unchecked, because it was judged; from `balladeer anchor` there is no push,
|
|
705
|
+
* and the lines say what the push check can do instead.
|
|
706
|
+
*/
|
|
707
|
+
export function anchoringLines(run, context = { push: true }) {
|
|
708
|
+
const lines = [];
|
|
709
|
+
const by = (status) => run.outcomes.filter((outcome) => outcome.status === status &&
|
|
710
|
+
(status === "anchored" ||
|
|
711
|
+
status === "seeded" ||
|
|
712
|
+
context.judged?.has(outcome.promiseId) !== true));
|
|
713
|
+
const called = by("anchored");
|
|
714
|
+
const seeded = by("seeded");
|
|
715
|
+
const fresh = [...called, ...seeded];
|
|
716
|
+
const left = run.budget.remaining();
|
|
717
|
+
const remaining = `${plural(left, "anchoring", "anchorings")} left today`;
|
|
718
|
+
if (fresh.length > 0) {
|
|
719
|
+
const again = fresh.filter((outcome) => outcome.again === true).length;
|
|
720
|
+
const againText = again === 0
|
|
721
|
+
? ""
|
|
722
|
+
: ` (${again === fresh.length && again === 1 ? "again, because" : `${again} of them again, because`} code it was anchored to is gone)`;
|
|
723
|
+
const head = `Balladeer anchored ${plural(fresh.length, "promise", "promises")} to this repository's code${againText}`;
|
|
724
|
+
if (called.length === 0)
|
|
725
|
+
lines.push(`${head} from ${seeded.length === 1 ? "its behavior map" : "their behavior maps"}, with no model call (${remaining}).`);
|
|
726
|
+
else if (seeded.length === 0)
|
|
727
|
+
lines.push(`${head} on ${subscription(run.client)} (${aboutTokens(run.tokens)}; ${remaining}).`);
|
|
728
|
+
else
|
|
729
|
+
lines.push(`${head}: ${seeded.length} from ${seeded.length === 1 ? "its behavior map" : "behavior maps"}, and ${called.length} on ${subscription(run.client)} (${aboutTokens(run.tokens)}; ${remaining}).`);
|
|
730
|
+
}
|
|
731
|
+
const reasons = [
|
|
732
|
+
["budget", `${budgetSpent(run.budget.limit)}; anchoring starts again on a push tomorrow`],
|
|
733
|
+
["capped", "the call went past its cap and was stopped"],
|
|
734
|
+
["failed", "the call did not give usable anchors"],
|
|
735
|
+
["empty", "its call found nothing in this repository's code that realizes it"],
|
|
736
|
+
["settled", "its call came back empty or capped earlier today"],
|
|
737
|
+
[
|
|
738
|
+
"no_login",
|
|
739
|
+
"no Claude Code signed in through claude.ai, and no Codex signed in with ChatGPT, is on this laptop",
|
|
740
|
+
],
|
|
741
|
+
["unreadable", "its meaning could not be read"],
|
|
742
|
+
["time", "the push check ran out of time"],
|
|
743
|
+
];
|
|
744
|
+
const parts = [];
|
|
745
|
+
let count = 0;
|
|
746
|
+
for (const [status, why] of reasons) {
|
|
747
|
+
const these = by(status);
|
|
748
|
+
if (these.length === 0)
|
|
749
|
+
continue;
|
|
750
|
+
count += these.length;
|
|
751
|
+
parts.push(`${named(these)}: ${why}`);
|
|
752
|
+
}
|
|
753
|
+
const one = count === 1;
|
|
754
|
+
if (count > 0)
|
|
755
|
+
lines.push(`Balladeer could not anchor ${plural(count, "promise", "promises")}, so ${context.push
|
|
756
|
+
? `this push did not check ${one ? "it" : "them"}`
|
|
757
|
+
: `the push check cannot select ${one ? "it" : "them"} until ${one ? "it is" : "they are"} anchored`}. ${parts.join(". ")}.`);
|
|
758
|
+
const stale = by("stale");
|
|
759
|
+
const single = stale.length === 1;
|
|
760
|
+
if (stale.length > 0)
|
|
761
|
+
lines.push(`Code that ${plural(stale.length, "promise was", "promises were")} anchored to is gone, and ${single ? "it was" : "they were"} not anchored again yet, so ${single ? "it is" : "they are"} checked on what is left of ${single ? "its" : "their"} anchors: ${named(stale)}.`);
|
|
762
|
+
const unshared = fresh.filter((outcome) => outcome.recorded === false);
|
|
763
|
+
if (unshared.length > 0)
|
|
764
|
+
lines.push(`The anchors for ${named(unshared)} ${context.push ? "were used on this push but " : "were "}not shared with your team: ${unshared[0]?.recordReason ?? "Balladeer did not keep them"}.`);
|
|
765
|
+
return lines;
|
|
766
|
+
}
|