Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ Notable changes to Agent Code Guard are recorded here.

### Fixed

- Known per-file syntax and provider failures now produce blocking structured
incomplete results while preserving independent LOC, Markdown, and unaffected
syntax evidence; completed output remains schema- and byte-compatible.
- Valid C# that uses `async` as an expression identifier or named-argument name
now receives a narrow, coordinate-preserving parser compatibility retry while
unknown, ambiguous, and malformed syntax still fails closed.
Expand Down
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,14 @@ hook-assisted process.
warranted; normally exit `1`.
- **Completed FAIL** — blocks normal completion until fixed or an explicitly
authorized exception applies; exit `2` with a completed report on stdout.
- **INCOMPLETE** — one or more requested syntax analyses are unavailable;
independent completed findings remain authoritative, but the run exits `3`.
- An argparse usage or invalid-choice error exits `2`, writes usage/error text
to stderr, and produces no completed report.
- Other Code Guard tool, configuration, scope, or provider errors exit `3`.
- Other Code Guard tool, configuration, scope, or unexpected errors exit `3`.

`--ci` makes REVIEW nonblocking at the process level by changing its exit to
`0`; it does not hide the findings or change FAIL and tool-error exits.
`0`; it does not hide findings or change FAIL, INCOMPLETE, and tool-error exits.

