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 +39 -6
- package/SPEC.md +28 -9
- package/dist/cli/index.js +22 -10
- package/dist/cli/inspect.d.ts +1 -0
- package/dist/cli/inspect.js +108 -0
- package/dist/core/checker.d.ts +1 -0
- package/dist/core/checker.js +8 -11
- package/dist/core/inspect.d.ts +62 -0
- package/dist/core/inspect.js +167 -0
- package/dist/core/lexer.d.ts +6 -4
- package/dist/core/lexer.js +137 -27
- package/dist/core/limits.js +4 -0
- package/dist/core/source-index.d.ts +1 -1
- package/dist/core/source-index.js +3 -1
- package/dist/core/source-reader.js +50 -10
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/schema/types.d.ts +1 -1
- package/dist/schema/types.js +1 -1
- package/package.json +16 -2
- package/schema/agent-cfml-check-result-v1.schema.json +1 -1
- package/schema/agent-cfml-inspect-result-v2.schema.json +50 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## Current status
|
|
6
6
|
|
|
7
|
-
Version `0.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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`
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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 `
|
|
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.
|
|
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 `
|
|
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,
|
|
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
|
-
|
|
15
|
-
executes CFML, follows includes, uses the network, or modifies
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
}
|
package/dist/core/checker.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/checker.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/core/lexer.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/core/lexer.js
CHANGED
|
@@ -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
|
-
:
|
|
90
|
-
?
|
|
91
|
-
:
|
|
92
|
-
? "
|
|
93
|
-
:
|
|
94
|
-
? "
|
|
95
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/core/limits.js
CHANGED
|
@@ -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,
|
|
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(
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
package/dist/schema/types.d.ts
CHANGED
package/dist/schema/types.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-cfml-check",
|
|
3
|
-
"version": "0.
|
|
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 &&
|
|
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":
|
|
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
|
+
}
|