agent-cfml-check 0.2.0 → 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 +13 -4
- package/SPEC.md +11 -3
- package/dist/cli/index.js +7 -2
- package/dist/cli/inspect.d.ts +1 -0
- package/dist/cli/inspect.js +108 -0
- package/dist/core/checker.js +1 -1
- 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 +113 -26
- package/dist/core/source-index.d.ts +1 -1
- package/dist/core/source-index.js +3 -1
- 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 +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;
|
|
@@ -69,6 +69,15 @@ agent-cfml-check capabilities [--json] [--pretty]
|
|
|
69
69
|
agent-cfml-check check --root <directory> <file> [--json] [--pretty]
|
|
70
70
|
```
|
|
71
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
|
+
|
|
72
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`.
|
|
73
82
|
|
|
74
83
|
`max_output_bytes` must be at least 512, reserving a complete failure envelope.
|
|
@@ -76,11 +85,11 @@ CLI ceilings count actual UTF-8 stdout, including indentation and the final
|
|
|
76
85
|
newline. If pretty output cannot fit, checking returns incomplete/exit 3 and may
|
|
77
86
|
emit its failure envelope as compact JSON. Default fitting output is unchanged.
|
|
78
87
|
|
|
79
|
-
The
|
|
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.
|
|
80
89
|
|
|
81
90
|
## Safety and boundaries
|
|
82
91
|
|
|
83
|
-
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.
|
|
84
93
|
|
|
85
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.
|
|
86
95
|
|
|
@@ -100,4 +109,4 @@ The supported profile is `cfml-structure-v1`. Unknown/custom/imported tags, opti
|
|
|
100
109
|
- `dist/`: generated package output;
|
|
101
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.
|
|
102
111
|
|
|
103
|
-
`package.json` includes `dist`, `README.md`, `SPEC.md`, `schema`, and `fixtures` in the package; the design, planning, progress, goal, prompt, changelog, and Agent Skill documents remain repository-maintainer documentation. The repository exposes the TypeScript library through `main`, `types`, and `exports`. The source package version is `0.
|
|
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,7 +22,7 @@ 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
|
|
|
@@ -32,6 +32,14 @@ The TypeScript `capabilities(limitsInput)` API may receive limit overrides, whil
|
|
|
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
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.
|
package/dist/cli/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
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) {
|
|
@@ -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.js
CHANGED
|
@@ -27,7 +27,7 @@ export function capabilities(limitsInput) {
|
|
|
27
27
|
complete: true,
|
|
28
28
|
data: {
|
|
29
29
|
profile: PROFILE_ID,
|
|
30
|
-
operations: ["capabilities", "check"],
|
|
30
|
+
operations: ["capabilities", "check", "inspect"],
|
|
31
31
|
extensions: [".cfm", ".cfc"],
|
|
32
32
|
supported_tags: {
|
|
33
33
|
paired_bodies: [...PAIRED_TAGS].sort(),
|
|
@@ -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
|
@@ -13,6 +13,10 @@ export const PAIRED_TAGS = new Set([
|
|
|
13
13
|
"cftry",
|
|
14
14
|
"cfcatch",
|
|
15
15
|
"cffinally",
|
|
16
|
+
"cftransaction",
|
|
17
|
+
"cfswitch",
|
|
18
|
+
"cfcase",
|
|
19
|
+
"cfdefaultcase",
|
|
16
20
|
]);
|
|
17
21
|
export const BRANCH_TAGS = new Set(["cfelseif", "cfelse"]);
|
|
18
22
|
export const BODYLESS_TAGS = new Set(["cfset", "cfreturn", "cfinclude", "cfargument", "cfqueryparam", "cfparam"]);
|
|
@@ -45,6 +49,34 @@ function consumeCfmlComment(source, start) {
|
|
|
45
49
|
}
|
|
46
50
|
return null;
|
|
47
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
|
+
}
|
|
48
80
|
function parseTag(source, start) {
|
|
49
81
|
const closing = source[start + 1] === "/";
|
|
50
82
|
let cursor = closing ? start + 2 : start + 1;
|
|
@@ -52,6 +84,7 @@ function parseTag(source, start) {
|
|
|
52
84
|
while (isNameCharacter(source[cursor]))
|
|
53
85
|
cursor += 1;
|
|
54
86
|
const name = source.slice(nameStart, cursor).toLowerCase();
|
|
87
|
+
const attributesStart = cursor;
|
|
55
88
|
if (!name.startsWith("cf"))
|
|
56
89
|
return { error: "NOT_CF_TAG", at: start };
|
|
57
90
|
let quote = null;
|
|
@@ -91,13 +124,15 @@ function parseTag(source, start) {
|
|
|
91
124
|
const selfClosing = source[beforeEnd] === "/";
|
|
92
125
|
const kind = closing
|
|
93
126
|
? "close"
|
|
94
|
-
:
|
|
95
|
-
?
|
|
96
|
-
:
|
|
97
|
-
? "
|
|
98
|
-
:
|
|
99
|
-
? "
|
|
100
|
-
:
|
|
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";
|
|
101
136
|
return { token: { kind, name, start, end: cursor + 1, self_closing: selfClosing }, next: cursor + 1 };
|
|
102
137
|
}
|
|
103
138
|
cursor += 1;
|
|
@@ -201,10 +236,11 @@ function scanScriptDelimiters(context, start, end, findings) {
|
|
|
201
236
|
addFinding(context, findings, finding(context, "UNBALANCED_DELIMITER", `Unclosed CFScript delimiter "${open.character}"`, open.start, open.start + 1));
|
|
202
237
|
}
|
|
203
238
|
}
|
|
204
|
-
function scanScriptRegion(context, start) {
|
|
239
|
+
function scanScriptRegion(context, start, recover = false) {
|
|
205
240
|
let quote = null;
|
|
206
241
|
let blockCommentStart = null;
|
|
207
242
|
let cursor = start;
|
|
243
|
+
const issues = [];
|
|
208
244
|
while (cursor < context.source.length) {
|
|
209
245
|
checkDeadline(context, cursor);
|
|
210
246
|
const character = context.source[cursor];
|
|
@@ -253,19 +289,26 @@ function scanScriptRegion(context, start) {
|
|
|
253
289
|
if (startsCfTag(context.source, cursor)) {
|
|
254
290
|
const parsed = parseTag(context.source, cursor);
|
|
255
291
|
if ("error" in parsed)
|
|
256
|
-
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})` } };
|
|
257
293
|
if (parsed.token.name === "cfscript" && parsed.token.kind === "close") {
|
|
258
|
-
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;
|
|
259
302
|
}
|
|
260
303
|
return { close: null, content_end: cursor, incomplete: { code: "UNSUPPORTED_SYNTAX", message: "CFML tag island inside cfscript is outside the supported profile" } };
|
|
261
304
|
}
|
|
262
305
|
cursor += 1;
|
|
263
306
|
}
|
|
264
307
|
if (quote !== null)
|
|
265
|
-
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" } };
|
|
266
309
|
if (blockCommentStart !== null)
|
|
267
|
-
return { close: null, content_end: context.source.length, incomplete: { code: "UNTERMINATED_COMMENT", message: "Unterminated CFScript block comment" } };
|
|
268
|
-
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" } };
|
|
269
312
|
}
|
|
270
313
|
function processTag(context, token, stack, findings) {
|
|
271
314
|
if (token.kind === "unknown") {
|
|
@@ -311,7 +354,19 @@ function processTag(context, token, stack, findings) {
|
|
|
311
354
|
current.try_finally_seen = true;
|
|
312
355
|
}
|
|
313
356
|
}
|
|
314
|
-
|
|
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 });
|
|
315
370
|
return;
|
|
316
371
|
}
|
|
317
372
|
if (token.name === "cfscript" && stack[stack.length - 1]?.name !== "cfscript") {
|
|
@@ -335,21 +390,23 @@ function processTag(context, token, stack, findings) {
|
|
|
335
390
|
}
|
|
336
391
|
stack.pop();
|
|
337
392
|
}
|
|
338
|
-
export function scanCfml(source, limits) {
|
|
339
|
-
const index = new SourceIndex(source);
|
|
393
|
+
export function scanCfml(source, limits, recover = false) {
|
|
394
|
+
const index = new SourceIndex(source, recover);
|
|
340
395
|
const context = { source, index, deadline: Date.now() + limits.time_limit_ms, max_nesting: limits.max_nesting, max_findings: limits.max_findings };
|
|
341
396
|
const findings = [];
|
|
342
397
|
const incomplete = [];
|
|
343
398
|
const script_regions = [];
|
|
344
399
|
const stack = [];
|
|
345
400
|
let saw_cfml_tag = false;
|
|
401
|
+
let scan_complete = true;
|
|
346
402
|
let cursor = 0;
|
|
347
403
|
while (cursor < source.length) {
|
|
348
404
|
checkDeadline(context, cursor);
|
|
349
405
|
if (source.startsWith("<!---", cursor)) {
|
|
350
406
|
const end = consumeCfmlComment(source, cursor);
|
|
351
407
|
if (end === null) {
|
|
352
|
-
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;
|
|
353
410
|
break;
|
|
354
411
|
}
|
|
355
412
|
cursor = end;
|
|
@@ -362,17 +419,38 @@ export function scanCfml(source, limits) {
|
|
|
362
419
|
saw_cfml_tag = true;
|
|
363
420
|
const parsed = parseTag(source, cursor);
|
|
364
421
|
if ("error" in parsed) {
|
|
365
|
-
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;
|
|
366
424
|
break;
|
|
367
425
|
}
|
|
368
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
|
+
}
|
|
369
441
|
if (token.kind === "open" && token.name === "cfscript") {
|
|
370
442
|
processTag(context, token, stack, findings);
|
|
371
|
-
const script = scanScriptRegion(context, token.end);
|
|
443
|
+
const script = scanScriptRegion(context, token.end, recover);
|
|
372
444
|
scanScriptDelimiters(context, token.end, script.content_end, findings);
|
|
373
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
|
+
}
|
|
374
451
|
if (script.incomplete !== undefined) {
|
|
375
|
-
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;
|
|
376
454
|
break;
|
|
377
455
|
}
|
|
378
456
|
if (script.close !== null) {
|
|
@@ -381,27 +459,36 @@ export function scanCfml(source, limits) {
|
|
|
381
459
|
continue;
|
|
382
460
|
}
|
|
383
461
|
}
|
|
384
|
-
|
|
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
|
+
}
|
|
385
472
|
cursor = parsed.next;
|
|
386
473
|
}
|
|
387
474
|
for (const open of stack.reverse()) {
|
|
388
475
|
addFinding(context, findings, finding(context, "UNCLOSED_TAG", `Unclosed CFML tag "${open.name}"`, open.start, open.end, open));
|
|
389
476
|
}
|
|
390
477
|
findings.sort((left, right) => left.location.start_byte - right.location.start_byte || left.code.localeCompare(right.code));
|
|
391
|
-
return { findings, incomplete, script_regions, saw_cfml_tag };
|
|
478
|
+
return { findings, incomplete, script_regions, saw_cfml_tag, scan_complete };
|
|
392
479
|
}
|
|
393
|
-
export function checkPureScriptCfc(source, extension, limits) {
|
|
480
|
+
export function checkPureScriptCfc(source, extension, limits, recover = false) {
|
|
394
481
|
const leading = source.replace(/^\uFEFF/u, "").trimStart();
|
|
395
482
|
const pureScript = extension.toLowerCase() === ".cfc" && /^(?:component|interface)\b/iu.test(leading);
|
|
396
483
|
if (pureScript) {
|
|
397
|
-
const result = { findings: [], incomplete: [], script_regions: [], saw_cfml_tag: false };
|
|
398
|
-
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);
|
|
399
486
|
const context = { source, index, deadline: Date.now() + limits.time_limit_ms, max_nesting: limits.max_nesting, max_findings: limits.max_findings };
|
|
400
487
|
scanScriptDelimiters(context, 0, source.length, result.findings);
|
|
401
488
|
result.findings.sort((left, right) => left.location.start_byte - right.location.start_byte || left.code.localeCompare(right.code));
|
|
402
489
|
return result;
|
|
403
490
|
}
|
|
404
|
-
const result = scanCfml(source, limits);
|
|
491
|
+
const result = scanCfml(source, limits, recover);
|
|
405
492
|
if (extension.toLowerCase() !== ".cfc" || result.saw_cfml_tag)
|
|
406
493
|
return result;
|
|
407
494
|
if (!pureScript) {
|
|
@@ -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) {
|
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
|
@@ -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
|
+
}
|