jev-agent-tools 0.1.4 → 0.3.0

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 (180) hide show
  1. package/CHANGELOG.md +106 -1
  2. package/CONTRIBUTING.md +43 -0
  3. package/README.md +58 -17
  4. package/SECURITY.md +43 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +198 -0
  7. package/dist/adapters/ask-proof.js +200 -0
  8. package/dist/adapters/ask-syntax.js +385 -0
  9. package/dist/adapters/canonical-path.js +17 -0
  10. package/dist/adapters/command.js +234 -0
  11. package/dist/adapters/docs.js +192 -0
  12. package/dist/adapters/evidence-context.js +119 -0
  13. package/dist/adapters/exec.js +207 -0
  14. package/dist/adapters/files.js +418 -0
  15. package/dist/adapters/find.js +150 -0
  16. package/dist/adapters/git-base.js +32 -0
  17. package/dist/adapters/git-inventory.js +71 -0
  18. package/dist/adapters/git.js +483 -0
  19. package/dist/adapters/locate-file.js +197 -0
  20. package/dist/adapters/output-lines.js +46 -0
  21. package/dist/adapters/private-storage.js +106 -0
  22. package/dist/adapters/risk-callers.js +429 -0
  23. package/dist/adapters/runner-version.js +78 -0
  24. package/dist/adapters/shell.js +92 -0
  25. package/dist/adapters/syntax.js +187 -0
  26. package/dist/adapters/test-inventory.js +139 -0
  27. package/dist/adapters/usage.js +20 -0
  28. package/dist/adapters/utf8.js +47 -0
  29. package/dist/configuration.js +267 -0
  30. package/dist/constants.js +140 -0
  31. package/dist/core/ask-closure.js +282 -0
  32. package/dist/core/ask-proof.js +1 -0
  33. package/dist/core/ask-references.js +278 -0
  34. package/dist/core/asks.js +507 -0
  35. package/dist/core/batches.js +65 -0
  36. package/dist/core/command-output.js +224 -0
  37. package/dist/core/diff.js +178 -0
  38. package/dist/core/docs.js +302 -0
  39. package/dist/core/find.js +108 -0
  40. package/dist/core/git.js +1 -0
  41. package/dist/core/imports.js +550 -0
  42. package/dist/core/integrity.js +45 -0
  43. package/dist/core/lexical.js +132 -0
  44. package/dist/core/locate.js +169 -0
  45. package/dist/core/output.js +137 -0
  46. package/dist/core/pointer.js +29 -0
  47. package/dist/core/result-report.js +302 -0
  48. package/dist/core/risk-callers.js +851 -0
  49. package/dist/core/runner-version.js +45 -0
  50. package/dist/core/secret-path.js +34 -0
  51. package/dist/core/sections.js +230 -0
  52. package/dist/core/state.js +51 -0
  53. package/dist/core/syntax.js +1 -0
  54. package/dist/core/test-commands.js +334 -0
  55. package/dist/core/test-coverage.js +74 -0
  56. package/dist/core/test-discovery.js +1382 -0
  57. package/dist/core/test-evidence.js +527 -0
  58. package/dist/core/test-state.js +81 -0
  59. package/dist/core/truncate.js +12 -0
  60. package/dist/core/units.js +349 -0
  61. package/dist/describe.js +23 -0
  62. package/dist/guide.js +33 -0
  63. package/dist/host.js +24 -0
  64. package/dist/jev/client.js +456 -0
  65. package/dist/jev/pool.js +54 -0
  66. package/dist/jev/types.js +1 -0
  67. package/dist/mcp/main.js +124 -0
  68. package/dist/mcp/protocol.js +210 -0
  69. package/dist/mcp/tools.js +129 -0
  70. package/dist/presets/docs.js +62 -0
  71. package/dist/presets/risk.js +179 -0
  72. package/dist/presets/spec.js +81 -0
  73. package/dist/presets/witnesses.js +249 -0
  74. package/dist/render.js +114 -0
  75. package/dist/report-schema.js +1356 -0
  76. package/dist/result-types.js +1 -0
  77. package/dist/result.js +3 -0
  78. package/dist/runtime.js +1 -0
  79. package/dist/session.js +147 -0
  80. package/dist/texts/ask-files.js +3 -0
  81. package/dist/texts/ask.js +4 -0
  82. package/dist/texts/check-diff.js +20 -0
  83. package/dist/texts/configuration.js +1 -0
  84. package/dist/texts/find.js +19 -0
  85. package/dist/texts/guide.js +3 -0
  86. package/dist/texts/instructions.js +72 -0
  87. package/dist/texts/locate.js +15 -0
  88. package/dist/texts/select-tests.js +4 -0
  89. package/dist/tools/ask-files.js +450 -0
  90. package/dist/tools/ask-schema.js +70 -0
  91. package/dist/tools/ask.js +1147 -0
  92. package/dist/tools/check-diff.js +594 -0
  93. package/dist/tools/docs-check.js +408 -0
  94. package/dist/tools/find.js +682 -0
  95. package/dist/tools/locate.js +602 -0
  96. package/dist/tools/review-report.js +230 -0
  97. package/dist/tools/select-tests.js +821 -0
  98. package/dist/tools/spec-check.js +263 -0
  99. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  100. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  101. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  102. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  103. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  104. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  105. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  106. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  107. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  108. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  109. package/docs/agent-instructions.md +120 -0
  110. package/docs/design.md +16 -4
  111. package/docs/mcp.md +233 -0
  112. package/docs/tools/jev_ask.md +8 -5
  113. package/docs/tools/jev_ask_files.md +2 -1
  114. package/docs/tools/jev_check_diff.md +4 -1
  115. package/docs/tools/jev_find_files.md +2 -1
  116. package/docs/tools/jev_locate_in_file.md +5 -0
  117. package/docs/tools/jev_select_tests.md +4 -1
  118. package/package.json +19 -4
  119. package/rules/jev-ask.md +22 -1
  120. package/server.json +57 -0
  121. package/src/adapters/ask-files.ts +11 -3
  122. package/src/adapters/ask-proof.ts +69 -11
  123. package/src/adapters/canonical-path.ts +18 -0
  124. package/src/adapters/command.ts +102 -36
  125. package/src/adapters/docs.ts +33 -14
  126. package/src/adapters/evidence-context.ts +169 -0
  127. package/src/adapters/exec.ts +226 -0
  128. package/src/adapters/files.ts +146 -16
  129. package/src/adapters/find.ts +37 -7
  130. package/src/adapters/git-base.ts +7 -1
  131. package/src/adapters/git.ts +61 -8
  132. package/src/adapters/locate-file.ts +51 -9
  133. package/src/adapters/private-storage.ts +155 -0
  134. package/src/adapters/risk-callers.ts +7 -2
  135. package/src/adapters/shell.ts +113 -0
  136. package/src/adapters/test-inventory.ts +12 -4
  137. package/src/configuration.ts +55 -14
  138. package/src/constants.ts +37 -5
  139. package/src/core/ask-references.ts +262 -146
  140. package/src/core/asks.ts +79 -7
  141. package/src/core/command-output.ts +17 -1
  142. package/src/core/import-boundaries.ts +8 -3
  143. package/src/core/locate.ts +8 -5
  144. package/src/core/output.ts +34 -0
  145. package/src/core/result-report.ts +410 -0
  146. package/src/core/secret-path.ts +37 -0
  147. package/src/core/state.ts +8 -1
  148. package/src/core/units.ts +3 -2
  149. package/src/host.ts +11 -0
  150. package/src/index.ts +3 -0
  151. package/src/jev/client.ts +66 -16
  152. package/src/jev/types.ts +24 -3
  153. package/src/mcp/main.ts +135 -0
  154. package/src/mcp/protocol.ts +332 -0
  155. package/src/mcp/tools.ts +179 -0
  156. package/src/render.ts +109 -0
  157. package/src/report-schema.ts +1380 -0
  158. package/src/result-types.ts +234 -0
  159. package/src/result.ts +4 -1
  160. package/src/runtime.ts +6 -0
  161. package/src/session.ts +59 -0
  162. package/src/setup.ts +13 -5
  163. package/src/texts/ask-files.ts +4 -1
  164. package/src/texts/ask.ts +8 -1
  165. package/src/texts/check-diff.ts +7 -4
  166. package/src/texts/find.ts +8 -2
  167. package/src/texts/guide.ts +8 -16
  168. package/src/texts/instructions.ts +98 -0
  169. package/src/texts/locate.ts +8 -2
  170. package/src/texts/run-end.ts +2 -2
  171. package/src/texts/select-tests.ts +4 -1
  172. package/src/tools/ask-files.ts +311 -18
  173. package/src/tools/ask.ts +722 -95
  174. package/src/tools/check-diff.ts +337 -31
  175. package/src/tools/docs-check.ts +241 -38
  176. package/src/tools/find.ts +389 -29
  177. package/src/tools/locate.ts +387 -25
  178. package/src/tools/review-report.ts +308 -0
  179. package/src/tools/select-tests.ts +484 -23
  180. package/src/tools/spec-check.ts +194 -19
