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.
Files changed (95) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -0
  4. package/docs/design.md +187 -0
  5. package/docs/tools/jev_ask.md +92 -0
  6. package/docs/tools/jev_ask_files.md +80 -0
  7. package/docs/tools/jev_check_diff.md +77 -0
  8. package/docs/tools/jev_find_files.md +59 -0
  9. package/docs/tools/jev_locate_in_file.md +51 -0
  10. package/docs/tools/jev_select_tests.md +59 -0
  11. package/package.json +85 -0
  12. package/rules/jev-ask.md +4 -0
  13. package/src/adapters/analysis-context.ts +100 -0
  14. package/src/adapters/ask-files.ts +236 -0
  15. package/src/adapters/ask-proof.ts +202 -0
  16. package/src/adapters/ask-syntax.ts +463 -0
  17. package/src/adapters/command.ts +240 -0
  18. package/src/adapters/docs.ts +222 -0
  19. package/src/adapters/files.ts +411 -0
  20. package/src/adapters/find.ts +151 -0
  21. package/src/adapters/git-base.ts +32 -0
  22. package/src/adapters/git-inventory.ts +94 -0
  23. package/src/adapters/git.ts +525 -0
  24. package/src/adapters/locate-file.ts +197 -0
  25. package/src/adapters/output-lines.ts +50 -0
  26. package/src/adapters/risk-callers.ts +525 -0
  27. package/src/adapters/runner-version.ts +102 -0
  28. package/src/adapters/syntax.ts +229 -0
  29. package/src/adapters/test-inventory.ts +168 -0
  30. package/src/adapters/usage.ts +21 -0
  31. package/src/adapters/utf8.ts +57 -0
  32. package/src/constants.ts +109 -0
  33. package/src/core/ask-closure.ts +419 -0
  34. package/src/core/ask-proof.ts +32 -0
  35. package/src/core/ask-references.ts +249 -0
  36. package/src/core/asks.ts +616 -0
  37. package/src/core/batches.ts +83 -0
  38. package/src/core/command-output.ts +249 -0
  39. package/src/core/diff.ts +226 -0
  40. package/src/core/docs.ts +399 -0
  41. package/src/core/find.ts +157 -0
  42. package/src/core/git.ts +5 -0
  43. package/src/core/import-boundaries.ts +102 -0
  44. package/src/core/imports.ts +691 -0
  45. package/src/core/integrity.ts +64 -0
  46. package/src/core/lexical.ts +130 -0
  47. package/src/core/locate.ts +213 -0
  48. package/src/core/output.ts +264 -0
  49. package/src/core/pointer.ts +51 -0
  50. package/src/core/risk-callers.ts +1270 -0
  51. package/src/core/runner-version.ts +66 -0
  52. package/src/core/sections.ts +269 -0
  53. package/src/core/state.ts +53 -0
  54. package/src/core/syntax.ts +8 -0
  55. package/src/core/test-commands.ts +430 -0
  56. package/src/core/test-coverage.ts +103 -0
  57. package/src/core/test-discovery.ts +1695 -0
  58. package/src/core/test-evidence.ts +649 -0
  59. package/src/core/test-state.ts +99 -0
  60. package/src/core/truncate.ts +14 -0
  61. package/src/core/units.ts +531 -0
  62. package/src/describe.ts +26 -0
  63. package/src/guide.ts +42 -0
  64. package/src/host.ts +22 -0
  65. package/src/index.ts +40 -0
  66. package/src/jev/client.ts +505 -0
  67. package/src/jev/pool.ts +60 -0
  68. package/src/jev/types.ts +60 -0
  69. package/src/presets/docs.ts +85 -0
  70. package/src/presets/risk.ts +263 -0
  71. package/src/presets/spec.ts +111 -0
  72. package/src/presets/witnesses.ts +313 -0
  73. package/src/render.ts +45 -0
  74. package/src/result.ts +4 -0
  75. package/src/run-end.ts +157 -0
  76. package/src/runtime.ts +16 -0
  77. package/src/session.ts +106 -0
  78. package/src/texts/ask-files.ts +2 -0
  79. package/src/texts/ask.ts +4 -0
  80. package/src/texts/check-diff.ts +24 -0
  81. package/src/texts/configuration.ts +2 -0
  82. package/src/texts/find.ts +14 -0
  83. package/src/texts/guide.ts +16 -0
  84. package/src/texts/locate.ts +10 -0
  85. package/src/texts/run-end.ts +13 -0
  86. package/src/texts/select-tests.ts +3 -0
  87. package/src/tools/ask-files.ts +263 -0
  88. package/src/tools/ask-schema.ts +116 -0
  89. package/src/tools/ask.ts +925 -0
  90. package/src/tools/check-diff.ts +510 -0
  91. package/src/tools/docs-check.ts +399 -0
  92. package/src/tools/find.ts +529 -0
  93. package/src/tools/locate.ts +369 -0
  94. package/src/tools/select-tests.ts +746 -0
  95. package/src/tools/spec-check.ts +210 -0
