agent-cfml-check 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Current status
6
6
 
7
- Version `0.1.0` contains the first feasibility slice. The current implementation is locally verified for:
7
+ Version `0.2.0` extends the `0.1.1` structural profile with parameter and exception tags. The implementation checks:
8
8
 
9
9
  - paired CFML tag nesting;
10
10
  - `cfelse` and `cfelseif` ownership and ordering;
@@ -17,17 +17,39 @@ Version `0.1.0` contains the first feasibility slice. The current implementation
17
17
  - explicit-root path safety, source stability checks, input validation, output limits, nesting limits, finding limits, and time limits;
18
18
  - deterministic JSON and text CLI output.
19
19
 
20
- Verification at repository commit `1d5c768`:
20
+ Current repository source also recognizes `cfparam` as a bodyless tag and checks paired `cftry`, `cfcatch`, and `cffinally` structure. These changes are not included in the published `0.1.1` artifact or existing installed snapshots. They do not validate tag attributes, exception behavior, or runtime semantics. Self-closing paired tags, including `cfcatch`, return incomplete rather than an engine-validity claim.
21
21
 
22
- - `npm test`: 17/17 passed;
22
+ The current local source corrects CFScript identifiers containing `cf` being
23
+ misclassified as markup. Ordinary variables and calls remain script text; actual
24
+ CFML tag islands still return `incomplete` with their source position. This fix
25
+ is included in the `0.2.0` source.
26
+
27
+ Historical verification for the `0.1.1` release baseline:
28
+
29
+ - `npm test`: 22/22 passed;
23
30
  - `npm run typecheck`: passed;
24
31
  - `npm pack --dry-run --json`: passed;
25
- - capabilities, valid-fixture, and misnested-fixture CLI checks: passed.
32
+ - capabilities, valid-fixture, and misnested-fixture CLI checks: passed;
33
+ - temporary packed-install CLI and library import smoke checks: passed;
34
+ - targeted Lucee `6.2.2.91` execution probe: passed;
35
+ - Ubuntu WSL Node `18.19.1` `npm ci`, tests, and typecheck: passed.
26
36
 
27
- This is not yet a published package, a complete CFML parser, or evidence of Lucee/Adobe ColdFusion compatibility. Hub lifecycle and publication status remain outside this repository's locally verified implementation evidence.
37
+ This is not a complete CFML parser or evidence of Adobe ColdFusion compatibility. A targeted Lucee `6.2.2.91` probe and an Ubuntu WSL Node `18.19.1` run for prior source do not establish a full engine or platform matrix for `0.2.0`.
28
38
 
29
39
  ## Install and use
30
40
 
41
+ Install the published CLI globally for Codex CLI or any terminal session:
42
+
43
+ ```sh
44
+ npm install --global agent-cfml-check@latest
45
+ agent-cfml-check capabilities --json
46
+ agent-cfml-check check --root /path/to/repository path/to/file.cfm --json
47
+ ```
48
+
49
+ The global installation exposes the `agent-cfml-check` command from any working
50
+ directory. For a checkout, use `npm ci`, `npm run build`, and the same command
51
+ through `node dist/cli/index.js`.
52
+
31
53
  ```sh
32
54
  npm install
33
55
  npm test
@@ -38,7 +60,7 @@ node dist/cli/index.js check --root . fixtures/valid.cfm --json
38
60
  node dist/cli/index.js check --root . fixtures/misnested.cfm --json
39
61
  ```
40
62
 
41
- The package requires Node.js `>=18.18.0`. It has no runtime npm dependencies; TypeScript and Node.js type definitions are development dependencies.
63
+ The package requires Node.js `>=18.18.0`. It has no runtime npm dependencies; Ajv, TypeScript, and Node.js type definitions are development dependencies. The package exposes `dist/index.js` and `dist/index.d.ts` through `main`, `types`, and `exports`; temporary packed-install CLI and library import smoke checks pass.
42
64
 
43
65
  The CLI supports:
44
66
 
