From 6b18e16f8282499140797f95241d94dce664cbcd Mon Sep 17 00:00:00 2001 From: Rudra Date: Tue, 9 Jun 2026 02:26:41 +0530 Subject: [PATCH] Make ANCP output ultra-surgical --- MANIFEST.in | 3 + README.md | 6 +- docs/cli-contract.md | 38 ++++++++-- docs/compact-signal-layer.md | 84 ++++++++++++++++------- docs/toolchains.md | 4 +- examples/buggy/javascript/broken.js | 5 ++ pyproject.toml | 1 + src/ancp/adapters/base.py | 66 ++++++++++++++++++ src/ancp/cli.py | 103 +++++++++++++++++++++++++--- src/ancp/install.py | 4 +- src/ancp/proxy.py | 16 +++-- src/ancp/render.py | 103 ++++++++++++++++++++++++++++ src/ancp/shim.py | 15 ++++ tests/test_cli_documents.py | 65 +++++++++++++++++- tests/test_proxy_and_shims.py | 18 +++-- tests/test_render.py | 72 ++++++++++++++++++- tools/check_toolchains.py | 29 +++++++- tools/run_bug_corpus.py | 1 + 18 files changed, 571 insertions(+), 62 deletions(-) create mode 100644 examples/buggy/javascript/broken.js diff --git a/MANIFEST.in b/MANIFEST.in index b456009..e40b923 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,6 +11,9 @@ recursive-include taxonomies *.json recursive-include examples *.json recursive-include examples/buggy * recursive-exclude examples/buggy .ancp/* native-output*.txt +prune examples/buggy/*/.ancp +prune examples/buggy/*/*/.ancp +recursive-exclude examples/buggy *.pyc native-output*.txt recursive-include docs *.md recursive-include spec *.md recursive-include tools *.py diff --git a/README.md b/README.md index 6848d0b..b6705ec 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ compiler / linter / test / LSP / build tool agents, IDEs, CI, repair bots ``` -For agent terminals, ANCP can also render compact raw text from the same JSON: +For agent terminals, ANCP can also render ultra-minimal raw text from the same JSON: ```text normal compiler command @@ -71,11 +71,11 @@ normal compiler command ANCP result.check JSON + raw native logs | v -minimal root-cause text for agents +one-line root-cause text for agents ``` See [Compact Signal Layer](docs/compact-signal-layer.md) for `ancp enable`, -`auto-compact`, token savings telemetry, and raw-output fallback behavior. +`auto-ultra`, token savings telemetry, `ancp raw`, and `ancp off -- ...`. ## Design Principles diff --git a/docs/cli-contract.md b/docs/cli-contract.md index b7b7994..25047f6 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -27,7 +27,9 @@ ancp repair --apply --json ancp verify --json ancp graph --json ancp skills --json -ancp render --from check-result.json --format text +ancp render --from check-result.json --format ultra +ancp raw +ancp off -- ancp enable ancp disable ancp status @@ -242,11 +244,39 @@ Renders ANCP JSON into a user- or agent-facing text format. ```bash ancp render --from check-result.json --format markdown ancp render --from check-result.json --format text --budget 800 +ancp render --from check-result.json --format ultra --budget 200 ``` Markdown output is intended for human inspection. Text output is the compact -agent signal format: no Markdown fences, no tables, root-cause groups first, -raw-output fallback path included when available. +agent signal format. Ultra output is the default terminal failure style: +no protocol banner, no raw log path, no token stats, no Markdown fences, and no +guidance unless explicitly requested elsewhere. + +## `raw` + +Prints raw native output captured by the latest or selected ANCP check result. + +```bash +ancp raw +ancp raw --path +ancp raw --stream stderr +ancp raw --from .ancp/last-check.json +``` + +Raw logs are saved by proxy/shim mode but not printed in ultra terminal output. +`raw` is the explicit retrieval command. + +## `off` + +Runs a native command while bypassing ANCP interception. + +```bash +ancp off -- python -m py_compile app.py +ancp off -- cargo check +``` + +This command resolves the real executable while ignoring ANCP shim scripts, then +prints native stdout/stderr exactly as the underlying tool produced them. ## `enable`, `disable`, `uninstall`, and `status` @@ -262,7 +292,7 @@ ancp uninstall ancp status ``` -`enable` installs compiler/tool shims and configures `auto-compact` output by +`enable` installs compiler/tool shims and configures `auto-ultra` output by default. On Windows, `--scope user` prepends the ANCP shim directory to the user PATH. `--scope session` prints activation commands without mutating persistent PATH. diff --git a/docs/compact-signal-layer.md b/docs/compact-signal-layer.md index dbdb45c..655ec90 100644 --- a/docs/compact-signal-layer.md +++ b/docs/compact-signal-layer.md @@ -15,8 +15,8 @@ smaller and more stable signal: - primary source location, - root-cause grouping, - repair direction, -- raw output fallback path, -- token/byte savings telemetry. +- raw output fallback available by command, +- token/byte savings telemetry in JSON. The compact signal layer does not replace the ANCP JSON contract. It renders the JSON into minimal raw text for agent context. @@ -43,42 +43,47 @@ raw stdout/stderr saved under .ancp/runs// result.check JSON written to .ancp/last-check.json | v -compact raw text emitted to the agent when the command fails +ultra-minimal error text emitted to the agent when the command fails ``` -Successful commands pass through by default in `auto-compact` mode. Failed -commands emit compact ANCP text and keep full native output on disk. +Successful commands pass through by default in `auto-ultra` mode. Failed +commands emit surgical one-line ANCP text and keep full native output on disk. ## Output Modes | Mode | Behavior | |---|---| | `passthrough` | Print native stdout/stderr exactly as emitted. | -| `auto-compact` | Print native output for successful commands; print compact ANCP text for failed commands. | +| `auto-ultra` | Print native output for successful commands; print the shortest useful ANCP text for failed commands. | +| `auto-compact` | Compatibility alias for auto-ultra failure rendering. | +| `ultra` | Always print ultra-minimal ANCP text. | | `compact` | Always print compact ANCP raw text. | | `json` | Print the full `result.check` JSON document. | | `both` | Print native output and append compact ANCP text. | Project-local shims default to `passthrough` unless configured otherwise. -`ancp enable` defaults to `auto-compact`. +`ancp enable` defaults to `auto-ultra`. -## Minimal Text Format +## Ultra Text Format -The compact text intentionally avoids Markdown syntax. It is designed for agent -context, not documentation. +The default failure output intentionally avoids Markdown syntax, protocol names, +raw log paths, token stats, and guidance. It is designed to be smaller than the +useful portion of the native error when possible. ```text -ANCP result.check failed diagnostics=1 -exit=1 durationMs=92 -raw=C:\repo\.ancp\runs\sha256-abc\native.log -tokens native~2400 compact~180 saved~92% -summary severity=error:1 kind=syntax:1 -root_causes=1 -1. code=ancp.diag.syntax.invalid native=SyntaxError kind=syntax count=1 - at=C:/repo/app.py:15:1 - msg=SyntaxError: expected ':' - fix=Fix Python syntax [review_required] c=0.40 -agent_next=fix root_causes first; rerun native command before claiming verified +SyntaxError src/app.py:15 expected ':' fix:fix syntax +``` + +For repeated failures, ANCP groups by root cause: + +```text +TS2304 src/app.ts:8 Cannot find name 'user' x14 fix:import symbol +``` + +The richer compact renderer is still available when wanted: + +```powershell +ancp render --from .ancp\last-check.json --format text --budget 800 ``` ## Global Windows Enablement @@ -128,7 +133,7 @@ $env:PATH="$HOME\.ancp\bin;$env:PATH" Render compact text from any ANCP JSON document: ```powershell -ancp render --from .ancp\last-check.json --format text --budget 800 +ancp render --from .ancp\last-check.json --format ultra --budget 200 ``` Render Markdown for human inspection: @@ -137,6 +142,35 @@ Render Markdown for human inspection: ancp render --from .ancp\last-check.json --format markdown ``` +## Raw Output And Bypass + +Raw logs are saved but not printed in ultra output. Show the latest native log: + +```powershell +ancp raw +``` + +Print only the raw log path: + +```powershell +ancp raw --path +``` + +Show raw stderr from the latest run: + +```powershell +ancp raw --stream stderr +``` + +Run a command without ANCP interception: + +```powershell +ancp off -- python -m py_compile app.py +``` + +This is the intended manual escape hatch when an agent or user wants native +errors exactly as the compiler produced them. + ## Raw Output Contract Every proxied run records raw output metadata in `data.rawOutput`: @@ -158,9 +192,9 @@ Every proxied run also records `data.signalMetrics`: "nativeBytes": 2048, "compactBytes": 420, "estimatedNativeTokens": 512, - "estimatedCompactTokens": 105, - "estimatedSavingsPercent": 79, - "renderer": "raw-text" + "estimatedCompactTokens": 20, + "estimatedSavingsPercent": 96, + "renderer": "ultra" } ``` diff --git a/docs/toolchains.md b/docs/toolchains.md index 451c8e8..73a04ea 100644 --- a/docs/toolchains.md +++ b/docs/toolchains.md @@ -43,14 +43,14 @@ Python and PowerShell usually exist on Windows machines already, but they are st | --- | --- | | Python | `python` | | TypeScript | `tsc` | -| JavaScript | `eslint` | +| JavaScript | `eslint` preferred, `node --check` fallback | | Rust | `cargo`, `rustc` | | Go | `go` | | C | `gcc` or `clang` | | C++ | `g++` or `clang++` | | Java | `javac` | | Kotlin | `kotlinc` | -| C#/.NET | `dotnet` | +| C#/.NET | .NET SDK via `dotnet` | | Swift | `swift` | | Zig | `zig` | | Ruby | `ruby` | diff --git a/examples/buggy/javascript/broken.js b/examples/buggy/javascript/broken.js new file mode 100644 index 0000000..5eeca3f --- /dev/null +++ b/examples/buggy/javascript/broken.js @@ -0,0 +1,5 @@ +function broken(value) { + if (value > 1) { + console.log(value) +} + diff --git a/pyproject.toml b/pyproject.toml index 9e128d2..5f3ffa3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ ancp = "ancp.cli:main" ancp-validate = "ancp.cli:validate_main" ancp-tsc = "ancp.proxy:tsc_main" ancp-eslint = "ancp.proxy:eslint_main" +ancp-node = "ancp.proxy:node_main" ancp-pyright = "ancp.proxy:pyright_main" ancp-ruff = "ancp.proxy:ruff_main" ancp-python = "ancp.proxy:python_main" diff --git a/src/ancp/adapters/base.py b/src/ancp/adapters/base.py index d140310..36871df 100644 --- a/src/ancp/adapters/base.py +++ b/src/ancp/adapters/base.py @@ -276,9 +276,30 @@ class JavaScriptAdapter(Adapter): tools = [ ToolSpec("eslint", "linter", ["eslint", "--format", "json", "."]), ToolSpec("eslint", "linter", ["npx", "--no-install", "eslint", "--format", "json", "."], version_args=["npx", "--no-install", "eslint", "--version"]), + ToolSpec("node", "compiler", ["node", "--check"], version_args=["node", "--version"]), ] + def run_check(self, root: pathlib.Path, tool: ToolSpec, timeout: int) -> CommandResult: + if tool.name != "node": + return run_command(tool.command, root, timeout) + files = [path for path in list_files(root, self.file_extensions, limit=100) if path.suffix.lower() in {".js", ".mjs", ".cjs"}] + if not files: + return run_command(["node", "--version"], root, timeout=timeout) + stdout: list[str] = [] + stderr: list[str] = [] + last: CommandResult | None = None + for path in files: + last = run_command(["node", "--check", str(path)], root, timeout=timeout) + stdout.append(last.stdout) + stderr.append(last.stderr) + if last.exit_code not in (0, None): + break + assert last is not None + return CommandResult(last.argv, root, last.started_at, last.ended_at, last.duration_ms, last.exit_code, "\n".join(stdout), "\n".join(stderr)) + def parse_result(self, root: pathlib.Path, result: CommandResult, tool: ToolSpec) -> list[dict[str, Any]]: + if tool.name == "node": + return self._parse_node_check(root, result.stderr + "\n" + result.stdout) import json try: @@ -312,6 +333,43 @@ def parse_result(self, root: pathlib.Path, result: CommandResult, tool: ToolSpec ) return diagnostics + def _parse_node_check(self, root: pathlib.Path, text: str) -> list[dict[str, Any]]: + import re + + diagnostics: list[dict[str, Any]] = [] + pattern = re.compile(r"^(?P.+?\.(?:cjs|mjs|js)):(?P\d+)$") + lines = text.splitlines() + for index, line in enumerate(lines): + match = pattern.match(line.strip()) + if not match: + continue + message = "JavaScript syntax error" + native_code = "SyntaxError" + for candidate in lines[index + 1 : index + 10]: + stripped = candidate.strip() + if stripped.startswith("SyntaxError:"): + message = stripped.removeprefix("SyntaxError:").strip() + break + path = pathlib.Path(match.group("file")) + if not path.is_absolute(): + path = (root / path).resolve() + canonical, kind, hints = canonical_for_native(native_code, message) + diagnostics.append( + doc.diagnostic( + f"diag-node-{len(diagnostics) + 1:04d}", + canonical, + native_code, + "error", + kind, + message, + doc.location(path, "javascript", int(match.group("line")) - 1, 0), + "node", + hints, + {"raw": "\n".join(lines[index : index + 8])}, + ) + ) + return diagnostics + class PythonAdapter(Adapter): key = "python" @@ -500,6 +558,14 @@ class DotnetAdapter(Adapter): markers = {".sln", ".csproj", ".fsproj", ".vbproj", "Directory.Build.props"} tools = [ToolSpec("dotnet", "build", ["dotnet", "build", "--nologo"])] + def available_tool(self) -> ToolSpec | None: + if not find_executable("dotnet"): + return None + result = run_command(["dotnet", "--list-sdks"], pathlib.Path.cwd(), timeout=10) + if result.exit_code in (0, None) and result.stdout.strip(): + return self.tools[0] + return None + def parse_result(self, root: pathlib.Path, result: CommandResult, tool: ToolSpec) -> list[dict[str, Any]]: return parse_text_lines(result.stdout + "\n" + result.stderr, DOTNET_RE, root, "csharp", "dotnet", "diag-dotnet") diff --git a/src/ancp/cli.py b/src/ancp/cli.py index 22f24d6..29825be 100644 --- a/src/ancp/cli.py +++ b/src/ancp/cli.py @@ -6,6 +6,7 @@ import json import pathlib import os +import subprocess import sys from typing import Any @@ -18,10 +19,10 @@ from .install import uninstall as uninstall_ancp from .install import write_shims from .proxy import compile_main -from .render import render_markdown, render_text +from .render import render_markdown, render_text, render_ultra from .schema import load_schema, validate_document, validate_path from .shim import executable_names -from .util import find_workspace, read_json, write_json_stdout +from .util import find_executable, find_workspace, read_json, write_json_stdout def resolve_workspace(value: str | None) -> pathlib.Path: @@ -45,6 +46,11 @@ def manifest_document() -> dict[str, Any]: {"name": "verify", "profile": "verify", "supported": True, "stability": "stable"}, {"name": "graph", "profile": "graph", "supported": True, "stability": "stable"}, {"name": "skills", "profile": "skills", "supported": True, "stability": "stable"}, + {"name": "render", "profile": "core", "supported": True, "stability": "stable"}, + {"name": "raw", "profile": "core", "supported": True, "stability": "stable"}, + {"name": "off", "profile": "core", "supported": True, "stability": "stable"}, + {"name": "enable", "profile": "core", "supported": True, "stability": "stable"}, + {"name": "disable", "profile": "core", "supported": True, "stability": "stable"}, {"name": "export sarif", "profile": "export", "supported": True, "stability": "experimental"}, ], "languages": [adapter.language_entry() for adapter in ADAPTERS], @@ -430,6 +436,71 @@ def validate_command(args: argparse.Namespace) -> int: return 1 if failures else 0 +def raw_command(args: argparse.Namespace) -> int: + path = pathlib.Path(args.from_path) + document = read_json(path) + raw = ((document.get("data") or {}).get("rawOutput") or {}) if isinstance(document, dict) else {} + key = "combinedPath" + if args.stream == "stdout": + key = "stdoutPath" + elif args.stream == "stderr": + key = "stderrPath" + raw_path_text = raw.get(key) + if not raw_path_text: + print(f"No raw {args.stream} log recorded in {path}", file=sys.stderr) + return 2 + raw_path = pathlib.Path(raw_path_text) + if args.path: + print(raw_path) + return 0 + if args.open: + if os.name == "nt": + os.startfile(raw_path) # type: ignore[attr-defined] + return 0 + opener = "open" if sys.platform == "darwin" else "xdg-open" + subprocess.Popen([opener, str(raw_path)]) + return 0 + try: + if args.tail and args.tail > 0: + lines = raw_path.read_text(encoding="utf-8", errors="replace").splitlines() + print("\n".join(lines[-args.tail:])) + else: + sys.stdout.write(raw_path.read_text(encoding="utf-8", errors="replace")) + except OSError as exc: + print(f"Could not read raw log {raw_path}: {exc}", file=sys.stderr) + return 2 + return 0 + + +def off_command(args: argparse.Namespace) -> int: + command = list(args.command_args) + if command and command[0] == "--": + command = command[1:] + if not command: + print("usage: ancp off -- [args...]", file=sys.stderr) + return 2 + executable = find_executable(command[0]) + if not executable: + print(f"Executable not found: {command[0]}", file=sys.stderr) + return 127 + env = os.environ.copy() + env["ANCP_BYPASS"] = "1" + proc = subprocess.run( + [executable, *command[1:]], + cwd=os.getcwd(), + env=env, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=False, + ) + sys.stdout.write(proc.stdout) + sys.stderr.write(proc.stderr) + return proc.returncode + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="ancp", description="Agent Native Compiler Protocol reference CLI.") parser.add_argument("--version", action="version", version=f"ancp {__version__}") @@ -470,7 +541,7 @@ def build_parser() -> argparse.ArgumentParser: shims.add_argument("--workspace", default=None) shims.add_argument("--dir", default=".ancp/bin") shims.add_argument("--force", action="store_true") - shims.add_argument("--output-mode", default="passthrough", choices=["passthrough", "auto-compact", "compact", "json", "both"]) + shims.add_argument("--output-mode", default="passthrough", choices=["passthrough", "auto-compact", "auto-ultra", "ultra", "compact", "json", "both"]) shims.add_argument("--output-budget", type=int, default=None) enable = sub.add_parser("enable") @@ -479,8 +550,8 @@ def build_parser() -> argparse.ArgumentParser: enable.add_argument("--home", default=None) enable.add_argument("--force", action="store_true") enable.add_argument("--dry-run", action="store_true") - enable.add_argument("--output-mode", default="auto-compact", choices=["passthrough", "auto-compact", "compact", "json", "both"]) - enable.add_argument("--output-budget", type=int, default=800) + enable.add_argument("--output-mode", default="auto-ultra", choices=["passthrough", "auto-compact", "auto-ultra", "ultra", "compact", "json", "both"]) + enable.add_argument("--output-budget", type=int, default=200) disable = sub.add_parser("disable") disable.add_argument("--scope", default="user", choices=["user", "session"]) @@ -494,13 +565,23 @@ def build_parser() -> argparse.ArgumentParser: status_parser = sub.add_parser("status") status_parser.add_argument("--home", default=None) + raw = sub.add_parser("raw") + raw.add_argument("--from", dest="from_path", default=".ancp/last-check.json") + raw.add_argument("--stream", choices=["combined", "stdout", "stderr"], default="combined") + raw.add_argument("--path", action="store_true") + raw.add_argument("--open", action="store_true") + raw.add_argument("--tail", type=int, default=None) + + off = sub.add_parser("off") + off.add_argument("command_args", nargs=argparse.REMAINDER) + validate = sub.add_parser("validate") validate.add_argument("paths", nargs="+") render = sub.add_parser("render") render.add_argument("--from", dest="from_path", required=True) render.add_argument("--max-diagnostics", type=int, default=40) - render.add_argument("--format", default="markdown", choices=["markdown", "text"]) + render.add_argument("--format", default="markdown", choices=["markdown", "text", "ultra"]) render.add_argument("--budget", type=int, default=None) schema = sub.add_parser("schema") @@ -579,12 +660,18 @@ def main(argv: list[str] | None = None) -> int: if args.command == "status": write_json_stdout(install_status(home=pathlib.Path(args.home) if args.home else None)) return 0 + if args.command == "raw": + return raw_command(args) + if args.command == "off": + return off_command(args) if args.command == "validate": return validate_command(args) if args.command == "render": document = read_json(pathlib.Path(args.from_path)) - if args.format == "text": - print(render_text(document, max_diagnostics=args.max_diagnostics, token_budget=args.budget)) + if args.format == "ultra": + sys.stdout.write(render_ultra(document, token_budget=args.budget)) + elif args.format == "text": + sys.stdout.write(render_text(document, max_diagnostics=args.max_diagnostics, token_budget=args.budget)) else: print(render_markdown(document, max_diagnostics=args.max_diagnostics)) return 0 diff --git a/src/ancp/install.py b/src/ancp/install.py index cac9b31..44b1f75 100644 --- a/src/ancp/install.py +++ b/src/ancp/install.py @@ -183,8 +183,8 @@ def enable( home: pathlib.Path | None = None, force: bool = False, dry_run: bool = False, - output_mode: str = "auto-compact", - output_budget: int | None = 800, + output_mode: str = "auto-ultra", + output_budget: int | None = 200, ) -> dict[str, Any]: resolved_home = (home or default_home()).resolve() shim_dir = default_shim_dir(resolved_home) diff --git a/src/ancp/proxy.py b/src/ancp/proxy.py index a25bf45..21718f4 100644 --- a/src/ancp/proxy.py +++ b/src/ancp/proxy.py @@ -17,7 +17,7 @@ from . import documents as doc from .adapters import get_adapter from .adapters.base import ToolSpec -from .render import estimate_tokens, render_text +from .render import estimate_tokens, render_text, render_ultra from .schema import validate_document from .util import command_run_object, find_workspace, path_to_uri, run_command, sha256_text @@ -25,6 +25,7 @@ SHIM_TO_ADAPTER_AND_COMMAND = { "ancp-tsc": ("typescript", "tsc"), "ancp-eslint": ("javascript", "eslint"), + "ancp-node": ("javascript", "node"), "ancp-pyright": ("python", "pyright"), "ancp-ruff": ("python", "ruff"), "ancp-python": ("python", "python"), @@ -146,7 +147,7 @@ def write_raw_output(root: pathlib.Path, run_id: str, stdout: str, stderr: str) def annotate_signal_metrics(document: dict[str, Any], stdout: str, stderr: str) -> None: native_text = stdout + stderr native_tokens = estimate_tokens(native_text) - compact_tokens = estimate_tokens(render_text(document, max_diagnostics=12, token_budget=None)) + compact_tokens = estimate_tokens(render_ultra(document, token_budget=None)) savings = 0 if native_tokens: savings = max(0, round((1 - (compact_tokens / native_tokens)) * 100)) @@ -156,9 +157,9 @@ def annotate_signal_metrics(document: dict[str, Any], stdout: str, stderr: str) "estimatedNativeTokens": native_tokens, "estimatedCompactTokens": compact_tokens, "estimatedSavingsPercent": savings, - "renderer": "raw-text", + "renderer": "ultra", } - final_compact = render_text(document, max_diagnostics=12, token_budget=None) + final_compact = render_ultra(document, token_budget=None) final_compact_tokens = estimate_tokens(final_compact) final_savings = max(0, round((1 - (final_compact_tokens / native_tokens)) * 100)) if native_tokens else 0 document["data"]["signalMetrics"].update( @@ -180,6 +181,8 @@ def format_proxy_output( normalized = mode.lower().replace("_", "-") if normalized in {"passthrough", "native", "raw"}: return stdout, stderr + if normalized in {"ultra", "surgical", "tiny"}: + return render_ultra(document, token_budget=token_budget), "" if normalized in {"compact", "text", "minimal"}: return render_text(document, token_budget=token_budget), "" if normalized in {"json", "result"}: @@ -187,10 +190,10 @@ def format_proxy_output( if normalized in {"both", "native-and-compact"}: compact = render_text(document, token_budget=token_budget) return stdout, stderr + ("\n" if stderr and not stderr.endswith("\n") else "") + compact - if normalized in {"auto", "auto-compact", "agent"}: + if normalized in {"auto", "auto-compact", "agent", "auto-ultra"}: if document.get("status") == "passed": return stdout, stderr - return render_text(document, token_budget=token_budget), "" + return render_ultra(document, token_budget=token_budget), "" raise ValueError(f"unknown ANCP output mode: {mode}") @@ -292,6 +295,7 @@ def _main() -> int: tsc_main = make_shim("ancp-tsc") eslint_main = make_shim("ancp-eslint") +node_main = make_shim("ancp-node") pyright_main = make_shim("ancp-pyright") ruff_main = make_shim("ancp-ruff") python_main = make_shim("ancp-python") diff --git a/src/ancp/render.py b/src/ancp/render.py index e088501..36a6e0e 100644 --- a/src/ancp/render.py +++ b/src/ancp/render.py @@ -19,6 +19,24 @@ def _location_label(diagnostic: dict[str, Any]) -> str: return f"{uri}:{line}:{char}" +def _short_location_label(diagnostic: dict[str, Any]) -> str: + loc = diagnostic.get("primaryLocation", {}) + artifact = loc.get("artifact", {}) + uri = artifact.get("uri", "") + rng = loc.get("range", {}) + start = rng.get("start", {}) + line = int(start.get("line", 0)) + 1 + if uri.startswith("file://"): + uri = uri.replace("file:///", "").replace("file://", "") + if uri: + text = uri.replace("\\", "/") + parts = [part for part in text.split("/") if part] + short = "/".join(parts[-2:]) if len(parts) >= 2 else parts[0] if parts else "" + else: + short = "" + return f"{short}:{line}" + + def _repair_label(diagnostic: dict[str, Any]) -> str: hints = diagnostic.get("repairHints") or [] if not hints: @@ -42,6 +60,45 @@ def _repair_title(diagnostic: dict[str, Any]) -> str: return title + suffix +def _repair_ultra(diagnostic: dict[str, Any]) -> str: + hints = diagnostic.get("repairHints") or [] + if not hints: + return "" + best = sorted(hints, key=lambda item: item.get("confidence", 0), reverse=True)[0] + title = best.get("title") or best.get("repairId") or "" + replacements = { + "Fix Python syntax": "fix syntax", + "Fix invalid syntax": "fix syntax", + "Import missing symbol": "import symbol", + "Add or fix the missing dependency/import path": "fix import", + } + return replacements.get(title, str(title).strip().lower())[:60] + + +def _short_code(diagnostic: dict[str, Any]) -> str: + native = diagnostic.get("nativeCode") + canonical = str(diagnostic.get("canonicalCode", "diag")).removeprefix("ancp.diag.") + kind = str(diagnostic.get("kind", "")).strip() + if native and len(str(native)) <= 16 and str(native) not in {"ArgumentError"}: + return str(native) + if canonical: + return canonical + return kind or "diag" + + +def _ultra_message(diagnostic: dict[str, Any]) -> str: + message = " ".join(str(diagnostic.get("message", "")).split()) + native = diagnostic.get("nativeCode") + if native and message.lower().startswith(str(native).lower() + ":"): + message = message[len(str(native)) + 1 :].strip() + if diagnostic.get("kind") == "import": + message = message.removeprefix("package ") + if " is not in std " in message: + message = message.split(" is not in std ", 1)[0] + " not found" + message = message.replace("Package ", "").replace(" not found in current path.", " not found") + return message[:160] + + def _group_key(diagnostic: dict[str, Any]) -> tuple[str, str, str]: return ( diagnostic.get("canonicalCode", "ancp.diag.unknown"), @@ -241,3 +298,49 @@ def render_text( if include_guidance: lines.append("agent_next=fix root_causes first; rerun native command before claiming verified") return "\n".join(truncate_to_budget(lines, token_budget)).rstrip() + "\n" + + +def render_ultra(document: dict[str, Any], max_groups: int = 8, token_budget: int | None = 200) -> str: + """Render the smallest useful agent-facing diagnostic output. + + Ultra output intentionally hides protocol metadata, raw paths, token stats, + guidance, and document identity. Full detail remains in JSON/raw logs. + """ + + diagnostics = document.get("diagnostics") or [] + data = document.get("data") or {} + if not diagnostics: + reason = data.get("stderrSummary") or data.get("stdoutSummary") + if reason: + text = " ".join(str(reason).split())[:180] + return truncate_text(f"tool_failed {text}\n", token_budget) + status = document.get("status", "ok") + return f"{status}\n" + + grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list) + for diagnostic in diagnostics: + grouped[_group_key(diagnostic)].append(diagnostic) + + lines: list[str] = [] + for _, group in sorted(grouped.items(), key=lambda item: len(item[1]), reverse=True)[:max_groups]: + diagnostic = group[0] + count = f" x{len(group)}" if len(group) > 1 else "" + location = _short_location_label(diagnostic) + code = _short_code(diagnostic) + message = _ultra_message(diagnostic) + repair = _repair_ultra(diagnostic) + line = f"{code} {location} {message}{count}".strip() + if repair and repair not in line.lower(): + line += f" fix:{repair}" + lines.append(line) + if len(grouped) > max_groups: + lines.append(f"+{len(grouped) - max_groups} more") + return truncate_text("\n".join(lines).rstrip() + "\n", token_budget) + + +def truncate_text(text: str, token_budget: int | None) -> str: + if token_budget is None or token_budget <= 0 or estimate_tokens(text) <= token_budget: + return text + byte_budget = max(16, token_budget * 4) + encoded = text.encode("utf-8", errors="replace")[:byte_budget] + return encoded.decode("utf-8", errors="ignore").rstrip() + "\n" diff --git a/src/ancp/shim.py b/src/ancp/shim.py index 2d069e5..e9bc1d0 100644 --- a/src/ancp/shim.py +++ b/src/ancp/shim.py @@ -11,6 +11,7 @@ import os import pathlib import shutil +import subprocess import sys from typing import Iterable @@ -22,6 +23,7 @@ NATIVE_TO_ADAPTER = { "tsc": "typescript", "eslint": "javascript", + "node": "javascript", "pyright": "python", "ruff": "python", "python": "python", @@ -103,6 +105,19 @@ def run_native_name(native_name: str, argv: list[str], shim_dir: pathlib.Path | if not real: print(f"ANCP shim could not find real executable for {native_name}", file=sys.stderr) return 127 + if os.environ.get("ANCP_BYPASS"): + proc = subprocess.run( + [real, *argv], + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=False, + ) + sys.stdout.write(proc.stdout) + sys.stderr.write(proc.stderr) + return proc.returncode root = pathlib.Path(os.environ["ANCP_WORKSPACE"]).resolve() if os.environ.get("ANCP_WORKSPACE") else pathlib.Path.cwd().resolve() timeout = int(os.environ.get("ANCP_TIMEOUT", "120")) out = pathlib.Path(os.environ["ANCP_OUT"]) if os.environ.get("ANCP_OUT") else None diff --git a/tests/test_cli_documents.py b/tests/test_cli_documents.py index 47b6ce2..bc84913 100644 --- a/tests/test_cli_documents.py +++ b/tests/test_cli_documents.py @@ -1,8 +1,10 @@ from __future__ import annotations +import json +import sys from pathlib import Path -from ancp.cli import aggregate_status, capabilities_document, graph_document, resolve_workspace, skills_document, verify_document +from ancp.cli import aggregate_status, capabilities_document, graph_document, main, resolve_workspace, skills_document, verify_document from ancp.adapters import get_adapter from ancp.schema import validate_document @@ -64,3 +66,64 @@ def test_internal_json_toml_yaml_adapters_validate(tmp_path: Path) -> None: assert document["status"] == "failed" assert document["diagnostics"] assert validate_document(document) == [] + + +def test_javascript_adapter_accepts_node_check_fallback(tmp_path: Path) -> None: + (tmp_path / "broken.js").write_text("function broken( {\n", encoding="utf-8") + adapter = get_adapter("javascript") + assert adapter is not None + document = adapter.check(tmp_path) + if document["status"] == "failed": + assert document["diagnostics"] + assert document["diagnostics"][0]["canonicalCode"] == "ancp.diag.syntax.invalid" + else: + assert document["status"] == "tool_failed" + assert validate_document(document) == [] + + +def test_raw_command_reads_recorded_native_log(tmp_path: Path, capsys) -> None: + raw_log = tmp_path / "native.log" + raw_log.write_text("native error text\n", encoding="utf-8") + check = tmp_path / "last-check.json" + check.write_text( + json.dumps({"documentKind": "result.check", "data": {"rawOutput": {"combinedPath": str(raw_log)}}}), + encoding="utf-8", + ) + assert main(["raw", "--from", str(check)]) == 0 + assert capsys.readouterr().out == "native error text\n" + + +def test_render_ultra_command_does_not_add_extra_blank_line(tmp_path: Path, capsys) -> None: + check = tmp_path / "last-check.json" + check.write_text( + json.dumps( + { + "documentKind": "result.check", + "status": "failed", + "diagnostics": [ + { + "id": "d1", + "canonicalCode": "ancp.diag.syntax.invalid", + "nativeCode": "SyntaxError", + "severity": "error", + "kind": "syntax", + "message": "SyntaxError: expected ':'", + "primaryLocation": { + "artifact": {"uri": "file:///repo/src/app.py"}, + "range": {"start": {"line": 14, "character": 0}}, + }, + } + ], + } + ), + encoding="utf-8", + ) + assert main(["render", "--from", str(check), "--format", "ultra"]) == 0 + assert capsys.readouterr().out == "SyntaxError src/app.py:15 expected ':'\n" + + +def test_off_command_runs_native_command_without_ancp(capsys) -> None: + assert main(["off", "--", sys.executable, "-c", "print('native')"]) == 0 + captured = capsys.readouterr() + assert captured.out == "native\n" + assert captured.err == "" diff --git a/tests/test_proxy_and_shims.py b/tests/test_proxy_and_shims.py index ea964c0..6f59d6f 100644 --- a/tests/test_proxy_and_shims.py +++ b/tests/test_proxy_and_shims.py @@ -33,7 +33,7 @@ def test_proxy_preserves_python_failure_and_emits_valid_ancp(tmp_path: Path) -> raw = document["data"]["rawOutput"] assert Path(raw["combinedPath"]).exists() assert document["data"]["signalMetrics"]["estimatedNativeTokens"] >= 1 - assert document["data"]["signalMetrics"]["renderer"] == "raw-text" + assert document["data"]["signalMetrics"]["renderer"] == "ultra" def test_auto_compact_replaces_failure_output_with_minimal_signal(tmp_path: Path) -> None: @@ -47,24 +47,28 @@ def test_auto_compact_replaces_failure_output_with_minimal_signal(tmp_path: Path ) compact_stdout, compact_stderr = format_proxy_output(document, stdout, stderr, "auto-compact", 120) assert compact_stderr == "" - assert "ANCP result.check failed" in compact_stdout - assert "code=ancp.diag.syntax.invalid" in compact_stdout + assert "ANCP" not in compact_stdout + assert "result.check" not in compact_stdout + assert "raw=" not in compact_stdout assert "SyntaxError" in compact_stdout assert "File \"" not in compact_stdout + assert len(compact_stdout) < len(stderr) def test_install_shims_creates_native_names(tmp_path: Path) -> None: shim_dir = tmp_path / "bin" - created = install_shims(shim_dir, force=True, output_mode="auto-compact", output_budget=800) + created = install_shims(shim_dir, force=True, output_mode="auto-ultra", output_budget=200) assert created if os.name == "nt": assert (shim_dir / "cargo.cmd").exists() + assert (shim_dir / "node.cmd").exists() assert (shim_dir / "python.cmd").exists() - assert "ANCP_OUTPUT_MODE=auto-compact" in (shim_dir / "python.cmd").read_text(encoding="utf-8") + assert "ANCP_OUTPUT_MODE=auto-ultra" in (shim_dir / "python.cmd").read_text(encoding="utf-8") else: assert (shim_dir / "cargo").exists() + assert (shim_dir / "node").exists() assert (shim_dir / "python").exists() - assert "ANCP_OUTPUT_MODE=\"auto-compact\"" in (shim_dir / "python").read_text(encoding="utf-8") + assert "ANCP_OUTPUT_MODE=\"auto-ultra\"" in (shim_dir / "python").read_text(encoding="utf-8") def test_enable_dry_run_profiles_global_shims_without_mutating_path(tmp_path: Path) -> None: @@ -72,7 +76,7 @@ def test_enable_dry_run_profiles_global_shims_without_mutating_path(tmp_path: Pa assert payload["dryRun"] is True assert payload["enabled"] is False assert payload["wouldEnable"] is True - assert payload["outputMode"] == "auto-compact" + assert payload["outputMode"] == "auto-ultra" assert "python" in payload["tools"] assert "powershell" not in payload["tools"] assert payload["wouldCreate"] diff --git a/tests/test_render.py b/tests/test_render.py index 1b7b0f1..dbb0891 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ancp.render import render_markdown, render_text +from ancp.render import render_markdown, render_text, render_ultra def test_render_groups_and_compresses_diagnostics() -> None: @@ -96,3 +96,73 @@ def test_render_text_is_minimal_and_budgeted() -> None: assert "tokens native~1000 compact~80 saved~92%" in text assert "code=ancp.diag.syntax.invalid native=SyntaxError" in text assert "fix=Fix Python syntax [review_required] c=0.40" in text + + +def test_render_ultra_hides_protocol_metadata() -> None: + document = { + "documentKind": "result.check", + "status": "failed", + "data": { + "rawOutput": {"combinedPath": ".ancp/runs/abc/native.log"}, + "signalMetrics": {"estimatedNativeTokens": 1000, "estimatedCompactTokens": 80, "estimatedSavingsPercent": 92}, + }, + "diagnostics": [ + { + "id": "d1", + "canonicalCode": "ancp.diag.syntax.invalid", + "nativeCode": "SyntaxError", + "severity": "error", + "kind": "syntax", + "message": "SyntaxError: expected ':'", + "primaryLocation": { + "artifact": {"uri": "file:///repo/src/app.py"}, + "range": {"start": {"line": 14, "character": 0}}, + }, + "repairHints": [ + { + "repairId": "ancp.repair.syntax.insert_token", + "title": "Fix Python syntax", + "confidence": 0.4, + "safetyLevel": "review_required", + } + ], + } + ], + } + ultra = render_ultra(document) + assert ultra == "SyntaxError src/app.py:15 expected ':' fix:fix syntax\n" + assert "ANCP" not in ultra + assert "result.check" not in ultra + assert "raw=" not in ultra + assert "tokens" not in ultra + + +def test_render_ultra_shortens_missing_import_noise() -> None: + document = { + "documentKind": "result.check", + "status": "failed", + "diagnostics": [ + { + "id": "d1", + "canonicalCode": "ancp.diag.import.missing", + "severity": "error", + "kind": "import", + "message": "package not/a/real/package is not in std (C:\\Program Files\\Go\\src\\not\\a\\real\\package)", + "primaryLocation": { + "artifact": {"uri": "file:///repo/go/main.go"}, + "range": {"start": {"line": 3, "character": 7}}, + }, + "repairHints": [ + { + "repairId": "ancp.repair.module.add_dependency", + "title": "Add or fix the missing dependency/import path", + "confidence": 0.5, + "safetyLevel": "review_required", + } + ], + } + ], + } + ultra = render_ultra(document) + assert ultra == "import.missing go/main.go:4 not/a/real/package not found fix:fix import\n" + assert "Program Files" not in ultra diff --git a/tools/check_toolchains.py b/tools/check_toolchains.py index 155a148..0101770 100644 --- a/tools/check_toolchains.py +++ b/tools/check_toolchains.py @@ -6,6 +6,7 @@ import argparse import json import shutil +import subprocess from dataclasses import dataclass @@ -19,7 +20,7 @@ class Toolchain: TOOLCHAINS = [ Toolchain("python", ("python", "python3"), "Required for ANCP itself and Python syntax checks."), Toolchain("typescript", ("tsc",), "TypeScript compiler checks."), - Toolchain("javascript", ("eslint", "node"), "ESLint preferred; Node is runtime-only fallback context."), + Toolchain("javascript", ("eslint", "node"), "ESLint preferred; Node --check syntax fallback accepted."), Toolchain("rust", ("cargo", "rustc"), "Cargo/rustc JSON diagnostics."), Toolchain("go", ("go",), "Go build/test tooling."), Toolchain("c", ("gcc", "clang"), "GCC or Clang C frontend."), @@ -57,10 +58,13 @@ def availability(languages: set[str] | None = None) -> list[dict[str, object]]: if languages and toolchain.language not in languages: continue found = {name: shutil.which(name) for name in toolchain.required_any} + available = any(found.values()) + if toolchain.language == "csharp": + available = _dotnet_sdk_available() rows.append( { "language": toolchain.language, - "available": any(found.values()), + "available": available, "tools": found, "notes": toolchain.notes, } @@ -68,6 +72,23 @@ def availability(languages: set[str] | None = None) -> list[dict[str, object]]: return rows +def _dotnet_sdk_available() -> bool: + if not shutil.which("dotnet"): + return False + try: + result = subprocess.run( + ["dotnet", "--list-sdks"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + shell=False, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 and bool(result.stdout.strip()) + + def print_table(rows: list[dict[str, object]]) -> None: width = max(len(str(row["language"])) for row in rows) for row in rows: @@ -76,7 +97,9 @@ def print_table(rows: list[dict[str, object]]) -> None: present = [name for name, path in tools.items() if path] missing = [name for name, path in tools.items() if not path] status = "ok" if row["available"] else "missing" - detail = ", ".join(present) if present else "missing: " + ", ".join(missing) + detail = ", ".join(present) if present and row["available"] else "missing: " + ", ".join(missing) + if row["language"] == "csharp" and present and not row["available"]: + detail = "dotnet present, SDK missing" print(f"{str(row['language']).ljust(width)} {status.ljust(7)} {detail}") diff --git a/tools/run_bug_corpus.py b/tools/run_bug_corpus.py index 13e60d5..a10b3e3 100644 --- a/tools/run_bug_corpus.py +++ b/tools/run_bug_corpus.py @@ -22,6 +22,7 @@ def ancp_command(*args: str) -> list[str]: CASES = [ ("python", ROOT / "examples/buggy/python", ancp_command("check", "--workspace", ".", "--language", "python")), ("typescript", ROOT / "examples/buggy/typescript", ancp_command("check", "--workspace", ".", "--language", "typescript")), + ("javascript", ROOT / "examples/buggy/javascript", ancp_command("check", "--workspace", ".", "--language", "javascript")), ("rust", ROOT / "examples/buggy/rust", ancp_command("check", "--workspace", ".", "--language", "rust")), ("go", ROOT / "examples/buggy/go", ancp_command("check", "--workspace", ".", "--language", "go")), ("c-cpp-c", ROOT / "examples/buggy/c", ancp_command("check", "--workspace", ".", "--language", "c-cpp")),