agent-cfml-check 0.1.1 → 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.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Current status
6
6
 
7
- Version `0.1.1` contains the first feasibility slice plus executable schema validation, package exports, and Agent onboarding. The prior `agent-cfml-check@0.1.0` publication predates these additions. The current implementation is verified for:
7
+ Version `0.3.0` extends the structural profile with transaction and switch tags and adds inspection workflows. The implementation checks:
8
8
 
9
9
  - paired CFML tag nesting;
10
10
  - `cfelse` and `cfelseif` ownership and ordering;
@@ -17,7 +17,14 @@ Version `0.1.1` contains the first feasibility slice plus executable schema vali
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
- Local verification before this documentation update:
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
+
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:
21
28
 
22
29
  - `npm test`: 22/22 passed;
23
30
  - `npm run typecheck`: passed;
@@ -27,10 +34,22 @@ Local verification before this documentation update:
27
34
  - targeted Lucee `6.2.2.91` execution probe: passed;
28
35
  - Ubuntu WSL Node `18.19.1` `npm ci`, tests, and typecheck: passed.
29
36
 
30
- The prior npm publication of `agent-cfml-check@0.1.0` is immutable and predates this release's schema, package-export, and documentation updates. This release targets `agent-cfml-check@0.1.1`; authenticated registry publication/readback is the final release step. 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` clean dependency install/test run pass; these do not establish a full engine matrix. Hub lifecycle, canonical ownership, admission, and Adobe compatibility remain outside the current verified 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`.
31
38
 
32
39
  ## Install and use
33
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
+
34
53
  ```sh
35
54
  npm install
36
55
  npm test
@@ -50,13 +69,27 @@ agent-cfml-check capabilities [--json] [--pretty]
50
69
  agent-cfml-check check --root <directory> <file> [--json] [--pretty]
51
70
  ```
52
71
 
72
+ Version `0.3.0` provides `inspect` for one or more explicit files, scoped line findings, and an explicit before/after comparison:
73
+
74
+ ```text
75
+ agent-cfml-check inspect --root <directory> <file> [file ...] [--lines <start:end>] --json
76
+ agent-cfml-check inspect --root <directory> --baseline <before-file> <after-file> --json
77
+ ```
78
+
79
+ `inspect` uses its own v2 result schema. It can continue after bounded unsupported tags, but the result remains incomplete and findings are provisional. A line range filters reported findings while the whole file is scanned for context; unsupported syntax elsewhere still makes the result incomplete. Comparison classifications use conservative diagnostic signatures and may be `uncertain`; they do not prove a semantic regression or code identity. This functionality is not in the published `0.2.0` artifact.
80
+
53
81
  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`.
54
82
 
55
- 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.
83
+ `max_output_bytes` must be at least 512, reserving a complete failure envelope.
84
+ CLI ceilings count actual UTF-8 stdout, including indentation and the final
85
+ newline. If pretty output cannot fit, checking returns incomplete/exit 3 and may
86
+ emit its failure envelope as compact JSON. Default fitting output is unchanged.
87
+
88
+ The `check` command 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.
56
89
 
57
90
  ## Safety and boundaries
58
91
 
59
- The checker never executes CFML or JavaScript from the inspected source, follows includes, reads directories as input, uses the network, installs packages, or modifies the inspected source tree. It reads exactly one regular UTF-8 source file under the explicit root, rejects symlink escapes, and fails closed when the bounded profile cannot establish a result.
92
+ The checker never executes CFML or JavaScript from the inspected source, follows includes, reads directories as input, uses the network, installs packages, or modifies the inspected source tree. `check` reads exactly one regular UTF-8 source file under the explicit root; `inspect` reads only the explicitly selected files under that root. Both reject symlink escapes and report incomplete when the bounded profile cannot establish a result.
60
93
 
61
94
  The supported profile is `cfml-structure-v1`. Unknown/custom/imported tags, optional-body semantics outside the catalog, full expression and runtime semantics, SQL/HTML validity, CFML tag islands inside `cfscript`, and engine behavior are outside the profile or explicitly unverified.
62
95
 
@@ -76,4 +109,4 @@ The supported profile is `cfml-structure-v1`. Unknown/custom/imported tags, opti
76
109
  - `dist/`: generated package output;
77
110
  - `DESIGN.md`, `EPIC.md`, `ROADMAP.md`, `TASK.md`, `GOAL.md`, `PROGRESS.md`, `GOAL_PROMPT.md`, `CHANGELOG.md`: repository design, planning, status, and maintenance documents.
78
111
 
79
- `package.json` currently publishes `dist`, `README.md`, `SPEC.md`, `schema`, and `fixtures`; 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`, with package-surface and packed-install tests. The release package version is `0.1.1`.
112
+ `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.3.0`.
package/SPEC.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # Agent CFML Check Specification
2
2
 
3
3
  **Status:** frozen feasibility contract for implementation baseline `c76b8be`, extended by release hardening
4
- **Tool version:** `0.1.1`
4
+ **Tool version:** `0.3.0`
5
5
  **Profile:** `cfml-structure-v1`
6
6
  **Envelope schema:** `schema/agent-cfml-check-result-v1.schema.json`
7
7
 
8
8
  ## 1. Scope
9
9
 
10
- The tool performs deterministic, read-only structural checks on exactly one explicitly selected local UTF-8 `.cfm` or `.cfc` file under an explicitly supplied root. It does not execute CFML, resolve includes, inspect a directory as source input, use the network, or modify the inspected source tree.
10
+ The v1 `check` operation performs deterministic, read-only structural checks on exactly one explicitly selected local UTF-8 `.cfm` or `.cfc` file under an explicitly supplied root. The `inspect` operation accepts multiple explicitly selected files under one root. Neither operation executes CFML, resolves includes, inspects a directory as source input, uses the network, or modifies the inspected source tree.
11
11
 
12
12
  A completed result proves only the checks listed in that result. It does not prove Lucee or Adobe ColdFusion execution compatibility.
13
13
 
@@ -22,27 +22,37 @@ 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.1`; 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.3.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. 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 verify the current `0.1.1` artifact.
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
 
33
33
  ## 3. Supported structures
34
34
 
35
+ ### Inspection extension
36
+
37
+ Version `0.3.0` additionally recognizes `cftransaction`, `cfswitch`, `cfcase`, and `cfdefaultcase`. A switch case must belong to the directly open switch, which can contain at most one default case. For transactions, absent or literal `begin` action is treated as a paired body; literal `commit`, `rollback`, and `setsavepoint` are treated as bodyless. Dynamic, duplicate, attribute-collection, or otherwise unrecognized transaction attributes return incomplete. This checks tag structure only, not database transaction behavior. These additions are not part of the published `0.2.0` artifact.
38
+
39
+ The opt-in `inspect` operation uses `schema/agent-cfml-inspect-result-v2.schema.json` and does not alter the v1 `check` envelope. It accepts one to 32 explicitly named source files under one root, an optional `--lines start:end`, or a `--baseline` file plus one current file. Relative file paths are resolved against the selected root; all paths use the existing source reader. Scope is recorded per file; the scanner reads the entire file for surrounding context, then filters findings to the requested range. Unsupported syntax anywhere in the scanned file makes that file incomplete, and its findings are provisional. Recoverable unsupported tags and CFScript tag islands are located and scanning continues; unterminated lexical constructs can stop scanning, reflected by `scan_complete: false`. No incomplete file receives a `pass` verdict.
40
+
41
+ Inspection reports file, position, related opener and a non-automatic hint. It uses the existing per-file byte and nesting limits, a shared time limit, a 16 MiB aggregate source limit, a 32-file cap, and the output limit. When output cannot fit, results are withheld rather than truncated into apparent success. For a baseline comparison, the complete baseline is compared with findings in the current file's requested range; line numbers may have shifted. Unique matches of finding code and normalized source/opening lines are `existing`; findings with a code absent from the complete baseline are `new`; ambiguous or incomplete cases are `uncertain`. The response includes `comparison_basis` to state that these are diagnostic-signature classifications, not proof of semantic identity, causation, or runtime behavior. Baseline and current files must both be explicitly selected under the root; each is read as an individually verified snapshot rather than a simultaneous filesystem snapshot. `0` means inspection completed, including findings; `3` means incomplete, and input/security errors retain their applicable exit code.
42
+
35
43
  ### Paired bodies
36
44
 
37
- `cfcomponent`, `cffunction`, `cfif`, `cfloop`, `cfoutput`, `cfquery`, `cfsavecontent`, `cfscript`, and `cfsilent` require matching closing tags and cannot be self-closing.
45
+ `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
46
 
