agent-cfml-check 0.1.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.
@@ -0,0 +1,388 @@
1
+ import { SourceIndex } from "./source-index.js";
2
+ import { ToolFailure } from "../schema/errors.js";
3
+ export const PAIRED_TAGS = new Set([
4
+ "cfif",
5
+ "cfloop",
6
+ "cfoutput",
7
+ "cfquery",
8
+ "cfsavecontent",
9
+ "cfsilent",
10
+ "cfcomponent",
11
+ "cffunction",
12
+ "cfscript",
13
+ ]);
14
+ export const BRANCH_TAGS = new Set(["cfelseif", "cfelse"]);
15
+ export const BODYLESS_TAGS = new Set(["cfset", "cfreturn", "cfinclude", "cfargument", "cfqueryparam"]);
16
+ function isNameCharacter(character) {
17
+ return character !== undefined && /[A-Za-z0-9_:-]/u.test(character);
18
+ }
19
+ function startsCfTag(source, index) {
20
+ const afterSlash = source[index + 1] === "/" ? index + 2 : index + 1;
21
+ return source.slice(afterSlash, afterSlash + 2).toLowerCase() === "cf" && isNameCharacter(source[afterSlash + 2]);
22
+ }
23
+ function consumeCfmlComment(source, start) {
24
+ let depth = 1;
25
+ let cursor = start + 5;
26
+ while (cursor < source.length) {
27
+ if (source.startsWith("<!---", cursor)) {
28
+ depth += 1;
29
+ cursor += 5;
30
+ continue;
31
+ }
32
+ if (source.startsWith("--->", cursor)) {
33
+ depth -= 1;
34
+ cursor += 4;
35
+ if (depth === 0)
36
+ return cursor;
37
+ continue;
38
+ }
39
+ cursor += 1;
40
+ }
41
+ return null;
42
+ }
43
+ function parseTag(source, start) {
44
+ const closing = source[start + 1] === "/";
45
+ let cursor = closing ? start + 2 : start + 1;
46
+ const nameStart = cursor;
47
+ while (isNameCharacter(source[cursor]))
48
+ cursor += 1;
49
+ const name = source.slice(nameStart, cursor).toLowerCase();
50
+ if (!name.startsWith("cf"))
51
+ return { error: "NOT_CF_TAG", at: start };
52
+ let quote = null;
53
+ while (cursor < source.length) {
54
+ const character = source[cursor];
55
+ if (quote !== null) {
56
+ if (character === "\\") {
57
+ cursor += 2;
58
+ continue;
59
+ }
60
+ if (character === quote) {
61
+ if (source[cursor + 1] === quote) {
62
+ cursor += 2;
63
+ continue;
64
+ }
65
+ quote = null;
66
+ }
67
+ cursor += 1;
68
+ continue;
69
+ }
70
+ if (character === "\"" || character === "'") {
71
+ quote = character;
72
+ cursor += 1;
73
+ continue;
74
+ }
75
+ if (source.startsWith("<!---", cursor)) {
76
+ const commentEnd = consumeCfmlComment(source, cursor);
77
+ if (commentEnd === null)
78
+ return { error: "UNTERMINATED_COMMENT", at: cursor };
79
+ cursor = commentEnd;
80
+ continue;
81
+ }
82
+ if (character === ">") {
83
+ let beforeEnd = cursor - 1;
84
+ while (beforeEnd >= start && /\s/u.test(source[beforeEnd] ?? ""))
85
+ beforeEnd -= 1;
86
+ const selfClosing = source[beforeEnd] === "/";
87
+ const kind = closing
88
+ ? "close"
89
+ : BRANCH_TAGS.has(name)
90
+ ? "branch"
91
+ : BODYLESS_TAGS.has(name)
92
+ ? "bodyless"
93
+ : PAIRED_TAGS.has(name)
94
+ ? "open"
95
+ : "unknown";
96
+ return { token: { kind, name, start, end: cursor + 1, self_closing: selfClosing }, next: cursor + 1 };
97
+ }
98
+ cursor += 1;
99
+ }
100
+ if (quote !== null)
101
+ return { error: "UNTERMINATED_STRING", at: cursor };
102
+ return { error: "UNTERMINATED_TAG", at: start };
103
+ }
104
+ function addFinding(context, findings, finding) {
105
+ if (findings.length >= context.max_findings) {
106
+ throw new ToolFailure("LIMIT_EXCEEDED", "The findings limit was reached; no partial success is returned", 3);
107
+ }
108
+ findings.push(finding);
109
+ }
110
+ function related(index, tag) {
111
+ return tag === undefined ? null : index.location(tag.start, tag.end);
112
+ }
113
+ function finding(context, code, message, start, end, open) {
114
+ return { code, message, location: context.index.location(start, end), related_open: related(context.index, open) };
115
+ }
116
+ function checkDeadline(context, cursor) {
117
+ if ((cursor & 255) === 0 && Date.now() > context.deadline) {
118
+ throw new ToolFailure("LIMIT_EXCEEDED", "The processing time limit was reached; no partial success is returned", 3);
119
+ }
120
+ }
121
+ function scanScriptDelimiters(context, start, end, findings) {
122
+ const stack = [];
123
+ let quote = null;
124
+ let blockCommentStart = null;
125
+ let cursor = start;
126
+ while (cursor < end) {
127
+ checkDeadline(context, cursor);
128
+ const character = context.source[cursor];
129
+ if (quote !== null) {
130
+ if (character === "\\") {
131
+ cursor += 2;
132
+ continue;
133
+ }
134
+ if (character === quote) {
135
+ if (context.source[cursor + 1] === quote) {
136
+ cursor += 2;
137
+ continue;
138
+ }
139
+ quote = null;
140
+ }
141
+ cursor += 1;
142
+ continue;
143
+ }
144
+ if (blockCommentStart !== null) {
145
+ if (context.source.startsWith("*/", cursor)) {
146
+ blockCommentStart = null;
147
+ cursor += 2;
148
+ }
149
+ else
150
+ cursor += 1;
151
+ continue;
152
+ }
153
+ if (context.source.startsWith("//", cursor)) {
154
+ cursor += 2;
155
+ while (cursor < end && context.source[cursor] !== "\n" && context.source[cursor] !== "\r")
156
+ cursor += 1;
157
+ continue;
158
+ }
159
+ if (context.source.startsWith("/*", cursor)) {
160
+ blockCommentStart = cursor;
161
+ cursor += 2;
162
+ continue;
163
+ }
164
+ if (character === "\"" || character === "'") {
165
+ quote = character;
166
+ cursor += 1;
167
+ continue;
168
+ }
169
+ if (character === "(" || character === "[" || character === "{") {
170
+ if (stack.length >= context.max_nesting) {
171
+ throw new ToolFailure("LIMIT_EXCEEDED", "The nesting limit was reached; no partial success is returned", 3);
172
+ }
173
+ stack.push({ character, start: cursor });
174
+ cursor += 1;
175
+ continue;
176
+ }
177
+ if (character === ")" || character === "]" || character === "}") {
178
+ const expected = character === ")" ? "(" : character === "]" ? "[" : "{";
179
+ const open = stack[stack.length - 1];
180
+ if (open?.character !== expected) {
181
+ addFinding(context, findings, finding(context, "UNBALANCED_DELIMITER", `Unmatched CFScript delimiter "${character}"`, cursor, cursor + 1));
182
+ }
183
+ else {
184
+ stack.pop();
185
+ }
186
+ }
187
+ cursor += 1;
188
+ }
189
+ if (quote !== null) {
190
+ addFinding(context, findings, finding(context, "UNTERMINATED_STRING", "Unterminated CFScript string", end, end));
191
+ }
192
+ if (blockCommentStart !== null) {
193
+ addFinding(context, findings, finding(context, "UNTERMINATED_COMMENT", "Unterminated CFScript block comment", blockCommentStart, end));
194
+ }
195
+ for (const open of stack) {
196
+ addFinding(context, findings, finding(context, "UNBALANCED_DELIMITER", `Unclosed CFScript delimiter "${open.character}"`, open.start, open.start + 1));
197
+ }
198
+ }
199
+ function scanScriptRegion(context, start) {
200
+ let quote = null;
201
+ let blockCommentStart = null;
202
+ let cursor = start;
203
+ while (cursor < context.source.length) {
204
+ checkDeadline(context, cursor);
205
+ const character = context.source[cursor];
206
+ if (quote !== null) {
207
+ if (character === "\\") {
208
+ cursor += 2;
209
+ continue;
210
+ }
211
+ if (character === quote) {
212
+ if (context.source[cursor + 1] === quote)
213
+ cursor += 2;
214
+ else {
215
+ quote = null;
216
+ cursor += 1;
217
+ }
218
+ continue;
219
+ }
220
+ cursor += 1;
221
+ continue;
222
+ }
223
+ if (blockCommentStart !== null) {
224
+ if (context.source.startsWith("*/", cursor)) {
225
+ blockCommentStart = null;
226
+ cursor += 2;
227
+ }
228
+ else
229
+ cursor += 1;
230
+ continue;
231
+ }
232
+ if (context.source.startsWith("//", cursor)) {
233
+ cursor += 2;
234
+ while (cursor < context.source.length && context.source[cursor] !== "\n" && context.source[cursor] !== "\r")
235
+ cursor += 1;
236
+ continue;
237
+ }
238
+ if (context.source.startsWith("/*", cursor)) {
239
+ blockCommentStart = cursor;
240
+ cursor += 2;
241
+ continue;
242
+ }
243
+ if (character === "\"" || character === "'") {
244
+ quote = character;
245
+ cursor += 1;
246
+ continue;
247
+ }
248
+ if (startsCfTag(context.source, cursor)) {
249
+ const parsed = parseTag(context.source, cursor);
250
+ if ("error" in parsed)
251
+ return { close: null, content_end: cursor, incomplete: { code: parsed.error, message: `CFScript region cannot be parsed (${parsed.error})` } };
252
+ if (parsed.token.name === "cfscript" && parsed.token.kind === "close") {
253
+ return { close: parsed.token, content_end: cursor };
254
+ }
255
+ return { close: null, content_end: cursor, incomplete: { code: "UNSUPPORTED_SYNTAX", message: "CFML tag island inside cfscript is outside the supported profile" } };
256
+ }
257
+ cursor += 1;
258
+ }
259
+ if (quote !== null)
260
+ return { close: null, content_end: context.source.length, incomplete: { code: "UNTERMINATED_STRING", message: "Unterminated CFScript string" } };
261
+ if (blockCommentStart !== null)
262
+ return { close: null, content_end: context.source.length, incomplete: { code: "UNTERMINATED_COMMENT", message: "Unterminated CFScript block comment" } };
263
+ return { close: null, content_end: context.source.length, incomplete: { code: "UNTERMINATED_TAG", message: "cfscript has no closing tag" } };
264
+ }
265
+ function processTag(context, token, stack, findings) {
266
+ if (token.kind === "unknown") {
267
+ throw new ToolFailure("UNSUPPORTED_SYNTAX", `Unsupported CFML tag "${token.name}"`, 3);
268
+ }
269
+ if (token.kind === "bodyless") {
270
+ return;
271
+ }
272
+ if (token.kind === "branch") {
273
+ const current = stack[stack.length - 1];
274
+ if (current?.name !== "cfif") {
275
+ addFinding(context, findings, finding(context, "INVALID_BRANCH", `${token.name} must belong to the directly open cfif`, token.start, token.end));
276
+ return;
277
+ }
278
+ if (token.name === "cfelse") {
279
+ if (current.branch_else_seen) {
280
+ addFinding(context, findings, finding(context, "INVALID_BRANCH", "cfif cannot contain more than one cfelse", token.start, token.end, current));
281
+ }
282
+ current.branch_else_seen = true;
283
+ }
284
+ else if (current.branch_else_seen) {
285
+ addFinding(context, findings, finding(context, "INVALID_BRANCH", "cfelseif cannot appear after cfelse", token.start, token.end, current));
286
+ }
287
+ return;
288
+ }
289
+ if (token.kind === "open") {
290
+ if (token.self_closing)
291
+ throw new ToolFailure("UNSUPPORTED_SYNTAX", `Paired tag "${token.name}" cannot be self-closing`, 3);
292
+ if (stack.length >= context.max_nesting)
293
+ throw new ToolFailure("LIMIT_EXCEEDED", "The tag nesting limit was reached; no partial success is returned", 3);
294
+ stack.push({ name: token.name, start: token.start, end: token.end, branch_else_seen: false });
295
+ return;
296
+ }
297
+ if (token.name === "cfscript" && stack[stack.length - 1]?.name !== "cfscript") {
298
+ addFinding(context, findings, finding(context, "UNEXPECTED_CLOSE", "Closing cfscript has no directly open cfscript", token.start, token.end));
299
+ return;
300
+ }
301
+ if (BODYLESS_TAGS.has(token.name)) {
302
+ throw new ToolFailure("UNSUPPORTED_SYNTAX", `Bodyless tag "${token.name}" cannot have a closing tag`, 3);
303
+ }
304
+ const current = stack[stack.length - 1];
305
+ if (current === undefined) {
306
+ addFinding(context, findings, finding(context, "UNEXPECTED_CLOSE", `Unexpected closing tag "${token.name}"`, token.start, token.end));
307
+ return;
308
+ }
309
+ if (current.name !== token.name) {
310
+ addFinding(context, findings, finding(context, "MISMATCHED_CLOSE", `Closing tag "${token.name}" does not match open "${current.name}"`, token.start, token.end, current));
311
+ return;
312
+ }
313
+ stack.pop();
314
+ }
315
+ export function scanCfml(source, limits) {
316
+ const index = new SourceIndex(source);
317
+ const context = { source, index, deadline: Date.now() + limits.time_limit_ms, max_nesting: limits.max_nesting, max_findings: limits.max_findings };
318
+ const findings = [];
319
+ const incomplete = [];
320
+ const script_regions = [];
321
+ const stack = [];
322
+ let saw_cfml_tag = false;
323
+ let cursor = 0;
324
+ while (cursor < source.length) {
325
+ checkDeadline(context, cursor);
326
+ if (source.startsWith("<!---", cursor)) {
327
+ const end = consumeCfmlComment(source, cursor);
328
+ if (end === null) {
329
+ incomplete.push({ code: "UNTERMINATED_COMMENT", message: `Unterminated CFML comment at line ${index.position(cursor).line}, column ${index.position(cursor).column}` });
330
+ break;
331
+ }
332
+ cursor = end;
333
+ continue;
334
+ }
335
+ if (source[cursor] !== "<" || !startsCfTag(source, cursor)) {
336
+ cursor += 1;
337
+ continue;
338
+ }
339
+ saw_cfml_tag = true;
340
+ const parsed = parseTag(source, cursor);
341
+ if ("error" in parsed) {
342
+ incomplete.push({ code: parsed.error, message: `CFML tag cannot be parsed (${parsed.error}) at line ${index.position(parsed.at).line}, column ${index.position(parsed.at).column}` });
343
+ break;
344
+ }
345
+ const token = parsed.token;
346
+ if (token.kind === "open" && token.name === "cfscript") {
347
+ processTag(context, token, stack, findings);
348
+ const script = scanScriptRegion(context, token.end);
349
+ scanScriptDelimiters(context, token.end, script.content_end, findings);
350
+ script_regions.push({ content_start: token.end, content_end: script.content_end });
351
+ if (script.incomplete !== undefined) {
352
+ incomplete.push({ code: script.incomplete.code, message: `${script.incomplete.message} at line ${index.position(script.content_end).line}, column ${index.position(script.content_end).column}` });
353
+ break;
354
+ }
355
+ if (script.close !== null) {
356
+ processTag(context, script.close, stack, findings);
357
+ cursor = script.close.end;
358
+ continue;
359
+ }
360
+ }
361
+ processTag(context, token, stack, findings);
362
+ cursor = parsed.next;
363
+ }
364
+ for (const open of stack.reverse()) {
365
+ addFinding(context, findings, finding(context, "UNCLOSED_TAG", `Unclosed CFML tag "${open.name}"`, open.start, open.end, open));
366
+ }
367
+ findings.sort((left, right) => left.location.start_byte - right.location.start_byte || left.code.localeCompare(right.code));
368
+ return { findings, incomplete, script_regions, saw_cfml_tag };
369
+ }
370
+ export function checkPureScriptCfc(source, extension, limits) {
371
+ const leading = source.replace(/^\uFEFF/u, "").trimStart();
372
+ const pureScript = extension.toLowerCase() === ".cfc" && /^(?:component|interface)\b/iu.test(leading);
373
+ if (pureScript) {
374
+ const result = { findings: [], incomplete: [], script_regions: [], saw_cfml_tag: false };
375
+ const index = new SourceIndex(source);
376
+ const context = { source, index, deadline: Date.now() + limits.time_limit_ms, max_nesting: limits.max_nesting, max_findings: limits.max_findings };
377
+ scanScriptDelimiters(context, 0, source.length, result.findings);
378
+ result.findings.sort((left, right) => left.location.start_byte - right.location.start_byte || left.code.localeCompare(right.code));
379
+ return result;
380
+ }
381
+ const result = scanCfml(source, limits);
382
+ if (extension.toLowerCase() !== ".cfc" || result.saw_cfml_tag)
383
+ return result;
384
+ if (!pureScript) {
385
+ result.incomplete.push({ code: "UNSUPPORTED_SYNTAX", message: "The .cfc source does not use a recognized pure-script component entry mode" });
386
+ }
387
+ return result;
388
+ }
@@ -0,0 +1,4 @@
1
+ import type { CheckLimits } from "../schema/types.js";
2
+ export declare const DEFAULT_LIMITS: CheckLimits;
3
+ export declare const HARD_LIMITS: CheckLimits;
4
+ export declare function normalizeLimits(requested: Partial<CheckLimits> | undefined): CheckLimits;
@@ -0,0 +1,32 @@
1
+ import { ToolFailure } from "../schema/errors.js";
2
+ export const DEFAULT_LIMITS = {
3
+ max_source_bytes: 2 * 1024 * 1024,
4
+ max_nesting: 256,
5
+ max_findings: 100,
6
+ max_output_bytes: 64 * 1024,
7
+ time_limit_ms: 5_000,
8
+ };
9
+ export const HARD_LIMITS = {
10
+ max_source_bytes: 16 * 1024 * 1024,
11
+ max_nesting: 1_024,
12
+ max_findings: 1_000,
13
+ max_output_bytes: 1024 * 1024,
14
+ time_limit_ms: 30_000,
15
+ };
16
+ function positiveInteger(value, name) {
17
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
18
+ throw new ToolFailure("INVALID_LIMIT", `${name} must be a positive integer`, 2);
19
+ }
20
+ return value;
21
+ }
22
+ export function normalizeLimits(requested) {
23
+ const values = { ...DEFAULT_LIMITS, ...(requested ?? {}) };
24
+ for (const key of Object.keys(DEFAULT_LIMITS)) {
25
+ const value = positiveInteger(values[key], key);
26
+ if (value > HARD_LIMITS[key]) {
27
+ throw new ToolFailure("INVALID_LIMIT", `${key} exceeds the hard cap of ${HARD_LIMITS[key]}`, 2);
28
+ }
29
+ values[key] = value;
30
+ }
31
+ return values;
32
+ }
@@ -0,0 +1,10 @@
1
+ import type { Position, SourceLocation } from "../schema/types.js";
2
+ export declare class SourceIndex {
3
+ readonly source: string;
4
+ private readonly byteOffsets;
5
+ private readonly lineStarts;
6
+ constructor(source: string);
7
+ byteOffset(index: number): number;
8
+ position(index: number): Position;
9
+ location(start: number, end: number): SourceLocation;
10
+ }
@@ -0,0 +1,63 @@
1
+ import { TextEncoder } from "node:util";
2
+ export class SourceIndex {
3
+ source;
4
+ byteOffsets;
5
+ lineStarts = [0];
6
+ constructor(source) {
7
+ this.source = source;
8
+ this.byteOffsets = new Uint32Array(source.length + 1);
9
+ const encoder = new TextEncoder();
10
+ let byteOffset = 0;
11
+ for (let index = 0; index < source.length;) {
12
+ this.byteOffsets[index] = byteOffset;
13
+ const codePoint = source.codePointAt(index);
14
+ if (codePoint === undefined)
15
+ break;
16
+ const width = codePoint > 0xffff ? 2 : 1;
17
+ const character = source.slice(index, index + width);
18
+ const byteLength = encoder.encode(character).byteLength;
19
+ if (width === 2)
20
+ this.byteOffsets[index + 1] = byteOffset;
21
+ byteOffset += byteLength;
22
+ index += width;
23
+ this.byteOffsets[index] = byteOffset;
24
+ if (character === "\n")
25
+ this.lineStarts.push(index);
26
+ }
27
+ }
28
+ byteOffset(index) {
29
+ const safe = Math.max(0, Math.min(index, this.source.length));
30
+ return this.byteOffsets[safe] ?? 0;
31
+ }
32
+ position(index) {
33
+ const safe = Math.max(0, Math.min(index, this.source.length));
34
+ let low = 0;
35
+ let high = this.lineStarts.length - 1;
36
+ while (low <= high) {
37
+ const middle = Math.floor((low + high) / 2);
38
+ const start = this.lineStarts[middle] ?? 0;
39
+ if (start <= safe)
40
+ low = middle + 1;
41
+ else
42
+ high = middle - 1;
43
+ }
44
+ const lineStart = this.lineStarts[Math.max(0, high)] ?? 0;
45
+ let column = 1;
46
+ for (let cursor = lineStart; cursor < safe;) {
47
+ const codePoint = this.source.codePointAt(cursor);
48
+ if (codePoint === undefined)
49
+ break;
50
+ cursor += codePoint > 0xffff ? 2 : 1;
51
+ column += 1;
52
+ }
53
+ return { line: Math.max(1, high + 1), column };
54
+ }
55
+ location(start, end) {
56
+ return {
57
+ start: this.position(start),
58
+ end: this.position(end),
59
+ start_byte: this.byteOffset(start),
60
+ end_byte: this.byteOffset(end),
61
+ };
62
+ }
63
+ }
@@ -0,0 +1,6 @@
1
+ import type { CheckLimits, LoadedSource } from "../schema/types.js";
2
+ export declare function loadSource(options: {
3
+ root: string;
4
+ file: string;
5
+ limits: CheckLimits;
6
+ }): LoadedSource;
@@ -0,0 +1,67 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { TextDecoder } from "node:util";
4
+ import { ToolFailure } from "../schema/errors.js";
5
+ function escapesRoot(root, candidate) {
6
+ const relative = path.relative(root, candidate);
7
+ return relative.startsWith("..") || path.isAbsolute(relative);
8
+ }
9
+ export function loadSource(options) {
10
+ if (!/\.(?:cfm|cfc)$/iu.test(options.file)) {
11
+ throw new ToolFailure("UNSUPPORTED_EXTENSION", "Only .cfm and .cfc files are supported", 2);
12
+ }
13
+ const cwd = process.cwd();
14
+ const rootPath = path.isAbsolute(options.root) ? options.root : path.resolve(cwd, options.root);
15
+ const requestedPath = path.isAbsolute(options.file) ? options.file : path.resolve(cwd, options.file);
16
+ if (escapesRoot(rootPath, requestedPath)) {
17
+ throw new ToolFailure("FILE_OUTSIDE_ROOT", "The selected file resolves outside the explicit root", 4);
18
+ }
19
+ if (!existsSync(rootPath))
20
+ throw new ToolFailure("ROOT_NOT_FOUND", "The explicit root does not exist", 4);
21
+ if (!existsSync(requestedPath))
22
+ throw new ToolFailure("FILE_NOT_FOUND", "The selected file does not exist", 2);
23
+ let realRoot;
24
+ let resolvedPath;
25
+ try {
26
+ realRoot = realpathSync(rootPath);
27
+ resolvedPath = realpathSync(requestedPath);
28
+ }
29
+ catch {
30
+ throw new ToolFailure("PATH_UNRESOLVED", "The root or file could not be resolved", 4);
31
+ }
32
+ if (escapesRoot(realRoot, resolvedPath)) {
33
+ throw new ToolFailure("FILE_OUTSIDE_ROOT", "The selected file resolves outside the explicit root", 4);
34
+ }
35
+ const before = statSync(resolvedPath);
36
+ if (!before.isFile())
37
+ throw new ToolFailure("FILE_NOT_FOUND", "The selected path is not a regular file", 2);
38
+ if (before.size > options.limits.max_source_bytes) {
39
+ throw new ToolFailure("LIMIT_EXCEEDED", `Source exceeds the ${options.limits.max_source_bytes}-byte limit`, 3);
40
+ }
41
+ const bytes = readFileSync(resolvedPath);
42
+ const after = statSync(resolvedPath);
43
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
44
+ throw new ToolFailure("SOURCE_CHANGED", "The source changed while it was being read", 3);
45
+ }
46
+ if (bytes.byteLength > options.limits.max_source_bytes) {
47
+ throw new ToolFailure("LIMIT_EXCEEDED", `Source exceeds the ${options.limits.max_source_bytes}-byte limit`, 3);
48
+ }
49
+ if (bytes.includes(0))
50
+ throw new ToolFailure("BINARY_INPUT", "Binary or NUL-containing input is not supported", 2);
51
+ let source;
52
+ try {
53
+ source = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
54
+ }
55
+ catch {
56
+ throw new ToolFailure("ENCODING_UNSUPPORTED", "Source must be valid UTF-8", 2);
57
+ }
58
+ const relativePath = path.relative(realRoot, resolvedPath).split(path.sep).join("/");
59
+ return {
60
+ requested_path: options.file,
61
+ resolved_path: resolvedPath,
62
+ relative_path: relativePath,
63
+ source,
64
+ bytes,
65
+ bom: source.startsWith("\uFEFF"),
66
+ };
67
+ }
@@ -0,0 +1,3 @@
1
+ export { capabilities, checkFile } from "./core/checker.js";
2
+ export { DEFAULT_LIMITS, HARD_LIMITS, normalizeLimits } from "./core/limits.js";
3
+ export type * from "./schema/types.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { capabilities, checkFile } from "./core/checker.js";
2
+ export { DEFAULT_LIMITS, HARD_LIMITS, normalizeLimits } from "./core/limits.js";
@@ -0,0 +1,5 @@
1
+ export declare class ToolFailure extends Error {
2
+ readonly code: string;
3
+ readonly exitCode: 1 | 2 | 3 | 4;
4
+ constructor(code: string, message: string, exitCode: 1 | 2 | 3 | 4);
5
+ }
@@ -0,0 +1,10 @@
1
+ export class ToolFailure extends Error {
2
+ code;
3
+ exitCode;
4
+ constructor(code, message, exitCode) {
5
+ super(message);
6
+ this.code = code;
7
+ this.exitCode = exitCode;
8
+ this.name = "ToolFailure";
9
+ }
10
+ }