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
package/src/core/docs.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DOCS_ANCHOR_MAX_FILES,
|
|
3
|
+
DOCS_MAX_SECTIONS,
|
|
4
|
+
DOCS_NAME_MAX_FILES,
|
|
5
|
+
} from "../constants.ts";
|
|
6
|
+
import {
|
|
7
|
+
createImportGraphBuilder,
|
|
8
|
+
type ImportGraph,
|
|
9
|
+
type ImportSource,
|
|
10
|
+
importClosureLazy,
|
|
11
|
+
} from "./imports.ts";
|
|
12
|
+
import type { LexicalSource } from "./lexical.ts";
|
|
13
|
+
import { markdownSections } from "./sections.ts";
|
|
14
|
+
import type { EvidenceUnit, SourceFile } from "./units.ts";
|
|
15
|
+
|
|
16
|
+
export interface DocSentence {
|
|
17
|
+
id: string;
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
export interface DocsCandidate {
|
|
21
|
+
path: string;
|
|
22
|
+
heading: string;
|
|
23
|
+
start: number;
|
|
24
|
+
end: number;
|
|
25
|
+
sentences: readonly DocSentence[];
|
|
26
|
+
units: readonly EvidenceUnit[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DocsCandidates {
|
|
30
|
+
candidates: DocsCandidate[];
|
|
31
|
+
omitted: DocsCandidate[];
|
|
32
|
+
limits: {
|
|
33
|
+
path: string;
|
|
34
|
+
reason: string;
|
|
35
|
+
kind?: "collection_budget";
|
|
36
|
+
sections?: string[];
|
|
37
|
+
}[];
|
|
38
|
+
}
|
|
39
|
+
/** Sentences retain verbatim text, including Markdown and code; no paraphrase enters a pointer. */
|
|
40
|
+
export function docSentences(text: string): DocSentence[] {
|
|
41
|
+
const result: DocSentence[] = [];
|
|
42
|
+
let fence: { marker: string; length: number } | undefined;
|
|
43
|
+
const paragraphs: string[] = [];
|
|
44
|
+
let current: string[] = [];
|
|
45
|
+
for (const line of text.split("\n")) {
|
|
46
|
+
const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
47
|
+
if (marker) {
|
|
48
|
+
if (!fence)
|
|
49
|
+
fence = {
|
|
50
|
+
marker: marker[1]?.[0] ?? "`",
|
|
51
|
+
length: marker[1]?.length ?? 3,
|
|
52
|
+
};
|
|
53
|
+
else if (
|
|
54
|
+
marker[1]?.[0] === fence.marker &&
|
|
55
|
+
marker[1].length >= fence.length &&
|
|
56
|
+
!marker[2]?.trim()
|
|
57
|
+
)
|
|
58
|
+
fence = undefined;
|
|
59
|
+
current.push(line);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!fence && !line.trim()) {
|
|
63
|
+
if (current.length) paragraphs.push(current.join("\n"));
|
|
64
|
+
current = [];
|
|
65
|
+
} else current.push(line);
|
|
66
|
+
}
|
|
67
|
+
if (current.length) paragraphs.push(current.join("\n"));
|
|
68
|
+
for (const paragraph of paragraphs) {
|
|
69
|
+
// A fenced block remains one exact piece; punctuation in code is never a sentence boundary.
|
|
70
|
+
const pieces = /^ {0,3}(?:`{3,}|~{3,})/m.test(paragraph)
|
|
71
|
+
? [paragraph]
|
|
72
|
+
: paragraph.split(/(?<=[.!?])(?:[\t ]+|\n)(?=[A-ZÀ-ÖØ-Þ`*])/u);
|
|
73
|
+
for (const text of pieces)
|
|
74
|
+
if (text.trim()) result.push({ id: `s${result.length + 1}`, text });
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
export function inlineDocIdentifiers(text: string): string[] {
|
|
79
|
+
return [
|
|
80
|
+
...new Set(
|
|
81
|
+
[...text.matchAll(/(?<!`)`([A-Za-z_$][\w$]{2,})`(?!`)/g)].map(
|
|
82
|
+
(match) => match[1] ?? "",
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function docsDeclarationPattern(
|
|
89
|
+
name: string | readonly string[],
|
|
90
|
+
): string {
|
|
91
|
+
const names = typeof name === "string" ? [name] : name;
|
|
92
|
+
const escaped = names
|
|
93
|
+
.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
94
|
+
.join("|");
|
|
95
|
+
return `(?:\\b(?:export|const|let|var|function|class|type|interface|enum|def)\\s+(?:(?:default|async|function|const|class)\\s+)*(${escaped})(?:[^\\w$]|$)|^\\s*(?:(?:public|private|protected|static|async)\\s+)*(${escaped})\\s*\\([^;]*\\)\\s*(?::[^={]+)?\\s*[{])`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function attributeDocsDeclarations(
|
|
99
|
+
names: readonly string[],
|
|
100
|
+
rows: readonly { path: string; text: string }[],
|
|
101
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
102
|
+
const owners = new Map(names.map((name) => [name, new Set<string>()]));
|
|
103
|
+
if (!names.length) return new Map();
|
|
104
|
+
const pattern = new RegExp(docsDeclarationPattern(names), "gm");
|
|
105
|
+
for (const row of rows)
|
|
106
|
+
for (const match of row.text.matchAll(pattern)) {
|
|
107
|
+
const paths = owners.get(match[1] ?? match[2] ?? "");
|
|
108
|
+
if (paths && paths.size <= DOCS_NAME_MAX_FILES) paths.add(row.path);
|
|
109
|
+
}
|
|
110
|
+
const result = new Map<string, readonly string[]>(
|
|
111
|
+
[...owners].map(([name, paths]) => [name, [...paths]]),
|
|
112
|
+
);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function collectDocsCandidates(
|
|
117
|
+
files: readonly ImportSource[],
|
|
118
|
+
units: readonly EvidenceUnit[],
|
|
119
|
+
sources: {
|
|
120
|
+
known: ReadonlySet<string>;
|
|
121
|
+
read: (path: string) => Promise<ImportSource | undefined>;
|
|
122
|
+
declarations?: (
|
|
123
|
+
names: readonly string[],
|
|
124
|
+
) => Promise<ReadonlyMap<string, readonly string[]>>;
|
|
125
|
+
changedFiles?: readonly SourceFile[];
|
|
126
|
+
shouldStop?: () => boolean;
|
|
127
|
+
lexical?: LexicalSource;
|
|
128
|
+
},
|
|
129
|
+
): Promise<DocsCandidates> {
|
|
130
|
+
const candidates: DocsCandidate[] = [];
|
|
131
|
+
const omitted: DocsCandidate[] = [];
|
|
132
|
+
const limits: DocsCandidates["limits"] = [];
|
|
133
|
+
const sections = files
|
|
134
|
+
.filter((file) => /\.(?:md|mdx|markdown)$/i.test(file.path))
|
|
135
|
+
.flatMap((file) =>
|
|
136
|
+
markdownSections(file.text).map((section) => ({
|
|
137
|
+
file: file.path,
|
|
138
|
+
identifiers: new Set(inlineDocIdentifiers(section.text)),
|
|
139
|
+
...section,
|
|
140
|
+
paths: [
|
|
141
|
+
...new Set(
|
|
142
|
+
(section.text.match(/[A-Za-z0-9_@./-]+/g) ?? [])
|
|
143
|
+
.map((path) =>
|
|
144
|
+
sources.known.has(path) ? path : path.replace(/\.+$/, ""),
|
|
145
|
+
)
|
|
146
|
+
.filter((path) => sources.known.has(path)),
|
|
147
|
+
),
|
|
148
|
+
],
|
|
149
|
+
})),
|
|
150
|
+
);
|
|
151
|
+
const selectedSections = new Set<number>();
|
|
152
|
+
const reachable = new Map<string, Set<EvidenceUnit>>();
|
|
153
|
+
for (const unit of units) {
|
|
154
|
+
const selected = reachable.get(unit.file) ?? new Set<EvidenceUnit>();
|
|
155
|
+
selected.add(unit);
|
|
156
|
+
reachable.set(unit.file, selected);
|
|
157
|
+
}
|
|
158
|
+
const match = (
|
|
159
|
+
paths: ReadonlyMap<string, Set<EvidenceUnit>>,
|
|
160
|
+
names: ReadonlyMap<string, Set<EvidenceUnit>>,
|
|
161
|
+
) => {
|
|
162
|
+
// Paths outrank names within each proximity level; already selected sections never move.
|
|
163
|
+
for (const anchors of [paths, names]) {
|
|
164
|
+
if (!anchors.size) continue;
|
|
165
|
+
for (let index = 0; index < sections.length; index++) {
|
|
166
|
+
if (selectedSections.has(index)) continue;
|
|
167
|
+
const section = sections[index];
|
|
168
|
+
if (!section) continue;
|
|
169
|
+
const selected = new Set<EvidenceUnit>();
|
|
170
|
+
const identifiers = anchors === names ? section.identifiers : undefined;
|
|
171
|
+
for (const [anchor, values] of anchors) {
|
|
172
|
+
if (
|
|
173
|
+
identifiers
|
|
174
|
+
? identifiers.has(anchor)
|
|
175
|
+
: section.text.includes(anchor)
|
|
176
|
+
)
|
|
177
|
+
for (const unit of values) selected.add(unit);
|
|
178
|
+
}
|
|
179
|
+
if (!selected.size) continue;
|
|
180
|
+
selectedSections.add(index);
|
|
181
|
+
const candidate = {
|
|
182
|
+
path: section.file,
|
|
183
|
+
heading: section.label,
|
|
184
|
+
start: section.start,
|
|
185
|
+
end: section.end,
|
|
186
|
+
sentences: docSentences(section.text),
|
|
187
|
+
units: units.filter((unit) => selected.has(unit)),
|
|
188
|
+
};
|
|
189
|
+
(candidates.length < DOCS_MAX_SECTIONS ? candidates : omitted).push(
|
|
190
|
+
candidate,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const initialNames = new Map<string, Set<EvidenceUnit>>();
|
|
196
|
+
for (const unit of units) {
|
|
197
|
+
const selected = initialNames.get(unit.name) ?? new Set<EvidenceUnit>();
|
|
198
|
+
selected.add(unit);
|
|
199
|
+
initialNames.set(unit.name, selected);
|
|
200
|
+
}
|
|
201
|
+
for (const file of sources.changedFiles ?? []) {
|
|
202
|
+
const changed = file.hunks
|
|
203
|
+
.flatMap((hunk) =>
|
|
204
|
+
hunk.changes.flatMap((change) => [
|
|
205
|
+
...(file.before
|
|
206
|
+
?.split("\n")
|
|
207
|
+
.slice(
|
|
208
|
+
change.beforeStart - 1,
|
|
209
|
+
change.beforeStart - 1 + change.beforeCount,
|
|
210
|
+
) ?? []),
|
|
211
|
+
...(file.after
|
|
212
|
+
?.split("\n")
|
|
213
|
+
.slice(
|
|
214
|
+
change.afterStart - 1,
|
|
215
|
+
change.afterStart - 1 + change.afterCount,
|
|
216
|
+
) ?? []),
|
|
217
|
+
]),
|
|
218
|
+
)
|
|
219
|
+
.join("\n");
|
|
220
|
+
for (const section of sections)
|
|
221
|
+
for (const literal of section.text.matchAll(
|
|
222
|
+
/(?<!`)`([^`\n]{3,})`(?!`)/g,
|
|
223
|
+
)) {
|
|
224
|
+
const text = literal[1] ?? "";
|
|
225
|
+
if (!/[^A-Za-z ]/.test(text) || !changed.includes(text)) continue;
|
|
226
|
+
const matching = units.filter((unit) => unit.file === file.path);
|
|
227
|
+
if (!matching.length) continue;
|
|
228
|
+
const selected = initialNames.get(text) ?? new Set<EvidenceUnit>();
|
|
229
|
+
for (const unit of matching) selected.add(unit);
|
|
230
|
+
initialNames.set(text, selected);
|
|
231
|
+
section.identifiers.add(text);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
match(reachable, initialNames);
|
|
235
|
+
if (candidates.length >= DOCS_MAX_SECTIONS) {
|
|
236
|
+
const unchecked = sections.filter(
|
|
237
|
+
(section, index) =>
|
|
238
|
+
!selectedSections.has(index) &&
|
|
239
|
+
(section.paths.length || section.identifiers.size),
|
|
240
|
+
);
|
|
241
|
+
if (unchecked.length)
|
|
242
|
+
limits.push({
|
|
243
|
+
path: "documentation",
|
|
244
|
+
kind: "collection_budget",
|
|
245
|
+
reason: "Candidate limit reached: documentation anchors unexplored.",
|
|
246
|
+
sections: unchecked.map(
|
|
247
|
+
(section) => `${section.file} § ${section.label}`,
|
|
248
|
+
),
|
|
249
|
+
});
|
|
250
|
+
return { candidates, omitted, limits };
|
|
251
|
+
}
|
|
252
|
+
const configuration = files.filter((file) =>
|
|
253
|
+
/(?:^|\/)(?:package|tsconfig[^/]*)\.json$/.test(file.path),
|
|
254
|
+
);
|
|
255
|
+
const cache = new Map<string, Promise<ImportGraph>>();
|
|
256
|
+
const build = createImportGraphBuilder(
|
|
257
|
+
configuration,
|
|
258
|
+
sources.known,
|
|
259
|
+
sources.lexical,
|
|
260
|
+
);
|
|
261
|
+
const modified = new Map(reachable);
|
|
262
|
+
const pending = sections.map((section, index) => ({ section, index }));
|
|
263
|
+
pending.sort(
|
|
264
|
+
(a, b) =>
|
|
265
|
+
Number(b.section.paths.length > 0) - Number(a.section.paths.length > 0),
|
|
266
|
+
);
|
|
267
|
+
const names = [
|
|
268
|
+
...new Set(pending.flatMap(({ section }) => [...section.identifiers])),
|
|
269
|
+
];
|
|
270
|
+
// The adapter performs one declarative search; no source is parsed to establish ownership.
|
|
271
|
+
const owners = sources.declarations
|
|
272
|
+
? await sources.declarations(names)
|
|
273
|
+
: new Map<string, readonly string[]>();
|
|
274
|
+
const anchors = new Map<string, Set<number>>();
|
|
275
|
+
for (const { section, index } of pending) {
|
|
276
|
+
const paths = new Set(section.paths);
|
|
277
|
+
for (const name of section.identifiers) {
|
|
278
|
+
const pathsForName = owners.get(name) ?? [];
|
|
279
|
+
if (
|
|
280
|
+
pathsForName.length === 1 &&
|
|
281
|
+
pathsForName.length <= DOCS_NAME_MAX_FILES
|
|
282
|
+
)
|
|
283
|
+
for (const path of pathsForName) paths.add(path);
|
|
284
|
+
}
|
|
285
|
+
for (const path of paths) {
|
|
286
|
+
const indices = anchors.get(path) ?? new Set<number>();
|
|
287
|
+
indices.add(index);
|
|
288
|
+
anchors.set(path, indices);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const directory = (path: string) =>
|
|
292
|
+
path.slice(0, Math.max(0, path.lastIndexOf("/")));
|
|
293
|
+
const packageRoots = configuration
|
|
294
|
+
.filter((file) => /(?:^|\/)package\.json$/.test(file.path))
|
|
295
|
+
.map((file) => directory(file.path))
|
|
296
|
+
.filter(Boolean)
|
|
297
|
+
.sort((a, b) => b.length - a.length);
|
|
298
|
+
const packageRoot = (path: string) =>
|
|
299
|
+
packageRoots.find((root) => path.startsWith(`${root}/`));
|
|
300
|
+
const modifiedDirectories = new Set([...modified.keys()].map(directory));
|
|
301
|
+
const modifiedPackages = new Set(
|
|
302
|
+
[...modified.keys()].map(packageRoot).filter(Boolean),
|
|
303
|
+
);
|
|
304
|
+
const proximity = (path: string) =>
|
|
305
|
+
modifiedDirectories.has(directory(path))
|
|
306
|
+
? 0
|
|
307
|
+
: modifiedPackages.has(packageRoot(path))
|
|
308
|
+
? 1
|
|
309
|
+
: 2;
|
|
310
|
+
const orderedAnchors = [...anchors].sort(
|
|
311
|
+
([a], [b]) => proximity(a) - proximity(b),
|
|
312
|
+
);
|
|
313
|
+
let interrupted = false;
|
|
314
|
+
const undecided = new Set<number>(
|
|
315
|
+
[...anchors.values()].flatMap((indices) => [...indices]),
|
|
316
|
+
);
|
|
317
|
+
const remaining = new Map<number, number>();
|
|
318
|
+
for (const indices of anchors.values())
|
|
319
|
+
for (const index of indices)
|
|
320
|
+
remaining.set(index, (remaining.get(index) ?? 0) + 1);
|
|
321
|
+
for (const [anchor, indices] of orderedAnchors) {
|
|
322
|
+
if (candidates.length >= DOCS_MAX_SECTIONS) {
|
|
323
|
+
interrupted = true;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
let visited = 0;
|
|
327
|
+
let anchorLimited = false;
|
|
328
|
+
const selected = new Set<EvidenceUnit>();
|
|
329
|
+
const closure = await importClosureLazy(sources.read, anchor, {
|
|
330
|
+
known: sources.known,
|
|
331
|
+
configuration,
|
|
332
|
+
cache,
|
|
333
|
+
build,
|
|
334
|
+
stop: (path) => {
|
|
335
|
+
if (path !== anchor && modified.has(path)) return true;
|
|
336
|
+
if (sources.shouldStop?.()) {
|
|
337
|
+
interrupted = true;
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
if (visited >= DOCS_ANCHOR_MAX_FILES) {
|
|
341
|
+
anchorLimited = true;
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
visited++;
|
|
345
|
+
return false;
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
for (const path of closure.paths)
|
|
349
|
+
for (const unit of modified.get(path) ?? []) selected.add(unit);
|
|
350
|
+
if (selected.size) {
|
|
351
|
+
for (const index of indices) {
|
|
352
|
+
const section = sections[index];
|
|
353
|
+
if (!section) continue;
|
|
354
|
+
if (selectedSections.has(index)) {
|
|
355
|
+
const existing = [...candidates, ...omitted].find(
|
|
356
|
+
(candidate) =>
|
|
357
|
+
candidate.path === section.file &&
|
|
358
|
+
candidate.start === section.start,
|
|
359
|
+
);
|
|
360
|
+
if (existing) {
|
|
361
|
+
const combined = new Set([...existing.units, ...selected]);
|
|
362
|
+
existing.units = units.filter((unit) => combined.has(unit));
|
|
363
|
+
}
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
selectedSections.add(index);
|
|
367
|
+
const candidate = {
|
|
368
|
+
path: section.file,
|
|
369
|
+
heading: section.label,
|
|
370
|
+
start: section.start,
|
|
371
|
+
end: section.end,
|
|
372
|
+
sentences: docSentences(section.text),
|
|
373
|
+
units: units.filter((unit) => selected.has(unit)),
|
|
374
|
+
};
|
|
375
|
+
(candidates.length < DOCS_MAX_SECTIONS ? candidates : omitted).push(
|
|
376
|
+
candidate,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (!interrupted && !anchorLimited)
|
|
381
|
+
for (const index of indices) {
|
|
382
|
+
const count = (remaining.get(index) ?? 1) - 1;
|
|
383
|
+
remaining.set(index, count);
|
|
384
|
+
if (count === 0) undecided.delete(index);
|
|
385
|
+
}
|
|
386
|
+
if (interrupted) break;
|
|
387
|
+
}
|
|
388
|
+
if (interrupted || undecided.size)
|
|
389
|
+
limits.push({
|
|
390
|
+
path: "documentation",
|
|
391
|
+
kind: "collection_budget",
|
|
392
|
+
reason:
|
|
393
|
+
"Dependency exploration incomplete: collection budget, candidate limit, or per-anchor bound reached.",
|
|
394
|
+
sections: [...undecided]
|
|
395
|
+
.filter((index) => !selectedSections.has(index))
|
|
396
|
+
.map((index) => `${sections[index]?.file} § ${sections[index]?.label}`),
|
|
397
|
+
});
|
|
398
|
+
return { candidates, omitted, limits };
|
|
399
|
+
}
|
package/src/core/find.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FIND_CONTENT_MIN,
|
|
3
|
+
FIND_EXCERPT_HEAD_CHARS,
|
|
4
|
+
FIND_NAME_GUARD_MIN,
|
|
5
|
+
FIND_NAME_GUARD_TOP,
|
|
6
|
+
FIND_POINTER_MAX,
|
|
7
|
+
} from "../constants.ts";
|
|
8
|
+
import { truncate } from "./truncate.ts";
|
|
9
|
+
|
|
10
|
+
const stopWords: Record<string, true> = Object.fromEntries(
|
|
11
|
+
"the and for with from that this into where what which when how does should could would file files code find implement implementation une les des dans pour avec sur est qui que quoi comment fichier fichiers chercher"
|
|
12
|
+
.split(" ")
|
|
13
|
+
.map((word) => [word, true]),
|
|
14
|
+
);
|
|
15
|
+
export function contentWords(goal: string): string[] {
|
|
16
|
+
return (goal.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? []).filter(
|
|
17
|
+
(word) => word.length >= 3 && !stopWords[word],
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
export function findKeywords(
|
|
21
|
+
goal: string,
|
|
22
|
+
keywords: readonly string[] = [],
|
|
23
|
+
): string[] {
|
|
24
|
+
return [
|
|
25
|
+
...new Set([
|
|
26
|
+
...contentWords(goal),
|
|
27
|
+
...keywords.map((word) => word.toLowerCase()).filter(Boolean),
|
|
28
|
+
]),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
export interface RankedFile {
|
|
32
|
+
path: string;
|
|
33
|
+
p: number;
|
|
34
|
+
}
|
|
35
|
+
export function rankFiles(files: readonly RankedFile[]): RankedFile[] {
|
|
36
|
+
return [...files].sort((a, b) => b.p - a.p || a.path.localeCompare(b.path));
|
|
37
|
+
}
|
|
38
|
+
export function retainFiles(
|
|
39
|
+
content: readonly RankedFile[],
|
|
40
|
+
names: readonly RankedFile[],
|
|
41
|
+
): RankedFile[] {
|
|
42
|
+
const retained = rankFiles(
|
|
43
|
+
content.filter((file) => file.p >= FIND_CONTENT_MIN),
|
|
44
|
+
).slice(0, FIND_POINTER_MAX);
|
|
45
|
+
for (const name of names.slice(0, FIND_NAME_GUARD_TOP)) {
|
|
46
|
+
const file = content.find((file) => file.path === name.path);
|
|
47
|
+
if (
|
|
48
|
+
name.p >= FIND_NAME_GUARD_MIN &&
|
|
49
|
+
file &&
|
|
50
|
+
!retained.some((item) => item.path === file.path)
|
|
51
|
+
) {
|
|
52
|
+
if (retained.length === FIND_POINTER_MAX) retained.pop();
|
|
53
|
+
retained.push(file);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return retained;
|
|
58
|
+
}
|
|
59
|
+
export function pathAllowed(
|
|
60
|
+
path: string,
|
|
61
|
+
scope: string | readonly string[] | undefined,
|
|
62
|
+
exclude: readonly string[] = [],
|
|
63
|
+
): boolean {
|
|
64
|
+
const scopes = typeof scope === "string" ? [scope] : scope;
|
|
65
|
+
if (
|
|
66
|
+
scopes?.length &&
|
|
67
|
+
!scopes.some((root) => {
|
|
68
|
+
const normalized = root.replace(/^\.\//u, "").replace(/\/$/u, "");
|
|
69
|
+
return (
|
|
70
|
+
normalized === "." ||
|
|
71
|
+
path === normalized ||
|
|
72
|
+
path.startsWith(`${normalized}/`)
|
|
73
|
+
);
|
|
74
|
+
})
|
|
75
|
+
)
|
|
76
|
+
return false;
|
|
77
|
+
return !exclude.some((glob) => {
|
|
78
|
+
const pattern = glob
|
|
79
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
80
|
+
.replace(/\*\*/g, "<<<recursive>>>")
|
|
81
|
+
.replace(/\*/g, "[^/]*")
|
|
82
|
+
.replace(/\?/g, "[^/]")
|
|
83
|
+
.replace(/<<<recursive>>>\//g, "(?:.*/)?")
|
|
84
|
+
.replace(/<<<recursive>>>/g, ".*");
|
|
85
|
+
return new RegExp(`^${pattern}$`, "u").test(path);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createExcerptCollector(
|
|
90
|
+
keywords: readonly string[],
|
|
91
|
+
limit: number,
|
|
92
|
+
) {
|
|
93
|
+
let head = "",
|
|
94
|
+
tail = "",
|
|
95
|
+
best = "",
|
|
96
|
+
bestScore = -1,
|
|
97
|
+
total = 0,
|
|
98
|
+
whole = "";
|
|
99
|
+
const separator = "\n…\n";
|
|
100
|
+
const room = limit - FIND_EXCERPT_HEAD_CHARS - separator.length;
|
|
101
|
+
return {
|
|
102
|
+
push(chunk: string): void {
|
|
103
|
+
const previousTotal = total;
|
|
104
|
+
total += chunk.length;
|
|
105
|
+
if (total <= limit) whole += chunk;
|
|
106
|
+
else whole = "";
|
|
107
|
+
if (head.length < FIND_EXCERPT_HEAD_CHARS)
|
|
108
|
+
head = truncate(head + chunk, FIND_EXCERPT_HEAD_CHARS);
|
|
109
|
+
const consumedByHead = Math.max(
|
|
110
|
+
0,
|
|
111
|
+
Math.min(chunk.length, FIND_EXCERPT_HEAD_CHARS - previousTotal),
|
|
112
|
+
);
|
|
113
|
+
const buffer = tail + chunk.slice(consumedByHead);
|
|
114
|
+
for (
|
|
115
|
+
let start = 0;
|
|
116
|
+
start < buffer.length;
|
|
117
|
+
start += Math.max(1, Math.floor(room / 4))
|
|
118
|
+
) {
|
|
119
|
+
const safeStart =
|
|
120
|
+
start > 0 &&
|
|
121
|
+
buffer.charCodeAt(start) >= 0xdc00 &&
|
|
122
|
+
buffer.charCodeAt(start) <= 0xdfff
|
|
123
|
+
? start + 1
|
|
124
|
+
: start;
|
|
125
|
+
const window = truncate(buffer.slice(safeStart), room);
|
|
126
|
+
const lower = window.toLowerCase();
|
|
127
|
+
const score = keywords.reduce((sum, word) => {
|
|
128
|
+
let count = 0,
|
|
129
|
+
offset = 0;
|
|
130
|
+
let match = lower.indexOf(word, offset);
|
|
131
|
+
while (match !== -1) {
|
|
132
|
+
count++;
|
|
133
|
+
offset = match + word.length;
|
|
134
|
+
match = lower.indexOf(word, offset);
|
|
135
|
+
}
|
|
136
|
+
return sum + count;
|
|
137
|
+
}, 0);
|
|
138
|
+
if (score > bestScore) {
|
|
139
|
+
best = window;
|
|
140
|
+
bestScore = score;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
let start = Math.max(0, buffer.length - room);
|
|
144
|
+
if (
|
|
145
|
+
start > 0 &&
|
|
146
|
+
buffer.charCodeAt(start) >= 0xdc00 &&
|
|
147
|
+
buffer.charCodeAt(start) <= 0xdfff
|
|
148
|
+
)
|
|
149
|
+
start++;
|
|
150
|
+
tail = buffer.slice(start);
|
|
151
|
+
},
|
|
152
|
+
finish(): string {
|
|
153
|
+
if (total <= limit) return whole;
|
|
154
|
+
return truncate(head + separator + best, limit);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
package/src/core/git.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { IMPORT_LAYERS } from "../constants.ts";
|
|
2
|
+
|
|
3
|
+
export interface ArchitectureImport {
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
specifier: string;
|
|
7
|
+
target?: string;
|
|
8
|
+
typeOnly: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface ImportViolation {
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
rule: string;
|
|
14
|
+
detail: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** All local edges participate in cycles, including erased type dependencies. */
|
|
18
|
+
export function checkImportBoundaries(
|
|
19
|
+
imports: readonly ArchitectureImport[],
|
|
20
|
+
): ImportViolation[] {
|
|
21
|
+
const violations: ImportViolation[] = [];
|
|
22
|
+
const graph = new Map<string, ArchitectureImport[]>();
|
|
23
|
+
for (const edge of imports) {
|
|
24
|
+
const layer = edge.file.split("/")[0] ?? "";
|
|
25
|
+
const allowed = IMPORT_LAYERS[layer as keyof typeof IMPORT_LAYERS];
|
|
26
|
+
if (edge.target) {
|
|
27
|
+
const destination = edge.target.split("/")[0] ?? "";
|
|
28
|
+
if (
|
|
29
|
+
allowed &&
|
|
30
|
+
!allowed.some((item: string) =>
|
|
31
|
+
item.includes("/") ? item === edge.target : item === destination,
|
|
32
|
+
)
|
|
33
|
+
) {
|
|
34
|
+
violations.push({
|
|
35
|
+
...edge,
|
|
36
|
+
rule: "layer-dependency",
|
|
37
|
+
detail: `${layer} cannot import ${edge.target}`,
|
|
38
|
+
});
|
|
39
|
+
} else if (
|
|
40
|
+
allowed &&
|
|
41
|
+
edge.target === "jev/types.ts" &&
|
|
42
|
+
layer !== "jev" &&
|
|
43
|
+
!edge.typeOnly
|
|
44
|
+
) {
|
|
45
|
+
violations.push({
|
|
46
|
+
...edge,
|
|
47
|
+
rule: "type-contract-only",
|
|
48
|
+
detail: "jev/types.ts must be imported as types",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const edges = graph.get(edge.file) ?? [];
|
|
52
|
+
edges.push(edge);
|
|
53
|
+
graph.set(edge.file, edges);
|
|
54
|
+
} else if (edge.specifier.startsWith(".")) {
|
|
55
|
+
violations.push({
|
|
56
|
+
...edge,
|
|
57
|
+
rule: "local-resolution",
|
|
58
|
+
detail: `cannot resolve ${edge.specifier} inside src/`,
|
|
59
|
+
});
|
|
60
|
+
} else if (
|
|
61
|
+
["core", "presets", "texts", "constants.ts", "result.ts"].includes(
|
|
62
|
+
layer,
|
|
63
|
+
) &&
|
|
64
|
+
!(layer === "core" && ["node:path", "path"].includes(edge.specifier)) &&
|
|
65
|
+
!(
|
|
66
|
+
layer === "core" &&
|
|
67
|
+
edge.specifier === "@ast-grep/napi" &&
|
|
68
|
+
edge.typeOnly
|
|
69
|
+
)
|
|
70
|
+
) {
|
|
71
|
+
violations.push({
|
|
72
|
+
...edge,
|
|
73
|
+
rule: "pure-dependency",
|
|
74
|
+
detail: `${layer} cannot import external module ${edge.specifier}`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const done = new Set<string>();
|
|
79
|
+
const active = new Set<string>();
|
|
80
|
+
const stack: string[] = [];
|
|
81
|
+
function visit(file: string): void {
|
|
82
|
+
if (done.has(file)) return;
|
|
83
|
+
active.add(file);
|
|
84
|
+
stack.push(file);
|
|
85
|
+
for (const edge of graph.get(file) ?? []) {
|
|
86
|
+
const target = edge.target;
|
|
87
|
+
if (!target) continue;
|
|
88
|
+
if (active.has(target)) {
|
|
89
|
+
violations.push({
|
|
90
|
+
...edge,
|
|
91
|
+
rule: "import-cycle",
|
|
92
|
+
detail: [...stack.slice(stack.indexOf(target)), target].join(" -> "),
|
|
93
|
+
});
|
|
94
|
+
} else visit(target);
|
|
95
|
+
}
|
|
96
|
+
stack.pop();
|
|
97
|
+
active.delete(file);
|
|
98
|
+
done.add(file);
|
|
99
|
+
}
|
|
100
|
+
for (const file of graph.keys()) visit(file);
|
|
101
|
+
return violations;
|
|
102
|
+
}
|