39
47
  ### Branches
40
48
 
41
49
  `cfelse` and `cfelseif` must belong to the directly open `cfif`. Only one `cfelse` is accepted, and `cfelseif` cannot follow `cfelse`.
42
50
 
51
+ `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.
52
+
43
53
  ### Bodyless tags
44
54
 
45
- `cfargument`, `cfinclude`, `cfqueryparam`, `cfreturn`, and `cfset` are standalone. Ordinary and self-closing spellings are accepted. Closing a bodyless tag is unsupported.
55
+ `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
56
 
47
57
  Tag names are case-insensitive. Unknown, custom, imported, and unlisted tags are unsupported.
48
58
 
@@ -91,9 +101,18 @@ Check data contains the profile, source path/SHA-256/byte size/UTF-8/BOM metadat
91
101
 
92
102
  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
103
 
104
+ `max_output_bytes` has a minimum of 512 to reserve the compact failure envelope
105
+ at every hard-limit combination. CLI accounting includes the selected rendering
106
+ and final newline. An oversized result is withheld as incomplete with exit 3;
107
+ pretty mode falls back to compact failure JSON if indentation cannot fit.
108
+
94
109
  ## 7. Input and safety behavior
95
110
 
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.
111
+ 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.
112
+
113
+ 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.
114
+
115
+ 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
116
 
98
117
  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
118
 
@@ -111,6 +130,6 @@ JSON mode emits one envelope on stdout; failure diagnostics are also written to
111
130
 
112
131
  ## 9. Evidence boundary
113
132
 
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`. The `agent-cfml-check@0.1.1` CLI package is prepared for publication; authenticated npm registry readback remains required. Repository package export metadata, executable schema validation, and temporary packed-install verification are complete. A targeted Lucee `6.2.2.91` probe and Ubuntu WSL Node `18.19.1` verification pass, but they do not establish a full engine matrix. Adobe ColdFusion, Hub lifecycle, canonical ownership/admission, and registry parity remain outside current verified evidence.
133
+ 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
134
 
116
- The repository tests cover CF-01 through CF-13, pure-script `.cfc` handling, three CLI behaviors, and the package export surface. Current local evidence is 22/22 tests, typecheck pass, package dry-run pass, Windows CLI smoke checks, temporary packed-install CLI/library smoke checks, and JSON Schema validation. The prior immutable `0.1.0` artifact predates this release hardening; `0.1.1` registry readback remains required. Full Lucee/Adobe engine comparison, canonical Hub admission, and registry parity remain pending.
135
+ 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,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { capabilities, checkFile } from "../core/checker.js";
2
+ import { capabilities, checkFile, fitOutput } from "../core/checker.js";
3
+ import { inspectMain } from "./inspect.js";
3
4
  import { DEFAULT_LIMITS } from "../core/limits.js";
4
5
  import { ToolFailure } from "../schema/errors.js";
5
6
  import { TOOL_ID, TOOL_VERSION } from "../schema/types.js";
@@ -10,9 +11,11 @@ const HELP = `agent-cfml-check — deterministic, read-only structural checks fo
10
11
  Usage:
11
12
  agent-cfml-check capabilities [--json] [--pretty]
12
13
  agent-cfml-check check --root <directory> <file> [--json] [--pretty]
14
+ agent-cfml-check inspect --root <directory> <file> [file ...] [--lines <start:end>] [--json] [--pretty]
15
+ agent-cfml-check inspect --root <directory> --baseline <file> <current-file> [--json] [--pretty]
13
16
 
