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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Current status
6
6
 
7
- Version `0.2.0` extends the `0.1.1` structural profile with parameter and exception tags. The implementation checks:
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 checker requires one explicit root and one `.cfm` or `.cfc` file. JSON mode emits one envelope on stdout; diagnostics stay on stderr. A completed check with structural violations still exits `0` and reports `data.verdict: "violations"`. Exit `3` means unsupported syntax, insufficient evidence, or a resource limit. Exit `4` means an explicit-root or access-policy rejection. See [SPEC.md](SPEC.md) for the complete contract.
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. It reads exactly one regular UTF-8 source file under the explicit root, rejects symlink escapes, and fails closed when the bounded profile cannot establish a result.
92
+ The checker never executes CFML or JavaScript from the inspected source, follows includes, reads directories as input, uses the network, installs packages, or modifies the inspected source tree. `check` reads exactly one regular UTF-8 source file under the explicit root; `inspect` reads only the explicitly selected files under that root. Both reject symlink escapes and report incomplete when the bounded profile cannot establish a result.
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.2.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.2.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 tool performs deterministic, read-only structural checks on exactly one explicitly selected local UTF-8 `.cfm` or `.cfc` file under an explicitly supplied root. It does not execute CFML, resolve includes, inspect a directory as source input, use the network, or modify the inspected source tree.
10
+ The v1 `check` operation performs deterministic, read-only structural checks on exactly one explicitly selected local UTF-8 `.cfm` or `.cfc` file under an explicitly supplied root. The `inspect` operation accepts multiple explicitly selected files under one root. Neither operation executes CFML, resolves includes, inspects a directory as source input, uses the network, or modifies the inspected source tree.
11
11
 
12
12
  A completed result proves only the checks listed in that result. It does not prove Lucee or Adobe ColdFusion execution compatibility.
13
13
 
@@ -22,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.2.0`; neither may be combined with other options.
25
+ `check` requires exactly one explicit root and one `.cfm` or `.cfc` file. `--pretty` requires `--json`. `--help` prints usage and `--version` prints `0.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
- The checker reads exactly one .cfm or .cfc file under the explicit root. It never
15
- executes CFML, follows includes, uses the network, or modifies source files.
17
+ Check reads one file; inspect reads only explicitly selected files under the root.
18
+ The tool never executes CFML, follows includes, uses the network, or modifies sources.
16
19
  `;
17
20
  function parseInteger(value, name) {
18
21
  if (!/^\d+$/u.test(value))
@@ -115,6 +118,8 @@ function renderText(envelope) {
115
118
  return `${lines.join("\n")}\n`;
116
119
  }
117
120
  export function main(argv = process.argv.slice(2)) {
121
+ if (argv[0] === "inspect")
122
+ return inspectMain(argv.slice(1));
118
123
  try {
119
124
  const args = parseArgs(argv);
120
125
  if (args.help) {
@@ -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
+ }
@@ -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
+ }
@@ -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 {};
@@ -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
- : BRANCH_TAGS.has(name)
95
- ? "branch"
96
- : BODYLESS_TAGS.has(name)
97
- ? "bodyless"
98
- : PAIRED_TAGS.has(name)
99
- ? "open"
100
- : "unknown";
127
+ : name === "cftransaction"
128
+ ? transactionKind(source.slice(attributesStart, selfClosing ? beforeEnd : cursor))
129
+ : BRANCH_TAGS.has(name)
130
+ ? "branch"
131
+ : BODYLESS_TAGS.has(name)
132
+ ? "bodyless"
133
+ : PAIRED_TAGS.has(name)
134
+ ? "open"
135
+ : "unknown";
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
- stack.push({ name: token.name, start: token.start, end: token.end, branch_else_seen: false, try_handler_seen: false, try_finally_seen: false });
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
- processTag(context, token, stack, findings);
462
+ try {
463
+ processTag(context, token, stack, findings);
464
+ }
465
+ catch (error) {
466
+ if (!recover || !(error instanceof ToolFailure) || error.code !== "UNSUPPORTED_SYNTAX")
467
+ throw error;
468
+ if (incomplete.length >= limits.max_findings)
469
+ throw new ToolFailure("LIMIT_EXCEEDED", "The diagnostic limit was reached", 3);
470
+ incomplete.push({ code: error.code, message: error.message, location: index.location(token.start, token.end) });
471
+ }
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
@@ -1,2 +1,3 @@
1
1
  export { capabilities, checkFile } from "./core/checker.js";
2
+ export { inspectFiles } from "./core/inspect.js";
2
3
  export { DEFAULT_LIMITS, HARD_LIMITS, normalizeLimits } from "./core/limits.js";
@@ -1,5 +1,5 @@
1
1
  export declare const TOOL_ID: "agent-cfml-check";
2
- export declare const TOOL_VERSION: "0.2.0";
2
+ export declare const TOOL_VERSION: "0.3.0";
3
3
  export declare const PROFILE_ID: "cfml-structure-v1";
4
4
  export interface Position {
5
5
  line: number;
@@ -1,3 +1,3 @@
1
1
  export const TOOL_ID = "agent-cfml-check";
2
- export const TOOL_VERSION = "0.2.0";
2
+ export const TOOL_VERSION = "0.3.0";
3
3
  export const PROFILE_ID = "cfml-structure-v1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-cfml-check",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Deterministic, read-only structural checks for a bounded CFML subset.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
+ }