@@ -49,6 +71,11 @@ agent-cfml-check check --root <directory> <file> [--json] [--pretty]
49
71
 
50
72
  Check limits can be overridden with `--max-source-bytes`, `--max-nesting`, `--max-findings`, `--max-output-bytes`, and `--time-limit-ms`, subject to the hard caps reported by `capabilities`.
51
73
 
74
+ `max_output_bytes` must be at least 512, reserving a complete failure envelope.
75
+ CLI ceilings count actual UTF-8 stdout, including indentation and the final
76
+ newline. If pretty output cannot fit, checking returns incomplete/exit 3 and may
77
+ emit its failure envelope as compact JSON. Default fitting output is unchanged.
78
+
52
79
  The checker requires one explicit root and one `.cfm` or `.cfc` file. JSON mode emits one envelope on stdout; diagnostics stay on stderr. A completed check with structural violations still exits `0` and reports `data.verdict: "violations"`. Exit `3` means unsupported syntax, insufficient evidence, or a resource limit. Exit `4` means an explicit-root or access-policy rejection. See [SPEC.md](SPEC.md) for the complete contract.
53
80
 
54
81
  ## Safety and boundaries
@@ -67,8 +94,10 @@ The supported profile is `cfml-structure-v1`. Unknown/custom/imported tags, opti
67
94
  - `src/cli/index.ts`: CLI argument parsing and rendering;
68
95
  - `schema/`: JSON Schema for result envelopes;
69
96
  - `fixtures/`: valid and misnested examples;
70
- - `test/`: implementation and CLI tests;
97
+ - `test/`: implementation, CLI, and package-surface tests;
98
+ - `skills/agent-cfml-check/SKILL.md`: repository-local Agent usage workflow;
99
+ - `.github/workflows/ci.yml`: Node 18/20/22 CI and CLI smoke checks;
71
100
  - `dist/`: generated package output;
72
101
  - `DESIGN.md`, `EPIC.md`, `ROADMAP.md`, `TASK.md`, `GOAL.md`, `PROGRESS.md`, `GOAL_PROMPT.md`, `CHANGELOG.md`: repository design, planning, status, and maintenance documents.
73
102
 
74
- `package.json` currently publishes `dist`, `README.md`, `SPEC.md`, `schema`, and `fixtures`; the design, planning, progress, goal, prompt, and changelog documents remain repository-maintainer documentation. Although `src/index.ts` contains TypeScript exports, installed-package library imports are not documented as supported until `main`/`exports` are added and tested.
103
+ `package.json` includes `dist`, `README.md`, `SPEC.md`, `schema`, and `fixtures` in the package; the design, planning, progress, goal, prompt, changelog, and Agent Skill documents remain repository-maintainer documentation. The repository exposes the TypeScript library through `main`, `types`, and `exports`. The source package version is `0.2.0`.
package/SPEC.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Agent CFML Check Specification
2
2
 
3
- **Status:** frozen feasibility contract for implementation baseline `1d5c768`
4
- **Tool version:** `0.1.0`
3
+ **Status:** frozen feasibility contract for implementation baseline `c76b8be`, extended by release hardening
4
+ **Tool version:** `0.2.0`
5
5
  **Profile:** `cfml-structure-v1`
6
6
  **Envelope schema:** `schema/agent-cfml-check-result-v1.schema.json`
7
7
 
@@ -22,11 +22,11 @@ agent-cfml-check check --root <directory> <file> [--json] [--pretty]
22
22
 
23
23
  `capabilities` reports the profile, operations, extensions, tag catalogue, effective default limits, and exclusions. The CLI does not accept a file, root, or check-limit override for this operation.
24
24
 
25
- `check` requires exactly one explicit root and one `.cfm` or `.cfc` file. `--pretty` requires `--json`. `--help` prints usage and `--version` prints `0.1.0`; neither may be combined with other options.
25
+ `check` requires exactly one explicit root and one `.cfm` or `.cfc` file. `--pretty` requires `--json`. `--help` prints usage and `--version` prints `0.2.0`; neither may be combined with other options.
26
26
 