14
- The checker reads exactly one .cfm or .cfc file under the explicit root. It never
15
- executes CFML, follows includes, uses the network, or modifies source files.
17
+ Check reads one file; inspect reads only explicitly selected files under the root.
18
+ The tool never executes CFML, follows includes, uses the network, or modifies sources.
16
19
  `;
17
20
  function parseInteger(value, name) {
18
21
  if (!/^\d+$/u.test(value))
@@ -115,6 +118,8 @@ function renderText(envelope) {
115
118
  return `${lines.join("\n")}\n`;
116
119
  }
117
120
  export function main(argv = process.argv.slice(2)) {
121
+ if (argv[0] === "inspect")
122
+ return inspectMain(argv.slice(1));
118
123
  try {
119
124
  const args = parseArgs(argv);
120
125
  if (args.help) {
@@ -128,14 +133,21 @@ export function main(argv = process.argv.slice(2)) {
128
133
  const result = args.operation === "capabilities"
129
134
  ? { envelope: capabilities(args.limits), exit_code: 0 }
130
135
  : checkFile({ root: args.root, file: args.file, limits: args.limits });
131
- if (args.json)
132
- process.stdout.write(`${JSON.stringify(result.envelope, null, args.pretty ? 2 : undefined)}\n`);
133
- else
134
- process.stdout.write(renderText(result.envelope));
135
- if (result.envelope.status !== "ok")
136
- for (const error of result.envelope.errors)
136
+ const serialize = (value) => args.json
137
+ ? `${JSON.stringify(value, null, args.pretty ? 2 : undefined)}\n`
138
+ : renderText(value);
139
+ const envelope = args.operation === "check"
140
+ ? fitOutput(result.envelope, result.envelope.meta.limits, serialize)
141
+ : result.envelope;
142
+ let output = serialize(envelope);
143
+ // A compact failure retains the complete envelope when indentation cannot fit.
144
+ if (args.json && Buffer.byteLength(output) > envelope.meta.limits.max_output_bytes)
145
+ output = `${JSON.stringify(envelope)}\n`;
146
+ process.stdout.write(output);
147
+ if (envelope.status !== "ok")
148
+ for (const error of envelope.errors)
137
149
  process.stderr.write(`error: ${error.code}: ${error.message}\n`);
138
- return result.exit_code;
150
+ return envelope.status === "incomplete" ? 3 : result.exit_code;
139
151
  }
140
152
  catch (error) {
141
153
  const failure = error instanceof ToolFailure ? error : new ToolFailure("INTERNAL_ERROR", "The CLI failed without a safe result", 1);
@@ -0,0 +1 @@
1
+ export declare function inspectMain(argv: readonly string[]): number;
@@ -0,0 +1,108 @@
1
+ import { inspectFiles } from "../core/inspect.js";
2
+ import { DEFAULT_LIMITS } from "../core/limits.js";
3
+ import { ToolFailure } from "../schema/errors.js";
4
+ import { TOOL_ID, TOOL_VERSION } from "../schema/types.js";
5
+ const HELP = `agent-cfml-check inspect --root <directory> <file> [file ...] [--lines <start:end>] [--json] [--pretty]\nagent-cfml-check inspect --root <directory> --baseline <file> <current-file> [--lines <start:end>] [--json] [--pretty]\n`;
6
+ function safeText(value) {
7
+ return value.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, (character) => `\\u${character.codePointAt(0).toString(16).padStart(4, "0")}`);
8
+ }
9
+ function errorResult(failure) {
10
+ return { schema_version: "2.0.0", tool: { id: TOOL_ID, version: TOOL_VERSION }, status: failure.exitCode === 3 ? "incomplete" : "error", complete: false, mode: "inspect", files: [], comparison: null, comparison_basis: null, errors: [{ code: failure.code, message: failure.message }], limits: DEFAULT_LIMITS };
11
+ }
12
+ function textResult(result) {
13
+ const lines = [`${result.status}: ${result.mode}`];
14
+ for (const file of result.files) {
15
+ lines.push(`${safeText(file.path)}: ${file.status}${file.range === null ? "" : ` (lines ${file.range.start}-${file.range.end})`}`);
16
+ for (const item of [...file.unsupported, ...file.findings]) {
17
+ const location = item.location === null ? "" : `:${item.location.start.line}:${item.location.start.column}`;
18
+ lines.push(` ${safeText(item.file)}${location}: ${item.code}: ${safeText(item.message)} [${item.confidence}]`);
19
+ if (item.related_open !== null)
20
+ lines.push(` opening: ${item.related_open.start.line}:${item.related_open.start.column}`);
21
+ lines.push(` hint: ${item.hint}`);
22
+ }
23
+ for (const error of file.errors)
24
+ lines.push(` ${error.code}: ${safeText(error.message)}`);
25
+ }
26
+ if (result.comparison !== null) {
27
+ if (result.comparison_basis !== null)
28
+ lines.push(result.comparison_basis);
29
+ for (const item of result.comparison)
30
+ lines.push(` ${item.classification}: ${item.finding.code} at ${item.finding.location?.start.line ?? "unknown"}`);
31
+ }
32
+ for (const error of result.errors)
33
+ lines.push(`${error.code}: ${safeText(error.message)}`);
34
+ return `${lines.join("\n")}\n`;
35
+ }
36
+ export function inspectMain(argv) {
37
+ let json = argv.includes("--json");
38
+ let pretty = false;
39
+ try {
40
+ if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) {
41
+ process.stdout.write(HELP);
42
+ return 0;
43
+ }
44
+ const options = { root: "", files: [], limits: {} };
45
+ const seen = new Set();
46
+ const limitFlags = { "--max-source-bytes": "max_source_bytes", "--max-nesting": "max_nesting", "--max-findings": "max_findings", "--max-output-bytes": "max_output_bytes", "--time-limit-ms": "time_limit_ms" };
47
+ for (let index = 0; index < argv.length; index += 1) {
48
+ const arg = argv[index];
49
+ if (arg === "--json" || arg === "--pretty") {
50
+ if (seen.has(arg))
51
+ throw new ToolFailure("INVALID_ARGUMENT", `Duplicate option ${arg}`, 2);
52
+ seen.add(arg);
53
+ if (arg === "--json")
54
+ json = true;
55
+ else
56
+ pretty = true;
57
+ continue;
58
+ }
59
+ if (arg === "--root" || arg === "--baseline" || arg === "--lines" || limitFlags[arg] !== undefined) {
60
+ if (seen.has(arg))
61
+ throw new ToolFailure("INVALID_ARGUMENT", `Duplicate option ${arg}`, 2);
62
+ seen.add(arg);
63
+ const value = argv[++index];
64
+ if (value === undefined || value.startsWith("--"))
65
+ throw new ToolFailure("INVALID_ARGUMENT", `${arg} requires a value`, 2);
66
+ if (arg === "--root")
67
+ options.root = value;
68
+ else if (arg === "--baseline")
69
+ options.baseline = value;
70
+ else if (arg === "--lines") {
71
+ const match = /^(\d+):(\d+)$/u.exec(value);
72
+ if (match === null)
73
+ throw new ToolFailure("INVALID_ARGUMENT", "--lines requires start:end", 2);
74
+ options.range = { start: Number(match[1]), end: Number(match[2]) };
75
+ }
76
+ else {
77
+ if (!/^\d+$/u.test(value) || !Number.isSafeInteger(Number(value)))
78
+ throw new ToolFailure("INVALID_ARGUMENT", `${arg} requires an integer`, 2);
79
+ options.limits[limitFlags[arg]] = Number(value);
80
+ }
81
+ continue;
82
+ }
83
+ if (arg.startsWith("-"))
84
+ throw new ToolFailure("INVALID_ARGUMENT", `Unknown option ${arg}`, 2);
85
+ options.files.push(arg);
86
+ }
87
+ if (pretty && !json)
88
+ throw new ToolFailure("INVALID_ARGUMENT", "--pretty requires --json", 2);
89
+ const checked = inspectFiles(options);
90
+ let result = checked.result;
91
+ let output = json ? `${JSON.stringify(result, null, pretty ? 2 : undefined)}\n` : textResult(result);
92
+ let exitCode = checked.exit_code;
93
+ if (Buffer.byteLength(output) > result.limits.max_output_bytes) {
94
+ result = { ...result, status: "incomplete", complete: false, files: [], comparison: null, errors: [{ code: "LIMIT_EXCEEDED", message: "The output limit was reached; partial results were withheld" }] };
95
+ output = json ? `${JSON.stringify(result)}\n` : textResult(result);
96
+ exitCode = 3;
97
+ }
98
+ process.stdout.write(output);
99
+ return exitCode;
100
+ }
101
+ catch (error) {
102
+ const failure = error instanceof ToolFailure ? error : new ToolFailure("INTERNAL_ERROR", "The inspection failed without a safe result", 1);
103
+ const result = errorResult(failure);
104
+ process.stdout.write(json ? `${JSON.stringify(result)}\n` : textResult(result));
105
+ process.stderr.write(`error: ${failure.code}: ${safeText(failure.message)}\n`);
106
+ return failure.exitCode;
107
+ }
108
+ }
@@ -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);
@@ -29,7 +27,7 @@ export function capabilities(limitsInput) {
29
27
  complete: true,
30
28
  data: {
31
29
  profile: PROFILE_ID,
32
- operations: ["capabilities", "check"],
30
+ operations: ["capabilities", "check", "inspect"],
33
31
  extensions: [".cfm", ".cfc"],
34
32
  supported_tags: {
35
33
  paired_bodies: [...PAIRED_TAGS].sort(),
@@ -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
  }
@@ -0,0 +1,62 @@
1
+ import { TOOL_ID, TOOL_VERSION } from "../schema/types.js";
2
+ import type { CheckLimits, SourceInfo, SourceLocation } from "../schema/types.js";
3
+ export interface LineRange {
4
+ start: number;
5
+ end: number;
6
+ }
7
+ export interface InspectOptions {
8
+ root: string;
9
+ files: string[];
10
+ range?: LineRange;
11
+ baseline?: string;
12
+ limits?: Partial<CheckLimits>;
13
+ }
14
+ export interface InspectDiagnostic {
15
+ code: string;
16
+ message: string;
17
+ file: string;
18
+ location: SourceLocation | null;
19
+ related_open: SourceLocation | null;
20
+ hint: string;
21
+ confidence: "confirmed" | "provisional";
22
+ }
23
+ export interface InspectedFile {
24
+ path: string;
25
+ status: "ok" | "incomplete" | "error";
26
+ complete: boolean;
27
+ scan_complete: boolean;
28
+ verdict: "pass" | "violations" | null;
29
+ source: SourceInfo | null;
30
+ range: LineRange | null;
31
+ findings: InspectDiagnostic[];
32
+ unsupported: InspectDiagnostic[];
33
+ errors: Array<{
34
+ code: string;
35
+ message: string;
36
+ }>;
37
+ }
38
+ export interface InspectResult {
39
+ schema_version: "2.0.0";
40
+ tool: {
41
+ id: typeof TOOL_ID;
42
+ version: typeof TOOL_VERSION;
43
+ };
44
+ status: "ok" | "incomplete" | "error";
45
+ complete: boolean;
46
+ mode: "inspect" | "compare";
47
+ files: InspectedFile[];
48
+ comparison: Array<{
49
+ finding: InspectDiagnostic;
50
+ classification: "existing" | "new" | "uncertain";
51
+ }> | null;
52
+ comparison_basis: string | null;
53
+ errors: Array<{
54
+ code: string;
55
+ message: string;
56
+ }>;
57
+ limits: CheckLimits;
58
+ }
59
+ export declare function inspectFiles(options: InspectOptions): {
60
+ result: InspectResult;
61
+ exit_code: number;
62
+ };
@@ -0,0 +1,167 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import { checkPureScriptCfc } from "./lexer.js";
4
+ import { normalizeLimits } from "./limits.js";
5
+ import { loadSource } from "./source-reader.js";
6
+ import { ToolFailure } from "../schema/errors.js";
7
+ import { TOOL_ID, TOOL_VERSION } from "../schema/types.js";
8
+ const MAX_FILES = 32;
9
+ const MAX_TOTAL_SOURCE_BYTES = 16 * 1024 * 1024;
10
+ const HINTS = {
11
+ UNEXPECTED_CLOSE: "Check whether this closing tag has an opening tag.",
12
+ MISMATCHED_CLOSE: "Check nesting and close the most recently opened tag first.",
13
+ UNCLOSED_TAG: "Check whether the matching closing tag is missing.",
14
+ INVALID_BRANCH: "Check the branch owner and ordering.",
15
+ UNTERMINATED_STRING: "Check the string's closing quote.",
16
+ UNTERMINATED_COMMENT: "Check the comment's closing delimiter.",
17
+ UNBALANCED_DELIMITER: "Check the matching script delimiter.",
18
+ };
19
+ function inRange(location, range) {
20
+ return range === undefined || location === null || (location.start.line <= range.end && location.end.line >= range.start);
21
+ }
22
+ function inspectOne(root, file, limits, deadline, range) {
23
+ try {
24
+ const selected = path.isAbsolute(file) ? file : path.resolve(root, file);
25
+ const loaded = loadSource({ root, file: selected, limits });
26
+ if (range !== undefined) {
27
+ const lines = loaded.source.split(/\r\n|\r|\n/u).length;
28
+ if (!Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.end) || range.start < 1 || range.end < range.start || range.end > lines) {
29
+ throw new ToolFailure("INVALID_ARGUMENT", "The requested line range is invalid for the selected source", 2);
30
+ }
31
+ }
32
+ const remaining = deadline - Date.now();
33
+ if (remaining <= 0)
34
+ throw new ToolFailure("LIMIT_EXCEEDED", "The inspection time limit was reached", 3);
35
+ const scan = checkPureScriptCfc(loaded.source, path.extname(loaded.relative_path), { ...limits, time_limit_ms: remaining }, true);
36
+ if (Date.now() > deadline)
37
+ throw new ToolFailure("LIMIT_EXCEEDED", "The inspection time limit was reached", 3);
38
+ const complete = scan.incomplete.length === 0 && scan.scan_complete;
39
+ const confidence = complete ? "confirmed" : "provisional";
40
+ const findings = scan.findings.filter((item) => inRange(item.location, range)).map((item) => ({
41
+ ...item, file: loaded.relative_path, hint: HINTS[item.code], confidence,
42
+ }));
43
+ const unsupported = scan.incomplete.map((item) => ({
44
+ code: item.code, message: item.message, file: loaded.relative_path,
45
+ location: item.location ?? null, related_open: null,
46
+ hint: "Review the unsupported or incomplete syntax before relying on structural findings.", confidence: "provisional",
47
+ }));
48
+ return {
49
+ result: {
50
+ path: loaded.relative_path, status: complete ? "ok" : "incomplete", complete,
51
+ scan_complete: scan.scan_complete, verdict: complete ? (findings.length === 0 ? "pass" : "violations") : null,
52
+ source: { path: loaded.relative_path, sha256: createHash("sha256").update(loaded.bytes).digest("hex"), byte_size: loaded.bytes.byteLength, encoding: "utf-8", bom: loaded.bom },
53
+ range: range ?? null, findings, unsupported, errors: [],
54
+ },
55
+ sourceText: loaded.source, exitCode: complete ? 0 : 3, bytes: loaded.bytes.byteLength,
56
+ };
57
+ }
58
+ catch (error) {
59
+ const failure = error instanceof ToolFailure ? error : new ToolFailure("INTERNAL_ERROR", "The checker failed without a safe result", 1);
60
+ return {
61
+ result: { path: file, status: failure.exitCode === 3 ? "incomplete" : "error", complete: false, scan_complete: false, verdict: null, source: null, range: range ?? null, findings: [], unsupported: [], errors: [{ code: failure.code, message: failure.message }] },
62
+ sourceText: "", exitCode: failure.exitCode, bytes: 0,
63
+ };
64
+ }
65
+ }
66
+ function signature(finding, lines) {
67
+ const line = finding.location === null ? "" : (lines[finding.location.start.line - 1] ?? "").trim();
68
+ const opening = finding.related_open === null ? "" : (lines[finding.related_open.start.line - 1] ?? "").trim();
69
+ return createHash("sha256").update(JSON.stringify([finding.code, line, opening])).digest("hex");
70
+ }
71
+ function compare(before, after) {
72
+ const prior = before.result.findings;
73
+ const current = after.result.findings;
74
+ if (!before.result.complete || !after.result.complete)
75
+ return current.map((finding) => ({ finding, classification: "uncertain" }));
76
+ const beforeLines = before.sourceText.split(/\r\n|\r|\n/u);
77
+ const afterLines = after.sourceText.split(/\r\n|\r|\n/u);
78
+ const beforeCounts = new Map();
79
+ const afterCounts = new Map();
80
+ const priorCodes = new Set(prior.map((item) => item.code));
81
+ for (const item of prior) {
82
+ const key = signature(item, beforeLines);
83
+ beforeCounts.set(key, (beforeCounts.get(key) ?? 0) + 1);
84
+ }
85
+ for (const item of current) {
86
+ const key = signature(item, afterLines);
87
+ afterCounts.set(key, (afterCounts.get(key) ?? 0) + 1);
88
+ }
89
+ return current.map((finding) => {
90
+ const key = signature(finding, afterLines);
91
+ const earlier = beforeCounts.get(key) ?? 0;
92
+ const later = afterCounts.get(key) ?? 0;
93
+ if (earlier === 1 && later === 1)
94
+ return { finding, classification: "existing" };
95
+ if (earlier === 0 && !priorCodes.has(finding.code))
96
+ return { finding, classification: "new" };
97
+ return { finding, classification: "uncertain" };
98
+ });
99
+ }
100
+ export function inspectFiles(options) {
101
+ let limits;
102
+ try {
103
+ limits = normalizeLimits(options?.limits);
104
+ }
105
+ catch (error) {
106
+ const failure = error instanceof ToolFailure ? error : new ToolFailure("INVALID_LIMIT", "Invalid limits", 2);
107
+ return { result: { schema_version: "2.0.0", tool: { id: TOOL_ID, version: TOOL_VERSION }, status: "error", complete: false, mode: "inspect", files: [], comparison: null, comparison_basis: null, errors: [{ code: failure.code, message: failure.message }], limits: normalizeLimits(undefined) }, exit_code: failure.exitCode };
108
+ }
109
+ const mode = options?.baseline === undefined ? "inspect" : "compare";
110
+ const result = { schema_version: "2.0.0", tool: { id: TOOL_ID, version: TOOL_VERSION }, status: "ok", complete: true, mode, files: [], comparison: null, comparison_basis: mode === "compare" ? "Diagnostic signatures are heuristic matches, not proof of semantic identity or causation." : null, errors: [], limits };
111
+ const range = options?.range;
112
+ const invalidRange = range !== undefined && (range === null || typeof range !== "object" || !Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.end) || range.start < 1 || range.end < range.start);
113
+ if (!options || typeof options.root !== "string" || !options.root || !Array.isArray(options.files) || options.files.length < 1 || options.files.length > MAX_FILES || options.files.some((file) => typeof file !== "string" || !file) || (mode === "compare" && (options.files.length !== 1 || typeof options.baseline !== "string" || !options.baseline))) {
114
+ result.status = "error";
115
+ result.complete = false;
116
+ result.errors.push({ code: "INVALID_ARGUMENT", message: "Select one to 32 explicit files; comparison requires exactly one current file and one baseline" });
117
+ return { result, exit_code: 2 };
118
+ }
119
+ if (invalidRange) {
120
+ result.status = "error";
121
+ result.complete = false;
122
+ result.errors.push({ code: "INVALID_ARGUMENT", message: "The line range must contain positive ordered integers" });
123
+ return { result, exit_code: 2 };
124
+ }
125
+ const names = options.baseline === undefined ? options.files : [options.baseline, options.files[0]];
126
+ const internal = [];
127
+ const deadline = Date.now() + limits.time_limit_ms;
128
+ let totalBytes = 0;
129
+ for (const [index, name] of names.entries()) {
130
+ const remaining = deadline - Date.now();
131
+ if (remaining <= 0 || totalBytes >= MAX_TOTAL_SOURCE_BYTES) {
132
+ result.status = "incomplete";
133
+ result.complete = false;
134
+ result.errors.push({ code: "LIMIT_EXCEEDED", message: "The aggregate inspection limit was reached; remaining files were not scanned" });
135
+ break;
136
+ }
137
+ // A changed line range belongs to the current source; baseline line numbers
138
+ // may have shifted, so compare against the entire baseline.
139
+ const selectedRange = mode === "compare" && index === 0 ? undefined : options.range;
140
+ const item = inspectOne(options.root, name, { ...limits, max_source_bytes: Math.min(limits.max_source_bytes, MAX_TOTAL_SOURCE_BYTES - totalBytes), time_limit_ms: remaining }, deadline, selectedRange);
141
+ totalBytes += item.bytes;
142
+ internal.push(item);
143
+ result.files.push(item.result);
144
+ }
145
+ const failed = internal.find((item) => item.exitCode === 4) ?? internal.find((item) => item.exitCode === 1) ?? internal.find((item) => item.exitCode !== 0 && item.exitCode !== 3);
146
+ if (failed !== undefined) {
147
+ result.status = "error";
148
+ result.complete = false;
149
+ }
150
+ else if (result.status !== "incomplete" && internal.some((item) => item.exitCode === 3)) {
151
+ result.status = "incomplete";
152
+ result.complete = false;
153
+ }
154
+ if (mode === "compare" && internal.length === 2 && Date.now() <= deadline)
155
+ result.comparison = compare(internal[0], internal[1]);
156
+ if (Date.now() > deadline && failed === undefined) {
157
+ result.status = "incomplete";
158
+ result.complete = false;
159
+ result.comparison = null;
160
+ result.errors.push({ code: "LIMIT_EXCEEDED", message: "The aggregate inspection time limit was reached" });
161
+ }
162
+ const exitCode = failed?.exitCode ?? (result.status === "incomplete" ? 3 : 0);
163
+ if (Buffer.byteLength(`${JSON.stringify(result)}\n`, "utf8") > limits.max_output_bytes) {
164
+ return { result: { ...result, status: "incomplete", complete: false, files: [], comparison: null, errors: [{ code: "LIMIT_EXCEEDED", message: "The output limit was reached; partial results were withheld" }] }, exit_code: 3 };
165
+ }
166
+ return { result, exit_code: exitCode };
167
+ }
@@ -1,4 +1,4 @@
1
- import type { Finding } from "../schema/types.js";
1
+ import type { Finding, SourceLocation } from "../schema/types.js";
2
2
  export declare const PAIRED_TAGS: Set<string>;
3
3
  export declare const BRANCH_TAGS: Set<string>;
4
4
  export declare const BODYLESS_TAGS: Set<string>;
@@ -6,23 +6,25 @@ interface ScriptRegion {
6
6
  content_start: number;
7
7
  content_end: number;
8
8
  }
9
- interface ScanResult {
9
+ export interface ScanResult {
10
10
  findings: Finding[];
11
11
  incomplete: Array<{
12
12
  code: string;
13
13
  message: string;
14
+ location?: SourceLocation;
14
15
  }>;
15
16
  script_regions: ScriptRegion[];
16
17
  saw_cfml_tag: boolean;
18
+ scan_complete: boolean;
17
19
  }
18
20
  export declare function scanCfml(source: string, limits: {
19
21
  max_nesting: number;
20
22
  max_findings: number;
21
23
  time_limit_ms: number;
22
- }): ScanResult;
24
+ }, recover?: boolean): ScanResult;
23
25
  export declare function checkPureScriptCfc(source: string, extension: string, limits: {
24
26
  max_nesting: number;
25
27
  max_findings: number;
26
28
  time_limit_ms: number;
27
- }): ScanResult;
29
+ }, recover?: boolean): ScanResult;
28
30
  export {};
@@ -10,13 +10,22 @@ export const PAIRED_TAGS = new Set([
10
10
  "cfcomponent",
11
11
  "cffunction",
12
12
  "cfscript",
13
+ "cftry",
14
+ "cfcatch",
15
+ "cffinally",
16
+ "cftransaction",
17
+ "cfswitch",
18
+ "cfcase",
19
+ "cfdefaultcase",
13
20
  ]);
14
21
  export const BRANCH_TAGS = new Set(["cfelseif", "cfelse"]);
15
- export const BODYLESS_TAGS = new Set(["cfset", "cfreturn", "cfinclude", "cfargument", "cfqueryparam"]);
22
+ export const BODYLESS_TAGS = new Set(["cfset", "cfreturn", "cfinclude", "cfargument", "cfqueryparam", "cfparam"]);
16
23
  function isNameCharacter(character) {
17
24
  return character !== undefined && /[A-Za-z0-9_:-]/u.test(character);
18
25
  }
19
26
  function startsCfTag(source, index) {
27
+ if (source[index] !== "<")
28
+ return false;
20
29
  const afterSlash = source[index + 1] === "/" ? index + 2 : index + 1;
21
30
  return source.slice(afterSlash, afterSlash + 2).toLowerCase() === "cf" && isNameCharacter(source[afterSlash + 2]);
22
31
  }
@@ -40,6 +49,34 @@ function consumeCfmlComment(source, start) {
40
49
  }
41
50
  return null;
42
51
  }
52
+ // Transaction control actions can be bodyless. Only classify literal attributes;
53
+ // dynamic attribute collections and expressions cannot safely establish a form.
54
+ function transactionKind(attributes) {
55
+ const matcher = /\s*([A-Za-z_][A-Za-z0-9_:.-]*)\s*=\s*("(?:""|[^"])*"|'(?:''|[^'])*'|[^\s"'=<>`]+)\s*/gy;
56
+ let cursor = 0;
57
+ let action;
58
+ while (cursor < attributes.length) {
59
+ if (/^\s*$/u.test(attributes.slice(cursor)))
60
+ break;
61
+ matcher.lastIndex = cursor;
62
+ const match = matcher.exec(attributes);
63
+ if (match === null)
64
+ return "unknown";
65
+ const name = match[1]?.toLowerCase();
66
+ const raw = match[2];
67
+ if (name === undefined || raw === undefined || name === "attributecollection")
68
+ return "unknown";
69
+ if (name === "action") {
70
+ if (action !== undefined)
71
+ return "unknown";
72
+ action = (raw.startsWith('"') || raw.startsWith("'") ? raw.slice(1, -1) : raw).toLowerCase();
73
+ }
74
+ cursor = matcher.lastIndex;
75
+ }
76
+ if (action === undefined || action === "begin")
77
+ return "open";
78
+ return action === "commit" || action === "rollback" || action === "setsavepoint" ? "bodyless" : "unknown";
79
+ }
43
80
  function parseTag(source, start) {
44
81
  const closing = source[start + 1] === "/";
45
82
  let cursor = closing ? start + 2 : start + 1;
@@ -47,6 +84,7 @@ function parseTag(source, start) {
47
84
  while (isNameCharacter(source[cursor]))
48
85
  cursor += 1;
49
86
  const name = source.slice(nameStart, cursor).toLowerCase();
87
+ const attributesStart = cursor;
50
88
  if (!name.startsWith("cf"))
51
89
  return { error: "NOT_CF_TAG", at: start };
52
90
  let quote = null;
@@ -86,13 +124,15 @@ function parseTag(source, start) {
86
124
  const selfClosing = source[beforeEnd] === "/";
87
125
  const kind = closing
88
126
  ? "close"
89
- : BRANCH_TAGS.has(name)
90
- ? "branch"
91
- : BODYLESS_TAGS.has(name)
92
- ? "bodyless"
93
- : PAIRED_TAGS.has(name)
94
- ? "open"
95
- : "unknown";
127
+ : name === "cftransaction"
128
+ ? transactionKind(source.slice(attributesStart, selfClosing ? beforeEnd : cursor))
129
+ : BRANCH_TAGS.has(name)
130
+ ? "branch"
131
+ : BODYLESS_TAGS.has(name)
132
+ ? "bodyless"
133
+ : PAIRED_TAGS.has(name)
134
+ ? "open"
135
+ : "unknown";
96
136
  return { token: { kind, name, start, end: cursor + 1, self_closing: selfClosing }, next: cursor + 1 };
97
137
  }
98
138
  cursor += 1;
@@ -196,10 +236,11 @@ function scanScriptDelimiters(context, start, end, findings) {
196
236
  addFinding(context, findings, finding(context, "UNBALANCED_DELIMITER", `Unclosed CFScript delimiter "${open.character}"`, open.start, open.start + 1));
197
237
  }
198
238
  }
199
- function scanScriptRegion(context, start) {
239
+ function scanScriptRegion(context, start, recover = false) {
200
240
  let quote = null;
201
241
  let blockCommentStart = null;
202
242
  let cursor = start;
243
+ const issues = [];
203
244
  while (cursor < context.source.length) {
204
245
  checkDeadline(context, cursor);
205
246
  const character = context.source[cursor];
@@ -248,19 +289,26 @@ function scanScriptRegion(context, start) {
248
289
  if (startsCfTag(context.source, cursor)) {
249
290
  const parsed = parseTag(context.source, cursor);
250
291
  if ("error" in parsed)
251
- return { close: null, content_end: cursor, incomplete: { code: parsed.error, message: `CFScript region cannot be parsed (${parsed.error})` } };
292
+ return { close: null, content_end: cursor, issues, incomplete: { code: parsed.error, message: `CFScript region cannot be parsed (${parsed.error})` } };
252
293
  if (parsed.token.name === "cfscript" && parsed.token.kind === "close") {
253
- return { close: parsed.token, content_end: cursor };
294
+ return { close: parsed.token, content_end: cursor, issues };
295
+ }
296
+ if (recover) {
297
+ if (issues.length >= context.max_findings)
298
+ throw new ToolFailure("LIMIT_EXCEEDED", "The diagnostic limit was reached", 3);
299
+ issues.push({ code: "UNSUPPORTED_SYNTAX", message: `CFML tag island "${parsed.token.name}" inside cfscript is outside the supported profile`, start: cursor, end: parsed.next });
300
+ cursor = parsed.next;
301
+ continue;
254
302
  }
255
303
  return { close: null, content_end: cursor, incomplete: { code: "UNSUPPORTED_SYNTAX", message: "CFML tag island inside cfscript is outside the supported profile" } };
256
304
  }
257
305
  cursor += 1;
258
306
  }
259
307
  if (quote !== null)
260
- return { close: null, content_end: context.source.length, incomplete: { code: "UNTERMINATED_STRING", message: "Unterminated CFScript string" } };
308
+ return { close: null, content_end: context.source.length, issues, incomplete: { code: "UNTERMINATED_STRING", message: "Unterminated CFScript string" } };
261
309
  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" } };
310
+ return { close: null, content_end: context.source.length, issues, incomplete: { code: "UNTERMINATED_COMMENT", message: "Unterminated CFScript block comment" } };
311
+ return { close: null, content_end: context.source.length, issues, incomplete: { code: "UNTERMINATED_TAG", message: "cfscript has no closing tag" } };
264
312
  }
265
313
  function processTag(context, token, stack, findings) {
266
314
  if (token.kind === "unknown") {
@@ -291,7 +339,34 @@ function processTag(context, token, stack, findings) {
291
339
  throw new ToolFailure("UNSUPPORTED_SYNTAX", `Paired tag "${token.name}" cannot be self-closing`, 3);
292
340
  if (stack.length >= context.max_nesting)
293
341
  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 });
342
+ if (token.name === "cfcatch" || token.name === "cffinally") {
343
+ const current = stack[stack.length - 1];
344
+ if (current?.name !== "cftry") {
345
+ addFinding(context, findings, finding(context, "INVALID_BRANCH", `${token.name} must belong to the directly open cftry`, token.start, token.end));
346
+ }
347
+ else {
348
+ if (current.try_finally_seen) {
349
+ const message = token.name === "cffinally" ? "cftry cannot contain more than one cffinally" : "cfcatch cannot appear after cffinally";
350
+ addFinding(context, findings, finding(context, "INVALID_BRANCH", message, token.start, token.end, current));
351
+ }
352
+ current.try_handler_seen = true;
353
+ if (token.name === "cffinally")
354
+ current.try_finally_seen = true;
355
+ }
356
+ }
357
+ if (token.name === "cfcase" || token.name === "cfdefaultcase") {
358
+ const current = stack[stack.length - 1];
359
+ if (current?.name !== "cfswitch") {
360
+ addFinding(context, findings, finding(context, "INVALID_BRANCH", `${token.name} must belong to the directly open cfswitch`, token.start, token.end));
361
+ }
362
+ else if (token.name === "cfdefaultcase") {
363
+ if (current.switch_default_seen) {
364
+ addFinding(context, findings, finding(context, "INVALID_BRANCH", "cfswitch cannot contain more than one cfdefaultcase", token.start, token.end, current));
365
+ }
366
+ current.switch_default_seen = true;
367
+ }
368
+ }
369
+ stack.push({ name: token.name, start: token.start, end: token.end, branch_else_seen: false, try_handler_seen: false, try_finally_seen: false, switch_default_seen: false });
295
370
  return;
296
371
  }
297
372
  if (token.name === "cfscript" && stack[stack.length - 1]?.name !== "cfscript") {
@@ -310,23 +385,28 @@ function processTag(context, token, stack, findings) {
310
385
  addFinding(context, findings, finding(context, "MISMATCHED_CLOSE", `Closing tag "${token.name}" does not match open "${current.name}"`, token.start, token.end, current));
311
386
  return;
312
387
  }
388
+ if (current.name === "cftry" && !current.try_handler_seen) {
389
+ addFinding(context, findings, finding(context, "INVALID_BRANCH", "cftry must contain a directly nested cfcatch or cffinally", token.start, token.end, current));
390
+ }
313
391
  stack.pop();
314
392
  }
315
- export function scanCfml(source, limits) {
316
- const index = new SourceIndex(source);
393
+ export function scanCfml(source, limits, recover = false) {
394
+ const index = new SourceIndex(source, recover);
317
395
  const context = { source, index, deadline: Date.now() + limits.time_limit_ms, max_nesting: limits.max_nesting, max_findings: limits.max_findings };
318
396
  const findings = [];
319
397
  const incomplete = [];
320
398
  const script_regions = [];
321
399
  const stack = [];
322
400
  let saw_cfml_tag = false;
401
+ let scan_complete = true;
323
402
  let cursor = 0;
324
403
  while (cursor < source.length) {
325
404
  checkDeadline(context, cursor);
326
405
  if (source.startsWith("<!---", cursor)) {
327
406
  const end = consumeCfmlComment(source, cursor);
328
407
  if (end === null) {
329
- incomplete.push({ code: "UNTERMINATED_COMMENT", message: `Unterminated CFML comment at line ${index.position(cursor).line}, column ${index.position(cursor).column}` });
408
+ incomplete.push({ code: "UNTERMINATED_COMMENT", message: `Unterminated CFML comment at line ${index.position(cursor).line}, column ${index.position(cursor).column}`, ...(recover ? { location: index.location(cursor, Math.min(cursor + 5, source.length)) } : {}) });
409
+ scan_complete = false;
330
410
  break;
331
411
  }
332
412
  cursor = end;
@@ -339,17 +419,38 @@ export function scanCfml(source, limits) {
339
419
  saw_cfml_tag = true;
340
420
  const parsed = parseTag(source, cursor);
341
421
  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}` });
422
+ 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}`, ...(recover ? { location: index.location(parsed.at, Math.min(parsed.at + 1, source.length)) } : {}) });
423
+ scan_complete = false;
343
424
  break;
344
425
  }
345
426
  const token = parsed.token;
427
+ if (recover && token.kind === "close" && !PAIRED_TAGS.has(token.name) && !BODYLESS_TAGS.has(token.name) && !BRANCH_TAGS.has(token.name)) {
428
+ if (incomplete.length >= limits.max_findings)
429
+ throw new ToolFailure("LIMIT_EXCEEDED", "The diagnostic limit was reached", 3);
430
+ incomplete.push({ code: "UNSUPPORTED_SYNTAX", message: `Unsupported CFML tag "${token.name}"`, location: index.location(token.start, token.end) });
431
+ cursor = parsed.next;
432
+ continue;
433
+ }
434
+ if (recover && token.kind === "open" && token.name === "cfscript" && token.self_closing) {
435
+ if (incomplete.length >= limits.max_findings)
436
+ throw new ToolFailure("LIMIT_EXCEEDED", "The diagnostic limit was reached", 3);
437
+ incomplete.push({ code: "UNSUPPORTED_SYNTAX", message: 'Paired tag "cfscript" cannot be self-closing', location: index.location(token.start, token.end) });
438
+ cursor = parsed.next;
439
+ continue;
440
+ }
346
441
  if (token.kind === "open" && token.name === "cfscript") {
347
442
  processTag(context, token, stack, findings);
348
- const script = scanScriptRegion(context, token.end);
443
+ const script = scanScriptRegion(context, token.end, recover);
349
444
  scanScriptDelimiters(context, token.end, script.content_end, findings);
350
445
  script_regions.push({ content_start: token.end, content_end: script.content_end });
446
+ for (const issue of script.issues ?? []) {
447
+ if (incomplete.length >= limits.max_findings)
448
+ throw new ToolFailure("LIMIT_EXCEEDED", "The diagnostic limit was reached", 3);
449
+ incomplete.push({ code: issue.code, message: issue.message, location: index.location(issue.start, issue.end) });
450
+ }
351
451
  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}` });
452
+ 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}`, ...(recover ? { location: index.location(script.content_end, Math.min(script.content_end + 1, source.length)) } : {}) });
453
+ scan_complete = false;
353
454
  break;
354
455
  }
355
456
  if (script.close !== null) {
@@ -358,27 +459,36 @@ export function scanCfml(source, limits) {
358
459
  continue;
359
460
  }
360
461
  }
361
- processTag(context, token, stack, findings);
462
+ try {
463
+ processTag(context, token, stack, findings);
464
+ }
465
+ catch (error) {
466
+ if (!recover || !(error instanceof ToolFailure) || error.code !== "UNSUPPORTED_SYNTAX")
467
+ throw error;
468
+ if (incomplete.length >= limits.max_findings)
469
+ throw new ToolFailure("LIMIT_EXCEEDED", "The diagnostic limit was reached", 3);
470
+ incomplete.push({ code: error.code, message: error.message, location: index.location(token.start, token.end) });
471
+ }
362
472
  cursor = parsed.next;
363
473
  }
