diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8a649b6..15aae4b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -77,6 +77,7 @@ jobs:
python3.12 -m pip install -e ".[all]"
python3.12 -m pip install "pytest>=7,<10"
+ # Includes Topic 9 parser, diagnostic, CLI and benchmark regression tests.
- name: Run all topic tests
run: |
mkdir -p benchmark_reports
@@ -114,7 +115,7 @@ jobs:
-o benchmark_reports/tests.html
- name: Upload test reports
- if: github.ref == 'refs/heads/main'
+ if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: test-reports
@@ -187,6 +188,24 @@ jobs:
python3.12 -m pip install -e ".[all]"
python3.12 -m pip install markdown "pytest>=7,<10"
+ - name: Topic 9 DSL diagnostics benchmark
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
+ run: |
+ if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
+ echo "::error::No baseline commit available for DSL comparison."
+ exit 1
+ fi
+ git fetch origin "$BASE_SHA" --depth=1
+ BASELINE=$(mktemp -d "$RUNNER_TEMP/dsl-baseline.XXXXXX")
+ git worktree add --detach "$BASELINE" "$BASE_SHA"
+ trap 'git worktree remove --force "$BASELINE"' EXIT
+ python3.12 -m benchmarks.bench_dsl_diagnostics \
+ --baseline-root "$BASELINE" \
+ --json-output benchmark_reports/dsl_diagnostics.json \
+ --markdown benchmark_reports/dsl_diagnostics.md \
+ --html benchmark_reports/dsl_diagnostics.html
+
# ── 3.1 ONNX 模型管线基准测试 ──────────────────────────────────────
- name: ONNX model pipeline benchmarks
run: python3.12 -m pytest benchmarks/test_benchmark.py -v --tb=short
@@ -338,6 +357,9 @@ jobs:
- name: Write job summary
if: always()
run: |
+ if [ -f benchmark_reports/dsl_diagnostics.md ]; then
+ cat benchmark_reports/dsl_diagnostics.md >> "$GITHUB_STEP_SUMMARY"
+ fi
if [ -f benchmark_reports/const_merge_report.md ]; then
cat benchmark_reports/const_merge_report.md >> $GITHUB_STEP_SUMMARY
fi
diff --git a/benchmarks/bench_dsl_diagnostics.py b/benchmarks/bench_dsl_diagnostics.py
new file mode 100644
index 0000000..054330a
--- /dev/null
+++ b/benchmarks/bench_dsl_diagnostics.py
@@ -0,0 +1,364 @@
+"""Topic 9 diagnostics acceptance and same-corpus parser A/B measurements.
+
+Run with ``python -m benchmarks.bench_dsl_diagnostics --baseline-root PATH``.
+Only the standard library is imported until an isolated worker selects a checkout.
+"""
+
+from __future__ import annotations
+
+import argparse
+import contextlib
+import gc
+import hashlib
+import html
+import importlib.metadata
+import io
+import json
+import math
+import os
+from pathlib import Path
+import platform
+import statistics
+import subprocess
+import sys
+import tempfile
+import time
+from typing import Any, Callable
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def measure(action: Callable[[], Any], warmup: int, groups: int, iterations: int) -> dict:
+ """Time complete batches, excluding imports, file I/O and explicit GC."""
+ for _ in range(warmup):
+ action()
+ samples = []
+ for _ in range(groups):
+ gc.collect()
+ start = time.perf_counter()
+ for _ in range(iterations):
+ action()
+ samples.append(time.perf_counter() - start)
+ return {"samples_s": samples, "median_s": statistics.median(samples)}
+
+
+def revision(root: Path) -> str | None:
+ """Do not label an unpacked baseline with its enclosing repository's SHA."""
+ try:
+ top = subprocess.check_output(
+ ["git", "-C", str(root), "rev-parse", "--show-toplevel"],
+ text=True, encoding="utf-8", stderr=subprocess.DEVNULL,
+ ).strip()
+ if Path(top).resolve() != root.resolve():
+ return None
+ return subprocess.check_output(
+ ["git", "-C", str(root), "rev-parse", "HEAD"],
+ text=True, encoding="utf-8", stderr=subprocess.DEVNULL,
+ ).strip()
+ except (OSError, subprocess.CalledProcessError):
+ return None
+
+
+def parse_worker(args: argparse.Namespace) -> dict:
+ from scratchv.frontend import dsl_extended
+
+ parser_file = Path(dsl_extended.__file__).resolve()
+ if not parser_file.is_relative_to(args.worker_root):
+ raise RuntimeError(f"parser imported outside selected checkout: {parser_file}")
+ paths = sorted(args.cases.glob("*.dsl"))
+ if not paths:
+ raise ValueError(f"no DSL cases in {args.cases}")
+ sources = {path.name: path.read_text(encoding="utf-8") for path in paths}
+
+ def parse_one(name: str, source: str) -> Any:
+ try:
+ # The legacy parser has no filename keyword. Use its public common API.
+ return dsl_extended.ExtendedDSLParser().parse(source)
+ except Exception as exc:
+ raise RuntimeError(f"{name}: {exc}") from exc
+
+ hashes = {
+ name: hashlib.sha256(parse_one(name, source).dump().encode("utf-8")).hexdigest()
+ for name, source in sources.items()
+ }
+
+ def batch() -> None:
+ for name, source in sources.items():
+ parse_one(name, source)
+
+ return {
+ **measure(batch, args.warmup, args.groups, args.iterations),
+ "root": str(args.worker_root), "revision": revision(args.worker_root),
+ "parser_file": str(parser_file), "parser": "ExtendedDSLParser",
+ "ir_hashes": hashes,
+ "case_hashes": {
+ name: hashlib.sha256(source.encode("utf-8")).hexdigest()
+ for name, source in sources.items()
+ },
+ }
+
+
+def diagnostic_cases() -> list[dict]:
+ """Expected locations are fixed independently of the validator output."""
+ rows = [
+ ("keyword_typo", "retrun x", [("E100", 1, 1)], "did you mean 'return'?"),
+ ("parenthesis", "x = add(a, b", [("E101", 1, 8)], "add the missing ')'"),
+ ("unknown_op", "x = ad(a, b)", [("E200", 1, 5)], "did you mean 'add'?"),
+ ("arity", "x = add(a)", [("E201", 1, 9)], None),
+ ("stray_closer", "endif", [("E110", 1, 1)], None),
+ ("missing_closer", "if (a > b):\nx = add(a, b)", [("E111", 1, 1)], "add missing 'endif'"),
+ ("duplicate_else", "if (a > b):\nelse:\nelse:\nendif", [("E112", 3, 1)], None),
+ ("keyword_argument", "x = softmax(a, unknown:1)", [("E202", 1, 16)], None),
+ ("numeric_argument", "x = softmax(a, axis:nope)", [("E203", 1, 21)], None),
+ ("tab_crlf_unicode", "# 中文\r\n\tretrun x\r\n", [("E100", 2, 2)], "did you mean 'return'?"),
+ ("three_errors", "retrun x\ny = ad(a, b)\nz = relu(a, b)\n",
+ [("E100", 1, 1), ("E200", 2, 5), ("E201", 3, 10)], None),
+ ("error_limit", "\n".join(f"bad statement {i}" for i in range(25)),
+ [("E100", i, 1) for i in range(1, 21)], None),
+ ]
+ return [
+ {"name": name, "source": source, "expected": expected, "hint": hint}
+ for name, source, expected, hint in rows
+ ]
+
+
+def diagnostics_worker(args: argparse.Namespace) -> dict:
+ from scratchv.frontend.dsl_extended import ExtendedDSLParser
+ from scratchv.main import main as compiler_main
+
+ results = []
+ with tempfile.TemporaryDirectory(prefix="dsl-diagnostics-") as directory:
+ for case in diagnostic_cases():
+ source = case["source"]
+ path = Path(directory) / f"{case['name']}.dsl"
+ path.write_bytes(source.encode("utf-8"))
+
+ def validate() -> Any:
+ return ExtendedDSLParser().validate(source, filename=str(path))
+
+ collector = validate()
+ errors = collector.errors
+ actual = [(error.error_code, error.line, error.col) for error in errors]
+ rendered = collector.report()
+ stdout, stderr = io.StringIO(), io.StringIO()
+ output = path.with_suffix(".s")
+ with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
+ exit_code = compiler_main([str(path), "-o", str(output)])
+ cli_text = stderr.getvalue()
+ checks = {
+ "codes_and_locations": actual == case["expected"],
+ "source_and_filename": all(
+ error.filename == str(path)
+ and error.source_line == source.splitlines()[error.line - 1]
+ for error in errors
+ ),
+ "hint": case["hint"] is None or case["hint"] in rendered,
+ "plain_output": "\x1b[" not in rendered + cli_text,
+ "source_marker": "^" in rendered,
+ "limit": collector.limit_reached == (case["name"] == "error_limit"),
+ "cli_exit_code": exit_code == 1,
+ "cli_diagnostics_once": cli_text.count(": error[") == len(case["expected"]),
+ "cli_locations": all(
+ f"{path}:{line}:{col}: error[{code}]" in cli_text
+ for code, line, col in case["expected"]
+ ),
+ "no_output_artifact": not output.exists(),
+ }
+ results.append({
+ "name": case["name"], "passed": all(checks.values()), "checks": checks,
+ "source": source, "expected": case["expected"], "actual": actual,
+ "actual_codes": [error.error_code for error in errors],
+ "actual_count": len(errors), "limit_reached": collector.limit_reached,
+ "rendered": rendered, "cli_stderr": cli_text, "cli_exit_code": exit_code,
+ "validation": measure(validate, args.warmup, args.groups, args.iterations),
+ "rendering": measure(collector.report, args.warmup, args.groups, args.iterations),
+ })
+ return {"passed": all(case["passed"] for case in results), "cases": results}
+
+
+def run_worker(root: Path, mode: str, args: argparse.Namespace) -> dict:
+ if not (root / "scratchv" / "frontend" / "dsl_extended.py").is_file():
+ raise ValueError(f"{mode} checkout has no DSL parser: {root}")
+ command = [
+ sys.executable, "-I", "-X", "utf8", str(Path(__file__).resolve()),
+ "--worker-root", str(root), "--worker-mode", mode,
+ "--cases", str(args.cases), "--warmup", str(args.warmup),
+ "--groups", str(args.groups), "--iterations", str(args.iterations),
+ ]
+ result = subprocess.run(
+ command, cwd=root, capture_output=True, text=True, encoding="utf-8",
+ timeout=args.worker_timeout,
+ env={**os.environ, "OMP_NUM_THREADS": "1", "OPENBLAS_NUM_THREADS": "1"},
+ )
+ if result.returncode:
+ raise RuntimeError(f"{mode} worker failed at {root}:\n{result.stderr}")
+ return json.loads(result.stdout)
+
+
+def compare_parsing(baseline: dict, current: dict, target: float) -> dict:
+ same_cases = baseline["case_hashes"] == current["case_hashes"]
+ same_ir = baseline["ir_hashes"] == current["ir_hashes"]
+ ratio = current["median_s"] / baseline["median_s"]
+ return {
+ "baseline": baseline, "current": current, "same_cases": same_cases,
+ "ir_equal": same_ir, "ratio": ratio, "target_ratio": target,
+ "target_met": ratio <= target,
+ }
+
+
+def build_report(args: argparse.Namespace) -> dict:
+ report: dict[str, Any] = {
+ "schema_version": 1, "benchmark": "DSL diagnostics", "status": "failed",
+ "environment": {
+ "python": platform.python_version(), "executable": sys.executable,
+ "platform": platform.platform(), "processor": platform.processor(),
+ "dependencies": {name: importlib.metadata.version(name) for name in ("onnx", "numpy")},
+ "worker_threads": 1,
+ },
+ "configuration": {
+ "warmup": args.warmup, "groups": args.groups, "iterations": args.iterations,
+ "performance_enforced": args.enforce_performance,
+ "timing_scope": "one batch = iterations through the full corpus; parser construction + parse; excludes imports, file I/O and IR hashing",
+ },
+ "warnings": [],
+ }
+ try:
+ # Record diagnostic evidence even if the legacy parser cannot parse a case.
+ report["diagnostics"] = run_worker(ROOT, "diagnostics", args)
+ if args.baseline_root is None:
+ raise ValueError("--baseline-root is required; no baseline is fabricated")
+ baseline = run_worker(args.baseline_root, "baseline", args)
+ current = run_worker(ROOT, "current", args)
+ parsing = compare_parsing(baseline, current, args.max_parse_ratio)
+ report["parsing"] = parsing
+ if not parsing["same_cases"] or not parsing["ir_equal"]:
+ raise ValueError("baseline/current case corpus or generated IR differs")
+ if not parsing["target_met"]:
+ report["warnings"].append(
+ f"Parsing ratio {parsing['ratio']:.3f}x exceeds target {args.max_parse_ratio:.3f}x."
+ )
+ if report["diagnostics"]["passed"] and (
+ parsing["target_met"] or not args.enforce_performance
+ ):
+ report["status"] = "passed"
+ except Exception as exc:
+ report["error"] = f"{type(exc).__name__}: {exc}"
+ return report
+
+
+def markdown_report(report: dict, *, include_examples: bool = True) -> str:
+ lines = ["# DSL diagnostics", "", f"Functional/report status: **{report['status']}**", ""]
+ if "error" in report:
+ lines += ["## Error", "", "```text", report["error"], "```", ""]
+ env = report["environment"]
+ lines += [f"Python: {env['python']}; platform: {env['platform']}", ""]
+ config = report["configuration"]
+ lines += [f"Warmups: {config['warmup']}; groups: {config['groups']}; iterations/group: {config['iterations']}.",
+ config["timing_scope"], ""]
+ if "parsing" in report:
+ parsing = report["parsing"]
+ lines += ["## Correct DSL parsing", "", "| Metric | Baseline | Current |", "|---|---:|---:|"]
+ for key, label in [("revision", "Revision"), ("median_s", "Batch median (s)")]:
+ lines.append(f"| {label} | {parsing['baseline'][key]} | {parsing['current'][key]} |")
+ lines += ["", f"Current / baseline: **{parsing['ratio']:.3f}x**; target: {parsing['target_ratio']:.3f}x; target met: **{parsing['target_met']}**.",
+ f"Same case corpus: {parsing['same_cases']}; identical IR dumps: {parsing['ir_equal']}.",
+ f"Performance target enforced: {config['performance_enforced']}.", "",
+ "Cases: " + ", ".join(parsing["current"]["case_hashes"]), ""]
+ if "diagnostics" in report:
+ lines += ["## Diagnostic acceptance and timings", "",
+ "Validation/rendering times are per operation (batch median divided by iterations). CLI runs are correctness checks, not timed.", "",
+ "| Case | Passed | Errors | Validate (ms) | Render (ms) |",
+ "|---|---|---:|---:|---:|"]
+ for case in report["diagnostics"]["cases"]:
+ scale = 1000 / config["iterations"]
+ lines.append(f"| {case['name']} | {case['passed']} | {case['actual_count']} | {case['validation']['median_s'] * scale:.4f} | {case['rendering']['median_s'] * scale:.4f} |")
+ for case in report["diagnostics"]["cases"]:
+ if not case["passed"]:
+ lines += ["", f"{case['name']} failed checks: " + ", ".join(key for key, value in case["checks"].items() if not value), ""]
+ if include_examples:
+ lines += ["", "## Diagnostic examples", ""]
+ for case in report["diagnostics"]["cases"]:
+ label = f"{case['name']}: {case['actual_count']} error(s) - view diagnostic log"
+ lines += ["", f"{html.escape(label)}
", "",
+ "```text", case["rendered"], "```", "", " ", ""]
+ for warning in report["warnings"]:
+ lines += [f"Warning: {warning}", ""]
+ return "\n".join(lines)
+
+
+def positive_int(value: str) -> int:
+ result = int(value)
+ if result <= 0:
+ raise argparse.ArgumentTypeError("must be positive")
+ return result
+
+
+def positive_float(value: str) -> float:
+ result = float(value)
+ if not math.isfinite(result) or result <= 0:
+ raise argparse.ArgumentTypeError("must be finite and positive")
+ return result
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--baseline-root", type=Path, help="separate checkout of the PR base revision")
+ parser.add_argument("--cases", type=Path, default=ROOT / "benchmarks" / "cases")
+ parser.add_argument("--warmup", type=int, default=5)
+ parser.add_argument("--groups", type=positive_int, default=10)
+ parser.add_argument("--iterations", type=positive_int, default=100)
+ parser.add_argument("--max-parse-ratio", type=positive_float, default=1.5)
+ parser.add_argument("--enforce-performance", action="store_true")
+ parser.add_argument("--worker-timeout", type=positive_int, default=300)
+ parser.add_argument("--json", action="store_true", help="emit only JSON to stdout")
+ parser.add_argument("--json-output", type=Path)
+ parser.add_argument("--markdown", type=Path)
+ parser.add_argument("--html", type=Path)
+ parser.add_argument("--worker-root", type=Path, help=argparse.SUPPRESS)
+ parser.add_argument("--worker-mode", choices=("baseline", "current", "diagnostics"), help=argparse.SUPPRESS)
+ args = parser.parse_args(argv)
+ if args.warmup < 0:
+ parser.error("--warmup must be non-negative")
+ args.cases = args.cases.resolve()
+ if args.baseline_root:
+ args.baseline_root = args.baseline_root.resolve()
+ if args.worker_root:
+ args.worker_root = args.worker_root.resolve()
+ sys.path.insert(0, str(args.worker_root))
+ # Keep stdout a JSON channel even if imported compiler code logs there.
+ with contextlib.redirect_stdout(sys.stderr):
+ result = diagnostics_worker(args) if args.worker_mode == "diagnostics" else parse_worker(args)
+ print(json.dumps(result, ensure_ascii=True))
+ return 0
+ report = build_report(args)
+ json_text = json.dumps(report, indent=2, ensure_ascii=True) + "\n"
+ markdown = markdown_report(report)
+ html_examples = []
+ for case in report.get("diagnostics", {}).get("cases", []):
+ label = f"{case['name']}: {case['actual_count']} error(s) - view diagnostic log"
+ html_examples.append(
+ '' + html.escape(label) + '
'
+ + html.escape(case["rendered"]) + '
'
+ )
+ html_text = (
+ ''
+ '
DSL diagnostics' + html.escape(markdown_report(report, include_examples=False))
+ + '
' + ('Diagnostic examples
' if html_examples else '')
+ + ''.join(html_examples) + '\n'
+ )
+ for path, content in [(args.json_output, json_text), (args.markdown, markdown), (args.html, html_text)]:
+ if path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ print(json_text if args.json else markdown, end="\n")
+ return 0 if report["status"] == "passed" else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/docs/superpowers/plans/2026-08-12-dsl-diagnostics-implementation.md b/docs/superpowers/plans/2026-08-12-dsl-diagnostics-implementation.md
new file mode 100644
index 0000000..0b2e1e9
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-dsl-diagnostics-implementation.md
@@ -0,0 +1,231 @@
+# DSL Diagnostics Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Integrate structured, location-aware diagnostics into ScratchV's base and extended DSL paths while preserving successful DSL IR output and leaving all non-DSL behavior unchanged.
+
+**Architecture:** Add a DSL-only source/validation layer shared by both parsers. Validation runs before IR generation, reports stable diagnostic codes through `DSLSyntaxError` and `ErrorCollector`, and passes structured diagnostics through only the DSL branches of `CompilerDriver` and the CLI.
+
+**Tech Stack:** Python 3.12-compatible type hints, dataclasses, regular expressions, argparse, pytest.
+
+---
+
+## File map and scope boundary
+
+- Create `scratchv/frontend/dsl_validator.py`: source preservation, shared operator signatures, line validation, block-stack recovery, multi-error collection.
+- Modify `scratchv/frontend/dsl_errors.py`: compatible diagnostic spans, plain/automatic rendering, deterministic collector semantics.
+- Modify `scratchv/frontend/dsl_parser.py`: pre-IR validation and filename-aware errors; preserve successful IR generation.
+- Modify `scratchv/frontend/dsl_extended.py`: use shared validation before extended IR generation; preserve successful control-flow IR.
+- Modify `scratchv/frontend/__init__.py`: export new DSL-only public diagnostic interfaces.
+- Modify `scratchv/compiler.py`: only DSL parsing and diagnostic result fields.
+- Modify `scratchv/main.py`: only structured DSL diagnostic rendering and top-level internal-error exit handling.
+- Create `tests/test_dsl_validator.py`: validator and parser integration tests.
+- Create `tests/test_dsl_diagnostics_cli.py`: CompilerDriver/CLI DSL diagnostics tests.
+- Modify `tests/test_dsl_errors.py`: error model, renderer and collector regression tests.
+- Do not modify ONNX parsing, IR definitions, optimizer passes, backends, simulators, benchmarks, or their tests.
+
+### Task 1: Diagnostic model and rendering
+
+**Files:**
+- Modify: `scratchv/frontend/dsl_errors.py`
+- Test: `tests/test_dsl_errors.py`
+
+- [ ] **Step 1: Write failing tests for compatibility, spans, tabs and automatic color**
+
+Add tests asserting that `DSLSyntaxError` remains catchable as `DSLParseError`, accepts exclusive `end_col`, renders `error[E100]`, expands tabs at four-column stops, keeps `str(error)` ANSI-free, and `render_error(..., use_color=None)` honors `isatty()` and `NO_COLOR`.
+
+- [ ] **Step 2: Verify the new renderer tests fail for missing behavior**
+
+Run: `python -m pytest tests/test_dsl_errors.py -q`
+
+Expected: failures for `end_col`, `render_error`, header placement, tab alignment, and collector limit metadata.
+
+- [ ] **Step 3: Implement the minimal compatible diagnostic API**
+
+Implement these interfaces without changing existing positional constructor compatibility:
+
+```python
+@dataclass
+class DSLSyntaxError(DSLParseError):
+ line: int
+ col: int
+ message: str
+ source_line: str = ""
+ filename: Optional[str] = None
+ fix_hint: Optional[str] = None
+ error_code: Optional[str] = None
+ end_col: Optional[int] = None
+
+def render_error(
+ err: DSLSyntaxError,
+ *,
+ stream: TextIO,
+ use_color: Optional[bool] = None,
+) -> str: ...
+```
+
+Keep `format_error()` available, default unknown filenames to ``, render codes as `error[E100]:`, and calculate display columns by expanding tabs to four-column stops.
+
+- [ ] **Step 4: Make collector semantics deterministic**
+
+Add `limit_reached`; stop at exactly `max_errors`; do not append a fake line-zero error; deduplicate by `(filename, line, col, error_code, message)`; sort reports by `(line, col, error_code or "")`; reset all state in `clear()`.
+
+- [ ] **Step 5: Verify Task 1 is green**
+
+Run: `python -m pytest tests/test_dsl_errors.py -q`
+
+Expected: all tests in the file pass with no warnings.
+
+### Task 2: Shared source buffer and base DSL validation
+
+**Files:**
+- Create: `scratchv/frontend/dsl_validator.py`
+- Modify: `scratchv/frontend/dsl_parser.py`
+- Test: `tests/test_dsl_validator.py`
+- Test: `tests/test_parser.py`
+
+- [ ] **Step 1: Write failing source-buffer and E100/E101/E200/E201 tests**
+
+Cover LF/CRLF, blank input, trailing newline, Unicode and tabs. Add parser-facing cases for `retrun x`, missing call parenthesis, unsupported `ad`, and incorrect arity for unary/binary/keyword operators. Assert exact 1-based line/column, source line, filename and stable code.
+
+- [ ] **Step 2: Verify base-validator RED behavior**
+
+Run: `python -m pytest tests/test_dsl_validator.py tests/test_parser.py -q`
+
+Expected: new tests fail because `SourceBuffer`, `DSLValidator.validate()` and filename-aware `parse()` do not exist.
+
+- [ ] **Step 3: Add the shared syntax facts**
+
+Define immutable `SourceBuffer`, `OpSignature`, base statement patterns, keyword sets, and `OP_SIGNATURES` for every operator dispatched by `DSLParser`. Validate assignment structure with `fullmatch`, split arguments without creating IR, and stop validation of a line after its first structural error.
+
+- [ ] **Step 4: Add base block and signature validation**
+
+Validate `for/endfor`, `return`, identifiers, parentheses, supported operations, positional counts, known/duplicate kwargs and numeric kwargs. Preserve the language rule that first-use variable names are valid inputs.
+
+- [ ] **Step 5: Gate base parsing before IR generation**
+
+Expose:
+
+```python
+def validate(
+ self,
+ text: str,
+ *,
+ filename: Optional[str] = None,
+ max_errors: int = 20,
+) -> ErrorCollector: ...
+
+def parse(
+ self,
+ text: str,
+ *,
+ filename: Optional[str] = None,
+) -> Program: ...
+```
+
+`parse()` raises the first `DSLSyntaxError` before resetting and using `IRBuilder`; successful programs retain their previous `Program.dump()` structure.
+
+- [ ] **Step 6: Verify base validation and successful IR are green**
+
+Run: `python -m pytest tests/test_dsl_validator.py tests/test_parser.py tests/test_dsl_errors.py -q`
+
+Expected: all selected tests pass.
+
+### Task 3: Extended block validation and recovery
+
+**Files:**
+- Modify: `scratchv/frontend/dsl_validator.py`
+- Modify: `scratchv/frontend/dsl_extended.py`
+- Test: `tests/test_dsl_validator.py`
+- Test: `tests/test_dsl_extended.py`
+
+- [ ] **Step 1: Write failing E110/E111/E112 recovery tests**
+
+Cover stray closers, missing closers, `else` without `if`, duplicate `else`, `while ... endif`, `if ... while ... endif`, nested blocks at EOF, and three independent errors in one file. Assert mismatched closers do not also create recovered-block E111 errors.
+
+- [ ] **Step 2: Verify extended-validator RED behavior**
+
+Run: `python -m pytest tests/test_dsl_validator.py tests/test_dsl_extended.py -q`
+
+Expected: new block-validation cases fail under the current silent-EOF behavior.
+
+- [ ] **Step 3: Implement one shared block stack**
+
+Use `BlockFrame(kind, line, col, saw_else)` in the validator. Match `endif/endwhile/endfor` against the stack; on mismatch, emit one E110 and recover by popping through an outer matching block or treating the closer as recovery for the top frame. Emit E111 only for frames left at EOF.
+
+- [ ] **Step 4: Gate extended parsing with the shared validator**
+
+Override validation only to enable extended rules, then validate before any labels, blocks or instructions are created. Keep the existing successful `if/else/while/for` IR path and reset parser state on every call.
+
+- [ ] **Step 5: Verify extended validation and IR regression**
+
+Run: `python -m pytest tests/test_dsl_validator.py tests/test_dsl_extended.py tests/test_parser.py -q`
+
+Expected: all selected tests pass.
+
+### Task 4: DSL-only CompilerDriver and CLI integration
+
+**Files:**
+- Modify: `scratchv/compiler.py`
+- Modify: `scratchv/main.py`
+- Modify: `scratchv/frontend/__init__.py`
+- Create: `tests/test_dsl_diagnostics_cli.py`
+
+- [ ] **Step 1: Write failing CompilerDriver and CLI tests**
+
+Test a temporary bad `.dsl` through `CompilerDriver.compile()` and `main(argv)`. Assert `success is False`, `diagnostics` contains the original `DSLSyntaxError`, `errors` contains the complete plain rendering without `Parse error:` duplication, CLI exits 1, redirected stderr has no ANSI, and missing `endif` is not overwritten by a base-parser error.
+
+- [ ] **Step 2: Verify integration RED behavior**
+
+Run: `python -m pytest tests/test_dsl_diagnostics_cli.py -q`
+
+Expected: failures for missing structured result fields, broad parser fallback and CLI rendering.
+
+- [ ] **Step 3: Add compatible result metadata**
+
+Append defaulted `diagnostics`, `diagnostic_limit_reached`, and `diagnostic_limit` fields to `CompileResult`. In the DSL branch only, use `ExtendedDSLParser.parse(text, filename=input_path)` and preserve `DSLSyntaxError` without broad fallback. Do not alter ONNX parsing or successful result semantics.
+
+- [ ] **Step 4: Render diagnostics once at the CLI boundary**
+
+When `result.diagnostics` is non-empty, render them to `sys.stderr` with automatic color and do not print `result.errors` again. Preserve exit 1 for input diagnostics. Convert unexpected top-level exceptions to `internal compiler error` and exit 2 without traceback in normal mode.
+
+- [ ] **Step 5: Verify end-to-end DSL diagnostics**
+
+Run: `python -m pytest tests/test_dsl_diagnostics_cli.py tests/test_dsl_errors.py tests/test_dsl_validator.py tests/test_parser.py tests/test_dsl_extended.py -q`
+
+Expected: all selected tests pass with no ANSI in captured output.
+
+### Task 5: Scope, regression and project verification
+
+**Files:**
+- Modify only files listed in the file map if verification exposes a DSL-specific defect.
+
+- [ ] **Step 1: Run every DSL-related test**
+
+Run: `python -m pytest tests/test_dsl_errors.py tests/test_dsl_validator.py tests/test_dsl_diagnostics_cli.py tests/test_parser.py tests/test_dsl_extended.py -q`
+
+Expected: all pass.
+
+- [ ] **Step 2: Run L1/L2 Harness if present**
+
+Run: `python .Codex/harness/verify/run.py --level L1` and `python .Codex/harness/verify/run.py --level L2`.
+
+If the Harness is absent, record that fact and run the repository-equivalent full command `python -m pytest tests -q` in the project environment containing declared dependencies.
+
+- [ ] **Step 3: Prove the scope boundary**
+
+Run: `git diff --name-only` and `git diff --check`.
+
+Expected: only the DSL files, DSL sections of `compiler.py`/`main.py`, DSL tests, and this plan appear; no whitespace errors.
+
+- [ ] **Step 4: Self-review diagnostic requirements**
+
+Confirm E100, E101, E110, E111, E200 and E201; three-error collection; no partial IR; no parser fallback masking; plain redirected output; successful DSL IR regression; and no non-DSL behavior changes.
+
+- [ ] **Step 5: Update shared memory only if a novel reusable lesson exists**
+
+If `memory/memory.md` exists, merge one non-duplicate entry in `[2026-08-12] ...` format. If it does not exist, record the missing Harness facility and do not create unrelated infrastructure in this DSL patch.
+
+- [ ] **Step 6: Stage, commit and push after all verification is green**
+
+Run `git add` with the explicit reviewed file list, commit with an English message such as `feat: integrate structured DSL diagnostics`, then push the current branch without force.
diff --git "a/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" "b/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md"
new file mode 100644
index 0000000..9cd0e8c
--- /dev/null
+++ "b/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md"
@@ -0,0 +1,75 @@
+# 课题 9:DSL 诊断 CI 与 benchmark
+
+课题 9 接入原有 `.github/workflows/ci.yml`,不新增 workflow 或 job。
+现有 `test` job 运行 DSL 测试,现有 `benchmark` job 增加
+`Topic 9 DSL diagnostics benchmark` 步骤。沿用上游 runner 配置(PR 使用 ubuntu-latest,
+push 使用 self-hosted)、Python 3.12
+和触发条件:指向 main 的 PR,以及 main、wjy_dev、jzj_dev 的 push。
+
+## 专项测试
+
+```powershell
+python -m pip install -e . "pytest>=7,<10"
+python -m pytest tests/test_dsl_errors.py tests/test_dsl_validator.py tests/test_dsl_diagnostics_cli.py tests/test_parser.py tests/test_dsl_extended.py tests/test_dsl_diagnostics_benchmark.py -v --tb=short
+```
+
+测试覆盖错误模型、位置、提示、块恢复、多错误、颜色、CLI 和正常解析回归,
+以及 benchmark 的失败传播、基线导入隔离和报告产物。原有 `pytest tests/` 会自动
+发现这些文件,不重复执行专项测试。结果合并到 `benchmark_reports/test_results.xml`,
+由原有 `test-reports` artifact 上传,PR 和失败运行也保留测试报告。
+
+## 本地 benchmark
+
+先为基线建立独立 checkout。以下 `997d2aa` 是本 PR 加入诊断实现之前的历史版本;
+rebase 后与最新上游比较时,应改用对应的 PR base SHA。
+
+```powershell
+git worktree add --detach ../ScratchV-dsl-baseline 997d2aa
+python -m benchmarks.bench_dsl_diagnostics --baseline-root ../ScratchV-dsl-baseline --json-output benchmark_reports/dsl_diagnostics.json --markdown benchmark_reports/dsl_diagnostics.md --html benchmark_reports/dsl_diagnostics.html
+```
+
+`--json` 是布尔开关,仅向 stdout 输出 JSON;文件输出使用 `--json-output`。
+HTML 使用标准库生成,不依赖外部样式、脚本或可视化库。报告同时包含成功与失败信息。
+
+### 正确 DSL 解析性能
+
+- 两个版本均使用**当前 checkout 的同一批** `benchmarks/cases/*.dsl`,包含基础语法、
+ for、if/else、while。统一调用 `ExtendedDSLParser().parse(source)`,覆盖 CLI 使用的解析器。
+- 同一脚本通过两个独立 Python 进程导入各自 checkout 的解析器,并验证模块路径。
+ 不用关闭当前 validator 的方式冒充旧版解析器。
+- 两侧复用当前项目安装的依赖及同一个 Python。每侧预热 5 次,再测量 10 组;
+ 每组解析完整语料 100 次。计时包含创建解析器和解析,排除进程启动、导入、文件读取、
+ IR 摘要计算和每组开始前的显式 GC;计时期间保留 Python 默认 GC。
+- 比较每个文件的源码 SHA-256 和 `Program.dump()` SHA-256,任何解析异常、空语料、
+ 源码差异或 IR 差异均使检查失败,不跳过失败用例。
+- JSON 记录原始组耗时、中位数、倍率、实际基线/当前 SHA、模块路径、Python、系统和依赖版本。
+ 两个 checkout 的提交信息不包含未提交修改,正式报告应使用干净 checkout。
+
+### 错误输入诊断
+
+12 个内置固定用例独立验收错误码和行列、源码、文件名、提示、无 ANSI 输出、CLI 退出码 1、
+诊断不重复和不产生汇编文件。包含 CRLF/tab/Unicode、三个独立错误以及 25 个错误输入
+触发 20 条诊断上限的情况。分别测量验证/收集和纯文本渲染;CLI 用于正确性验收,不计时。
+
+错误样例不放入正常 `benchmarks/cases/`,避免通用 DSL runner 将预期错误当作编译失败。
+旧版本不具备新诊断能力,不参与错误输入的等价性能比较。
+
+## 基线与结果判定
+
+- PR:基线为事件中的 `pull_request.base.sha`,当前为事件的 PR 合并测试提交。
+- push:基线为事件中的 `before`,当前为该 push 提交。新分支首次 push 的全零
+ `before` 无法用于对比,该步骤会明确失败;打开指向 main 的 PR 后使用 PR base。
+- 基线通过临时 Git worktree 准备,退出步骤时清理,不混入报告 artifact。
+- 两个 job 都必须 checkout 到事件的 `GITHUB_SHA`;获取提交、准备基线或 worker
+ 执行失败均使相应步骤失败,不静默回退到 main。
+
+**诊断验收、IR 一致性及报告执行错误是硬门禁。** 设计文档的解析倍率目标是 1.5x,
+默认明确显示 `target_met` 和超标提示,但不因共享 runner 的计时波动阻止合并。
+因此 CI 绿色不等于已经达到 1.5x 性能目标。需要严格执行该目标时添加
+`--enforce-performance`,阈值由 `--max-parse-ratio` 指定,默认 1.5。
+
+benchmark 结束后,Markdown 写入 Job Summary;JSON、Markdown 和 HTML 上传至
+原有 `benchmark-reports`。上传及汇总步骤使用 `always()`,功能失败仍保留诊断证据。
+Markdown 摘要和本地 HTML 中,每个用例的完整诊断日志默认折叠,点击用例名称展开。
+汇总表、失败检查和性能提示直接可见;JSON 保留完整诊断内容。
+这里不计算常量合并次数、TinyFive 指令减少量或 LLVM 指令数收益。
diff --git "a/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md"
new file mode 100644
index 0000000..dd5e243
--- /dev/null
+++ "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md"
@@ -0,0 +1,691 @@
+# 课题9:DSL错误提示美化器开发文档
+
+> **文档类型**:开发指南 | **状态**:草案 | **版本**:v0.1
+> **调研基线**:`main@997d2aa` | **建议周期**:12 周
+> **重要说明**:本文是后续开发指导,不表示文中功能已经在当前仓库实现
+
+---
+
+## 1. 课题目标
+
+本课题在现有 `scratchv/frontend/dsl_errors.py` 基础上,把结构化错误诊断真正接入:
+
+- `DSLParser`;
+- `ExtendedDSLParser`;
+- `CompilerDriver` 的 DSL 编译路径;
+- ScratchV CLI 的错误输出。
+
+最终效果是:用户提交错误 DSL 时,能够看到准确位置、源码高亮、稳定错误码和可信的修复建议;需要检查整个文件时,可以一次报告多个独立错误。
+
+详细架构和接口约束见 [设计文档](09-DSL错误提示美化器-设计文档.md)。
+
+---
+
+## 2. 当前起点
+
+### 2.1 已经存在的代码
+
+| 文件 | 需要先理解的内容 |
+|------|------------------|
+| `scratchv/frontend/dsl_errors.py` | `DSLSyntaxError`、`format_error()`、`ErrorCollector`、建议表 |
+| `scratchv/frontend/dsl_parser.py` | 基础 DSL 的逐行正则解析与 IRBuilder 调用 |
+| `scratchv/frontend/dsl_extended.py` | `if/while` 块解析、块索引和嵌套逻辑 |
+| `scratchv/compiler.py` | DSL/ONNX 分流、扩展解析器回退、`CompileResult.errors` |
+| `scratchv/main.py` | CLI 参数、编译结果和 stderr 输出 |
+| `tests/test_dsl_errors.py` | 现有错误模块的单元测试风格 |
+| `tests/test_parser.py` | 基础 DSL 成功路径 |
+| `tests/test_dsl_extended.py` | 扩展语法及 `DSLParseError` 兼容要求 |
+
+### 2.2 当前能力边界
+
+当前错误美化模块可以独立构造和打印错误:
+
+```python
+from scratchv.frontend.dsl_errors import make_error, format_error
+
+error = make_error(
+ line=2,
+ col=10,
+ message="unsupported operation 'ad'",
+ source_line="result = ad(a, b)",
+ filename="bad.dsl",
+ error_code="E200",
+)
+print(format_error(error, use_color=False))
+```
+
+但是解析器并不会自动产生这个错误对象。`ErrorCollector` 也只是容器,当前解析流程没有错误恢复机制。
+
+### 2.3 开发前必须复现的问题
+
+开始改代码前,至少记录以下三类基线输出:
+
+```text
+1. 无法识别的普通语句
+2. 不支持的算子
+3. 缺失 endif/endwhile 的扩展 DSL
+```
+
+基线记录应包含:输入 DSL、调用入口、异常类型、异常文本和退出码。后续用相同输入证明错误质量确实改善。
+
+---
+
+## 3. 前置知识
+
+开始课题前,建议掌握:
+
+1. Python 异常继承、`dataclass`、`enum` 和类型注解;
+2. 正则表达式的 `match`、`fullmatch` 和捕获组;
+3. 编译器前端中的源码位置、错误恢复和级联错误;
+4. ScratchV 的 `IRBuilder` 与 `Program`;
+5. pytest 参数化、异常断言和文本快照;
+6. ANSI 转义码、TTY 和 stderr;
+7. Git 小步提交和回归测试。
+
+不要求先实现完整 tokenizer、AST 或 LSP。
+
+---
+
+## 4. 推荐阅读顺序
+
+### 第一步:跑通正确 DSL
+
+阅读并运行:
+
+```bash
+python -m pytest tests/test_parser.py tests/test_dsl_extended.py -q
+```
+
+目标:理解正确程序如何从 DSL 进入 `IRBuilder`,不要先看错误模块就直接修改异常类型。
+
+### 第二步:单独理解错误模块
+
+```bash
+python -m pytest tests/test_dsl_errors.py -q
+```
+
+重点回答:
+
+- `DSLSyntaxError` 的字段哪些是 1-based?
+- `format_error()` 如何计算 caret 长度?
+- `ErrorCollector` 达到上限后怎样计数?
+- 自动建议来自显式 `fix_hint` 还是启发式规则?
+
+### 第三步:追踪 CLI 数据流
+
+```text
+scratchv/main.py
+ → CompilerDriver.compile()
+ → CompilerDriver._parse()
+ → ExtendedDSLParser 或 DSLParser
+ → CompileResult.diagnostics / diagnostic_limit_reached / errors
+ → stderr
+```
+
+特别关注 `_parse()` 中捕获所有异常再回退的逻辑。它是错误信息被覆盖的主要风险点。
+
+### 第四步:阅读设计约束
+
+阅读 [设计文档](09-DSL错误提示美化器-设计文档.md) 的第 3、5、7、9、12 和 16 节,再开始编码。
+
+---
+
+## 5. 建议目录与改动范围
+
+后续实现建议只修改与课题直接相关的文件:
+
+```text
+scratchv/frontend/
+├── dsl_errors.py # 扩展位置、渲染和收集语义
+├── dsl_grammar.py # 拟新增:Parser/Validator 共享语法与算子签名
+├── dsl_validator.py # 拟新增:无 IR 副作用的验证器
+├── dsl_parser.py # 接入 SourceBuffer/validator
+├── dsl_extended.py # 接入控制流块验证
+└── __init__.py # 必要时导出稳定公共接口
+
+scratchv/
+├── compiler.py # 保留结构化诊断,收窄回退条件
+└── main.py # 终端颜色与 stderr 输出
+
+tests/
+├── test_dsl_errors.py
+├── test_dsl_validator.py # 拟新增
+├── test_parser.py
+├── test_dsl_extended.py
+└── test_dsl_diagnostics_cli.py # 拟新增端到端测试
+```
+
+不要在本课题中顺便重构 IR、优化器或后端。
+
+---
+
+## 6. 开发策略
+
+采用测试先行和小步集成。每一步都应保持正确 DSL 可编译,不能等到最后一次性跑测试。
+
+建议顺序:
+
+```text
+锁定输出契约
+ → SourceBuffer
+ → 错误继承兼容
+ → 基础行级验证
+ → 算子签名验证
+ → 扩展块验证
+ → Parser 集成
+ → CompilerDriver/CLI 集成
+ → 多错误与颜色策略
+ → 全量回归
+```
+
+---
+
+## 7. 阶段一:锁定诊断输出契约
+
+### 7.1 先写失败测试
+
+为以下输出建立无颜色精确测试:
+
+```text
+bad.dsl:5:1: error[E100]: cannot parse statement
+ 5 | retrun result
+ | ^~~~~~
+note: did you mean 'return'?
+```
+
+至少断言:
+
+- 文件名;
+- 1-based 行列;
+- 错误码;
+- 源码行;
+- caret 起点和长度;
+- 无 ANSI 转义码。
+
+### 7.2 保持旧接口
+
+已有调用仍应工作:
+
+```python
+DSLSyntaxError(1, 1, "message")
+format_error(error, use_color=False)
+ErrorCollector(filename="test.dsl", max_errors=20)
+```
+
+不要为了新设计删除现有参数或更改已有字段顺序。
+
+### 7.3 完成标准
+
+- 新输出契约测试失败的原因是功能尚未实现,而不是测试写错;
+- 现有 `tests/test_dsl_errors.py` 仍通过;
+- 文档中的示例与测试期望完全一致。
+
+---
+
+## 8. 阶段二:实现 SourceBuffer
+
+### 8.1 目的
+
+解析器当前过早执行 `strip()`,导致缩进和原始列信息丢失。`SourceBuffer` 应成为唯一的源码位置来源。
+
+建议接口:
+
+```python
+@dataclass(frozen=True)
+class SourceBuffer:
+ text: str
+ filename: str = ""
+
+ def line_text(self, line: int) -> str:
+ ...
+```
+
+### 8.2 测试矩阵
+
+| 输入 | 预期 |
+|------|------|
+| `a\nb` | 第 1 行 `a`,第 2 行 `b` |
+| `a\r\nb` | 与 LF 行号一致 |
+| 空字符串 | 不越界,不虚构源码 |
+| 末尾换行 | 不产生错误的额外语句 |
+| 中文标识符或注释 | 列号按 Python 字符索引计算 |
+| 制表符 | 原始列稳定,渲染时 caret 对齐 |
+
+### 8.3 常见错误
+
+- 在保存原始行之前调用 `strip()`;
+- 混用 0-based 内部索引和 1-based 用户位置;
+- 用字节偏移计算 Unicode 列号;
+- 只在扩展解析器保留空行,基础解析器仍丢失行号。
+
+---
+
+## 9. 阶段三:统一异常兼容关系
+
+当前 `DSLParseError` 定义在 `dsl_parser.py`,`DSLSyntaxError` 独立继承 `Exception`。直接改为抛出新异常可能破坏捕获 `DSLParseError` 的调用方和测试。
+
+建议目标:
+
+```python
+try:
+ DSLParser().parse(bad_source)
+except DSLParseError as error:
+ assert isinstance(error, DSLSyntaxError)
+```
+
+实现时应避免 `dsl_parser.py` 与 `dsl_errors.py` 的循环导入。可选择:
+
+1. 把兼容基类移到 `dsl_errors.py`,再从旧路径重新导出;
+2. 把共享异常基类放进一个小型无依赖模块。
+
+不建议通过捕获任意异常再用字符串包装的方式伪造兼容,因为它会丢失原始错误类别和位置。
+
+---
+
+## 10. 阶段四:基础 DSL 验证器
+
+### 10.1 为什么不能在错误后继续构建 IR
+
+一条语句可能已经调用了部分 `IRBuilder` 方法才失败。继续解析会让 builder 状态不可预测。因此验证器只检查源码,不创建 `Value`、Block 或 Program。
+
+### 10.2 建议验证顺序
+
+对每个物理行:
+
+1. 跳过空行和注释;
+2. 判断语句类别;
+3. 检查外层结构;
+4. 检查关键字和块栈;
+5. 检查算子是否存在;
+6. 检查位置参数与关键字参数;
+7. 记录诊断或进入下一行。
+
+### 10.3 使用 `fullmatch`
+
+验证语法时优先使用 `fullmatch`,避免只匹配行首后忽略尾部垃圾。例如:
+
+```text
+x = add(a, b) unexpected
+```
+
+不能因为前半段匹配成功而被当成合法语句。
+
+### 10.4 算子签名表
+
+不要在多个 `if/elif` 中重复参数规则。建议集中描述:
+
+```python
+OP_SIGNATURES = {
+ "add": OpSignature(positional=2),
+ "relu": OpSignature(positional=1),
+ "softmax": OpSignature(positional=1, optional_kwargs={"axis"}),
+ "matmul": OpSignature(
+ positional=2,
+ optional_kwargs={"rows", "cols", "inner", "m", "n", "k"},
+ ),
+}
+```
+
+`OP_SIGNATURES`、语句正则和关键字集合必须放在 Parser 与 Validator 都导入的共享模块中,不能各自维护副本。Parser 的执行 handler 可以独立存在,但必须增加自动测试:`set(OP_SIGNATURES) == set(OP_HANDLERS)`。这样新增算子时,只要漏改一侧,测试会立即失败。
+
+### 10.5 完成标准
+
+- 不支持算子不再泄漏普通 `DSLParseError` 文本;
+- 参数不足不再泄漏 `IndexError`;
+- 一行结构错误不会产生多个级联错误;
+- 正确基础 DSL 的 Program 与基线一致。
+
+---
+
+## 11. 阶段五:扩展 DSL 块验证
+
+### 11.1 块栈
+
+使用独立的验证栈,不复用 IR builder 的 `_loop_stack`:
+
+```python
+@dataclass
+class BlockFrame:
+ kind: Literal["if", "while", "for"]
+ line: int
+ col: int
+ saw_else: bool = False
+```
+
+### 11.2 必测规则
+
+- `endif` 只关闭 `if`;
+- `endwhile` 只关闭 `while`;
+- `endfor` 只关闭 `for`;
+- `else` 必须位于 `if` 内;
+- 同一个 `if` 只能有一个 `else`;
+- 文件结束时每个未关闭块都报告开始位置;
+- 嵌套块错误不能破坏后续独立语句的位置。
+
+结束符类型不匹配统一使用 `E110`。例如 `while ... endif` 应在 `endif` 处报告“期望 `endwhile`”,随后把该 `endif` 作为 `while` 的恢复性结束符并弹栈,EOF 不重复报告 `E111`。对于 `if ... while ... endif`,报告一个 `E110` 后弹出内层 `while` 和匹配的外层 `if`。只有真正留到 EOF 的块才报告 `E111`。
+
+### 11.3 不要静默接受文件结尾
+
+当前扩展解析流程在找不到结束关键字时可能走到文件尾。验证器必须把这种情况转为 `E111`,并指向块开始处,而不是最后一行。
+
+---
+
+## 12. 阶段六:接入 Parser
+
+### 12.1 成功路径
+
+成功调用保持不变:
+
+```python
+program = DSLParser().parse(source)
+program = ExtendedDSLParser().parse(source)
+```
+
+建议增加 keyword-only 文件名:
+
+```python
+program = DSLParser().parse(source, filename="model.dsl")
+```
+
+### 12.2 失败路径
+
+默认 `parse()` 在验证失败时抛出第一个 `DSLSyntaxError`。它不返回 Program,也不进入 IR 生成阶段。
+
+多错误调用使用显式入口:
+
+```python
+collector = ExtendedDSLParser().validate(
+ source,
+ filename="bad.dsl",
+ max_errors=20,
+)
+
+if collector.has_errors:
+ print(collector.report(), file=sys.stderr)
+```
+
+### 12.3 Parser 状态重置
+
+每次 `parse()` 前确认 builder、变量表、循环栈和标签计数器处于干净状态。诊断集成不能让同一个 parser 实例第二次解析时继承上次失败状态。
+
+---
+
+## 13. 阶段七:接入 CompilerDriver 与 CLI
+
+### 13.1 收窄解析器回退
+
+当前 `_parse()` 会捕获扩展解析器的任意异常后尝试基础解析器。修改时应遵循:
+
+- 用户语法错误直接返回,不回退;
+- 由于扩展解析器继承基础语法,编译器驱动优先统一使用 `ExtendedDSLParser`;如需保留两种模式,使用显式配置选择,不扫描关键字猜测;
+- 只有明确的“解析器不适用”状态允许回退;
+- `_parse()` 不捕获 `IndexError`、`AssertionError` 等实现错误。
+
+还要修改外层 `CompilerDriver.compile()`:只把 `DSLSyntaxError` 等已知用户错误转换为失败结果,意外异常继续抛出,让库测试直接失败。CLI 顶层负责在正常模式输出 `internal compiler error` 并返回 2;只有显式调试模式打印 traceback。
+
+### 13.2 CompileResult
+
+建议给 `CompileResult` 增加 `diagnostics: list[DSLSyntaxError]`、`diagnostic_limit_reached: bool` 和 `diagnostic_limit: int`。结构化诊断供 CLI 最终渲染,后两个字段把错误抑制状态从 collector 传到 renderer;`errors` 继续保存完整的无颜色文本,兼容现有调用方。`diagnostics` 与 `errors` 同时存在时,CLI 只渲染 `diagnostics`,避免重复输出,但仍根据 `diagnostic_limit_reached` 输出 footer。
+
+不要再次添加模糊前缀:
+
+```text
+不建议:Parse error: bad.dsl:5:1: error...
+建议: bad.dsl:5:1: error[E100]: ...
+```
+
+避免出现 `Error: Parse error: ...` 的重复层级。
+
+### 13.3 CLI
+
+CLI 负责:
+
+- 把诊断写到 stderr;
+- 失败返回 1;
+- 把 `sys.stderr` 传给 renderer,终端交互时允许颜色;
+- 输出重定向或 `NO_COLOR` 存在时关闭颜色;
+- 不打印 traceback,除非用户显式启用调试模式。
+
+建议的手工验收命令:
+
+```bash
+scratchv bad.dsl -o output.s
+scratchv bad.dsl -o output.s 2> error.txt
+```
+
+检查 `error.txt` 中没有 `\x1b[` ANSI 序列。
+
+---
+
+## 14. 阶段八:多错误收集
+
+### 14.1 只恢复到可信边界
+
+基础 DSL 的可信边界是下一物理行;扩展 DSL 还包括 `else`、`endif`、`endwhile` 和 `endfor`。
+
+不要在参数列表中盲目寻找下一个逗号后继续,因为当前解析器不是 token 流,容易把后续字符误认为新语句。
+
+### 14.2 错误上限
+
+建议默认最多报告 20 个真实错误。达到上限后:
+
+- 停止继续验证;
+- 设置 `collector.limit_reached = True`;
+- renderer 输出一条无源码位置的 `note: error limit ...` footer;
+- 转换为 `CompileResult` 时同步复制 `diagnostic_limit_reached` 和 `diagnostic_limit`;
+- 标题中的错误数量仍为 20;
+- 不把抑制消息放进 `errors`,也不当作第 21 个源码错误。
+
+### 14.3 排序和去重
+
+报告按 `(line, col, error_code)` 排序。同一位置、同一错误码、同一消息只保留一次。
+
+---
+
+## 15. 阶段九:修复建议
+
+### 15.1 建议来源
+
+```text
+显式上下文提示 > 精确拼写表 > 错误码固定提示 > 无提示
+```
+
+示例:
+
+| 输入 | 错误 | 建议 |
+|------|------|------|
+| `retrun x` | 未识别关键字 | `did you mean 'return'?` |
+| `x = ad(a, b)` | 不支持算子 | `did you mean 'add'?` |
+| `if (a > b` | 缺少右括号 | `add the missing ')'` |
+| 随机未知单词 | 未识别语句 | 不猜测 |
+
+### 15.2 误报测试
+
+不仅要测试“应该出现建议”,也要测试“这里不应出现建议”。例如变量名与关键字相似时,不应建议把变量改成关键字。
+
+---
+
+## 16. 测试计划
+
+### 16.1 快速测试
+
+开发过程中每个小步骤运行:
+
+```bash
+python -m pytest tests/test_dsl_errors.py tests/test_dsl_validator.py -q
+```
+
+### 16.2 解析器回归
+
+```bash
+python -m pytest \
+ tests/test_parser.py \
+ tests/test_dsl_extended.py \
+ tests/test_dsl_errors.py \
+ tests/test_dsl_validator.py \
+ tests/test_dsl_diagnostics_cli.py -q
+```
+
+Windows PowerShell 可把路径放在同一行执行。
+
+```powershell
+python -m pytest tests/test_parser.py tests/test_dsl_extended.py tests/test_dsl_errors.py tests/test_dsl_validator.py tests/test_dsl_diagnostics_cli.py -q
+```
+
+### 16.3 项目验证
+
+```bash
+python .Codex/harness/verify/run.py --level L1
+python .Codex/harness/verify/run.py --level L2
+```
+
+如果本地专属 harness 不存在,应明确记录环境缺失,并运行仓库可用的等价检查:
+
+```bash
+make test
+python scripts/build_docs_html.py --output-dir benchmark_reports/docs
+```
+
+不能因为 harness 缺失就声称 L2 已通过。
+
+### 16.4 测试用例清单
+
+| 类别 | 最少用例 |
+|------|----------|
+| 正确基础 DSL | 算术、一元算子、matmul、for |
+| 正确扩展 DSL | if/else、while、嵌套块 |
+| 行列定位 | 首行、中间行、缩进、CRLF、Unicode、tab |
+| 语句结构 | 缺赋值号、括号、逗号、冒号、尾部垃圾 |
+| 块结构 | 多余结束符、错误类型结束符、缺失结束符、重复 else |
+| 算子签名 | 未知算子、参数不足、参数过多、未知 kwarg、非法值 |
+| 多错误 | 3 个独立错误、级联抑制、达到上限 |
+| 输出 | ANSI 开关、`NO_COLOR`、stderr、退出码 |
+| 兼容性 | `DSLParseError` 捕获、原 parse 调用、正确 IR 不变 |
+
+---
+
+## 17. 调试指南
+
+### 17.1 caret 偏移一列
+
+检查:
+
+- 内部索引是否 0-based;
+- 对外 `col` 是否 1-based;
+- 行号前缀宽度是否计入了源码列;
+- 原始行是否被 `strip()`;
+- tab 是否经过显示宽度映射。
+
+不要通过随意加减常数修复单个样例,应先写多个不同列位置的参数化测试。
+
+### 17.2 错误行号总是 1
+
+确认基础解析器没有使用 `text.strip().split("\n")` 后丢弃原始索引。应对原始物理行执行 `enumerate(..., start=1)`。
+
+### 17.3 扩展错误变成普通解析错误
+
+检查 `CompilerDriver._parse()` 是否仍然捕获所有异常并回退。结构化用户错误不应触发回退。
+
+### 17.4 一处错误产生很多错误
+
+验证器可能在外层结构失败后继续执行参数检查。为每行定义“主要结构错误后停止本行”的规则。
+
+### 17.5 正确 DSL 的 IR 改变
+
+错误诊断改动不应改变成功语义。比较改动前后的 IRPrinter 输出和指令结构,定位验证阶段是否误改了 parser 或 builder 状态。
+
+---
+
+## 18. 代码评审清单
+
+### 正确性
+
+- [ ] 所有行列均为 1-based,并有边界测试。
+- [ ] 原始源码行在位置计算前未被破坏。
+- [ ] 有错误时不生成或返回部分 IR。
+- [ ] 扩展语法错误不会被回退逻辑覆盖。
+- [ ] 用户错误不会泄漏 Python 内部异常。
+- [ ] 错误码与设计文档一致。
+
+### 兼容性
+
+- [ ] `DSLParser().parse(text)` 成功调用保持不变。
+- [ ] `DSLParseError` 旧捕获方式仍有效。
+- [ ] `format_error()` 现有参数仍可使用。
+- [ ] 正确 DSL 的 IR 与基线一致。
+
+### 用户体验
+
+- [ ] 消息说明问题而不是描述实现细节。
+- [ ] caret 指向真正错误 token。
+- [ ] 修复建议可信且没有明显误报。
+- [ ] 非 TTY 输出不含 ANSI。
+- [ ] 多错误报告没有明显级联噪音。
+
+### 测试
+
+- [ ] 单元、解析器集成、CompilerDriver 和 CLI 均有覆盖。
+- [ ] LF、CRLF、Unicode、tab 和空文件有覆盖。
+- [ ] 错误上限和去重有覆盖。
+- [ ] L1、L2 或明确记录的等价验证已执行。
+
+---
+
+## 19. 12 周开发计划
+
+| 周次 | 目标 | 可验收产物 |
+|------|------|------------|
+| W1 | 熟悉 DSL、IRBuilder 和当前错误路径 | 调研笔记、3 个基线错误样例 |
+| W2 | 锁定纯文本输出和错误码 | 失败测试、输出规范 |
+| W3 | 实现并测试 SourceBuffer | LF/CRLF/Unicode/tab 单元测试 |
+| W4 | 统一异常继承和兼容导出 | `DSLParseError` 兼容测试 |
+| W5 | 实现基础语句结构验证 | `E100`–`E103` 测试 |
+| W6 | 实现算子签名验证 | `E200`–`E203` 测试 |
+| W7 | 实现 if/while/for 块栈验证 | `E110`–`E112` 测试 |
+| W8 | 接入基础和扩展解析器 | 正确 IR 回归、结构化首错 |
+| W9 | 实现多错误恢复、去重和上限 | 3 错误样例、`limit_reached` footer 测试 |
+| W10 | 接入 CompilerDriver 和 CLI | stderr、退出码、无 traceback 测试 |
+| W11 | 完善颜色、建议和边界用例 | TTY/NO_COLOR、误报测试 |
+| W12 | 全量验证、性能测量和文档收尾 | L2 结果、评审报告、演示样例 |
+
+---
+
+## 20. 交付产物
+
+建议最终提交包含:
+
+- 结构化错误与渲染改进;
+- `SourceBuffer` 和无副作用 DSL validator;
+- 基础、扩展解析器集成;
+- CompilerDriver 和 CLI 集成;
+- 单元、集成、端到端测试;
+- 至少 10 个错误 DSL 示例及预期输出;
+- 错误码参考表;
+- L1/L2 或等价验证记录;
+- 性能测量结果;
+- 最终 self-review 报告。
+
+---
+
+## 21. 最终验收演示
+
+准备一个包含三个独立错误的 `bad.dsl`:
+
+```text
+x = ad(a, b)
+y = relu(x, 1)
+endwhile
+```
+
+期望演示:
+
+1. `validate()` 一次报告 3 个错误;
+2. 每个错误的位置和错误码正确;
+3. `ad` 获得可信的 `add` 拼写建议;
+4. `relu` 参数数量错误不泄漏 `IndexError`;
+5. `endwhile` 报告无匹配开始块;
+6. 编译流程失败且不产生输出汇编;
+7. 重定向到文件时没有 ANSI;
+8. 修正三处错误后,同一程序正常生成 IR 和目标代码。
+
+这组演示同时覆盖位置、建议、签名检查、块检查、多错误、CLI 和成功回归,是本课题最小但完整的验收闭环。
diff --git "a/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243.md"
new file mode 100644
index 0000000..c21faef
--- /dev/null
+++ "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243.md"
@@ -0,0 +1,588 @@
+# 课题9:DSL错误提示美化器设计文档
+
+> **文档类型**:技术设计 | **状态**:草案 | **版本**:v0.1
+> **调研基线**:`main@997d2aa` | **调研日期**:2026-07-15
+> **关联模块**:`scratchv/frontend/dsl_errors.py`、`dsl_parser.py`、`dsl_extended.py`、`scratchv/compiler.py`
+
+---
+
+## 1. 文档定位
+
+本文描述 ScratchV DSL 错误提示美化器的**拟议迭代方案**,用于指导后续开发和评审。
+
+本文不是已完成实现的说明。当前仓库已经具备独立的错误对象、文本格式化器和错误收集器,但尚未把这些能力完整接入基础 DSL 解析器、扩展 DSL 解析器和编译器驱动。文中标为“建议”“拟新增”的接口均属于设计提案。
+
+配套文档:
+
+- [课题9:DSL错误提示美化器](09-DSL错误提示美化器.md):现有课程概览
+- [课题9:DSL错误提示美化器开发文档](09-DSL错误提示美化器-开发文档.md):建议开发顺序、测试方法和交付标准
+- [课题1:DSL前端增强器](01-DSL前端增强器.md):扩展 DSL 的控制流语法
+
+---
+
+## 2. 背景与问题
+
+ScratchV 提供两套 DSL 解析器:
+
+- `DSLParser`:解析逐行表达式、`return` 和 `for/endfor`。
+- `ExtendedDSLParser`:在基础语法上增加 `if/else/endif` 和 `while/endwhile`。
+
+当前解析失败时,用户通常只能得到类似下面的消息:
+
+```text
+Error: Parse error: Cannot parse line: retrun result
+```
+
+这条消息没有文件名、行号、列号和修复建议。更重要的是,编译器驱动会先尝试扩展解析器,并用宽泛的 `except Exception` 回退到基础解析器。扩展解析器产生的原始错误可能被覆盖,最终报告与真正失败位置不一致。
+
+目标体验如下:
+
+```text
+bad.dsl:5:1: error[E100]: cannot parse statement
+ 5 | retrun result
+ | ^~~~~~
+note: did you mean 'return'?
+```
+
+好的诊断应回答四个问题:
+
+1. 哪个文件、哪一行、哪一列出错?
+2. 编译器实际发现了什么问题?
+3. 哪段源码与问题直接相关?
+4. 用户下一步应该怎样修复?
+
+---
+
+## 3. 当前实现审计
+
+### 3.1 已有能力
+
+| 位置 | 当前能力 | 结论 |
+|------|----------|------|
+| `dsl_errors.py` | `DSLSyntaxError` 保存行、列、消息、源码行、文件名、提示和错误码 | 可复用 |
+| `dsl_errors.py` | `format_error()` 输出 gcc/clang 风格文本并支持 ANSI 颜色 | 可复用,需补充自动颜色策略 |
+| `dsl_errors.py` | `ErrorCollector` 支持收集、上限、报告和清空 | 可复用,需明确恢复与计数语义 |
+| `dsl_errors.py` | `_SUGGESTIONS` 和 `_COMMON_FIXES` 提供少量启发式建议 | 可复用,需避免误报 |
+| `frontend/__init__.py` | 导出 `DSLSyntaxError`、`format_error` 和 `ErrorCollector` | 已形成部分公共接口 |
+| `tests/test_dsl_errors.py` | 覆盖错误对象、颜色、格式化、收集器和工厂函数 | 单模块测试基础较好 |
+
+### 3.2 主要差距
+
+| 差距 | 当前表现 | 用户影响 |
+|------|----------|----------|
+| 解析器未集成 | `DSLParser` 和 `ExtendedDSLParser` 仍抛出 `DSLParseError` 或底层异常 | 美化器只可手工调用 |
+| 原始位置丢失 | 基础解析器对每行执行 `strip()`,没有保存原始行号和缩进 | 无法可靠计算列号 |
+| 扩展错误被吞掉 | `CompilerDriver._parse()` 捕获扩展解析器的所有异常后回退 | 真正错误原因可能被替换 |
+| 参数错误泄漏 | 参数数量不足可能触发 `IndexError` | 用户看到 Python 内部异常 |
+| 块结构不完整 | 缺少 `endif` 或 `endwhile` 时可能静默到文件结尾 | 错误程序可能产生不完整 IR |
+| 多错误仅有容器 | `ErrorCollector` 能保存多个错误,但解析器没有同步和恢复机制 | 遇到首错仍无法继续 |
+| 上下文不完整 | `context_lines` 没有完整源文件,只能输出空的上下文行号 | 无法显示真实前后文 |
+| 颜色默认开启 | `use_color=True` 不检查 TTY 或 `NO_COLOR` | 重定向到文件时出现转义码 |
+| 测试缺少端到端覆盖 | 没有“错误 DSL → 解析器 → CompileResult/CLI”测试 | 集成回归无法被发现 |
+
+### 3.3 语义约束
+
+基础解析器的 `_resolve()` 会把首次出现的名字创建为输入值,因此当前 DSL 没有严格的“变量未定义”错误语义。错误美化器不应在未改变语言规则的前提下报告“未定义变量”。如需该能力,应另立语言语义课题。
+
+---
+
+## 4. 设计目标与非目标
+
+### 4.1 设计目标
+
+1. 基础和扩展 DSL 的解析错误均携带稳定、准确的位置。
+2. 默认库调用保持失败即停止,避免返回被错误污染的 IR。
+3. 显式验证模式可以一次报告多个相互独立的错误。
+4. 所有用户输入错误转换为结构化诊断,不暴露 `IndexError`、`KeyError` 等实现异常。
+5. 纯文本输出稳定,适合单元测试、CI、日志和文件重定向。
+6. 现有 `DSLParser().parse(text)` 的成功路径和 IR 结果保持兼容。
+7. 错误码、位置规则和恢复规则可测试、可扩展。
+
+### 4.2 非目标
+
+本课题不包含:
+
+- 重写完整词法器或引入第三方解析框架;
+- 修改 DSL 的变量定义、类型检查或算子语义;
+- 自动修改用户源码;
+- LSP、编辑器插件或 IDE 实时诊断;
+- JSON/SARIF 报告协议;
+- 修改 ONNX 解析器的错误体系;
+- 在语法错误存在时生成或执行部分 IR。
+
+---
+
+## 5. 设计原则
+
+### 5.1 先验证,后生成 IR
+
+当前解析器在读取源码的同时调用 `IRBuilder`。如果在错误后强行继续,builder 中可能残留半个循环或基本块,后续错误也容易成为连锁误报。
+
+因此多错误模式采用“两阶段”策略:
+
+```text
+源文件 ──▶ 轻量语法验证 ──▶ 0 个错误? ──是──▶ 现有 IR 生成流程
+ │
+ └────────否──▶ ErrorCollector ──▶ 格式化报告
+```
+
+验证阶段只检查可从源码直接确定的规则,不创建 IR。只有验证通过后才进入现有解析和 IR 构建流程。
+
+### 5.2 精确错误优先于大量错误
+
+多错误收集不是越多越好。一个缺失的右括号可能导致同一行出现多个派生错误。验证器应在确认一条语句的外层结构错误后停止分析该语句,只在下一条独立语句继续。
+
+### 5.3 公共接口渐进兼容
+
+现有 `dsl_errors.py` 的公开字段和常用函数继续保留。新增字段必须提供默认值;成功解析的调用方式不变;旧的 `DSLParseError` 导入路径继续有效。
+
+---
+
+## 6. 总体架构
+
+```text
+┌──────────────────────────────────────────────────────────────┐
+│ DSL source / filename │
+└─────────────────────────────┬────────────────────────────────┘
+ ▼
+┌──────────────────────────────────────────────────────────────┐
+│ SourceBuffer │
+│ 保留原始文本、物理行、文件名;负责 offset/line/column 映射 │
+└─────────────────────────────┬────────────────────────────────┘
+ ▼
+┌──────────────────────────────────────────────────────────────┐
+│ DSLValidator │
+│ 行级语法、算子签名、控制流块栈;不调用 IRBuilder │
+└───────────────┬───────────────────────────────┬──────────────┘
+ │ 诊断 │ 无诊断
+ ▼ ▼
+┌────────────────────────────┐ ┌───────────────────────────┐
+│ ErrorCollector │ │ DSLParser / Extended... │
+│ 去重、排序、上限、抑制提示 │ │ 复用现有 IR 生成流程 │
+└───────────────┬────────────┘ └─────────────┬─────────────┘
+ ▼ ▼
+┌────────────────────────────┐ ┌───────────────────────────┐
+│ DiagnosticRenderer │ │ Program │
+│ ANSI / plain text │ │ 后续优化与代码生成 │
+└────────────────────────────┘ └───────────────────────────┘
+```
+
+### 6.1 组件职责
+
+| 组件 | 职责 | 不负责 |
+|------|------|--------|
+| `SourceBuffer` | 保存原始源码、获取行文本、映射位置 | 判断语法是否正确 |
+| `DSLValidator` | 产生结构化错误并执行有限同步 | 创建 IR、修复源码 |
+| `DSLSyntaxError` | 表示一个诊断 | 读取文件、打印到终端 |
+| `ErrorCollector` | 收集、去重、排序和限制诊断 | 决定解析恢复点 |
+| `format_error()` | 把单个诊断渲染为文本 | 推断语言语义 |
+| `DSLParser` | 验证通过后生成 IR | 在错误状态下返回部分 Program |
+| `CompilerDriver` | 选择解析器并把诊断交给 `CompileResult` | 吞掉异常后盲目回退 |
+
+---
+
+## 7. 数据模型设计
+
+### 7.1 SourceBuffer(拟新增内部类型)
+
+```python
+@dataclass(frozen=True)
+class SourceBuffer:
+ text: str
+ filename: str = ""
+
+ def line_text(self, line: int) -> str: ...
+ def line_count(self) -> int: ...
+```
+
+约束:
+
+- 行号和列号均从 1 开始。
+- 列号以 Python 字符索引为基础,即按 Unicode code point 计数。
+- 保留原始行,不在位置计算前执行 `strip()`。
+- 渲染器把制表符按 4 列展开,但错误对象中的列号仍指向原始字符位置。caret 的显示列必须逐字符换算:普通字符增加 1 列,tab 增加到下一个 4 列制表位,不能直接把 raw column 当作显示空格数。
+- Windows `\r\n` 与 Unix `\n` 均映射到相同的物理行。
+
+### 7.2 DSLSyntaxError(兼容扩展)
+
+建议保留现有必需字段,并增加可选的结束列:
+
+```python
+@dataclass
+class DSLSyntaxError(DSLParseError):
+ line: int
+ col: int
+ message: str
+ source_line: str = ""
+ filename: Optional[str] = None
+ fix_hint: Optional[str] = None
+ error_code: Optional[str] = None
+ end_col: Optional[int] = None # 1-based, exclusive
+```
+
+兼容策略:
+
+- `DSLSyntaxError` 继承或等价兼容 `DSLParseError`,保留旧代码的捕获行为。
+- 新字段放在已有字段之后并提供默认值。
+- `end_col is None` 时继续使用当前 token 长度估算。
+- `__str__()` 始终输出无颜色文本,避免异常字符串携带终端控制码。
+
+### 7.3 验证结果
+
+多错误收集使用显式验证入口,避免改变 `parse()` 的返回类型:
+
+```python
+def validate(
+ self,
+ text: str,
+ *,
+ filename: Optional[str] = None,
+ max_errors: int = 20,
+) -> ErrorCollector: ...
+```
+
+调用约定:
+
+- `validate()` 不生成 IR。
+- `collector.has_errors` 为真时,调用者不得继续编译。
+- `parse()` 可先调用同一验证逻辑;发现错误时抛出第一个 `DSLSyntaxError`。
+- CLI 或教学工具需要一次展示多个错误时,先调用 `validate()`,再调用 `report()`。
+
+### 7.4 编译结果中的结构化诊断
+
+CLI 需要根据实际 stderr 是否为 TTY 决定颜色,因此 `CompilerDriver` 不能只返回已经格式化的字符串。建议为 `CompileResult` 增加兼容字段:
+
+```python
+@dataclass
+class CompileResult:
+ # 已有字段保持不变
+ errors: list[str]
+ diagnostics: list[DSLSyntaxError] = field(default_factory=list)
+ diagnostic_limit_reached: bool = False
+ diagnostic_limit: int = 20
+```
+
+数据流约定:
+
+- `diagnostics` 保存结构化错误,是 CLI 渲染的首选数据源。
+- `diagnostic_limit_reached` 和 `diagnostic_limit` 把 collector 的抑制状态传到 CLI,保证 renderer 能输出 footer。
+- `errors` 保留无 ANSI 文本,兼容现有库调用方和序列化逻辑。
+- 两个字段同时存在时,CLI 只渲染 `diagnostics`,不得重复打印 `errors`。
+- 只有无法表示为 DSL 诊断的预期业务错误才仅写入 `errors`。
+- 错误上限属于报告元数据,不伪造为带 `line=0, col=0` 的 `DSLSyntaxError`。
+
+---
+
+## 8. 错误分类与错误码
+
+错误码用于测试、文档检索和未来扩展。消息文字可以改进,错误码语义必须保持稳定。
+
+| 错误码 | 分类 | 触发条件 | 建议高亮 |
+|--------|------|----------|----------|
+| `E100` | 未识别语句 | 整行不符合任何 DSL 语句 | 第一个非空 token |
+| `E101` | 括号不配对 | 调用或条件缺少左右括号 | 缺失点或多余括号 |
+| `E102` | 缺少分隔符 | 赋值号、逗号或控制流冒号缺失 | 邻近 token |
+| `E103` | 非法标识符 | 目标名或循环变量不符合规则 | 完整标识符 |
+| `E110` | 块结束符不匹配 | `endif`、`endwhile`、`endfor` 无匹配开始,或与当前栈顶块类型不一致 | 结束关键字 |
+| `E111` | 块未闭合 | 到文件结尾仍存在打开的块 | 对应开始关键字 |
+| `E112` | `else` 位置错误 | 没有对应 `if` 或同一块重复 `else` | `else` token |
+| `E200` | 不支持的算子 | 算子名不在注册表 | 算子名 |
+| `E201` | 参数数量错误 | 位置参数数量不符合算子签名 | 调用参数区 |
+| `E202` | 关键字参数错误 | 未知、重复或缺失的 kwarg | 参数名 |
+| `E203` | 参数值错误 | `rows`、`axis` 等需要数值但格式非法 | 参数值 |
+
+不在本课题中使用的错误:
+
+- “变量未定义”:现有语言把首次出现的名字视为输入值。
+- 类型不匹配:当前 DSL 前端没有完整类型检查阶段。
+- IR 验证错误:应由 `IRVerifier` 报告,而不是 DSL 语法层报告。
+
+---
+
+## 9. 验证与错误恢复
+
+### 9.1 基础 DSL
+
+基础 DSL 以物理行为自然同步边界。单行验证流程建议如下:
+
+```text
+跳过空行/注释
+ ├─ for 语句 → 检查变量、范围和 block stack
+ ├─ endfor → 检查栈顶是否为 for
+ ├─ return → 检查关键字后是否具有非空操作数
+ └─ assignment → 检查 lhs、op、括号、参数与 kwargs
+```
+
+如果一行外层结构已经错误,验证器记录一条主要错误后直接进入下一物理行,不继续检查该行的算子参数。
+
+### 9.2 扩展 DSL
+
+扩展语法使用块栈:
+
+```python
+BlockFrame(kind="if", line=4, saw_else=False)
+BlockFrame(kind="while", line=9)
+BlockFrame(kind="for", line=12)
+```
+
+同步规则:
+
+1. 普通语句失败后跳到下一物理行。
+2. `else` 只与最近且尚未出现 `else` 的 `if` 匹配。
+3. 结束关键字与栈顶匹配时正常弹栈;栈为空时报告 `E110` 后继续。
+4. 栈非空但类型不匹配时,在当前结束符报告一个 `E110`,消息同时写明“发现什么”和“期望什么”。为避免 EOF 级联:若该结束符能匹配更外层块,则弹出到该外层块(含它);若栈中没有可匹配块,则把它作为栈顶块的恢复性结束符并弹出栈顶。被恢复弹出的块不再报告 `E111`。
+5. 到达文件尾时,只为仍留在栈中的每个未闭合块报告 `E111`。
+6. 达到错误上限后停止验证,并把 `collector.limit_reached` 设为真;抑制提示作为报告 footer 输出,不加入 `errors` 列表。
+
+恢复示例:
+
+| 源码结构 | 诊断序列 | 恢复后栈 |
+|----------|----------|----------|
+| `while ... endif` | 当前 `endif` 报 1 个 `E110`:期望 `endwhile` | 弹出 `while`,EOF 不再报 `E111` |
+| `if ... while ... endif` | 当前 `endif` 报 1 个 `E110`:应先出现 `endwhile` | 弹出 `while` 和匹配的 `if` |
+| `if ... while ... EOF` | `while`、`if` 的开始位置各报 1 个 `E111` | 文件结束 |
+
+### 9.3 去重和级联抑制
+
+建议用以下键去重:
+
+```text
+(filename, line, col, error_code, message)
+```
+
+同一行最多报告一个结构错误和一个独立的算子签名错误。由结构错误直接导致的后续错误不再报告。
+
+---
+
+## 10. 修复建议策略
+
+修复建议应当保守。错误建议错误时,比没有建议更影响用户判断。
+
+优先级从高到低:
+
+1. 解析器在明确上下文中提供的 `fix_hint`;
+2. 精确拼写表,例如 `retrun → return`;
+3. 基于错误码的固定建议,例如 `E101 → add the missing ')'`;
+4. 没有足够信息时不输出建议。
+
+建议规则:
+
+- 拼写建议仅比较同类关键字或算子名。
+- 不对任意源代码单词做全局替换建议。
+- 不建议会改变程序语义的操作。
+- 提示文字不参与控制流判断;逻辑只依赖错误码和结构化字段。
+
+---
+
+## 11. 渲染设计
+
+### 11.1 纯文本格式
+
+```text
+{filename}:{line}:{col}: error[{code}]: {message}
+ {line_width} | {source_line}
+ {padding} | {caret_and_tildes}
+note: {fix_hint}
+```
+
+约束:
+
+- 文件名未知时显示 ``,不输出空位置前缀。
+- 行号宽度按本次报告的最大行号计算。
+- `end_col` 存在时按跨度绘制 `^~~~`;不存在时才估算 token 长度。
+- `source_line` 为空时不显示源码和 caret。
+- 测试和 `str(error)` 强制无颜色。
+
+### 11.2 ANSI 颜色
+
+保留 `format_error(err, use_color: bool)` 作为兼容的纯格式化函数;另提供面向输出流的 renderer:
+
+```python
+def render_error(
+ err: DSLSyntaxError,
+ *,
+ stream: TextIO,
+ use_color: Optional[bool] = None,
+) -> str: ...
+```
+
+`render_error()` 的颜色规则:
+
+- `True`:强制开启;
+- `False`:强制关闭;
+- `None`:仅当传入的 `stream.isatty()` 为真且未设置 `NO_COLOR` 时开启。
+
+颜色只改变显示,不得改变可见字符内容、错误码或换行数量。
+
+### 11.3 多错误报告
+
+`ErrorCollector.report()` 应区分真实错误和抑制消息:
+
+```text
+--- 3 error(s) found ---
+...
+note: error limit (3) reached; further errors suppressed
+```
+
+标题中的数量只统计真实源码错误。错误上限提示在验证阶段来自 `ErrorCollector.limit_reached`,进入编译驱动后复制到 `CompileResult.diagnostic_limit_reached`;它不占用错误位置,也不计入 `error_count`。
+
+---
+
+## 12. 解析器与编译器驱动集成
+
+### 12.1 基础解析器
+
+建议的成功路径保持不变:
+
+```python
+program = DSLParser().parse(source)
+```
+
+拟议扩展:
+
+```python
+program = DSLParser().parse(source, filename="model.dsl")
+collector = DSLParser().validate(source, filename="model.dsl")
+```
+
+`_parse_line()` 应接收带原始位置的行上下文,而不是只接收 `strip()` 后的字符串。
+
+### 12.2 扩展解析器
+
+`ExtendedDSLParser` 与基础解析器共享 `SourceBuffer`、算子签名验证和错误工厂,只增加控制流块规则。不要复制一套错误消息和错误码。
+
+Validator 与 Parser 还必须共享语法事实源,例如 `STATEMENT_PATTERNS`、`OP_SIGNATURES` 和关键字集合。Parser 的执行分派可以保留独立 handler,但其算子名集合必须由测试断言与 `OP_SIGNATURES` 完全一致,避免“验证通过、执行失败”或“Parser 接受、Validator 拒绝”。
+
+### 12.3 CompilerDriver
+
+当前“扩展解析失败后捕获所有异常并尝试基础解析器”的策略需要移除或收窄:
+
+- 由于 `ExtendedDSLParser` 已继承基础语法,编译器驱动可统一使用扩展解析器;如果未来保留两种模式,则必须由显式配置选择,不能扫描关键字猜测,也不能通过异常回退选择;
+- `DSLSyntaxError` 属于用户输入错误,必须原样保留,不得触发回退;
+- 只有明确表示“该解析器不适用”的内部信号才允许回退;
+- `CompileResult.diagnostics` 保存结构化诊断,`diagnostic_limit_reached`/`diagnostic_limit` 保存抑制状态,`errors` 保存兼容的无 ANSI 文本;
+- CLI 把 `sys.stderr` 传给 renderer,由 renderer 根据该流的 TTY 状态决定颜色。
+
+`CompilerDriver.compile()` 的异常边界也必须同步调整:它只捕获 `DSLSyntaxError` 等已知用户输入错误并生成失败的 `CompileResult`。`IndexError`、`AssertionError` 等意外异常不应被包装成普通 Parse error;库调用时让它们抛出以便测试发现,CLI 顶层再把它们转换为明确的 `internal compiler error` 和退出码 2。正常模式不打印 traceback,显式调试模式才打印。
+
+这样可以避免扩展语法错误被基础解析器的次生错误覆盖。
+
+---
+
+## 13. 测试设计
+
+### 13.1 单元测试
+
+| 测试对象 | 重点 |
+|----------|------|
+| `SourceBuffer` | LF/CRLF、空文件、末尾换行、Unicode、制表符 |
+| `DSLSyntaxError` | 兼容构造、继承关系、`end_col`、无颜色 `str()` |
+| `format_error()` / renderer | 对齐、跨度、无文件名、无源码、指定 stream 的颜色自动策略 |
+| `ErrorCollector` | 去重、排序、错误上限、真实错误计数、清空 |
+| 建议规则 | 精确命中、大小写、无误报、显式提示优先 |
+
+### 13.2 解析器集成测试
+
+至少覆盖:
+
+- 无法识别的语句;
+- `retrun` 等拼写错误;
+- 缺少左右括号;
+- 不支持的算子;
+- 一元、二元和带 kwarg 算子的参数数量错误;
+- 多余 `endfor`、`endif`、`endwhile`;
+- 缺失块结束符;
+- `else` 无匹配或重复;
+- 嵌套 `if/while/for` 的恢复;
+- 同一文件多个独立错误;
+- 正确 DSL 的 IR 与改动前一致。
+
+### 13.3 端到端测试
+
+```text
+bad.dsl → CompilerDriver.compile() → CompileResult(success=False)
+```
+
+断言:
+
+- 包含文件名、行、列、错误码和源码行;
+- 不包含 Python traceback、`IndexError` 或 `KeyError`;
+- 扩展语法错误没有被基础解析器错误覆盖;
+- CLI 失败退出码为 1;
+- 重定向输出时没有 ANSI 转义码。
+
+### 13.4 快照测试原则
+
+只对纯文本格式使用快照。每个快照应尽量只包含一个概念,避免所有错误共用一个巨大 golden 文件。错误码和位置做精确断言,建议文本可以单独断言。
+
+---
+
+## 14. 兼容性与迁移
+
+| 风险 | 兼容措施 |
+|------|----------|
+| 调用方捕获 `DSLParseError` | 让 `DSLSyntaxError` 保持其子类或兼容别名关系 |
+| 调用方只传 `text` | `filename` 使用 keyword-only 可选参数 |
+| 测试依赖无颜色 `str(e)` | `__str__()` 固定调用 `use_color=False` |
+| 正确程序 IR 发生变化 | 增加改动前后 IR 快照或结构对比测试 |
+| 旧格式没有错误码 | 错误码作为新增内容,文档标明输出格式版本变化 |
+| 外部调用直接使用 `format_error()` | 保留现有参数,新增行为通过可选参数提供 |
+
+---
+
+## 15. 性能要求
+
+错误诊断不是编译热点,但验证阶段不能明显拖慢批量基准:
+
+- 源码扫描时间复杂度为 `O(n)`,`n` 为源码字符数。
+- 每行最多进行常数次正则匹配和算子表查询。
+- 拼写建议优先使用字典精确匹配,不对所有词执行无界编辑距离搜索。
+- 对 `benchmarks/cases/*.dsl` 执行 5 次预热,再执行 10 组测量;每组依次解析全部用例 100 次,使用组耗时中位数。相同机器、Python 版本和进程配置下,“验证 + IR 生成”的中位数目标不超过原解析流程的 1.5 倍。
+- 错误数量达到上限后立即停止进一步分析。
+
+报告必须同时给出基线、改动后中位数、倍率和测试环境;不能仅凭主观判断声明达成。
+
+---
+
+## 16. 验收标准
+
+满足以下条件时,课题可判定完成:
+
+1. 基础和扩展解析器的用户输入错误均转换为 `DSLSyntaxError`。
+2. 每个错误至少包含文件名、1-based 行列、稳定错误码和可读消息。
+3. 至少覆盖第 8 节列出的 `E100`、`E101`、`E110`、`E111`、`E200`、`E201`。
+4. 显式验证模式能从一个文件报告至少 3 个独立错误。
+5. 有语法错误时不返回可执行的部分 IR。
+6. `CompilerDriver` 不再用基础解析器错误覆盖扩展解析器错误。
+7. 纯文本输出不含 ANSI,终端自动颜色遵守 TTY 和 `NO_COLOR`。
+8. 现有正确 DSL 示例、解析器测试和 L2 验证全部通过。
+9. 新增单元、集成和端到端测试均通过。
+10. 文档说明如何增加新的错误码、验证规则和修复建议。
+
+---
+
+## 17. 风险与权衡
+
+| 选择 | 收益 | 代价 |
+|------|------|------|
+| 验证与 IR 生成分离 | 多错误收集安全,不污染 builder | 部分语法规则会被检查两次 |
+| 保留正则解析 | 改动小,适合教学课题 | 复杂语法扩展能力有限 |
+| 稳定错误码 | 测试与文档可长期引用 | 新错误分类需要谨慎评审 |
+| 保守修复建议 | 降低误导用户的概率 | 可提供的建议数量较少 |
+| 不做部分 IR | 保证下游阶段输入可信 | 无法展示错误后的局部编译结果 |
+
+如果未来 DSL 语法明显增长,应把本方案视为迁移到真正 tokenizer/parser 之前的过渡层,而不是无限扩展行级正则验证器。
+
+---
+
+## 18. 待后续课题讨论
+
+以下方向保留为未来工作,不作为本课题验收项:
+
+- 统一 ONNX、DSL、IR 验证器的 `Diagnostic` 协议;
+- JSON、SARIF 和机器可读错误输出;
+- LSP 诊断与编辑器下划线;
+- 跨行 SourceSpan 和相关位置(related locations);
+- 基于算子注册表自动生成参数诊断;
+- 国际化错误消息;
+- 将 DSL 正式迁移到 token 流和语法树。
diff --git a/memory/memory.md b/memory/memory.md
new file mode 100644
index 0000000..0c80287
--- /dev/null
+++ b/memory/memory.md
@@ -0,0 +1,9 @@
+# ScratchV engineering memory
+
+[2026-09-13] DSL benchmark 的详细日志按用例使用默认关闭的 details/summary 折叠,汇总表、失败检查和性能提示保留在外层;GitHub Markdown 与本地 HTML 均需支持,HTML 中的诊断源码必须转义。
+
+[2026-09-13] DSL 错误标记缩进须按实际行号前缀的可见宽度计算,不能固定为 6;覆盖 9/10、99/100 位数边界及空格/tab、有色和无色输出,避免多位行号导致 caret 左移。ANSI 控制序列不计入宽度。
+
+[2026-09-13] 维护者要求课题测试与 benchmark 接入原有 `.github/workflows/ci.yml` 的 test/benchmark jobs,不新建独立 pipeline;pytest tests/ 已自动发现 DSL 测试,报告复用现有 artifacts。适用于本仓库课题 PR 的 CI 接入。
+
+[2026-09-13] DSL 诊断 benchmark 应用同一脚本和固定正确输入,在独立进程中导入基线与当前 checkout,并检查实际模块路径;同时比较源码与 IR 摘要。适用于课题 9 前端回归,避免 editable install 导致两侧都测到当前代码;错误输入的新增诊断能力单独验收,性能目标达标状态与功能通过状态分开报告。
diff --git a/scratchv/compiler.py b/scratchv/compiler.py
index bd5648a..fa5459e 100644
--- a/scratchv/compiler.py
+++ b/scratchv/compiler.py
@@ -190,6 +190,9 @@ class CompileResult:
stats: dict[str, Any] = field(default_factory=dict)
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
+ diagnostics: list[Any] = field(default_factory=list)
+ diagnostic_limit_reached: bool = False
+ diagnostic_limit: int = 20
def summary(self) -> str:
"""Return a one-line summary."""
@@ -240,10 +243,44 @@ def compile(self, input_path: str, output_path: str | None = None,
if output_path is None:
output_path = "output.ll" if self.config.backend == "llvm" else "output.s"
+ use_dsl = (
+ dsl_source is not None
+ or (input_path and input_path.endswith(".dsl"))
+ )
+
+ if use_dsl:
+ source = dsl_source
+ if source is None and input_path:
+ with open(input_path) as source_file:
+ source = source_file.read()
+ from scratchv.frontend.dsl_extended import ExtendedDSLParser
+ parser = ExtendedDSLParser()
+ collector = parser.validate(
+ source or "", filename=input_path or "",
+ )
+ if collector.has_errors:
+ diagnostics = collector.errors
+ return CompileResult(
+ success=False,
+ errors=[str(error) for error in diagnostics],
+ diagnostics=diagnostics,
+ diagnostic_limit_reached=collector.limit_reached,
+ diagnostic_limit=collector.max_errors,
+ )
+
# --- 1. Parse ---
try:
program = self._parse(input_path, dsl_source)
except Exception as e:
+ if use_dsl:
+ from scratchv.frontend.dsl_errors import DSLSyntaxError
+ if isinstance(e, DSLSyntaxError):
+ return CompileResult(
+ success=False,
+ errors=[str(e)],
+ diagnostics=[e],
+ )
+ raise
return CompileResult(
success=False, errors=[f"Parse error: {e}"],
)
@@ -334,13 +371,10 @@ def _parse(self, input_path: str, dsl_source: str | None = None):
if source is None and input_path:
with open(input_path) as f:
source = f.read()
- # Try extended DSL first
- try:
- from scratchv.frontend.dsl_extended import ExtendedDSLParser
- return ExtendedDSLParser().parse(source)
- except Exception:
- from scratchv.frontend.dsl_parser import DSLParser
- return DSLParser().parse(source)
+ from scratchv.frontend.dsl_extended import ExtendedDSLParser
+ return ExtendedDSLParser().parse(
+ source or "", filename=input_path or "",
+ )
else:
from scratchv.frontend.onnx_parser import ONNXParser
return ONNXParser().parse(input_path)
diff --git a/scratchv/frontend/__init__.py b/scratchv/frontend/__init__.py
index e65ba35..d5e9f8a 100644
--- a/scratchv/frontend/__init__.py
+++ b/scratchv/frontend/__init__.py
@@ -1,13 +1,25 @@
from .onnx_parser import ONNXParser
from .dsl_parser import DSLParser
from .dsl_extended import ExtendedDSLParser
-from .dsl_errors import DSLSyntaxError, format_error, ErrorCollector
+from .dsl_errors import (
+ DSLParseError,
+ DSLSyntaxError,
+ ErrorCollector,
+ format_error,
+ render_error,
+)
+from .dsl_validator import DSLValidator, OP_SIGNATURES, SourceBuffer
__all__ = [
"ONNXParser",
"DSLParser",
"ExtendedDSLParser",
"DSLSyntaxError",
+ "DSLParseError",
"format_error",
+ "render_error",
"ErrorCollector",
+ "DSLValidator",
+ "OP_SIGNATURES",
+ "SourceBuffer",
]
diff --git a/scratchv/frontend/dsl_errors.py b/scratchv/frontend/dsl_errors.py
index d42b0f3..acb4c53 100644
--- a/scratchv/frontend/dsl_errors.py
+++ b/scratchv/frontend/dsl_errors.py
@@ -17,9 +17,10 @@
from __future__ import annotations
import enum
+import os
import sys
from dataclasses import dataclass
-from typing import Optional
+from typing import Optional, TextIO
# ---------------------------------------------------------------------------
@@ -90,8 +91,12 @@ def _color(text: str, color: Color) -> str:
# DSLSyntaxError
# ---------------------------------------------------------------------------
+class DSLParseError(Exception):
+ """Base exception retained for compatibility with existing callers."""
+
+
@dataclass
-class DSLSyntaxError(Exception):
+class DSLSyntaxError(DSLParseError):
"""Enriched syntax error with precise location information.
Attributes:
@@ -111,6 +116,7 @@ class DSLSyntaxError(Exception):
filename: Optional[str] = None
fix_hint: Optional[str] = None
error_code: Optional[str] = None
+ end_col: Optional[int] = None
def __str__(self) -> str:
return format_error(self, use_color=False)
@@ -182,23 +188,18 @@ def format_error(
parts: list[str] = []
# Build location prefix
- location = ""
- if err.filename:
- location += err.filename
- location += f":{err.line}:{err.col}: "
+ location = f"{err.filename or ''}:{err.line}:{err.col}: "
# Error header
- error_tag = "error"
+ error_label = (
+ f"error[{err.error_code}]" if err.error_code else "error"
+ )
if use_color:
location = _color(location, Color.BOLD)
- error_tag = _color("error", Color.RED)
+ error_tag = _color(error_label, Color.RED)
parts.append(f"{location}{error_tag}: {err.message}")
else:
- parts.append(f"{location}error: {err.message}")
-
- # Error code
- if err.error_code:
- parts[-1] += f" [{err.error_code}]"
+ parts.append(f"{location}{error_label}: {err.message}")
# Source line display
if err.source_line:
@@ -215,19 +216,28 @@ def format_error(
# Error line
if use_color:
line_prefix = _color(f" {err.line} |", Color.GRAY)
- parts.append(f"{line_prefix} {err.source_line}")
+ parts.append(f"{line_prefix} {err.source_line.expandtabs(4)}")
else:
- parts.append(f" {err.line} | {err.source_line}")
+ parts.append(f" {err.line} | {err.source_line.expandtabs(4)}")
# Column marker
if show_column_marker:
- token_len = _estimate_token_length(
- err.source_line, err.col - 1,
- )
- marker = " " * (err.col + 3) + "^"
+ raw_start = max(err.col - 1, 0)
+ display_start = len(err.source_line[:raw_start].expandtabs(4))
+ if err.end_col is not None:
+ raw_end = max(err.end_col - 1, raw_start + 1)
+ token_len = max(
+ len(err.source_line[:raw_end].expandtabs(4))
+ - display_start,
+ 1,
+ )
+ else:
+ token_len = _estimate_token_length(err.source_line, raw_start)
+ marker_padding = len(f" {err.line} | ") + display_start
+ marker = " " * marker_padding + "^"
if use_color:
marker = (
- " " * (err.col + 3)
+ " " * marker_padding
+ _color("^", Color.GREEN)
)
# Add tildes to indicate token length
@@ -246,6 +256,19 @@ def format_error(
return "\n".join(parts)
+def render_error(
+ err: DSLSyntaxError,
+ *,
+ stream: TextIO,
+ use_color: Optional[bool] = None,
+) -> str:
+ """Render an error, selecting color from the destination stream."""
+ if use_color is None:
+ is_tty = bool(getattr(stream, "isatty", lambda: False)())
+ use_color = is_tty and "NO_COLOR" not in os.environ
+ return format_error(err, use_color=use_color)
+
+
def _estimate_token_length(source_line: str, col_start: int) -> int:
"""Estimate the length of the token at the given column position.
@@ -301,11 +324,16 @@ def __init__(
self.use_color = use_color
self.max_errors = max_errors
self._errors: list[DSLSyntaxError] = []
+ self.limit_reached = False
+ self._keys: set[tuple[object, ...]] = set()
@property
def errors(self) -> list[DSLSyntaxError]:
"""Return the collected errors."""
- return list(self._errors)
+ return sorted(
+ self._errors,
+ key=lambda err: (err.line, err.col, err.error_code or ""),
+ )
@property
def has_errors(self) -> bool:
@@ -323,20 +351,17 @@ def add(self, err: DSLSyntaxError) -> None:
Args:
err: A DSLSyntaxError instance.
"""
- if len(self._errors) >= self.max_errors:
- if not getattr(self, "_max_error_warned", False):
- self._max_error_warned = True
- msg = (
- f"error limit ({self.max_errors}) reached; "
- f"further errors suppressed"
- )
- self._errors.append(DSLSyntaxError(
- line=0, col=0, message=msg,
- filename=self.filename,
- ))
- return
if err.filename is None and self.filename is not None:
err.filename = self.filename
+ key = (
+ err.filename, err.line, err.col, err.error_code, err.message,
+ )
+ if key in self._keys:
+ return
+ if len(self._errors) >= self.max_errors:
+ self.limit_reached = True
+ return
+ self._keys.add(key)
self._errors.append(err)
def add_error(
@@ -347,6 +372,7 @@ def add_error(
source_line: str = "",
fix_hint: Optional[str] = None,
error_code: Optional[str] = None,
+ end_col: Optional[int] = None,
) -> None:
"""Convenience method to add an error by components.
@@ -366,6 +392,7 @@ def add_error(
filename=self.filename,
fix_hint=fix_hint,
error_code=error_code,
+ end_col=end_col,
))
def report(self) -> str:
@@ -386,9 +413,15 @@ def report(self) -> str:
else:
parts.append(f"--- {len(self._errors)} error(s) found ---")
- for err in self._errors:
+ for err in self.errors:
parts.append(format_error(err, use_color=self.use_color))
+ if self.limit_reached:
+ parts.append(
+ f"note: error limit ({self.max_errors}) reached; "
+ "further errors suppressed"
+ )
+
return "\n".join(parts)
def report_and_exit(self, exit_code: int = 1) -> None:
@@ -404,6 +437,8 @@ def report_and_exit(self, exit_code: int = 1) -> None:
def clear(self) -> None:
"""Clear all collected errors."""
self._errors.clear()
+ self._keys.clear()
+ self.limit_reached = False
# ---------------------------------------------------------------------------
@@ -418,6 +453,7 @@ def make_error(
filename: Optional[str] = None,
fix_hint: Optional[str] = None,
error_code: Optional[str] = None,
+ end_col: Optional[int] = None,
) -> DSLSyntaxError:
"""Factory function to create a DSLSyntaxError.
@@ -441,4 +477,5 @@ def make_error(
filename=filename,
fix_hint=fix_hint,
error_code=error_code,
+ end_col=end_col,
)
diff --git a/scratchv/frontend/dsl_extended.py b/scratchv/frontend/dsl_extended.py
index 585f37c..e8ab463 100644
--- a/scratchv/frontend/dsl_extended.py
+++ b/scratchv/frontend/dsl_extended.py
@@ -26,6 +26,8 @@
# moved import above
from scratchv.frontend.dsl_parser import DSLParser, DSLParseError
+from scratchv.frontend.dsl_errors import ErrorCollector
+from scratchv.frontend.dsl_validator import DSLValidator
from scratchv.ir.builder import IRBuilder
from scratchv.ir.types import OpCode, Program, Value
@@ -97,7 +99,16 @@ def _fresh_label(self, prefix: str = "L") -> str:
# Core parse method (overrides base)
# -----------------------------------------------------------------------
- def parse(self, text: str) -> Program:
+ def validate(
+ self, text: str, *, filename: str | None = None,
+ max_errors: int = 20,
+ ) -> ErrorCollector:
+ """Validate base and extended DSL syntax without creating IR."""
+ return DSLValidator(extended=True).validate(
+ text, filename=filename, max_errors=max_errors,
+ )
+
+ def parse(self, text: str, *, filename: str | None = None) -> Program:
"""Parse DSL text into IR Program, supporting if/else and while.
Args:
@@ -106,6 +117,10 @@ def parse(self, text: str) -> Program:
Returns:
A Program object containing the generated IR.
"""
+ collector = self.validate(text, filename=filename)
+ if collector.has_errors:
+ raise collector.errors[0]
+
# Strip comments before splitting to handle block-level constructs
lines_raw = text.split("\n")
lines: list[str] = []
diff --git a/scratchv/frontend/dsl_parser.py b/scratchv/frontend/dsl_parser.py
index 19aa04b..3d57e9c 100644
--- a/scratchv/frontend/dsl_parser.py
+++ b/scratchv/frontend/dsl_parser.py
@@ -22,10 +22,8 @@
import re
from scratchv.ir.builder import IRBuilder
from scratchv.ir.types import Value, Program
-
-
-class DSLParseError(Exception):
- pass
+from scratchv.frontend.dsl_errors import DSLParseError, ErrorCollector
+from scratchv.frontend.dsl_validator import DSLValidator
class DSLParser:
@@ -36,7 +34,29 @@ def __init__(self):
self._vars: dict[str, Value] = {}
self._loop_stack: list[str] = []
- def parse(self, text: str) -> Program:
+ def validate(
+ self, text: str, *, filename: str | None = None,
+ max_errors: int = 20,
+ ) -> ErrorCollector:
+ return DSLValidator().validate(
+ text, filename=filename, max_errors=max_errors,
+ )
+
+ @staticmethod
+ def supported_operations() -> set[str]:
+ return {
+ "add", "sub", "mul", "div", "neg", "exp", "relu", "gelu",
+ "dot", "matmul", "softmax", "maxpool",
+ }
+
+ def parse(self, text: str, *, filename: str | None = None) -> Program:
+ collector = self.validate(text, filename=filename)
+ if collector.has_errors:
+ raise collector.errors[0]
+
+ self.builder = IRBuilder()
+ self._vars = {}
+ self._loop_stack = []
lines = text.strip().split("\n")
self.builder.new_function("main")
self.builder.new_block("entry")
@@ -162,6 +182,7 @@ def _dispatch_op(self, op: str, args: list[str]) -> Value:
kwargs.get("stride", 2),
),
}
+ assert set(handlers) == self.supported_operations()
handler = handlers.get(op)
if handler is None:
raise DSLParseError(f"Unsupported op: {op}")
diff --git a/scratchv/frontend/dsl_validator.py b/scratchv/frontend/dsl_validator.py
new file mode 100644
index 0000000..b0ddee4
--- /dev/null
+++ b/scratchv/frontend/dsl_validator.py
@@ -0,0 +1,300 @@
+"""Source-preserving validation shared by ScratchV DSL parsers."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Literal, Optional
+
+from scratchv.frontend.dsl_errors import ErrorCollector
+
+
+@dataclass(frozen=True)
+class SourceBuffer:
+ """Immutable DSL source with one-based physical-line access."""
+
+ text: str
+ filename: str = ""
+
+ @property
+ def lines(self) -> tuple[str, ...]:
+ return tuple(self.text.replace("\r\n", "\n").replace("\r", "\n").split("\n"))
+
+ @property
+ def line_count(self) -> int:
+ return len(self.lines)
+
+ def line_text(self, line: int) -> str:
+ if line < 1 or line > self.line_count:
+ return ""
+ return self.lines[line - 1]
+
+
+@dataclass(frozen=True)
+class OpSignature:
+ positional: int
+ optional_kwargs: frozenset[str] = frozenset()
+ required_kwargs: frozenset[str] = frozenset()
+ numeric_kwargs: frozenset[str] = frozenset()
+
+
+OP_SIGNATURES: dict[str, OpSignature] = {
+ "add": OpSignature(2),
+ "sub": OpSignature(2),
+ "mul": OpSignature(2),
+ "div": OpSignature(2),
+ "neg": OpSignature(1),
+ "exp": OpSignature(1),
+ "relu": OpSignature(1),
+ "gelu": OpSignature(1),
+ "dot": OpSignature(2, frozenset({"len", "length"}), numeric_kwargs=frozenset({"len", "length"})),
+ "matmul": OpSignature(2, frozenset({"rows", "cols", "inner", "m", "n", "k"}), numeric_kwargs=frozenset({"rows", "cols", "inner", "m", "n", "k"})),
+ "softmax": OpSignature(1, frozenset({"axis"}), numeric_kwargs=frozenset({"axis"})),
+ "maxpool": OpSignature(1, frozenset({"kernel", "stride"}), numeric_kwargs=frozenset({"kernel", "stride"})),
+}
+
+
+_IDENTIFIER = re.compile(r"^(?!\d)\w+$", re.UNICODE)
+_ASSIGNMENT = re.compile(
+ r"^(?P[^\s=]+)\s*=\s*(?P[A-Za-z_]\w*)\s*"
+ r"\((?P.*)\)\s*$",
+ re.UNICODE,
+)
+_FOR = re.compile(
+ r"^for\s+(?P[^\s=]+)\s*=\s*(?P\d+)\s*,\s*"
+ r"(?P\d+)\s*$",
+)
+_CONDITION = re.compile(
+ r"^(?Pif|while)\s*\(\s*.+?\s*"
+ r"(?:==|!=|<=|>=|<|>)\s*.+?\s*\)\s*:?\s*$"
+)
+_NUMBER = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$")
+
+
+@dataclass
+class BlockFrame:
+ kind: Literal["if", "while", "for"]
+ line: int
+ col: int
+ source_line: str
+ saw_else: bool = False
+
+
+class DSLValidator:
+ """Validate DSL syntax without constructing IR."""
+
+ def __init__(self, *, extended: bool = False):
+ self.extended = extended
+
+ def validate(
+ self,
+ text: str,
+ *,
+ filename: Optional[str] = None,
+ max_errors: int = 20,
+ ) -> ErrorCollector:
+ display_name = filename or ""
+ source = SourceBuffer(text, display_name)
+ collector = ErrorCollector(
+ filename=display_name, use_color=False, max_errors=max_errors,
+ )
+ stack: list[BlockFrame] = []
+
+ for line_no, raw_line in enumerate(source.lines, start=1):
+ if collector.limit_reached:
+ break
+ statement = self._statement(raw_line)
+ if not statement:
+ continue
+ col = len(raw_line) - len(raw_line.lstrip()) + 1
+
+ if statement.startswith("for "):
+ match = _FOR.fullmatch(statement)
+ if match is None:
+ self._add(collector, line_no, col, raw_line, "E100", "cannot parse for statement")
+ elif not _IDENTIFIER.fullmatch(match.group("var")):
+ self._add(collector, line_no, col + 4, raw_line, "E103", "invalid loop variable")
+ else:
+ stack.append(BlockFrame("for", line_no, col, raw_line))
+ continue
+
+ if statement in {"endfor", "endif", "endwhile"}:
+ self._close_block(statement, line_no, col, raw_line, stack, collector)
+ continue
+
+ if statement in {"else", "else:"}:
+ self._else(line_no, col, raw_line, stack, collector)
+ continue
+
+ if statement.startswith(("if ", "while ")):
+ if not self.extended:
+ self._add(collector, line_no, col, raw_line, "E100", "cannot parse statement")
+ continue
+ kind = "if" if statement.startswith("if ") else "while"
+ if _CONDITION.fullmatch(statement) is None:
+ code = "E101" if "(" not in statement or ")" not in statement else "E100"
+ hint = "add matching parentheses around the condition" if code == "E101" else None
+ self._add(collector, line_no, col, raw_line, code, f"invalid {kind} condition", fix_hint=hint)
+ else:
+ stack.append(BlockFrame(kind, line_no, col, raw_line))
+ continue
+
+ if statement.startswith("return"):
+ if re.fullmatch(r"return\s+\S+", statement) is None:
+ self._add(collector, line_no, col, raw_line, "E100", "return requires a value")
+ continue
+
+ self._validate_assignment(statement, line_no, col, raw_line, collector)
+
+ for frame in stack:
+ if collector.limit_reached:
+ break
+ expected = {"if": "endif", "while": "endwhile", "for": "endfor"}[frame.kind]
+ self._add(
+ collector, frame.line, frame.col, frame.source_line, "E111",
+ f"unterminated {frame.kind} block",
+ fix_hint=f"add missing '{expected}'",
+ )
+ return collector
+
+ @staticmethod
+ def _statement(raw_line: str) -> str:
+ stripped = raw_line.strip()
+ if not stripped or stripped.startswith("#"):
+ return ""
+ comment = stripped.find(" #")
+ if comment >= 0:
+ stripped = stripped[:comment].rstrip()
+ return stripped
+
+ def _validate_assignment(
+ self, statement: str, line: int, col: int, raw: str,
+ collector: ErrorCollector,
+ ) -> None:
+ left_count = statement.count("(")
+ right_count = statement.count(")")
+ if left_count != right_count:
+ if left_count < right_count:
+ depth = 0
+ unmatched_right = 0
+ for index, char in enumerate(raw):
+ if char == "(":
+ depth += 1
+ elif char == ")":
+ if depth == 0:
+ unmatched_right = index
+ break
+ depth -= 1
+ error_col = unmatched_right + 1
+ message = "missing opening '('"
+ hint = "add the missing '('"
+ else:
+ openings: list[int] = []
+ for index, char in enumerate(raw):
+ if char == "(":
+ openings.append(index)
+ elif char == ")" and openings:
+ openings.pop()
+ error_col = openings[0] + 1
+ message = "missing closing ')'"
+ hint = "add the missing ')'"
+ self._add(
+ collector, line, error_col, raw, "E101", message,
+ fix_hint=hint,
+ )
+ return
+ match = _ASSIGNMENT.fullmatch(statement)
+ if match is None:
+ if "=" in statement and "(" in statement and ")" not in statement:
+ paren_col = raw.find("(") + 1
+ self._add(collector, line, paren_col, raw, "E101", "missing closing ')'", fix_hint="add the missing ')'")
+ else:
+ hint = "did you mean 'return'?" if statement.split(maxsplit=1)[0].lower() == "retrun" else None
+ self._add(collector, line, col, raw, "E100", "cannot parse statement", fix_hint=hint)
+ return
+
+ dest = match.group("dest")
+ if _IDENTIFIER.fullmatch(dest) is None:
+ self._add(collector, line, raw.find(dest) + 1, raw, "E103", f"invalid identifier '{dest}'", end_col=raw.find(dest) + len(dest) + 1)
+ return
+
+ op = match.group("op")
+ op_col = raw.find(op, raw.find("=")) + 1
+ signature = OP_SIGNATURES.get(op)
+ if signature is None:
+ hint = "did you mean 'add'?" if op == "ad" else None
+ self._add(collector, line, op_col, raw, "E200", f"unsupported operation '{op}'", fix_hint=hint, end_col=op_col + len(op))
+ return
+
+ args_text = match.group("args")
+ args = [item.strip() for item in args_text.split(",") if item.strip()]
+ positional: list[str] = []
+ kwargs: dict[str, str] = {}
+ args_col = raw.find("(", op_col - 1) + 2
+ for arg in args:
+ if ":" not in arg:
+ positional.append(arg)
+ continue
+ key, value = (part.strip() for part in arg.split(":", 1))
+ if key in kwargs or key not in signature.optional_kwargs | signature.required_kwargs:
+ self._add(collector, line, raw.find(key, args_col - 1) + 1, raw, "E202", f"invalid keyword argument '{key}'")
+ return
+ if not value:
+ self._add(collector, line, raw.find(key, args_col - 1) + len(key) + 2, raw, "E203", f"missing value for '{key}'")
+ return
+ if key in signature.numeric_kwargs and _NUMBER.fullmatch(value) is None:
+ self._add(collector, line, raw.find(value, args_col - 1) + 1, raw, "E203", f"'{key}' requires a numeric value")
+ return
+ kwargs[key] = value
+
+ if len(positional) != signature.positional:
+ self._add(
+ collector, line, args_col, raw, "E201",
+ f"operation '{op}' expects {signature.positional} positional argument(s), got {len(positional)}",
+ )
+ return
+ missing = signature.required_kwargs - kwargs.keys()
+ if missing:
+ name = sorted(missing)[0]
+ self._add(collector, line, args_col, raw, "E202", f"missing keyword argument '{name}'")
+
+ def _close_block(
+ self, closer: str, line: int, col: int, raw: str,
+ stack: list[BlockFrame], collector: ErrorCollector,
+ ) -> None:
+ expected_kind = {"endif": "if", "endwhile": "while", "endfor": "for"}[closer]
+ if not stack:
+ self._add(collector, line, col, raw, "E110", f"'{closer}' without matching {expected_kind}")
+ return
+ if stack[-1].kind == expected_kind:
+ stack.pop()
+ return
+ expected_closer = {"if": "endif", "while": "endwhile", "for": "endfor"}[stack[-1].kind]
+ self._add(collector, line, col, raw, "E110", f"found '{closer}', expected '{expected_closer}'")
+ matching = next((i for i in range(len(stack) - 1, -1, -1) if stack[i].kind == expected_kind), None)
+ if matching is None:
+ stack.pop()
+ else:
+ del stack[matching:]
+
+ def _else(
+ self, line: int, col: int, raw: str,
+ stack: list[BlockFrame], collector: ErrorCollector,
+ ) -> None:
+ if not stack or stack[-1].kind != "if":
+ self._add(collector, line, col, raw, "E112", "else without matching if")
+ elif stack[-1].saw_else:
+ self._add(collector, line, col, raw, "E112", "duplicate else in if block")
+ else:
+ stack[-1].saw_else = True
+
+ @staticmethod
+ def _add(
+ collector: ErrorCollector, line: int, col: int, source_line: str,
+ code: str, message: str, *, fix_hint: Optional[str] = None,
+ end_col: Optional[int] = None,
+ ) -> None:
+ collector.add_error(
+ line, max(col, 1), message, source_line=source_line,
+ fix_hint=fix_hint, error_code=code, end_col=end_col,
+ )
diff --git a/scratchv/main.py b/scratchv/main.py
index 25eaf33..52feff5 100644
--- a/scratchv/main.py
+++ b/scratchv/main.py
@@ -234,13 +234,22 @@ def main(argv: list[str] | None = None) -> int:
# Build config and driver
config = args_to_config(args)
driver = CompilerDriver(config)
+ use_dsl = args.dsl is not None or bool(
+ args.input and args.input.endswith(".dsl")
+ )
# Compile
- result: CompileResult = driver.compile(
- input_path=args.input or "",
- output_path=args.output,
- dsl_source=args.dsl if hasattr(args, 'dsl') else None,
- )
+ try:
+ result: CompileResult = driver.compile(
+ input_path=args.input or "",
+ output_path=args.output,
+ dsl_source=args.dsl if hasattr(args, 'dsl') else None,
+ )
+ except Exception as exc:
+ if not use_dsl:
+ raise
+ print(f"internal compiler error: {exc}", file=sys.stderr)
+ return 2
# Report
if result.ir_dump:
@@ -262,8 +271,21 @@ def main(argv: list[str] | None = None) -> int:
return 0
else:
- for err in result.errors:
- print(f"Error: {err}", file=sys.stderr)
+ if result.diagnostics:
+ from scratchv.frontend.dsl_errors import render_error
+ for diagnostic in result.diagnostics:
+ print(render_error(
+ diagnostic, stream=sys.stderr, use_color=None,
+ ), file=sys.stderr)
+ if result.diagnostic_limit_reached:
+ print(
+ f"note: error limit ({result.diagnostic_limit}) reached; "
+ "further errors suppressed",
+ file=sys.stderr,
+ )
+ else:
+ for err in result.errors:
+ print(f"Error: {err}", file=sys.stderr)
return 1
diff --git a/tests/test_dsl_diagnostics_benchmark.py b/tests/test_dsl_diagnostics_benchmark.py
new file mode 100644
index 0000000..ef154a9
--- /dev/null
+++ b/tests/test_dsl_diagnostics_benchmark.py
@@ -0,0 +1,169 @@
+"""Exercise the Topic 9 report through its real subprocess interface."""
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+SCRIPT = ROOT / "benchmarks" / "bench_dsl_diagnostics.py"
+
+
+def run_report(tmp_path: Path, *extra: str) -> tuple[subprocess.CompletedProcess, dict]:
+ cases = tmp_path / "cases"
+ cases.mkdir(exist_ok=True)
+ source = cases / "valid.dsl"
+ if not source.exists():
+ source.write_text("x = add(a, b)\nreturn x\n", encoding="utf-8")
+ output = tmp_path / "report.json"
+ result = subprocess.run(
+ [
+ sys.executable, str(SCRIPT), "--baseline-root", str(ROOT),
+ "--cases", str(cases), "--warmup", "0", "--groups", "1",
+ "--iterations", "1", "--json-output", str(output),
+ "--markdown", str(tmp_path / "report.md"),
+ "--html", str(tmp_path / "report.html"), *extra,
+ ],
+ capture_output=True, text=True, encoding="utf-8", timeout=60,
+ )
+ report = json.loads(output.read_text(encoding="utf-8")) if output.exists() else {}
+ return result, report
+
+
+def test_real_report_checks_diagnostics_and_compares_same_corpus(tmp_path):
+ result, report = run_report(tmp_path, "--json")
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == report
+ assert report["status"] == "passed"
+ assert report["parsing"]["ir_equal"] is True
+ assert report["parsing"]["ratio"] > 0
+ assert report["parsing"]["baseline"]["case_hashes"] == report["parsing"]["current"]["case_hashes"]
+ assert report["parsing"]["current"]["parser_file"].startswith(str(ROOT))
+ assert report["parsing"]["current"]["samples_s"]
+ assert report["diagnostics"]["passed"] is True
+ checks = {case["name"]: case for case in report["diagnostics"]["cases"]}
+ assert checks["three_errors"]["actual_codes"] == ["E100", "E200", "E201"]
+ assert checks["error_limit"]["actual_count"] == 20
+ assert checks["error_limit"]["limit_reached"] is True
+ assert checks["three_errors"]["cli_exit_code"] == 1
+ assert "\x1b[" not in checks["three_errors"]["rendered"]
+ assert "DSL diagnostics" in (tmp_path / "report.md").read_text(encoding="utf-8")
+ html = (tmp_path / "report.html").read_text(encoding="utf-8")
+ assert "" in html.lower()
+ assert "