27
27
  ### TypeScript interface
28
28
 
29
- `src/index.ts` exports `capabilities`, `checkFile`, `DEFAULT_LIMITS`, `HARD_LIMITS`, `normalizeLimits`, and schema types. The source exports exist, but package metadata currently lacks `main`/`exports`; installed-package library imports are not yet a verified contract.
29
+ `src/index.ts` exports `capabilities`, `checkFile`, `DEFAULT_LIMITS`, `HARD_LIMITS`, `normalizeLimits`, and schema types. Repository package metadata declares `main: "./dist/index.js"`, `types: "./dist/index.d.ts"`, and an import/types `exports` map. Package-surface tests and a temporary packed-install CLI/library smoke check verified the previously published `0.1.1` artifact.
30
30
 
31
31
  The TypeScript `capabilities(limitsInput)` API may receive limit overrides, while the CLI `capabilities` operation rejects them.
32
32
 
@@ -34,15 +34,17 @@ The TypeScript `capabilities(limitsInput)` API may receive limit overrides, whil
34
34
 
35
35
  ### Paired bodies
36
36
 
37
- `cfcomponent`, `cffunction`, `cfif`, `cfloop`, `cfoutput`, `cfquery`, `cfsavecontent`, `cfscript`, and `cfsilent` require matching closing tags and cannot be self-closing.
37
+ `cfcatch`, `cfcomponent`, `cffinally`, `cffunction`, `cfif`, `cfloop`, `cfoutput`, `cfquery`, `cfsavecontent`, `cfscript`, `cfsilent`, and `cftry` require matching closing tags in this profile. Self-closing spellings of paired tags return incomplete; this does not assert that every CFML engine rejects them.
38
38
 
39
39
  ### Branches
40
40
 
41
41
  `cfelse` and `cfelseif` must belong to the directly open `cfif`. Only one `cfelse` is accepted, and `cfelseif` cannot follow `cfelse`.
42
42
 
43
+ `cfcatch` and `cffinally` must belong to the directly open `cftry`. A closed `cftry` must contain at least one direct `cfcatch` or `cffinally`; multiple catches are allowed, at most one `cffinally` is allowed, and catches cannot follow it. Nested `cftry` blocks have independent handlers. The scanner does not validate catch attributes, exception types, or arbitrary text and statements between handlers. Support for these tags is a source change after the published `0.1.1` artifact.
44
+
43
45
  ### Bodyless tags
44
46
 
45
- `cfargument`, `cfinclude`, `cfqueryparam`, `cfreturn`, and `cfset` are standalone. Ordinary and self-closing spellings are accepted. Closing a bodyless tag is unsupported.
47
+ `cfargument`, `cfinclude`, `cfparam`, `cfqueryparam`, `cfreturn`, and `cfset` are standalone. Ordinary and self-closing spellings are accepted. Closing a bodyless tag is unsupported. `cfparam` support is a source change after the published `0.1.1` artifact; it does not validate attributes or runtime semantics.
46
48
 
47
49
  Tag names are case-insensitive. Unknown, custom, imported, and unlisted tags are unsupported.
48
50
 
@@ -91,9 +93,18 @@ Check data contains the profile, source path/SHA-256/byte size/UTF-8/BOM metadat
91
93
 
92
94
  Requested limits must be positive safe integers and cannot exceed hard caps. Reaching a limit returns a fail-closed result rather than partial data.
93
95
 
96
+ `max_output_bytes` has a minimum of 512 to reserve the compact failure envelope
97
+ at every hard-limit combination. CLI accounting includes the selected rendering
98
+ and final newline. An oversized result is withheld as incomplete with exit 3;
99
+ pretty mode falls back to compact failure JSON if indentation cannot fit.
100
+
94
101
  ## 7. Input and safety behavior
95
102
 