@@ -0,0 +1,92 @@
1
+ import { accessSync, constants } from "node:fs";
2
+ // Candidates are Windows paths whatever the host OS, so the helpers use win32
3
+ // semantics explicitly; the host-native `node:path` splits PATH on ":" on POSIX.
4
+ import { win32 } from "node:path";
5
+ function executable(path) {
6
+ try {
7
+ accessSync(path, constants.X_OK);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ /**
15
+ * Windows ships `bash.exe` launchers for WSL in System32 (and SysWOW64,
16
+ * Sysnative) and in WindowsApps. They run in a Linux VM that cannot see Windows
17
+ * temporary paths, so they are never used for command evidence, including
18
+ * when named explicitly in JEV_TOOLS_BASH.
19
+ */
20
+ export function isWslLauncher(path) {
21
+ const lower = win32.normalize(path).toLowerCase();
22
+ return (/\\windows\\(system32|syswow64|sysnative)\\/.test(lower) ||
23
+ lower.includes("\\microsoft\\windowsapps\\"));
24
+ }
25
+ /** Permitted Git for Windows bash candidates, most specific first. */
26
+ export function windowsBashCandidates(env) {
27
+ const candidates = [];
28
+ if (env.JEV_TOOLS_BASH?.trim())
29
+ candidates.push(env.JEV_TOOLS_BASH.trim());
30
+ const pathEntries = (env.PATH ?? env.Path ?? "")
31
+ .split(win32.delimiter)
32
+ .filter(Boolean);
33
+ for (const entry of pathEntries) {
34
+ candidates.push(win32.join(entry, "bash.exe"));
35
+ // Git\cmd\git.exe or Git\bin\git.exe on PATH implies Git\bin\bash.exe.
36
+ if (/[\\/](cmd|bin)$/i.test(entry))
37
+ candidates.push(win32.join(win32.dirname(entry), "bin", "bash.exe"));
38
+ }
39
+ for (const root of [
40
+ env.ProgramFiles,
41
+ env["ProgramFiles(x86)"],
42
+ env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, "Programs"),
43
+ ])
44
+ if (root)
45
+ candidates.push(win32.join(root, "Git", "bin", "bash.exe"));
46
+ // Filter every source, so no candidate can reintroduce a WSL launcher.
47
+ return [...new Set(candidates)].filter((candidate) => win32.isAbsolute(candidate) && !isWslLauncher(candidate));
48
+ }
49
+ const UNAVAILABLE = "No permitted bash found: install Git for Windows or set JEV_TOOLS_BASH to the full path of a bash that is not the WSL launcher.";
50
+ /** Never handed to command children: the Jev API key stays with the extension. */
51
+ export const API_KEY_VARIABLE = "JEV_TOOLS_API_KEY";
52
+ /** Copy of `env` without the Jev API key (any case, for Windows). */
53
+ export function withoutApiKey(env) {
54
+ const copy = { ...env };
55
+ for (const key of Object.keys(copy))
56
+ if (key.toUpperCase() === API_KEY_VARIABLE)
57
+ delete copy[key];
58
+ return copy;
59
+ }
60
+ let cached;
61
+ /**
62
+ * POSIX hosts run `env -u JEV_TOOLS_API_KEY CI=1 bash -c`. Windows has no
63
+ * `env` and its default `bash` is the WSL launcher, so resolve Git for Windows
64
+ * bash (or JEV_TOOLS_BASH) to an absolute, permitted path, and unset the key
65
+ * and export CI inside the script. When none is found this fails closed: no
66
+ * bare name is returned, because spawning one would search PATH again without
67
+ * the WSL exclusion.
68
+ */
69
+ export function resolveShell(platform = process.platform, env = process.env, isExecutable = executable) {
70
+ if (platform !== "win32")
71
+ return {
72
+ ok: true,
73
+ executable: "env",
74
+ prefix: ["-u", API_KEY_VARIABLE, "CI=1", "bash"],
75
+ scriptPrefix: "",
76
+ };
77
+ const useCache = isExecutable === executable && env === process.env;
78
+ if (useCache && cached)
79
+ return cached;
80
+ const found = windowsBashCandidates(env).find((path) => isExecutable(path));
81
+ const resolved = found
82
+ ? {
83
+ ok: true,
84
+ executable: found,
85
+ prefix: [],
86
+ scriptPrefix: `unset ${API_KEY_VARIABLE}; export CI=1; `,
87
+ }
88
+ : { ok: false, error: UNAVAILABLE };
89
+ if (useCache && found)
90
+ cached = resolved;
91
+ return resolved;
92
+ }
@@ -0,0 +1,187 @@
1
+ let parserPromise;
2
+ /** Optional grammars resolve from this extension, not the repository under review. */
3
+ export function loadSyntaxParser() {
4
+ parserPromise ??= createParser();
5
+ return parserPromise;
6
+ }
7
+ /** Raw AST access shares grammar availability and registration with declaration extraction. */
8
+ export function loadSyntaxRootParser() {
9
+ parserPromise ??= createParser();
10
+ return parserPromise;
11
+ }
12
+ async function createParser() {
13
+ try {
14
+ const napi = await import("@ast-grep/napi");
15
+ let python = false;
16
+ try {
17
+ const language = await import("@ast-grep/lang-python");
18
+ napi.registerDynamicLanguage({ python: language.default });
19
+ python = true;
20
+ }
21
+ catch {
22
+ /* buildUnits reports the missing grammar for each affected file. */
23
+ }
24
+ const parser = {
25
+ supports: (path) => /\.[cm]?[jt]sx?$/.test(path) || (python && path.endsWith(".py")),
26
+ parseRaw(path, text) {
27
+ try {
28
+ const language = path.endsWith(".py")
29
+ ? "python"
30
+ : /\.[cm]?tsx?$/.test(path)
31
+ ? path.endsWith("tsx")
32
+ ? napi.Lang.Tsx
33
+ : napi.Lang.TypeScript
34
+ : path.endsWith(".jsx")
35
+ ? "jsx"
36
+ : napi.Lang.JavaScript;
37
+ const root = napi.parse(language, text).root();
38
+ return { ok: true, root };
39
+ }
40
+ catch {
41
+ return { ok: false, error: "Source parser unavailable" };
42
+ }
43
+ },
44
+ parse(path, text) {
45
+ const parsed = this.parseRaw(path, text);
46
+ if (!parsed.ok)
47
+ return parsed;
48
+ return parsed.root.find({ rule: { kind: "ERROR" } })
49
+ ? { ok: false, error: "Source syntax incomplete" }
50
+ : parsed;
51
+ },
52
+ declarations(path, text, options) {
53
+ const isPython = path.endsWith(".py");
54
+ const parsed = this.parseRaw(path, text);
55
+ if (!parsed.ok)
56
+ return { declarations: [], limits: [{ kind: "parse_failed" }] };
57
+ const root = parsed.root;
58
+ const result = [];
59
+ const limits = [];
60
+ const errors = root.findAll({ rule: { kind: "ERROR" } });
61
+ const declarationKind = /^(?:function_declaration|generator_function_declaration|method_definition|function_definition|lexical_declaration|variable_declaration|type_alias_declaration|interface_declaration|enum_declaration|class_declaration|class_definition)$/;
62
+ if (errors.some((error) => !error
63
+ .ancestors()
64
+ .some((node) => declarationKind.test(String(node.kind()))) &&
65
+ (error
66
+ .findAll({ rule: { regex: ".*" } })
67
+ .some((node) => declarationKind.test(String(node.kind()))) ||
68
+ /^(?:export\s+)?(?:function|const|let|var|class)\b/.test(error.text().trim()))))
69
+ return { declarations: [], limits: [{ kind: "parse_failed" }] };
70
+ const attributedErrors = new Set();
71
+ const source = text.split("\n");
72
+ const visit = (node) => {
73
+ const kind = String(node.kind());
74
+ // Most AST nodes cannot yield a declaration; avoid constructing their ancestor chains.
75
+ if (!/^(?:function_declaration|generator_function_declaration|method_definition|function_definition|lexical_declaration|variable_declaration|type_alias_declaration|interface_declaration|enum_declaration|expression_statement|class_declaration|class_definition)$/.test(kind)) {
76
+ for (const child of node.children())
77
+ visit(child);
78
+ return;
79
+ }
80
+ const ancestors = node.ancestors();
81
+ const functionNode = /^(?:function_declaration|generator_function_declaration|method_definition|function_definition)$/.test(kind);
82
+ const variable = /^(?:lexical_declaration|variable_declaration|type_alias_declaration|interface_declaration|enum_declaration)$/.test(kind) &&
83
+ ancestors.every((a) => !/function|method|statement_block/.test(String(a.kind())));
84
+ const callableVariable = variable &&
85
+ node.find({
86
+ rule: {
87
+ any: [
88
+ { kind: "arrow_function" },
89
+ { kind: "function_expression" },
90
+ ],
91
+ },
92
+ }) !== null;
93
+ const assignment = isPython &&
94
+ kind === "expression_statement" &&
95
+ node.parent()?.kind() === "module" &&
96
+ node.children().some((child) => child.kind() === "assignment");
97
+ const classNode = options?.descendAbove !== undefined &&
98
+ /^(?:class_declaration|class_definition)$/.test(kind);
99
+ if (functionNode || variable || assignment || classNode) {
100
+ let envelope = node;
101
+ if (node.parent()?.kind() === "decorated_definition" ||
102
+ node.parent()?.kind() === "export_statement")
103
+ envelope = node.parent() ?? node;
104
+ const named = node.field("name") ?? node.find({ rule: { kind: "identifier" } });
105
+ const owner = ancestors
106
+ .filter((a) => /^(?:class_definition|class_declaration|internal_module|module)$/.test(String(a.kind())))
107
+ .reverse()
108
+ .map((a) => a.field("name")?.text())
109
+ .filter(Boolean);
110
+ let name = [...owner, named?.text() ?? "declaration"].join(".");
111
+ if (assignment)
112
+ name = node.text().split(/\s*[:=]/, 1)[0] ?? "declaration";
113
+ let start = envelope.range().start.line;
114
+ const body = node.field("body") ??
115
+ (callableVariable
116
+ ? node
117
+ .find({
118
+ rule: {
119
+ any: [
120
+ { kind: "arrow_function" },
121
+ { kind: "function_expression" },
122
+ ],
123
+ },
124
+ })
125
+ ?.field("body")
126
+ : null);
127
+ const tokens = (part) => {
128
+ const children = part.children();
129
+ if (!children.length ||
130
+ /string|comment/.test(String(part.kind())))
131
+ return part.kind() === "comment" ? [] : [part.text()];
132
+ return children.flatMap(tokens);
133
+ };
134
+ const accessor = kind === "method_definition"
135
+ ? node
136
+ .children()
137
+ .find((child) => /^(get|set)$/.test(String(child.kind())))
138
+ ?.kind()
139
+ : undefined;
140
+ const end = envelope.range().end;
141
+ // Include the immediately preceding comment block without swallowing another declaration.
142
+ while (start > 0 &&
143
+ /^\s*(?:\/\/|\/\*|\*|\*\/|#)/.test(source[start - 1] ?? ""))
144
+ start--;
145
+ result.push({
146
+ kind: functionNode || callableVariable ? "function" : "declaration",
147
+ name,
148
+ type: accessor ? `${kind}:${accessor}` : kind,
149
+ signatureEnd: body
150
+ ? body.range().start.line + (isPython ? 0 : 1)
151
+ : undefined,
152
+ body: body ? JSON.stringify(tokens(body)) : undefined,
153
+ exported: isPython
154
+ ? !name.split(".").some((part) => part.startsWith("_"))
155
+ : ancestors.some((a) => a.kind() === "export_statement") ||
156
+ envelope.kind() === "export_statement",
157
+ start: start + 1,
158
+ end: end.line + (end.column === 0 ? 0 : 1),
159
+ });
160
+ const declarationErrors = envelope.findAll({
161
+ rule: { kind: "ERROR" },
162
+ });
163
+ if (declarationErrors.length) {
164
+ limits.push({ kind: "parse_partial", declaration: name });
165
+ for (const error of declarationErrors)
166
+ attributedErrors.add(error.id());
167
+ }
168
+ if (options?.descendAbove === undefined ||
169
+ end.line + (end.column === 0 ? 0 : 1) - start <=
170
+ options.descendAbove)
171
+ return;
172
+ }
173
+ for (const child of node.children())
174
+ visit(child);
175
+ };
176
+ visit(root);
177
+ if (errors.some((error) => !attributedErrors.has(error.id())))
178
+ limits.push({ kind: "parse_partial" });
179
+ return { declarations: result, limits };
180
+ },
181
+ };
182
+ return parser;
183
+ }
184
+ catch {
185
+ return undefined;
186
+ }
187
+ }
@@ -0,0 +1,139 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { TEST_SOURCE_MAX_BYTES, TIMEOUT_MS } from "../constants.js";
3
+ import { isSecretPath } from "../core/secret-path.js";
4
+ import { checkFileAdmission, openRepoFile, resolveInsideRepo, } from "./files.js";
5
+ import { decodeUtf8 } from "./utf8.js";
6
+ export async function collectTestInventory(exec, cwd, signal) {
7
+ const root = await exec("git", ["rev-parse", "--show-toplevel"], {
8
+ cwd,
9
+ timeout: TIMEOUT_MS,
10
+ signal,
11
+ });
12
+ if (root.code !== 0 || root.killed)
13
+ return {
14
+ ok: false,
15
+ cause: signal?.aborted ? "cancelled" : "git_failure",
16
+ error: root.stderr || "Cannot identify repository root.",
17
+ };
18
+ cwd = root.stdout.replace(/\n$/, "");
19
+ const listed = await exec("git", [
20
+ "ls-files",
21
+ "--cached",
22
+ "--others",
23
+ "--exclude-standard",
24
+ "--full-name",
25
+ "-z",
26
+ ], { cwd, timeout: TIMEOUT_MS, signal });
27
+ if (listed.code !== 0 || listed.killed)
28
+ return {
29
+ ok: false,
30
+ cause: signal?.aborted ? "cancelled" : "git_failure",
31
+ error: listed.stderr || "Cannot inventory repository files.",
32
+ };
33
+ const ignored = await exec("git", ["ls-files", "--cached", "--ignored", "--exclude-standard", "-z"], { cwd, timeout: TIMEOUT_MS, signal });
34
+ if (ignored.code !== 0 || ignored.killed)
35
+ return {
36
+ ok: false,
37
+ cause: signal?.aborted ? "cancelled" : "git_failure",
38
+ error: ignored.stderr || "Cannot identify ignored tracked files.",
39
+ };
40
+ const excluded = new Set(ignored.stdout.split("\0").filter(Boolean));
41
+ const listedPaths = listed.stdout
42
+ .split("\0")
43
+ .filter((path) => path &&
44
+ (!excluded.has(path) ||
45
+ /(?:^|\/)(?:package|tsconfig[^/]*)\.json$/.test(path)));
46
+ // Secret-named files are named exclusions, never inventory members to read.
47
+ const paths = listedPaths.filter((path) => !isSecretPath(path));
48
+ const admitted = new Set(paths);
49
+ const limits = listedPaths
50
+ .filter(isSecretPath)
51
+ .map((path) => ({ path, kind: "secret_pattern" }));
52
+ const cache = new Map();
53
+ for (let offset = 0; offset < paths.length; offset += 16) {
54
+ const inspected = await Promise.all(paths.slice(offset, offset + 16).map(async (path) => {
55
+ try {
56
+ signal?.throwIfAborted();
57
+ const admission = await checkFileAdmission(cwd, path, exec, signal, admitted);
58
+ if (!admission.ok)
59
+ return "unreadable";
60
+ const resolved = await resolveInsideRepo(cwd, path);
61
+ if (!resolved.ok)
62
+ return "unreadable";
63
+ const stat = await lstat(resolved.abs);
64
+ if (!stat.isFile())
65
+ return "unreadable";
66
+ return stat.size > TEST_SOURCE_MAX_BYTES
67
+ ? "too_large"
68
+ : undefined;
69
+ }
70
+ catch {
71
+ return "unreadable";
72
+ }
73
+ }));
74
+ for (const [index, kind] of inspected.entries()) {
75
+ if (!kind)
76
+ continue;
77
+ const path = paths[offset + index];
78
+ if (path === undefined || cache.has(path))
79
+ continue;
80
+ limits.push({ path, kind });
81
+ cache.set(path, Promise.resolve(undefined));
82
+ }
83
+ }
84
+ const read = (path) => {
85
+ const cached = cache.get(path);
86
+ if (cached)
87
+ return cached;
88
+ const pending = (async () => {
89
+ let handle;
90
+ try {
91
+ signal?.throwIfAborted();
92
+ const opened = await openRepoFile(cwd, path, {
93
+ exec,
94
+ signal,
95
+ inventory: admitted,
96
+ });
97
+ if (!opened.ok)
98
+ return { path, kind: "unreadable" };
99
+ handle = opened.handle;
100
+ const stat = { size: opened.size };
101
+ if (stat.size > TEST_SOURCE_MAX_BYTES)
102
+ return { path, kind: "too_large" };
103
+ const bytes = Buffer.alloc(Math.min(stat.size + 1, TEST_SOURCE_MAX_BYTES + 1));
104
+ let count = 0;
105
+ while (count < bytes.length) {
106
+ signal?.throwIfAborted();
107
+ const part = await handle.read(bytes, count, bytes.length - count, null);
108
+ if (!part.bytesRead)
109
+ break;
110
+ count += part.bytesRead;
111
+ }
112
+ if (count === bytes.length)
113
+ return { path, kind: "too_large" };
114
+ if (bytes.subarray(0, count).includes(0))
115
+ return { path, text: "" };
116
+ const decoded = decodeUtf8(bytes.subarray(0, count));
117
+ if (!decoded.ok)
118
+ return { path, kind: "not UTF-8 text" };
119
+ const text = decoded.text;
120
+ return { path, text };
121
+ }
122
+ catch {
123
+ return { path, kind: "unreadable" };
124
+ }
125
+ finally {
126
+ await handle?.close();
127
+ }
128
+ })().then((file) => {
129
+ if (file.kind) {
130
+ limits.push({ path: file.path, kind: file.kind });
131
+ return undefined;
132
+ }
133
+ return { path: file.path, text: file.text };
134
+ });
135
+ cache.set(path, pending);
136
+ return pending;
137
+ };
138
+ return { ok: true, cwd, paths, read, limits };
139
+ }
@@ -0,0 +1,20 @@
1
+ export function hostUsage(isOmp, usage) {
2
+ if (isOmp || !usage)
3
+ return {};
4
+ return {
5
+ usage: {
6
+ input: usage.inputTokens,
7
+ output: 0,
8
+ cacheRead: 0,
9
+ cacheWrite: 0,
10
+ totalTokens: usage.inputTokens,
11
+ cost: {
12
+ input: usage.costUsd,
13
+ output: 0,
14
+ cacheRead: 0,
15
+ cacheWrite: 0,
16
+ total: usage.costUsd,
17
+ },
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,47 @@
1
+ import { isUtf8 } from "node:buffer";
2
+ import { createHash } from "node:crypto";
3
+ /** Callers enforce byte budgets before decoding. BOM and legitimate controls are preserved. */
4
+ export function decodeUtf8(bytes) {
5
+ return isUtf8(bytes)
6
+ ? {
7
+ ok: true,
8
+ text: Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("utf8"),
9
+ }
10
+ : { ok: false, error: "not UTF-8 text" };
11
+ }
12
+ /** Keep incomplete code points between chunks, but reject them at EOF. */
13
+ export function createUtf8Decoder() {
14
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
15
+ return (bytes) => {
16
+ try {
17
+ return {
18
+ ok: true,
19
+ text: decoder.decode(bytes, { stream: bytes !== undefined }),
20
+ };
21
+ }
22
+ catch {
23
+ return { ok: false, error: "not UTF-8 text" };
24
+ }
25
+ };
26
+ }
27
+ /** Verify host-decoded text against the raw Git object; replacement cannot silently become evidence. */
28
+ export function verifyGitUtf8(text, id, size) {
29
+ const bytes = Buffer.from(text, "utf8");
30
+ if (size !== undefined && bytes.length !== size)
31
+ return { ok: false, error: "not UTF-8 text" };
32
+ if (id && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(id)) {
33
+ const actual = createHash(id.length === 64 ? "sha256" : "sha1")
34
+ .update(`blob ${bytes.length}\0`)
35
+ .update(bytes)
36
+ .digest("hex");
37
+ if (actual !== id)
38
+ return { ok: false, error: "not UTF-8 text" };
39
+ }
40
+ else if (text.includes("\uFFFD")) {
41
+ return {
42
+ ok: false,
43
+ error: "not verifiable as UTF-8 text (contains U+FFFD after host decoding)",
44
+ };
45
+ }
46
+ return decodeUtf8(bytes);
47
+ }