@@ -0,0 +1,419 @@
1
+ import { posix } from "node:path";
2
+ import {
3
+ CLOSURE_MAX_CHARS,
4
+ STATE_MAX_CHARS,
5
+ UNIT_MAX_CHARS,
6
+ } from "../constants.ts";
7
+ import type { Json, State } from "../jev/types.ts";
8
+ import type {
9
+ ProofBinding,
10
+ ProofDeclaration,
11
+ ProofSyntax,
12
+ } from "./ask-proof.ts";
13
+ import type { ImportGraph } from "./imports.ts";
14
+ import type { Limitation } from "./output.ts";
15
+ import { truncate } from "./truncate.ts";
16
+
17
+ interface Candidate {
18
+ declaration: ProofDeclaration;
19
+ priority: number;
20
+ depth: number;
21
+ typeOnly: boolean;
22
+ }
23
+ export function buildAskClosure({
24
+ graph,
25
+ syntax,
26
+ paths,
27
+ intentions,
28
+ state,
29
+ }: {
30
+ graph: ImportGraph;
31
+ syntax: ProofSyntax;
32
+ paths: readonly string[];
33
+ intentions: string;
34
+ state: State;
35
+ }): {
36
+ state: State;
37
+ added: readonly { path: string; name: string; chars: number }[];
38
+ omitted: readonly { path: string; name: string; reason: string }[];
39
+ limitations: readonly Limitation[];
40
+ } {
41
+ const limitations: Limitation[] = [];
42
+ const dependencyLimit = (
43
+ path: string,
44
+ dependency: string,
45
+ cause: string,
46
+ ): Limitation => {
47
+ const nonRelative =
48
+ /^(?:unresolved|ambiguous) import$/.test(cause) &&
49
+ dependency.length > 0 &&
50
+ !dependency.startsWith(".") &&
51
+ !dependency.startsWith("/") &&
52
+ !dependency.startsWith("sys.path");
53
+ return {
54
+ path,
55
+ dependency,
56
+ cause: `${cause}${nonRelative ? " (external or standard-library, or unresolved repository alias)" : ""}`,
57
+ fact: `${path} : ${dependency}, ${cause}${nonRelative ? "; external or standard-library dependency (or unresolved repository alias), not admitted as a repository file" : ""}`,
58
+ next: nonRelative
59
+ ? "Provide the dependency contract in state or a repository-local implementation in paths; do not read outside the repository."
60
+ : "Provide the dependency explicitly in paths.",
61
+ };
62
+ };
63
+ const relevant = new Set(paths);
64
+ const candidates = new Map<string, Candidate>();
65
+ const originalFiles =
66
+ state.files &&
67
+ typeof state.files === "object" &&
68
+ !Array.isArray(state.files)
69
+ ? state.files
70
+ : {};
71
+ const contained = (path: string) =>
72
+ paths.includes(path) || Object.hasOwn(originalFiles, path);
73
+ const declarations = (path: string, name: string) =>
74
+ syntax.declarations.filter(
75
+ (item) =>
76
+ item.path === path &&
77
+ (name === "*" ||
78
+ item.name === name ||
79
+ item.name.startsWith(`${name}.`)),
80
+ );
81
+ const mentioned = (name: string) =>
82
+ intentions.split(/[^\p{L}\p{N}_$.]+/u).includes(name);
83
+ const add = (
84
+ declaration: ProofDeclaration,
85
+ direct: boolean,
86
+ depth: number,
87
+ typeOnly = false,
88
+ ) => {
89
+ if (contained(declaration.path)) return;
90
+ const changed =
91
+ declaration.before !== undefined &&
92
+ declaration.before !== declaration.text;
93
+ const priority =
94
+ mentioned(declaration.name) || direct ? 0 : changed ? 1 : 2;
95
+ const key = `${declaration.path} (${declaration.name})`;
96
+ const previous = candidates.get(key);
97
+ if (
98
+ !previous ||
99
+ priority < previous.priority ||
100
+ (priority === previous.priority &&
101
+ (Number(typeOnly) < Number(previous.typeOnly) ||
102
+ (typeOnly === previous.typeOnly && depth < previous.depth)))
103
+ )
104
+ candidates.set(key, { declaration, priority, depth, typeOnly });
105
+ };
106
+ const target = (binding: ProofBinding): string | undefined => {
107
+ if (!binding.specifier) return binding.path;
108
+ const edges = graph.edges.get(binding.path) ?? [];
109
+ const python = binding.path.endsWith(".py");
110
+ let base: string;
111
+ if (python) {
112
+ const relative = binding.specifier.match(/^\.+/)?.[0].length ?? 0;
113
+ base = relative
114
+ ? posix.join(
115
+ posix.dirname(binding.path),
116
+ ...Array.from({ length: relative - 1 }, () => ".."),
117
+ binding.specifier.slice(relative).replaceAll(".", "/"),
118
+ )
119
+ : binding.specifier.replaceAll(".", "/");
120
+ } else
121
+ base =
122
+ binding.specifier.startsWith(".") || binding.data
123
+ ? posix.join(posix.dirname(binding.path), binding.specifier)
124
+ : binding.specifier;
125
+ base = posix.normalize(base);
126
+ const alternatives = python
127
+ ? [
128
+ base,
129
+ `${base}.py`,
130
+ `${base}/__init__.py`,
131
+ `${base}/${binding.imported}.py`,
132
+ ]
133
+ : [
134
+ base,
135
+ ...[".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"].map(
136
+ (extension) => base.replace(/\.js$/, "") + extension,
137
+ ),
138
+ ...["ts", "tsx", "js", "jsx"].map(
139
+ (extension) => `${base}/index.${extension}`,
140
+ ),
141
+ ];
142
+ const matches = edges.filter((edge) =>
143
+ alternatives.some(
144
+ (alternative) =>
145
+ edge === alternative ||
146
+ (!binding.specifier.startsWith(".") &&
147
+ edge.endsWith(`/${alternative}`)),
148
+ ),
149
+ );
150
+ if (matches.length === 1) return matches[0];
151
+ limitations.push(
152
+ dependencyLimit(
153
+ binding.path,
154
+ binding.specifier,
155
+ matches.length > 1 ? "ambiguous import" : "unresolved import",
156
+ ),
157
+ );
158
+ return undefined;
159
+ };
160
+ const resolve = (
161
+ path: string,
162
+ name: string,
163
+ direct: boolean,
164
+ depth: number,
165
+ visited: Set<string>,
166
+ typeOnly = false,
167
+ ) => {
168
+ relevant.add(path);
169
+ const key = `${path}:${name}`;
170
+ if (visited.has(key)) return;
171
+ visited.add(key);
172
+ const found = declarations(path, name);
173
+ for (const declaration of found) add(declaration, direct, depth, typeOnly);
174
+ const exports = syntax.bindings.filter(
175
+ (item) =>
176
+ item.path === path &&
177
+ item.reexport &&
178
+ (item.local === name || item.local === "*" || name === "*"),
179
+ );
180
+ if (!found.length && !exports.length && !contained(path))
181
+ limitations.push({
182
+ fact: `${path} (${name}) : used declaration not found`,
183
+ next: "Provide the declaration explicitly in paths.",
184
+ });
185
+ for (const binding of exports) {
186
+ const destination = target(binding);
187
+ if (destination)
188
+ resolve(
189
+ destination,
190
+ binding.imported === "*" ? name : binding.imported,
191
+ direct,
192
+ depth,
193
+ visited,
194
+ typeOnly,
195
+ );
196
+ }
197
+ };
198
+ for (const path of paths) {
199
+ const used =
200
+ syntax.uses?.get(path) ??
201
+ syntax.declarations
202
+ .filter((item) => item.path === path)
203
+ .flatMap((item) => item.usedNames);
204
+ for (const binding of syntax.bindings.filter(
205
+ (item) =>
206
+ item.path === path &&
207
+ (!item.reexport || path.endsWith("__init__.py")) &&
208
+ (item.used ?? used.includes(item.local)),
209
+ )) {
210
+ const destination = target(binding);
211
+ if (!destination) continue;
212
+ if (binding.data) {
213
+ relevant.add(destination);
214
+ const source = syntax.sources?.find(
215
+ (item) => item.path === destination,
216
+ );
217
+ if (source)
218
+ add(
219
+ {
220
+ path: destination,
221
+ name: "data",
222
+ text: source.text,
223
+ ...(syntax.before && Object.hasOwn(syntax.before, destination)
224
+ ? { before: syntax.before[destination] }
225
+ : {}),
226
+ usedNames: [],
227
+ },
228
+ true,
229
+ 1,
230
+ );
231
+ else
232
+ limitations.push({
233
+ fact: `${destination} : data missing from the inventory`,
234
+ next: "Provide this file in paths.",
235
+ });
236
+ } else if (binding.imported === "*" && binding.local !== "*") {
237
+ const members = used.filter((item) =>
238
+ item.startsWith(`${binding.local}.`),
239
+ );
240
+ for (const name of members)
241
+ resolve(
242
+ destination,
243
+ name.slice(binding.local.length + 1),
244
+ true,
245
+ 1,
246
+ new Set(),
247
+ binding.valueMembers
248
+ ? !binding.valueMembers.includes(name)
249
+ : binding.typeOnly,
250
+ );
251
+ if (!members.length)
252
+ limitations.push({
253
+ fact: `${path} : namespace ${binding.local} used without a statically resolved member`,
254
+ next: "Provide the namespace declarations explicitly.",
255
+ });
256
+ } else
257
+ resolve(
258
+ destination,
259
+ binding.imported,
260
+ true,
261
+ 1,
262
+ new Set(),
263
+ binding.typeOnly,
264
+ );
265
+ }
266
+ if (
267
+ path.endsWith(".py") &&
268
+ /(^|\/)(test[^/]*|[^/]*_test)\.py$/.test(path)
269
+ ) {
270
+ const fixtures = syntax.declarations.filter(
271
+ (item) =>
272
+ item.fixture &&
273
+ posix.basename(item.path) === "conftest.py" &&
274
+ (posix.dirname(item.path) === "." ||
275
+ path.startsWith(`${posix.dirname(item.path)}/`)),
276
+ );
277
+ // Nearest fixture shadows ancestors, as in pytest.
278
+ fixtures.sort((a, b) => b.path.length - a.path.length);
279
+ const selected = new Set<string>();
280
+ for (const fixture of fixtures) {
281
+ if (
282
+ selected.has(fixture.name) ||
283
+ (!fixture.autouse && !used.includes(fixture.name))
284
+ )
285
+ continue;
286
+ selected.add(fixture.name);
287
+ relevant.add(fixture.path);
288
+ add(fixture, true, 1);
289
+ for (const name of fixture.usedNames)
290
+ for (const helper of declarations(fixture.path, name))
291
+ add(helper, false, 1);
292
+ for (const binding of syntax.bindings.filter(
293
+ (item) =>
294
+ item.path === fixture.path &&
295
+ fixture.usedNames.includes(item.local),
296
+ )) {
297
+ const destination = target(binding);
298
+ if (destination)
299
+ resolve(destination, binding.imported, false, 1, new Set());
300
+ }
301
+ }
302
+ }
303
+ }
304
+ for (const limit of syntax.limits)
305
+ if ([...relevant].some((path) => limit.fact.includes(path)))
306
+ limitations.push(limit);
307
+ for (const limit of graph.limits)
308
+ if (relevant.has(limit.path))
309
+ limitations.push(
310
+ dependencyLimit(limit.path, limit.specifier, `${limit.kind} import`),
311
+ );
312
+ const ordered = [...candidates.values()].sort(
313
+ (a, b) =>
314
+ a.priority - b.priority ||
315
+ Number(a.typeOnly) - Number(b.typeOnly) ||
316
+ Number(
317
+ b.declaration.before !== undefined &&
318
+ b.declaration.before !== b.declaration.text,
319
+ ) -
320
+ Number(
321
+ a.declaration.before !== undefined &&
322
+ a.declaration.before !== a.declaration.text,
323
+ ) ||
324
+ a.depth - b.depth ||
325
+ a.declaration.path.localeCompare(b.declaration.path) ||
326
+ a.declaration.name.localeCompare(b.declaration.name),
327
+ );
328
+ const existing =
329
+ state.closure &&
330
+ typeof state.closure === "object" &&
331
+ !Array.isArray(state.closure)
332
+ ? state.closure
333
+ : {};
334
+ const closure: Record<string, Json> = { ...existing };
335
+ let nextState = state;
336
+ let budget = JSON.stringify(existing).length - 2;
337
+ const added: { path: string; name: string; chars: number }[] = [];
338
+ const omitted: { path: string; name: string; reason: string }[] = [];
339
+ for (const { declaration } of ordered) {
340
+ const { path, name } = declaration;
341
+ const key = `${path} (${name})`;
342
+ const paired =
343
+ declaration.before !== undefined &&
344
+ declaration.before !== declaration.text;
345
+ if (Object.hasOwn(closure, key)) continue;
346
+ const beforeBudget =
347
+ !paired || declaration.before == null
348
+ ? 0
349
+ : declaration.before.length + declaration.text.length > UNIT_MAX_CHARS
350
+ ? Math.floor(UNIT_MAX_CHARS / 2)
351
+ : declaration.before.length;
352
+ const before = !paired
353
+ ? undefined
354
+ : declaration.before == null
355
+ ? declaration.before
356
+ : truncate(declaration.before, beforeBudget);
357
+ const after = truncate(
358
+ declaration.text,
359
+ UNIT_MAX_CHARS - (before?.length ?? 0),
360
+ );
361
+ const sliced =
362
+ after.length < declaration.text.length ||
363
+ (paired && (before?.length ?? 0) < (declaration.before?.length ?? 0));
364
+ const value: Json = !paired
365
+ ? after
366
+ : { before: before ?? null, after: declaration.deleted ? null : after };
367
+ const chars = after.length + (before?.length ?? 0);
368
+ const contribution =
369
+ JSON.stringify(key).length +
370
+ 1 +
371
+ JSON.stringify(value).length +
372
+ (Object.keys(closure).length ? 1 : 0);
373
+ const candidateState = { ...state, closure: { ...closure, [key]: value } };
374
+ const reason =
375
+ budget + contribution > CLOSURE_MAX_CHARS
376
+ ? "closure budget exceeded"
377
+ : JSON.stringify(candidateState).length > STATE_MAX_CHARS
378
+ ? "serialized state limit exceeded"
379
+ : undefined;
380
+ if (reason) {
381
+ omitted.push({ path, name, reason });
382
+ limitations.push({
383
+ fact: `${key} : declaration omitted, ${reason}`,
384
+ next: "Provide this declaration in a smaller call.",
385
+ });
386
+ continue;
387
+ }
388
+ closure[key] = value;
389
+ nextState = candidateState;
390
+ budget += contribution;
391
+ added.push({ path, name, chars });
392
+ if (sliced)
393
+ limitations.push({
394
+ fact: `${key} : sliced declaration (${chars} characters)`,
395
+ next: "Read the complete declaration before drawing conclusions about an omitted part.",
396
+ });
397
+ }
398
+ limitations.push({
399
+ fact: "Import closure only, semantic depth 1; files without an import link are not checked.",
400
+ next: "Provide configuration, documentation or data dependencies not linked by imports in paths.",
401
+ });
402
+ const seen = new Map<string, Limitation>();
403
+ for (const limit of limitations) {
404
+ const key = JSON.stringify(
405
+ limit.path
406
+ ? [limit.path, limit.cause, limit.dependency]
407
+ : [limit.fact, limit.next],
408
+ );
409
+ const previous = seen.get(key);
410
+ if (previous) {
411
+ if (limit.cause === "dynamic import") {
412
+ previous.count = (previous.count ?? 1) + 1;
413
+ previous.fact = `${limit.fact} (${previous.count} occurrences)`;
414
+ }
415
+ } else seen.set(key, { ...limit });
416
+ }
417
+ const unique = [...seen.values()];
418
+ return { state: nextState, added, omitted, limitations: unique };
419
+ }
@@ -0,0 +1,32 @@
1
+ import type { ImportSource } from "./imports.ts";
2
+ import type { Limitation } from "./output.ts";
3
+
4
+ export interface ProofDeclaration {
5
+ path: string;
6
+ name: string;
7
+ text: string;
8
+ before?: string | null;
9
+ usedNames: readonly string[];
10
+ fixture?: boolean;
11
+ autouse?: boolean;
12
+ deleted?: boolean;
13
+ }
14
+ export interface ProofBinding {
15
+ path: string;
16
+ local: string;
17
+ imported: string;
18
+ specifier: string;
19
+ reexport: boolean;
20
+ used?: boolean;
21
+ typeOnly?: boolean;
22
+ valueMembers?: readonly string[];
23
+ data?: boolean;
24
+ }
25
+ export interface ProofSyntax {
26
+ declarations: readonly ProofDeclaration[];
27
+ bindings: readonly ProofBinding[];
28
+ limits: readonly Limitation[];
29
+ sources?: readonly ImportSource[];
30
+ uses?: ReadonlyMap<string, readonly string[]>;
31
+ before?: Readonly<Record<string, string | null>>;
32
+ }
@@ -0,0 +1,249 @@
1
+ import { ASK_MAX_FILES } from "../constants.ts";
2
+ import type { Question, State } from "../jev/types.ts";
3
+ import type { CompiledAsks } from "./asks.ts";
4
+
5
+ export interface UnresolvedAskReference {
6
+ reference: string;
7
+ reason: string;
8
+ }
9
+
10
+ const fileSelector =
11
+ /\b(files|files_before)\s*\[\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*\]/g;
12
+ const selector =
13
+ /\b[A-Za-z_$][\w$]*(?:(?:\s*\.\s*[A-Za-z_$][\w$]*)|(?:\s*\[\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\d+)\s*\]))*/g;
14
+
15
+ function unquote(value: string): string {
16
+ if (value.startsWith('"')) {
17
+ try {
18
+ const parsed: unknown = JSON.parse(value);
19
+ if (typeof parsed === "string") return parsed;
20
+ } catch {
21
+ return value.slice(1, -1);
22
+ }
23
+ }
24
+ return value.slice(1, -1).replace(/\\(['\\])/g, "$1");
25
+ }
26
+
27
+ function looksLikePath(value: string): boolean {
28
+ if (/\s/.test(value) || /^(?:[a-z]+:|www\.)/i.test(value)) return false;
29
+ if (
30
+ !/[A-Za-z_]/.test(value) ||
31
+ /^(?:state|docs?|files|files_before)\./.test(value)
32
+ )
33
+ return false;
34
+ if (/^(?:e\.g|i\.e|Node\.js|Next\.js|Vue\.js)$/i.test(value)) return false;
35
+ return (
36
+ /^(?:\.?\.?\/)?[\w.@+-]+(?:\/[\w.@+-]+)+$/.test(value) ||
37
+ /^[\w@+-][\w.@+-]*\.(?:[cm]?[jt]sx?|py|json|jsonc|ya?ml|toml|ini|cfg|conf|md|mdx|txt|rst|html?|css|scss|sql|sh|bash|rs|go|java|rb|php|c|h|cpp|hpp|vue|svelte|xml|csv|env|lock)$/i.test(
38
+ value,
39
+ ) ||
40
+ /^\.[A-Za-z][\w.-]*$/.test(value) ||
41
+ /^(?:README|LICENSE|LICENCE|Makefile|Dockerfile|CHANGELOG|AGENTS|CLAUDE)(?:\.[\w-]+)?$/.test(
42
+ value,
43
+ )
44
+ );
45
+ }
46
+
47
+ /** Explicit paths only: numbers, dates and URLs are not evidence references. */
48
+ export function citedPaths(text: string): string[] {
49
+ const found = new Set<string>();
50
+ const withoutUrls = text.replace(
51
+ /\b(?:[a-z][a-z\d+.-]*:\/\/|www\.)[^\s<>"'`]+/gi,
52
+ " ",
53
+ );
54
+ const withoutSelectors = withoutUrls.replace(
55
+ fileSelector,
56
+ (_whole, _root: string, quoted: string) => {
57
+ found.add(unquote(quoted));
58
+ return " ";
59
+ },
60
+ );
61
+ const withoutFields = withoutSelectors.replace(selector, (value) =>
62
+ value.includes("[") || /^(?:state|docs?|files|files_before)\./.test(value)
63
+ ? " "
64
+ : value,
65
+ );
66
+ const unquoted = withoutFields.replace(
67
+ /`([^`\n]+)`|"([^"\n]+)"|'([^'\n]+)'/g,
68
+ (
69
+ _whole,
70
+ backtick: string | undefined,
71
+ double: string | undefined,
72
+ single: string | undefined,
73
+ ) => {
74
+ const value = backtick ?? double ?? single ?? "";
75
+ if (looksLikePath(value)) {
76
+ found.add(value);
77
+ return " ";
78
+ }
79
+ return value;
80
+ },
81
+ );
82
+ for (const match of unquoted.matchAll(/[\w.@+-]+(?:\/[\w.@+-]+)*/g)) {
83
+ const value = match[0].replace(/[.,;:!?]+$/, "");
84
+ if (
85
+ looksLikePath(value) &&
86
+ (!value.includes("/") ||
87
+ looksLikePath(value.slice(value.lastIndexOf("/") + 1)))
88
+ )
89
+ found.add(value);
90
+ }
91
+ return [...found];
92
+ }
93
+
94
+ function questionText(question: Question): string {
95
+ const criteria = question.criteria;
96
+ return [
97
+ question.instructions,
98
+ ...(criteria ? Object.values(criteria) : []),
99
+ ].join("\n");
100
+ }
101
+
102
+ function readingTexts(plan: CompiledAsks): { id: string; text: string }[] {
103
+ return plan.readings.map((reading) => ({
104
+ id: reading.id,
105
+ text: [reading.id, reading.twin, ...Object.values(reading.controls ?? {})]
106
+ .flatMap((id) =>
107
+ id && plan.questions[id] ? [questionText(plan.questions[id])] : [],
108
+ )
109
+ .join("\n"),
110
+ }));
111
+ }
112
+
113
+ function normalized(path: string): string {
114
+ return path.replace(/^(?:\.\/)+/, "");
115
+ }
116
+
117
+ export function resolveAskReferences(
118
+ plan: CompiledAsks,
119
+ note: string | undefined,
120
+ files: Readonly<Record<string, string>>,
121
+ inventory: readonly string[],
122
+ ): {
123
+ additions: readonly { reference: string; path: string }[];
124
+ unresolved: readonly UnresolvedAskReference[];
125
+ } {
126
+ const references = new Set(
127
+ readingTexts(plan).flatMap(({ text }) => citedPaths(text)),
128
+ );
129
+ for (const reference of citedPaths(note ?? "")) references.add(reference);
130
+ const paths = [...new Set([...inventory, ...Object.keys(files)])];
131
+ const exact = new Map(paths.map((path) => [normalized(path), path]));
132
+ const selected = new Set(Object.keys(files).map(normalized));
133
+ const additions: { reference: string; path: string }[] = [];
134
+ const unresolved: UnresolvedAskReference[] = [];
135
+ for (const reference of references) {
136
+ if (Object.hasOwn(files, reference)) continue;
137
+ const key = normalized(reference);
138
+ const direct = exact.get(key);
139
+ const matches = direct
140
+ ? [direct]
141
+ : paths.filter((path) => normalized(path).endsWith(`/${key}`));
142
+ if (matches.length !== 1) {
143
+ unresolved.push({
144
+ reference,
145
+ reason: `${reference} is cited but missing from state (${matches.length ? "ambiguous path" : "not found"})`,
146
+ });
147
+ continue;
148
+ }
149
+ const path = matches[0];
150
+ if (path === undefined) continue;
151
+ if (!selected.has(normalized(path)) && selected.size >= ASK_MAX_FILES) {
152
+ unresolved.push({
153
+ reference,
154
+ reason: `${reference} is cited but missing from state (ASK_MAX_FILES limit: ${ASK_MAX_FILES})`,
155
+ });
156
+ continue;
157
+ }
158
+ selected.add(normalized(path));
159
+ additions.push({ reference, path });
160
+ }
161
+ return { additions, unresolved };
162
+ }
163
+
164
+ function object(value: unknown): value is Record<string, unknown> {
165
+ return value !== null && typeof value === "object";
166
+ }
167
+
168
+ function unpack(value: unknown): unknown {
169
+ if (typeof value !== "string" || !/^[\s]*[[{]/.test(value)) return value;
170
+ try {
171
+ return JSON.parse(value) as unknown;
172
+ } catch {
173
+ return value;
174
+ }
175
+ }
176
+
177
+ function empty(value: unknown): boolean {
178
+ return (
179
+ value === null ||
180
+ (typeof value === "string" && value.trim() === "") ||
181
+ (object(value) && Object.keys(value).length === 0)
182
+ );
183
+ }
184
+
185
+ function emptyReference(text: string, state: State): string | undefined {
186
+ // File references can be plain paths, not just selectors.
187
+ const files = state.files;
188
+ if (object(files)) {
189
+ for (const reference of citedPaths(text)) {
190
+ if (Object.hasOwn(files, reference) && empty(files[reference]))
191
+ return reference;
192
+ }
193
+ }
194
+ for (const match of text.matchAll(selector)) {
195
+ const parts = [
196
+ ...match[0].matchAll(
197
+ /[A-Za-z_$][\w$]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\d+/g,
198
+ ),
199
+ ].map(([part]) => (/^["']/.test(part) ? unquote(part) : part));
200
+ const root = parts[0];
201
+ if (root === undefined) continue;
202
+ let value: unknown;
203
+ let offset: number;
204
+ if (root === "state") {
205
+ value = state;
206
+ offset = 1;
207
+ } else if (Object.hasOwn(state, root)) {
208
+ value = state[root];
209
+ offset = 1;
210
+ } else continue;
211
+ let present = true;
212
+ for (const part of parts.slice(offset)) {
213
+ value = unpack(value);
214
+ if (!object(value) || !Object.hasOwn(value, part)) {
215
+ present = false;
216
+ break;
217
+ }
218
+ value = value[part];
219
+ }
220
+ // A file absent from the base proves creation; it is not empty evidence.
221
+ if (root === "files_before" && value === null) continue;
222
+ if (present && empty(unpack(value))) return match[0].trim();
223
+ }
224
+ return undefined;
225
+ }
226
+
227
+ export function blockedAskReadings(
228
+ plan: CompiledAsks,
229
+ state: State,
230
+ unresolved: readonly UnresolvedAskReference[],
231
+ note?: string,
232
+ ): ReadonlyMap<string, string> {
233
+ const blocked = new Map<string, string>();
234
+ const notePaths = new Set(citedPaths(note ?? ""));
235
+ const noteEmpty = emptyReference(note ?? "", state);
236
+ for (const { id, text } of readingTexts(plan)) {
237
+ const paths = new Set(citedPaths(text));
238
+ const missing = unresolved.find(
239
+ ({ reference }) => paths.has(reference) || notePaths.has(reference),
240
+ );
241
+ if (missing) {
242
+ blocked.set(id, missing.reason);
243
+ continue;
244
+ }
245
+ const reference = noteEmpty ?? emptyReference(text, state);
246
+ if (reference) blocked.set(id, `${reference} is empty`);
247
+ }
248
+ return blocked;
249
+ }