96
- The source reader requires `.cfm` or `.cfc`, an existing root and file, realpath containment, a regular file, valid UTF-8, no NUL byte, and an effective source-size limit. It checks size and modification time before and after reading and rejects a changed snapshot. Symlink escapes are rejected.
103
+ The source reader requires `.cfm` or `.cfc`, an existing root and file, realpath containment, a regular file, valid UTF-8, no NUL byte, and an effective source-size limit. Static symlink escapes are rejected before admission.
104
+
105
+ Reading is bound to a regular-file descriptor opened with no-follow and nonblocking flags where supported. Before consuming bytes, the reader compares admitted and opened device/inode identity, mode, size and nanosecond modification/change timestamps, checks the current pathname state, and rechecks the canonical path. Observed leaf or ancestor substitutions return the existing `SOURCE_CHANGED`/incomplete/exit 3 result. The same descriptor and pathname state are checked after reading, and the descriptor is closed on success or rejection.
106
+
107
+ Oversized metadata is rejected before body reads. Actual reads are bounded to the admitted size plus one byte for growth detection and cannot exceed the selected source-byte limit plus one. Over-limit growth returns `LIMIT_EXCEEDED`/incomplete/exit 3 before UTF-8 decoding. These checks reject observed mutation; they do not provide an atomic filesystem snapshot or operating-system path confinement.
97
108
 
98
109
  Known envelope error codes include `INVALID_ARGUMENT`, `INVALID_LIMIT`, `UNSUPPORTED_EXTENSION`, `ROOT_NOT_FOUND`, `FILE_NOT_FOUND`, `PATH_UNRESOLVED`, `FILE_OUTSIDE_ROOT`, `LIMIT_EXCEEDED`, `SOURCE_CHANGED`, `BINARY_INPUT`, `ENCODING_UNSUPPORTED`, `UNSUPPORTED_SYNTAX`, and `INTERNAL_ERROR`.
99
110
 
@@ -111,6 +122,6 @@ JSON mode emits one envelope on stdout; failure diagnostics are also written to
111
122
 
112
123
  ## 9. Evidence boundary
113
124
 
114
- The profile does not validate full CFML grammar, expression/type/runtime semantics, HTML, SQL, include expansion, directory/project context, Lucee/Adobe execution, unknown/custom/imported tags, or CFML tag islands inside `cfscript`. Package publication, Hub lifecycle, cross-platform compatibility, and installed-package library imports are also outside current verified evidence.
125
+ The profile does not validate full CFML grammar, expression/type/runtime semantics, HTML, SQL, include expansion, directory/project context, Lucee/Adobe execution, unknown/custom/imported tags, or CFML tag islands inside `cfscript`. The prior `0.1.1` artifact was published and registry-verified. A targeted Lucee `6.2.2.91` probe and Ubuntu WSL Node `18.19.1` verification for prior source do not establish a full engine matrix for `0.2.0`.
115
126
 
116
- The repository tests cover CF-01 through CF-13, pure-script `.cfc` handling, and three CLI behaviors. Current local evidence is 17/17 tests, typecheck pass, package dry-run pass, and Windows CLI smoke checks. JSON Schema validation, engine comparison, non-Windows verification, and publication remain pending.
127
+ The repository tests cover CF-01 through CF-13, pure-script `.cfc` handling, CLI behavior, package exports, and the parameter and exception tag additions. Full Lucee/Adobe engine comparison and a broader platform matrix remain unverified.
package/dist/cli/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { capabilities, checkFile } from "../core/checker.js";
2
+ import { capabilities, checkFile, fitOutput } from "../core/checker.js";
3
3
  import { DEFAULT_LIMITS } from "../core/limits.js";
4
4
  import { ToolFailure } from "../schema/errors.js";
5
+ import { TOOL_ID, TOOL_VERSION } from "../schema/types.js";
5
6
  import { pathToFileURL } from "node:url";
6
7
  import path from "node:path";