364
474
  for (const open of stack.reverse()) {
365
475
  addFinding(context, findings, finding(context, "UNCLOSED_TAG", `Unclosed CFML tag "${open.name}"`, open.start, open.end, open));
366
476
  }
367
477
  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 };
478
+ return { findings, incomplete, script_regions, saw_cfml_tag, scan_complete };
369
479
  }
370
- export function checkPureScriptCfc(source, extension, limits) {
480
+ export function checkPureScriptCfc(source, extension, limits, recover = false) {
371
481
  const leading = source.replace(/^\uFEFF/u, "").trimStart();
372
482
  const pureScript = extension.toLowerCase() === ".cfc" && /^(?:component|interface)\b/iu.test(leading);
373
483
  if (pureScript) {
374
- const result = { findings: [], incomplete: [], script_regions: [], saw_cfml_tag: false };
375
- const index = new SourceIndex(source);
484
+ const result = { findings: [], incomplete: [], script_regions: [], saw_cfml_tag: false, scan_complete: true };
485
+ const index = new SourceIndex(source, recover);
376
486
  const context = { source, index, deadline: Date.now() + limits.time_limit_ms, max_nesting: limits.max_nesting, max_findings: limits.max_findings };
377
487
  scanScriptDelimiters(context, 0, source.length, result.findings);
378
488
  result.findings.sort((left, right) => left.location.start_byte - right.location.start_byte || left.code.localeCompare(right.code));
379
489
  return result;
380
490
  }
381
- const result = scanCfml(source, limits);
491
+ const result = scanCfml(source, limits, recover);
382
492
  if (extension.toLowerCase() !== ".cfc" || result.saw_cfml_tag)
383
493
  return result;
384
494
  if (!pureScript) {
@@ -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
  }
@@ -3,7 +3,7 @@ export declare class SourceIndex {
3
3
  readonly source: string;
4
4
  private readonly byteOffsets;
5
5
  private readonly lineStarts;
6
- constructor(source: string);
6
+ constructor(source: string, crAsNewline?: boolean);
7
7
  byteOffset(index: number): number;
8
8
  position(index: number): Position;
9
9
  location(start: number, end: number): SourceLocation;
@@ -3,7 +3,7 @@ export class SourceIndex {
3
3
  source;
4
4
  byteOffsets;
5
5
  lineStarts = [0];
6
- constructor(source) {
6
+ constructor(source, crAsNewline = false) {
7
7
  this.source = source;
8
8
  this.byteOffsets = new Uint32Array(source.length + 1);
9
9
  const encoder = new TextEncoder();
@@ -23,6 +23,8 @@ export class SourceIndex {
23
23
  this.byteOffsets[index] = byteOffset;
24
24
  if (character === "\n")
25
25
  this.lineStarts.push(index);
26
+ else if (crAsNewline && character === "\r" && source[index] !== "\n")
27
+ this.lineStarts.push(index);
26
28
  }
27
29
  }
28
30
  byteOffset(index) {
@@ -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);
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { capabilities, checkFile } from "./core/checker.js";
2
+ export { inspectFiles } from "./core/inspect.js";
3
+ export type { InspectOptions, InspectResult, InspectDiagnostic, InspectedFile, LineRange } from "./core/inspect.js";
2
4
  export { DEFAULT_LIMITS, HARD_LIMITS, normalizeLimits } from "./core/limits.js";
3
5
  export type * from "./schema/types.js";
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { capabilities, checkFile } from "./core/checker.js";
2
+ export { inspectFiles } from "./core/inspect.js";
2
3
  export { DEFAULT_LIMITS, HARD_LIMITS, normalizeLimits } from "./core/limits.js";
@@ -1,5 +1,5 @@
1
1
  export declare const TOOL_ID: "agent-cfml-check";
2
- export declare const TOOL_VERSION: "0.1.1";
2
+ export declare const TOOL_VERSION: "0.3.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.1";
2
+ export const TOOL_VERSION = "0.3.0";
3
3
  export const PROFILE_ID = "cfml-structure-v1";
package/package.json CHANGED
@@ -1,7 +1,16 @@
1
1
  {
2
2
  "name": "agent-cfml-check",
3
- "version": "0.1.1",
3
+ "version": "0.3.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",
7
16
  "main": "./dist/index.js",
@@ -28,9 +37,14 @@
28
37
  "scripts": {
29
38
  "typecheck": "tsc -p tsconfig.json --noEmit",
30
39
  "build": "tsc -p tsconfig.build.json",
31
- "test": "npm run build && 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",
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",
32
42
  "prepack": "npm run build"
33
43
  },
44
+ "publishConfig": {
45
+ "registry": "https://registry.npmjs.org/",
46
+ "access": "public"
47
+ },
34
48
  "devDependencies": {
35
49
  "@types/node": "^22.10.0",
36
50
  "ajv": "^8.20.0",
@@ -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
@@ -0,0 +1,50 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:agent-cfml-check:inspect-result-v2",
4
+ "type": "object",
5
+ "required": ["schema_version", "tool", "status", "complete", "mode", "files", "comparison", "comparison_basis", "errors", "limits"],
6
+ "properties": {
7
+ "schema_version": { "const": "2.0.0" },
8
+ "tool": {
9
+ "type": "object", "required": ["id", "version"],
10
+ "properties": { "id": { "const": "agent-cfml-check" }, "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } },
11
+ "additionalProperties": false
12
+ },
13
+ "status": { "enum": ["ok", "incomplete", "error"] },
14
+ "complete": { "type": "boolean" },
15
+ "mode": { "enum": ["inspect", "compare"] },
16
+ "files": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/file" } },
17
+ "comparison": { "anyOf": [{ "type": "null" }, { "type": "array", "items": { "$ref": "#/$defs/comparison" } }] },
18
+ "comparison_basis": { "type": ["string", "null"] },
19
+ "errors": { "type": "array", "items": { "$ref": "#/$defs/error" } },
20
+ "limits": { "$ref": "#/$defs/limits" }
21
+ },
22
+ "allOf": [
23
+ { "if": { "properties": { "status": { "const": "ok" } } }, "then": { "properties": { "complete": { "const": true }, "errors": { "maxItems": 0 }, "files": { "items": { "properties": { "complete": { "const": true } } } } } } },
24
+ { "if": { "properties": { "status": { "enum": ["incomplete", "error"] } } }, "then": { "properties": { "complete": { "const": false } } } }
25
+ ],
26
+ "additionalProperties": false,
27
+ "$defs": {
28
+ "error": { "type": "object", "required": ["code", "message"], "properties": { "code": { "type": "string" }, "message": { "type": "string" } }, "additionalProperties": false },
29
+ "position": { "type": "object", "required": ["line", "column"], "properties": { "line": { "type": "integer", "minimum": 1 }, "column": { "type": "integer", "minimum": 1 } }, "additionalProperties": false },
30
+ "location": { "type": "object", "required": ["start", "end", "start_byte", "end_byte"], "properties": { "start": { "$ref": "#/$defs/position" }, "end": { "$ref": "#/$defs/position" }, "start_byte": { "type": "integer", "minimum": 0 }, "end_byte": { "type": "integer", "minimum": 0 } }, "additionalProperties": false },
31
+ "diagnostic": {
32
+ "type": "object", "required": ["code", "message", "file", "location", "related_open", "hint", "confidence"],
33
+ "properties": { "code": { "type": "string" }, "message": { "type": "string" }, "file": { "type": "string" }, "location": { "anyOf": [{ "$ref": "#/$defs/location" }, { "type": "null" }] }, "related_open": { "anyOf": [{ "$ref": "#/$defs/location" }, { "type": "null" }] }, "hint": { "type": "string" }, "confidence": { "enum": ["confirmed", "provisional"] } },
34
+ "additionalProperties": false
35
+ },
36
+ "source": { "type": "object", "required": ["path", "sha256", "byte_size", "encoding", "bom"], "properties": { "path": { "type": "string" }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "byte_size": { "type": "integer", "minimum": 0 }, "encoding": { "const": "utf-8" }, "bom": { "type": "boolean" } }, "additionalProperties": false },
37
+ "range": { "type": "object", "required": ["start", "end"], "properties": { "start": { "type": "integer", "minimum": 1 }, "end": { "type": "integer", "minimum": 1 } }, "additionalProperties": false },
38
+ "file": {
39
+ "type": "object", "required": ["path", "status", "complete", "scan_complete", "verdict", "source", "range", "findings", "unsupported", "errors"],
40
+ "properties": { "path": { "type": "string" }, "status": { "enum": ["ok", "incomplete", "error"] }, "complete": { "type": "boolean" }, "scan_complete": { "type": "boolean" }, "verdict": { "enum": ["pass", "violations", null] }, "source": { "anyOf": [{ "$ref": "#/$defs/source" }, { "type": "null" }] }, "range": { "anyOf": [{ "$ref": "#/$defs/range" }, { "type": "null" }] }, "findings": { "type": "array", "items": { "$ref": "#/$defs/diagnostic" } }, "unsupported": { "type": "array", "items": { "$ref": "#/$defs/diagnostic" } }, "errors": { "type": "array", "items": { "$ref": "#/$defs/error" } } },
41
+ "allOf": [
42
+ { "if": { "properties": { "status": { "const": "ok" } } }, "then": { "properties": { "complete": { "const": true }, "verdict": { "enum": ["pass", "violations"] } } } },
43
+ { "if": { "properties": { "status": { "enum": ["incomplete", "error"] } } }, "then": { "properties": { "complete": { "const": false }, "verdict": { "type": "null" } } } }
44
+ ],
45
+ "additionalProperties": false
46
+ },
47
+ "comparison": { "type": "object", "required": ["finding", "classification"], "properties": { "finding": { "$ref": "#/$defs/diagnostic" }, "classification": { "enum": ["existing", "new", "uncertain"] } }, "additionalProperties": false },
48
+ "limits": { "type": "object", "required": ["max_source_bytes", "max_nesting", "max_findings", "max_output_bytes", "time_limit_ms"], "properties": { "max_source_bytes": { "type": "integer", "minimum": 1 }, "max_nesting": { "type": "integer", "minimum": 1 }, "max_findings": { "type": "integer", "minimum": 1 }, "max_output_bytes": { "type": "integer", "minimum": 512 }, "time_limit_ms": { "type": "integer", "minimum": 1 } }, "additionalProperties": false }
49
+ }
50
+ }