**Never game a metric.** Do not create artificial helpers, files,
abstractions, formatting, exclusions, or policy changes merely to lower a
Expand Down Expand Up @@ -173,6 +175,12 @@ result, scope, required policies, guards, ordering, and actionable findings.
Named modes require `--json`. See [Usage](https://github.com/stef-k/agent-code-guard/blob/main/docs/usage.md) for the schema and
option contract.

Known per-file syntax or provider failures instead produce `overall:
"incomplete"`, ordered structured `unavailable` records, and completed guard
evidence. Syntax guards identify unavailable paths; LOC and Markdown remain
complete. Full, debug, and compact JSON retain unavailable records unchanged,
and completed runs retain their existing schema.

### Common scope commands

```bash
Expand All @@ -198,7 +206,8 @@ Markdown guards apply to `.md` files.
Generic `.h` files are not syntax-dispatched; `.markdown` is not enabled; Vue
template and style regions are not executable syntax input; and unsupported
artifacts are inapplicable. Malformed applicable syntax or a required provider
failure is a fail-closed tool error. See [Language support](https://github.com/stef-k/agent-code-guard/blob/main/docs/language-support.md).
failure is unavailable evidence, distinct from both an inapplicable file and a
completed FAIL finding. See [Language support](https://github.com/stef-k/agent-code-guard/blob/main/docs/language-support.md).

### Skill integration

Expand Down
9 changes: 6 additions & 3 deletions docs/design-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,12 @@ and decision relationships. Tree-sitter nodes never cross the extraction boundar
Tree-sitter 0.26.0 and tree-sitter-language-pack 1.14.3 are the pinned initial
provider. Python 3.10+ and a compatible platform wheel/native build are required
only when syntax analysis is invoked. Unsupported ordinary artifacts are
inapplicable; a supported artifact with malformed syntax or an unavailable
provider/grammar is a deterministic analysis error suitable for the existing
runner exit-3 boundary.
inapplicable. The strict `analyze_files` seam raises for malformed syntax or an
unavailable provider/grammar. The runner's batch seam catches only those known
per-file failures, returns immutable completed facts plus ordered immutable
unavailable records, and continues independent files. Arbitrary reads,
extraction, configuration, guard, and programming failures retain the existing
abort boundary. An incomplete public result remains blocking with exit `3`.

## D23 — Second-wave languages preserve the production fact contract

Expand Down
13 changes: 9 additions & 4 deletions docs/language-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,15 @@ documentation format, independently of syntax-language dispatch.
## Failure behavior

Malformed applicable syntax and failures to load a required parser provider or
grammar are fail-closed tool errors (exit `3`). Code Guard does not produce a
partial heuristic result. Files with unsupported extensions are simply
inapplicable to syntax or Markdown guards; other applicable guards may still
consider them according to their own configured extension policy.
grammar are fail-closed unavailable evidence (exit `3`). The runner records the
affected path, embedded language, failure kind, and exact message, discards all
syntax facts for that file, and continues independent LOC, Markdown, and other
source-language analysis. It never manufactures partial or heuristic syntax
metrics. Direct syntax-pipeline callers remain strict and raise the original
error. Files with unsupported extensions are instead inapplicable to syntax or
Markdown guards; other applicable guards may still consider them according to
their own configured extension policy. A completed FAIL is different again: it
is an authoritative completed guard finding, not unavailable provider evidence.

Operating-system, architecture, Python-version, and native-wheel availability
are documented separately in [Platform support](platform-support.md).
26 changes: 22 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,11 +202,13 @@ must not create a ratchet baseline.
- `PASS` means no special action and exits `0`.
- `REVIEW` means inspect findings and apply judgment; normal invocation exits `1`.
- `FAIL` blocks normal completion and exits `2`.
- Tool, configuration, parser/provider, or scope errors exit `3`.
- `INCOMPLETE` retains independent completed evidence when known per-file
syntax/provider evidence is unavailable and exits `3`.
- Other tool, configuration, or scope errors exit `3` without a completed report.

`--ci` changes a REVIEW-only result to exit `0`. It does not suppress FAIL or
tool errors. REVIEW is not automatic refactoring, and metrics must never be
gamed.
`--ci` changes a REVIEW-only result to exit `0`. It does not suppress FAIL,
INCOMPLETE, or tool errors. REVIEW is not automatic refactoring, and metrics
must never be gamed.

## Human and JSON output

Expand Down Expand Up @@ -253,6 +255,17 @@ Counts do not change aggregate state, findings, required policies, or exit
codes. Tool errors retain their existing human or JSON error form and do not
include a successful `scope` object.

When a known per-file syntax or provider failure occurs, the headline is
`INCOMPLETE`, followed by ordered unavailable context and the incomplete syntax
guard identifiers before ordinary findings. JSON uses `overall: "incomplete"`,
adds the authoritative completed aggregate as `completedOverall`, and includes
ordered top-level `unavailable` records containing `path`, embedded `language`,
`kind`, and the exact provider message. `scope.unavailable` overlaps
`analyzed`, so `analyzed + inapplicable == selected` remains true. Every guard
adds `complete` only on incomplete runs; incomplete syntax guards also add
ordered `unavailablePaths`. Guard states, findings, and `requiredPolicies`
continue to describe only completed evidence.

Choose a completed-analysis serialization mode explicitly when needed:

```bash
Expand All @@ -269,6 +282,11 @@ but their normalized state is `pass`. `debug` is an explicit name for the full
output and is byte-for-byte identical to bare `--json` for the same completed
analysis.

For incomplete output, full and debug retain identical unavailable records;
compact filters only ordinary passing findings and also retains those records
unchanged. All three JSON modes, human output, normal invocation, and `--ci`
exit `3`.

Both named modes require `--json` and apply only to completed analysis output.
They do not change analysis, scope, policies, ordering, aggregate or guard
states, exit codes, or error shapes and channels. Version JSON supports only
Expand Down
4 changes: 4 additions & 0 deletions src/agent_code_guard/analysis/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
class AnalysisError(RuntimeError):
"""Base error suitable for Code Guard's existing exit-3 boundary."""

def __init__(self, message: str, *, language: str | None = None) -> None:
super().__init__(message)
self.language = language


class ProviderUnavailableError(AnalysisError):
"""The configured parser provider or a required grammar is unavailable."""
Expand Down
91 changes: 66 additions & 25 deletions src/agent_code_guard/analysis/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

from .adapters import extract_facts
from .csharp_compat import corrected_csharp_root
from .errors import SyntaxAnalysisError
from .errors import ProviderUnavailableError, SyntaxAnalysisError
from .facts import AnalysisFacts, FileFacts
from .provider import ParserProvider, TreeSitterProvider
from .regions import executable_regions, is_applicable
Expand All @@ -15,31 +16,71 @@
def analyze_files(files: tuple[Path, ...] | list[Path], provider: ParserProvider | None = None) -> AnalysisFacts:
"""Analyze only applicable entries from the already-resolved caller scope."""
active_provider = provider or TreeSitterProvider()
results = [_analyze_file(Path(path), active_provider) for path in files if is_applicable(Path(path))]
return AnalysisFacts(tuple(results))


@dataclass(frozen=True)
class UnavailableAnalysis:
path: Path
language: str
kind: str
message: str


@dataclass(frozen=True)
class BatchAnalysis:
facts: AnalysisFacts
unavailable: tuple[UnavailableAnalysis, ...]


def analyze_files_for_runner(
files: tuple[Path, ...] | list[Path], provider: ParserProvider | None = None,
) -> BatchAnalysis:
"""Analyze selected files independently while retaining only known unavailable evidence."""
active_provider = provider or TreeSitterProvider()
results: list[FileFacts] = []
for path in files:
path = Path(path)
unavailable: list[UnavailableAnalysis] = []
for value in files:
path = Path(value)
if not is_applicable(path):
continue
callables = []
controls = []
decisions = []
regions = executable_regions(path, active_provider)
for region in regions:
tree = active_provider.parse(region.language, region.source)
root = tree.root_node
if tree.root_node.has_error:
root = (
corrected_csharp_root(active_provider, region.source, tree)
if region.language == "csharp"
else None
try:
results.append(_analyze_file(path, active_provider))
except (SyntaxAnalysisError, ProviderUnavailableError) as exc:
if exc.language is None:
raise
kind = "syntax" if isinstance(exc, SyntaxAnalysisError) else "provider"
unavailable.append(UnavailableAnalysis(path, exc.language, kind, str(exc)))
return BatchAnalysis(AnalysisFacts(tuple(results)), tuple(unavailable))


def _analyze_file(path: Path, provider: ParserProvider) -> FileFacts:
callables = []
controls = []
decisions = []
regions = executable_regions(path, provider)
for region in regions:
try:
tree = provider.parse(region.language, region.source)
except (SyntaxAnalysisError, ProviderUnavailableError) as exc:
if exc.language is None:
raise type(exc)(str(exc), language=region.language) from exc
raise
root = tree.root_node
if tree.root_node.has_error:
root = (
corrected_csharp_root(provider, region.source, tree)
if region.language == "csharp"
else None
)
if root is None:
raise SyntaxAnalysisError(
f"unable to parse {path}: embedded {region.language} syntax tree contains errors",
language=region.language,
)
if root is None:
raise SyntaxAnalysisError(
f"unable to parse {path}: embedded {region.language} syntax tree contains errors"
)
region_callables, region_controls, region_decisions = extract_facts(root, region)
callables.extend(region_callables)
controls.extend(region_controls)
decisions.extend(region_decisions)
results.append(FileFacts(path, tuple(callables), tuple(controls), tuple(decisions), len(regions)))
return AnalysisFacts(tuple(results))
region_callables, region_controls, region_decisions = extract_facts(root, region)
callables.extend(region_callables)
controls.extend(region_controls)
decisions.extend(region_decisions)
return FileFacts(path, tuple(callables), tuple(controls), tuple(decisions), len(regions))
8 changes: 5 additions & 3 deletions src/agent_code_guard/analysis/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ def parse(self, language: str, source: bytes):
except (ImportError, LookupError, OSError, RuntimeError) as exc:
raise ProviderUnavailableError(
f"syntax provider unavailable for supported language {language!r}: {exc}; "
"reinstall Agent Code Guard"
"reinstall Agent Code Guard",
language=language,
) from exc
self._parsers[language] = parser
try:
return parser.parse(source)
except Exception as exc:
except RuntimeError as exc:
raise ProviderUnavailableError(
f"syntax provider failed for supported language {language!r}: {exc}; "
"verify the Agent Code Guard installation"
"verify the Agent Code Guard installation",
language=language,
) from exc
12 changes: 9 additions & 3 deletions src/agent_code_guard/analysis/regions.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,19 @@ def executable_regions(path: Path, provider: ParserProvider) -> tuple[Executable
def _vue_regions(path: Path, source: bytes, provider: ParserProvider) -> tuple[ExecutableRegion, ...]:
root = provider.parse("vue", source).root_node
if root.has_error:
raise SyntaxAnalysisError(f"unable to parse {path}: Vue container syntax tree contains errors")
raise SyntaxAnalysisError(
f"unable to parse {path}: Vue container syntax tree contains errors", language="vue",
)
regions: list[ExecutableRegion] = []
for element in root.named_children:
if element.type != "script_element":
continue
start_tag = next(child for child in element.named_children if child.type == "start_tag")
attributes = _attributes(start_tag, source)
if "src" in attributes:
raise SyntaxAnalysisError(f"unable to analyze {path}: external Vue script regions are unsupported")
raise SyntaxAnalysisError(
f"unable to analyze {path}: external Vue script regions are unsupported", language="vue",
)
language = _script_language(path, attributes.get("lang"))
raw_text = next((child for child in element.named_children if child.type == "raw_text"), None)
if raw_text is not None:
Expand Down Expand Up @@ -106,4 +110,6 @@ def _script_language(path: Path, value: str | None) -> str:
return "javascript"
if value in {"ts", "typescript"}:
return "typescript"
raise SyntaxAnalysisError(f"unable to analyze {path}: unsupported Vue script language: {value}")
raise SyntaxAnalysisError(
f"unable to analyze {path}: unsupported Vue script language: {value}", language="vue",
)
Loading
Loading