7
8
  const HELP = `agent-cfml-check — deterministic, read-only structural checks for a bounded CFML subset
@@ -121,26 +122,33 @@ export function main(argv = process.argv.slice(2)) {
121
122
  return 0;
122
123
  }
123
124
  if (args.version) {
124
- process.stdout.write(`${"0.1.0"}\n`);
125
+ process.stdout.write(`${TOOL_VERSION}\n`);
125
126
  return 0;
126
127
  }
127
128
  const result = args.operation === "capabilities"
128
129
  ? { envelope: capabilities(args.limits), exit_code: 0 }
129
130
  : checkFile({ root: args.root, file: args.file, limits: args.limits });
130
- if (args.json)
131
- process.stdout.write(`${JSON.stringify(result.envelope, null, args.pretty ? 2 : undefined)}\n`);
132
- else
133
- process.stdout.write(renderText(result.envelope));
134
- if (result.envelope.status !== "ok")
135
- for (const error of result.envelope.errors)
131
+ const serialize = (value) => args.json
132
+ ? `${JSON.stringify(value, null, args.pretty ? 2 : undefined)}\n`
133
+ : renderText(value);
134
+ const envelope = args.operation === "check"
135
+ ? fitOutput(result.envelope, result.envelope.meta.limits, serialize)
136
+ : result.envelope;
137
+ let output = serialize(envelope);
138
+ // A compact failure retains the complete envelope when indentation cannot fit.
139
+ if (args.json && Buffer.byteLength(output) > envelope.meta.limits.max_output_bytes)
140
+ output = `${JSON.stringify(envelope)}\n`;
141
+ process.stdout.write(output);
142
+ if (envelope.status !== "ok")
143
+ for (const error of envelope.errors)
136
144
  process.stderr.write(`error: ${error.code}: ${error.message}\n`);
137
- return result.exit_code;
145
+ return envelope.status === "incomplete" ? 3 : result.exit_code;
138
146
  }
139
147
  catch (error) {
140
148
  const failure = error instanceof ToolFailure ? error : new ToolFailure("INTERNAL_ERROR", "The CLI failed without a safe result", 1);
141
149
  const envelope = {
142
150
  schema_version: "1.0.0",
143
- tool: { id: "agent-cfml-check", version: "0.1.0" },
151
+ tool: { id: TOOL_ID, version: TOOL_VERSION },
144
152
  status: "error",
145
153
  complete: false,
146
154
  data: null,
@@ -1,4 +1,5 @@
1
1
  import type { CapabilitiesData, CheckData, CheckLimits, Envelope, CheckOptions } from "../schema/types.js";
2
+ export declare function fitOutput<T>(envelope: Envelope<T>, limits: CheckLimits, serialize?: (value: Envelope<T>) => string): Envelope<T>;
2
3
  export declare function capabilities(limitsInput?: Partial<CheckLimits>): Envelope<CapabilitiesData>;
3
4
  export declare function checkFile(options: CheckOptions): {
4
5
  envelope: Envelope<CheckData>;
@@ -11,14 +11,12 @@ function meta(limits) {
11
11
  function emptyEnvelope(status, limits, errors) {
12
12
  return { schema_version: "1.0.0", tool: { id: TOOL_ID, version: TOOL_VERSION }, status, complete: false, data: null, errors, warnings: [], meta: meta(limits) };
13
13
  }
14
- function fitOutput(envelope, limits) {
15
- const serialized = JSON.stringify(envelope);
14
+ export function fitOutput(envelope, limits, serialize = (value) => `${JSON.stringify(value)}\n`) {
15
+ const serialized = serialize(envelope);
16
16
  if (Buffer.byteLength(serialized, "utf8") <= limits.max_output_bytes)
17
17
  return envelope;
18
18
  const fallback = emptyEnvelope("incomplete", limits, [{ code: "LIMIT_EXCEEDED", message: "The output limit was reached; result data was withheld" }]);
19
- if (Buffer.byteLength(JSON.stringify(fallback), "utf8") <= limits.max_output_bytes)
20
- return fallback;
21
- return { schema_version: "1.0.0", tool: { id: TOOL_ID, version: TOOL_VERSION }, status: "incomplete", complete: false, data: null, errors: [{ code: "LIMIT_EXCEEDED", message: "Output limit exceeded" }], warnings: [], meta: meta(limits) };
19
+ return fallback;
22
20
  }
23
21
  export function capabilities(limitsInput) {
24
22
  const limits = normalizeLimits(limitsInput);
@@ -82,13 +80,12 @@ export function checkFile(options) {
82
80
  ],
83
81
  findings: scanned.findings,
84
82
  };
85
- return {
86
- envelope: fitOutput({ schema_version: "1.0.0", tool: { id: TOOL_ID, version: TOOL_VERSION }, status: "ok", complete: true, data, errors: [], warnings: [], meta: meta(limits) }, limits),
87
- exit_code: 0,
88
- };
83
+ const envelope = fitOutput({ schema_version: "1.0.0", tool: { id: TOOL_ID, version: TOOL_VERSION }, status: "ok", complete: true, data, errors: [], warnings: [], meta: meta(limits) }, limits);
84
+ return { envelope, exit_code: envelope.status === "incomplete" ? 3 : 0 };
89
85
  }
90
86
  catch (error) {
91
87
  const failure = error instanceof ToolFailure ? error : new ToolFailure("INTERNAL_ERROR", "The checker failed without a safe result", 1);
92
- return { envelope: fitOutput(emptyEnvelope(failure.exitCode === 3 ? "incomplete" : "error", limits, [{ code: failure.code, message: failure.message }]), limits), exit_code: failure.exitCode };
88
+ const envelope = fitOutput(emptyEnvelope(failure.exitCode === 3 ? "incomplete" : "error", limits, [{ code: failure.code, message: failure.message }]), limits);
89
+ return { envelope, exit_code: envelope.status === "incomplete" ? 3 : failure.exitCode };
93
90
  }
94
91
  }
@@ -10,13 +10,18 @@ export const PAIRED_TAGS = new Set([
10
10
  "cfcomponent",
11
11
  "cffunction",
12
12
  "cfscript",
13
+ "cftry",
14
+ "cfcatch",
15
+ "cffinally",
13
16
  ]);
14
17
  export const BRANCH_TAGS = new Set(["cfelseif", "cfelse"]);
15
- export const BODYLESS_TAGS = new Set(["cfset", "cfreturn", "cfinclude", "cfargument", "cfqueryparam"]);
18
+ export const BODYLESS_TAGS = new Set(["cfset", "cfreturn", "cfinclude", "cfargument", "cfqueryparam", "cfparam"]);
16
19
  function isNameCharacter(character) {
17
20
  return character !== undefined && /[A-Za-z0-9_:-]/u.test(character);
18
21
  }
19
22
  function startsCfTag(source, index) {
23
+ if (source[index] !== "<")
24
+ return false;
20
25
  const afterSlash = source[index + 1] === "/" ? index + 2 : index + 1;
21
26
  return source.slice(afterSlash, afterSlash + 2).toLowerCase() === "cf" && isNameCharacter(source[afterSlash + 2]);
22
27
  }
@@ -291,7 +296,22 @@ function processTag(context, token, stack, findings) {
291
296
  throw new ToolFailure("UNSUPPORTED_SYNTAX", `Paired tag "${token.name}" cannot be self-closing`, 3);
292
297
  if (stack.length >= context.max_nesting)
293
298
  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 });
299
+ if (token.name === "cfcatch" || token.name === "cffinally") {
300
+ const current = stack[stack.length - 1];
301
+ if (current?.name !== "cftry") {
302
+ addFinding(context, findings, finding(context, "INVALID_BRANCH", `${token.name} must belong to the directly open cftry`, token.start, token.end));
303
+ }
304
+ else {
305
+ if (current.try_finally_seen) {
306
+ const message = token.name === "cffinally" ? "cftry cannot contain more than one cffinally" : "cfcatch cannot appear after cffinally";
307
+ addFinding(context, findings, finding(context, "INVALID_BRANCH", message, token.start, token.end, current));
308
+ }
309
+ current.try_handler_seen = true;
310
+ if (token.name === "cffinally")
311
+ current.try_finally_seen = true;
312
+ }
313
+ }
314
+ stack.push({ name: token.name, start: token.start, end: token.end, branch_else_seen: false, try_handler_seen: false, try_finally_seen: false });
295
315
  return;
296
316
  }
297
317
  if (token.name === "cfscript" && stack[stack.length - 1]?.name !== "cfscript") {
@@ -310,6 +330,9 @@ function processTag(context, token, stack, findings) {
310
330
  addFinding(context, findings, finding(context, "MISMATCHED_CLOSE", `Closing tag "${token.name}" does not match open "${current.name}"`, token.start, token.end, current));
311
331
  return;
312
332
  }
333
+ if (current.name === "cftry" && !current.try_handler_seen) {
334
+ addFinding(context, findings, finding(context, "INVALID_BRANCH", "cftry must contain a directly nested cfcatch or cffinally", token.start, token.end, current));
335
+ }
313
336
  stack.pop();
314
337
  }
315
338
  export function scanCfml(source, limits) {
@@ -28,5 +28,9 @@ export function normalizeLimits(requested) {
28
28
  }
29
29
  values[key] = value;
30
30
  }
31
+ // Reserve enough space for the compact failure envelope at every hard limit.
32
+ if (values.max_output_bytes < 512) {
33
+ throw new ToolFailure("INVALID_LIMIT", "max_output_bytes must be at least 512", 2);
34
+ }
31
35
  return values;
32
36
  }
@@ -1,10 +1,14 @@
1
- import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
1
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { TextDecoder } from "node:util";
4
4
  import { ToolFailure } from "../schema/errors.js";
5
5
  function escapesRoot(root, candidate) {
6
6
  const relative = path.relative(root, candidate);
7
- return relative.startsWith("..") || path.isAbsolute(relative);
7
+ return relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
8
+ }
9
+ function sameFileState(left, right) {
10
+ return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode &&
11
+ left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
8
12
  }
9
13
  export function loadSource(options) {
10
14
  if (!/\.(?:cfm|cfc)$/iu.test(options.file)) {
@@ -32,19 +36,55 @@ export function loadSource(options) {
32
36
  if (escapesRoot(realRoot, resolvedPath)) {
33
37
  throw new ToolFailure("FILE_OUTSIDE_ROOT", "The selected file resolves outside the explicit root", 4);
34
38
  }
35
- const before = statSync(resolvedPath);
39
+ const before = statSync(resolvedPath, { bigint: true });
36
40
  if (!before.isFile())
37
41
  throw new ToolFailure("FILE_NOT_FOUND", "The selected path is not a regular file", 2);
38
- if (before.size > options.limits.max_source_bytes) {
42
+ if (before.size > BigInt(options.limits.max_source_bytes)) {
39
43
  throw new ToolFailure("LIMIT_EXCEEDED", `Source exceeds the ${options.limits.max_source_bytes}-byte limit`, 3);
40
44
  }
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
+ let descriptor;
46
+ let bytes;
47
+ const changed = () => new ToolFailure("SOURCE_CHANGED", "The admitted source changed before or during reading", 3);
48
+ const exceeded = () => new ToolFailure("LIMIT_EXCEEDED", "Source exceeds the configured byte limit", 3);
49
+ try {
50
+ // Admission must describe the descriptor we read, including ancestor replacements.
51
+ descriptor = openSync(resolvedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
52
+ const opened = fstatSync(descriptor, { bigint: true });
53
+ if (!opened.isFile())
54
+ throw changed();
55
+ if (opened.size > BigInt(options.limits.max_source_bytes))
56
+ throw exceeded();
57
+ const current = lstatSync(resolvedPath, { bigint: true });
58
+ if (!current.isFile() || !sameFileState(before, opened) || !sameFileState(opened, current) ||
59
+ realpathSync(resolvedPath) !== resolvedPath)
60
+ throw changed();
61
+ const buffer = Buffer.alloc(Number(opened.size) + 1);
62
+ let length = 0;
63
+ while (length < buffer.length) {
64
+ const count = readSync(descriptor, buffer, length, buffer.length - length, length);
65
+ if (count === 0)
66
+ break;
67
+ length += count;
68
+ if (length > options.limits.max_source_bytes)
69
+ throw exceeded();
70
+ }
71
+ const after = fstatSync(descriptor, { bigint: true });
72
+ if (after.size > BigInt(options.limits.max_source_bytes))
73
+ throw exceeded();
74
+ if (!sameFileState(opened, after) || after.size !== BigInt(length) ||
75
+ !sameFileState(after, lstatSync(resolvedPath, { bigint: true })) ||
76
+ realpathSync(resolvedPath) !== resolvedPath)
77
+ throw changed();
78
+ bytes = buffer.subarray(0, length);
79
+ }
80
+ catch (error) {
81
+ if (error instanceof ToolFailure)
82
+ throw error;
83
+ throw changed();
45
84
  }
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);
85
+ finally {
86
+ if (descriptor !== undefined)
87
+ closeSync(descriptor);
48
88
  }
49
89
  if (bytes.includes(0))
50
90
  throw new ToolFailure("BINARY_INPUT", "Binary or NUL-containing input is not supported", 2);
@@ -1,5 +1,5 @@
1
1
  export declare const TOOL_ID: "agent-cfml-check";
2
- export declare const TOOL_VERSION: "0.1.0";
2
+ export declare const TOOL_VERSION: "0.2.0";
3
3
  export declare const PROFILE_ID: "cfml-structure-v1";
4
4
  export interface Position {
5
5
  line: number;
@@ -1,3 +1,3 @@
1
1
  export const TOOL_ID = "agent-cfml-check";
2
- export const TOOL_VERSION = "0.1.0";
2
+ export const TOOL_VERSION = "0.2.0";
3
3
  export const PROFILE_ID = "cfml-structure-v1";
package/package.json CHANGED
@@ -1,9 +1,26 @@
1
1
  {
2
2
  "name": "agent-cfml-check",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Deterministic, read-only structural checks for a bounded CFML subset.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/yapweijun1996/AI-Agent-Tools.git",
8
+ "directory": "packages/cfml-check"
9
+ },
10
+ "homepage": "https://github.com/yapweijun1996/AI-Agent-Tools/tree/main/packages/cfml-check#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/yapweijun1996/AI-Agent-Tools/issues"
13
+ },
5
14
  "type": "module",
6
15
  "license": "MIT",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
7
24
  "engines": {
8
25
  "node": ">=18.18.0"
9
26
  },
@@ -20,11 +37,17 @@
20
37
  "scripts": {
21
38
  "typecheck": "tsc -p tsconfig.json --noEmit",
22
39
  "build": "tsc -p tsconfig.build.json",
23
- "test": "npm run build && tsc -p tsconfig.test.json && node --test .test-dist/test/check.test.js .test-dist/test/cli.test.js",
40
+ "test": "npm run build && npm run test:prepared",
41
+ "test:prepared": "tsc -p tsconfig.test.json && node --test .test-dist/test/check.test.js .test-dist/test/cli.test.js .test-dist/test/package.test.js .test-dist/test/schema.test.js",
24
42
  "prepack": "npm run build"
25
43
  },
44
+ "publishConfig": {
45
+ "registry": "https://registry.npmjs.org/",
46
+ "access": "public"
47
+ },
26
48
  "devDependencies": {
27
49
  "@types/node": "^22.10.0",
50
+ "ajv": "^8.20.0",
28
51
  "typescript": "^5.7.0"
29
52
  }
30
53
  }
@@ -118,7 +118,7 @@
118
118
  "max_source_bytes": { "type": "integer", "minimum": 1 },
119
119
  "max_nesting": { "type": "integer", "minimum": 1 },
120
120
  "max_findings": { "type": "integer", "minimum": 1 },
121
- "max_output_bytes": { "type": "integer", "minimum": 1 },
121
+ "max_output_bytes": { "type": "integer", "minimum": 512 },
122
122
  "time_limit_ms": { "type": "integer", "minimum": 1 }
123
123
  },
124
124
  "additionalProperties": false