diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 2abc8d0e1..a4687d349 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -64,6 +64,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ ENV VIRTUAL_ENV="/workspace/.venv" ENV PATH="/workspace/.venv/bin:${PATH}" ENV CHIPCOMPILER_OSS_CAD_DIR="/workspace/chipcompiler/thirdparty/oss-cad-suite" +ENV CHIPCOMPILER_ICS55_PDK_ROOT="/workspace/chipcompiler/thirdparty/icsprout55-pdk" RUN echo 'if [ -f /workspace/.venv/bin/activate ]; then source /workspace/.venv/bin/activate; fi' >> /root/.bashrc diff --git a/CLAUDE.md b/CLAUDE.md index 354facb5a..b8bfbd49d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -ECC is the EDA toolchain component of ECOS Studio, orchestrating EDA tools (Yosys, ECC-Tools, OpenROAD, Magic, KLayout) for RTL-to-GDS flows. See `docs/architecture.md` for architecture details and `docs/development.md` for workflows. +ECC is the EDA toolchain component of ECOS Studio, orchestrating EDA tools (Yosys, ECC-Tools, OpenROAD, Magic, KLayout) for RTL-to-GDS flows. See `docs/development.md` for workflows. For setup, testing, and code quality commands, see [docs/development.md](docs/development.md). diff --git a/README.cn.md b/README.cn.md index a409dd0e1..517153eeb 100644 --- a/README.cn.md +++ b/README.cn.md @@ -32,7 +32,7 @@ GUI(ECOS Studio)已迁移至 [ecos-studio](https://github.com/0xharry/ecos-s ### 安装脚本(推荐) -安装 `ecc` CLI(Linux x86_64,glibc 2.34+): +安装 `ecc` CLI(Linux x86_64,glibc 2.34+,fontconfig): ```sh curl -fsSL http://release.openecos.com/installers/ecc/latest/ecc-installer.sh | sh @@ -71,7 +71,25 @@ git submodule update --init --recursive ### 源码构建 使用 `uv` 进行 Python 开发时,按上述方式(带 `--recursive`)克隆仓库, -然后参照 [开发指南](docs/development.md) 配置工作区。 +然后配置工作区(源码开发的推荐方式): + +```bash +uv sync --no-build-isolation-package ecc-dreamplace --no-build-isolation-package ecc-tools-bin --verbose +``` + +完整搭建见 [开发指南](docs/development.cn.md)。 + +如需自己编译可安装的 CLI 包(与官方 release 相同的 PyInstaller 流程): + +```bash +ECOS_PYINSTALLER_MODE=onedir uv run --no-sync --managed-python \ + pyinstaller ecc.spec --clean --noconfirm +# 重建 dist/ecc/(onedir,约 0.9G;首跑会触发 dreamplace 的 cmake 安装,属正常) + +# 安装到本机(覆盖现有安装位,如 ~/.local/ecc;PATH 中指向它的软链无需改动) +rm -rf ~/.local/ecc && mkdir -p ~/.local/ecc && cp -a dist/ecc/. ~/.local/ecc/ +ecc --help # 验证应有的命令已列出 +``` ## 快速开始 @@ -107,7 +125,6 @@ root = "/path/to/icsprout55-pdk" [flow] preset = "rtl2gds" # rtl2gds | syn_sta | synthesis_lec -run = "default" ``` 然后校验并运行: @@ -128,24 +145,27 @@ ecc log --project gcd | `ecc init ` | 创建项目骨架和 `ecc.toml` | | `ecc check` | 校验 RTL、约束、PDK、工具和配置 | | `ecc doctor` | 检查主机环境:PDK、yosys(含 slang)和内置工具 | +| `ecc doc ` | 在终端阅读内置文档(`config` 配置参考、`ug` 用户指南、`tutorial` 教程) | | `ecc run` | 运行配置的 RTL-to-GDS 流程 | | `ecc status` | 快速查看 run/步骤进度概要 | | `ecc log [step]` | 显示可用日志或步骤日志内容 | | `ecc config [step]` | 显示解析后的项目或步骤配置 | | `ecc migrate` | 将旧版 `runs/` 项目迁移到 manifest 布局 | | `ecc param` | 管理参数覆盖(`list`、`show`、`set`、`unset`、`diff`) | -| `ecc pdk` | 管理 PDK 路径(`setup`、`set-root`、`show`、`unset`) | +| `ecc pdk` | 管理 PDK 路径(`set-root`、`show`、`unset`) | +| `ecc project` | 编辑 `ecc.toml` 中的项目声明(`set`、`unset`、`add`、`remove`、`show`) | +| `ecc workspace` | 从项目配置刷新受管 workspace | | `ecc signoff` | 检查签核就绪度并导出签核包 | | `ecc report` | 生成设计总结、QoR、签核清单和步骤报告 | | `ecc version` | 显示 ECC 运行时和组件版本 | | `ecc layout-image` | 将 GDS 文件渲染为版图图像 | 项目命令均接受 `--project `(默认为当前目录)。大多数命令支持 -`--plain`、`--json` 和 `--jsonl` 输出,便于脚本化。 +`--plain` 输出,便于脚本化。 + +完整指南随 CLI 分发、可离线阅读:`ecc doc ug --lang cn`(用户指南)、 +`ecc doc config --lang cn`(配置参考)、`ecc doc tutorial --lang cn`(从零上手的教程)。 -完整的命令模型——`ecc.toml` 参考、流程预设、步骤级重跑 -(`--resume`、`--from`、`--only`)和参数覆盖——请参阅 -[CLI 设计规范](docs/specification/cli-design.md)。 ## 功能特性 @@ -166,14 +186,12 @@ ecc log --project gcd ## 文档 - [文档索引](docs/index.md) - 完整导航 -- [CLI 设计规范](docs/specification/cli-design.md) - 命令接口和 `ecc.toml` 参考 -- [架构](docs/architecture.md) - 系统设计和模式 -- [开发指南](docs/development.md) - 配置和工作流 +- [开发指南](docs/development.cn.md) - 配置和工作流 - [示例](docs/examples/) - 使用示例 ## 参与贡献 -欢迎贡献!配置说明请参阅 [开发指南](docs/development.md)。 +欢迎贡献!贡献规则与评审要求见 [CONTRIBUTING.md](CONTRIBUTING.md),环境搭建见 [开发指南](docs/development.cn.md)。 ## 致谢 diff --git a/README.md b/README.md index 0140c8d22..06f227c3e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The GUI (ECOS Studio) has been moved to the [ecos-studio](https://github.com/0xh ### Installer (recommended) -Install the `ecc` CLI (Linux x86_64, glibc 2.34+): +Install the `ecc` CLI (Linux x86_64, glibc 2.34+, fontconfig): ```sh curl -fsSL http://release.openecos.com/installers/ecc/latest/ecc-installer.sh | sh @@ -72,8 +72,28 @@ git submodule update --init --recursive ### Build from source For Python development with `uv`, clone the repository as above (with -`--recursive`), then follow the [Development Guide](docs/development.md) to -set up the workspace. +`--recursive`), then set up the workspace — the recommended way to develop +from source: + +```bash +uv sync --no-build-isolation-package ecc-dreamplace --no-build-isolation-package ecc-tools-bin --verbose +``` + +See the [Development Guide](docs/development.md) for the full setup. + +To build the installable CLI bundle yourself (the same PyInstaller pipeline +as the official release): + +```bash +ECOS_PYINSTALLER_MODE=onedir uv run --no-sync --managed-python \ + pyinstaller ecc.spec --clean --noconfirm +# rebuilds dist/ecc/ (onedir, ~0.9G; the first run triggers dreamplace's cmake install, which is normal) + +# Install locally (overwrite your install location, e.g. ~/.local/ecc; +# a PATH symlink pointing at it needs no change) +rm -rf ~/.local/ecc && mkdir -p ~/.local/ecc && cp -a dist/ecc/. ~/.local/ecc/ +ecc --help # verify the expected commands are listed +``` ## Quick Start @@ -110,7 +130,6 @@ root = "/path/to/icsprout55-pdk" [flow] preset = "rtl2gds" # rtl2gds | syn_sta | synthesis_lec -run = "default" ``` Then validate and run: @@ -131,24 +150,28 @@ Run `ecc --help` (or `ecc --help`) for full usage. Common commands: | `ecc init ` | Create a project skeleton and `ecc.toml` | | `ecc check` | Validate RTL, constraints, PDK, tools, and config | | `ecc doctor` | Probe host environment: PDK, yosys (+slang), bundled tools | +| `ecc doc ` | Read the bundled guides (`config` reference, `ug` user guide, `tutorial`) in the terminal | | `ecc run` | Run the configured RTL-to-GDS flow (`--preset` overrides for one run) | | `ecc status` | Show a quick run/step progress summary | | `ecc log [step]` | Show available logs or step log content | | `ecc config [step]` | Show resolved project or step configuration | | `ecc migrate` | Migrate a legacy `runs/` project to the manifest layout | | `ecc param` | Manage parameter overrides (`list`, `show`, `set`, `unset`, `diff`) | -| `ecc pdk` | PDK path setup (`setup` clones + unzips, `set-root`, `show`, `unset`) | +| `ecc pdk` | Manage the PDK path (`set-root`, `show`, `unset`) | +| `ecc project` | Edit project declarations in `ecc.toml` (`set`, `unset`, `add`, `remove`, `show`) | +| `ecc workspace` | Refresh managed workspaces from project configuration | | `ecc signoff` | Inspect readiness and export the signoff package | | `ecc report` | Write design-summary, QoR, checklist, and step reports | | `ecc version` | Show ECC runtime and component versions | | `ecc layout-image` | Render a GDS file into a layout image | Project commands accept `--project ` (defaults to the current directory). -Most commands support `--plain`, `--json`, and `--jsonl` output for scripting. +Most commands support `--plain` output for scripting. + +The full guides ship with the CLI and work offline: `ecc doc ug` (user guide, +`--lang cn` for 中文), `ecc doc config` (configuration reference), and +`ecc doc tutorial` (step-by-step first flow). -For the full command model — `ecc.toml` reference, flow presets, step-level -rerun (`--resume`, `--from`, `--only`), and parameter overrides — see the -[CLI Design Specification](docs/specification/cli-design.md). ## Features @@ -169,14 +192,14 @@ rerun (`--resume`, `--from`, `--only`), and parameter overrides — see the ## Documentation - [Documentation Index](docs/index.md) - Complete navigation -- [CLI Design Specification](docs/specification/cli-design.md) - Command surface and `ecc.toml` reference -- [Architecture](docs/architecture.md) - System design and patterns - [Development Guide](docs/development.md) - Setup and workflows - [Examples](docs/examples/) - Usage examples ## Contributing -Contributions welcome! See [Development Guide](docs/development.md) for setup instructions. +Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for the rules +and review expectations, and the [Development Guide](docs/development.md) for +setup instructions. ## Acknowledgments diff --git a/chipcompiler/cli/app.py b/chipcompiler/cli/app.py index 5dad46840..57b606568 100644 --- a/chipcompiler/cli/app.py +++ b/chipcompiler/cli/app.py @@ -46,27 +46,20 @@ def root_callback( @app.command("version", help="Show ECC runtime, component, and installed tool versions") def version_cmd( *, - json_output: Annotated[bool, typer.Option("--json")] = False, - jsonl: Annotated[bool, typer.Option("--jsonl")] = False, - plain: Annotated[bool, typer.Option("--plain")] = False, + # Machine-readable output for the desktop app; not part of the documented CLI surface. + json_output: Annotated[bool, typer.Option("--json", hidden=True)] = False, ) -> None: payload = version_payload() tools = tool_versions() - if jsonl: - for name in ("ecc", "dreamplace", "ecc_tools"): - typer.echo(json.dumps({"component": name, "version": payload[name]})) - for name, version in tools.items(): - typer.echo(json.dumps({"component": name, "version": version})) - elif json_output: + if json_output: typer.echo(json.dumps({**payload, "tools": tools})) - elif plain: - from chipcompiler.cli.rendering.render import render_plain - - render_plain(({**payload, **tools},)) else: typer.echo(version_text(payload, tools)) +register_doc_commands(app) + + @app.command("layout-image", help="Render a GDS file into a layout image") def layout_image_cmd( gds: Annotated[str, typer.Option("--gds", help="Input GDS path")], @@ -83,7 +76,6 @@ def layout_image_cmd( register_project_commands(app) register_doctor_commands(app) -register_doc_commands(app) app.add_typer(param_app, name="param") app.add_typer(pdk_app, name="pdk") app.add_typer(project_app, name="project") diff --git a/chipcompiler/cli/command_handlers/param.py b/chipcompiler/cli/command_handlers/param.py index e4988c942..8a56b08fd 100644 --- a/chipcompiler/cli/command_handlers/param.py +++ b/chipcompiler/cli/command_handlers/param.py @@ -11,6 +11,7 @@ validate_pdk_target, validate_value, ) +from chipcompiler.utility.file import write_text_atomic def _manifest_mode_error(ctx: CommandContext) -> CommandResult | None: @@ -207,7 +208,12 @@ def param_set(args, ctx: CommandContext) -> CommandResult: [error_record("invalid_value", param=key, reason=problem)], exit_code=1 ) - _write_param_to_toml(config_path, schema, value) + try: + _write_param_to_toml(config_path, schema, value) + except (OSError, ValueError) as exc: + return CommandResult.err( + [error_record("config_error", param=key, reason=str(exc))], exit_code=1 + ) return CommandResult.ok( [ @@ -255,7 +261,12 @@ def param_unset(args, ctx: CommandContext) -> CommandResult: ] ) - removed = _remove_param_from_toml(config_path, schema) + try: + removed = _remove_param_from_toml(config_path, schema) + except (OSError, ValueError) as exc: + return CommandResult.err( + [error_record("config_error", param=key, reason=str(exc))], exit_code=1 + ) if removed: return CommandResult.ok( @@ -375,9 +386,7 @@ def _write_param_to_toml(config_path: str, schema, value: object) -> None: original = f.read() new_text = toml_edit.set_scoped_key(original, target_table, name, value) - - with open(config_path, "w") as f: - f.write(new_text) + write_text_atomic(config_path, new_text) def _remove_param_from_toml(config_path: str, schema) -> bool: @@ -394,6 +403,5 @@ def _remove_param_from_toml(config_path: str, schema) -> bool: if result is None: return False - with open(config_path, "w") as f: - f.write(result) + write_text_atomic(config_path, result) return True diff --git a/chipcompiler/cli/command_handlers/pdk.py b/chipcompiler/cli/command_handlers/pdk.py index f11a37ab9..ef22a91e9 100644 --- a/chipcompiler/cli/command_handlers/pdk.py +++ b/chipcompiler/cli/command_handlers/pdk.py @@ -5,17 +5,39 @@ from chipcompiler.cli.core.records import error_record from chipcompiler.cli.core.types import CommandContext, CommandResult from chipcompiler.cli.project.toml_edit import set_pdk_root +from chipcompiler.utility.file import write_text_atomic def _write_pdk_root(config_path: str, value: str) -> None: - """Set `root = ""` under the existing [pdk] table, preserving layout.""" + """Set `root = ""` under the existing [pdk] table, preserving layout. + + Raises OSError when the config cannot be read or replaced; handlers map + that to a structured config_error at the command boundary. + """ with open(config_path) as f: original = f.read() - new_text = set_pdk_root(original, value) + write_text_atomic(config_path, set_pdk_root(original, value)) + - with open(config_path, "w") as f: - f.write(new_text) +def _write_root_or_error(config_path: str, value: str, project: str | None) -> CommandResult | None: + """Persist the PDK root; maps I/O failures to a structured config_error.""" + from chipcompiler.cli.core.output import disclosure_cmd + + try: + _write_pdk_root(config_path, value) + except OSError as exc: + return CommandResult.err( + [ + error_record( + "config_error", + path=config_path, + reason=str(exc), + inspect=disclosure_cmd("ecc check", project), + ) + ] + ) + return None def _resolve_root_source(cfg, project_dir: str) -> tuple[str, str]: @@ -67,7 +89,9 @@ def set_root(command_input, ctx: CommandContext) -> CommandResult: ) ] ) - _write_pdk_root(config_path, path) + error = _write_root_or_error(config_path, path, ctx.project) + if error is not None: + return error records = [ { @@ -161,7 +185,9 @@ def unset(command_input, ctx: CommandContext) -> CommandResult: config_path = find_config_path(ctx.project_dir) if config_path is None: return CommandResult.err([error_record("missing_config")]) - _write_pdk_root(config_path, "") + error = _write_root_or_error(config_path, "", ctx.project) + if error is not None: + return error return CommandResult.ok( [ { @@ -171,119 +197,3 @@ def unset(command_input, ctx: CommandContext) -> CommandResult: } ] ) - - -PDK_URL = "https://github.com/openecos-projects/icsprout55-pdk.git" -DEFAULT_PDK_DIR = "~/.local/icsprout55-pdk" -_UNZIP_ATTEMPTS = 3 - - -def setup(command_input, ctx: CommandContext) -> CommandResult: - """Clone + `make unzip` a PDK checkout, then set it as the project root. - - Only the missing parts run: an existing complete checkout is only - wired in via set-root. Downloads honor `GH_PROXY` (proxy-prefixed - clone URL, USE_PROXY=true). - """ - import shutil - import subprocess - - from chipcompiler.cli.project.config import ( - _validate_pdk_contents, - find_config_path, - load_project_config, - ) - - config_path = find_config_path(ctx.project_dir) - if config_path is None: - return CommandResult.err( - [error_record("missing_config", path=os.path.join(ctx.project_dir, "ecc.toml"))] - ) - cfg = load_project_config(config_path) - pdk_name = cfg.pdk_name if cfg is not None and cfg.pdk_name else "ics55" - - raw = (command_input.path or DEFAULT_PDK_DIR).strip() - path = os.path.abspath(os.path.expanduser(raw)) - action_records: list[dict] = [] - actions: list[str] = [] - - def contents_problem() -> str | None: - return _validate_pdk_contents(pdk_name, path, None) - - if not os.path.isdir(path): - missing_tools = [tool for tool in ("git", "make") if shutil.which(tool) is None] - if missing_tools: - return CommandResult.err( - [ - error_record( - "missing_tool", - reason=f"required for setup: {', '.join(missing_tools)}", - ) - ] - ) - clone_url = PDK_URL - gh_proxy = os.environ.get("GH_PROXY", "").strip() - if gh_proxy: - clone_url = f"{gh_proxy}{PDK_URL}" - result = subprocess.run( - ["git", "clone", "--depth", "1", clone_url, path], - capture_output=True, - text=True, - ) - if result.returncode != 0: - return CommandResult.err( - [ - error_record( - "clone_failed", - path=path, - reason=(result.stderr or result.stdout or "").strip()[-400:], - ) - ] - ) - actions.append("clone") - action_records.append({"pdk": "clone", "status": "cloned", "path": path}) - - problem = contents_problem() - if problem is not None: - if shutil.which("make") is None: - return CommandResult.err( - [error_record("missing_tool", reason="required for setup: make")] - ) - make_cmd = ["make", "unzip"] - gh_proxy = os.environ.get("GH_PROXY", "").strip() - if gh_proxy: - make_cmd += ["USE_PROXY=true", f"GH_PROXY={gh_proxy}"] - extracted = False - for attempt in range(1, _UNZIP_ATTEMPTS + 1): - result = subprocess.run(make_cmd, cwd=path, capture_output=True, text=True) - if result.returncode == 0: - extracted = True - break - action_records.append( - { - "pdk": "unzip", - "status": "failed", - "attempt": attempt, - "reason": (result.stderr or result.stdout or "").strip()[-200:], - } - ) - if not extracted: - return CommandResult.err([error_record("unzip_failed", path=path)] + action_records) - still_incomplete = contents_problem() - if still_incomplete is not None: - return CommandResult.err( - [error_record("unzip_failed", path=path, reason=still_incomplete)] - ) - actions.append("unzip") - action_records.append({"pdk": "unzip", "status": "extracted", "path": path}) - - _write_pdk_root(config_path, path) - summary = { - "pdk": "setup", - "status": "ready", - "path": path, - "actions": actions, - "config": "ecc.toml", - "check": "ecc check", - } - return CommandResult.ok([summary] + action_records) diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index 64664430b..6ea8a5973 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -127,7 +127,7 @@ def check(command_input: CheckInput, ctx: CommandContext) -> CommandResult: "status": "fail", "reason": err, "source": "ecc.toml" if ctx.config is not None else "project.json", - "inspect": disclosure_cmd("ecc check --json", project), + "inspect": disclosure_cmd("ecc check", project), } for err in errors ] @@ -169,7 +169,7 @@ def check(command_input: CheckInput, ctx: CommandContext) -> CommandResult: "status": "fail", "path": entry, "reason": reason, - "inspect": disclosure_cmd("ecc check --json", project), + "inspect": disclosure_cmd("ecc check", project), } for reason in reasons ) @@ -180,7 +180,7 @@ def check(command_input: CheckInput, ctx: CommandContext) -> CommandResult: "check": "rtl", "status": "pass", "path": cfg.design_rtl[0], - "inspect": disclosure_cmd("ecc check --json", project), + "inspect": disclosure_cmd("ecc check", project), } ) @@ -189,11 +189,43 @@ def check(command_input: CheckInput, ctx: CommandContext) -> CommandResult: return CommandResult.ok(records) -def _preflight_environment(preset: str, project: str | None) -> CommandResult | None: - """Fail fast when the tools a preset needs are missing. None means ready.""" +def _preflight_environment(preset: str | None, project: str | None) -> CommandResult | None: + """Fail fast when the tools a fresh flow target needs are missing. + + None means ready. + """ from chipcompiler.cli.inspection import env_probe probes = env_probe.probe_environment(env_probe.probe_components_for_preset(preset)) + return _preflight_failures(probes, project, preset) + + +def _preflight_flow_range(flow_config: dict, project: str | None) -> CommandResult | None: + """Fail fast when the tools a fresh flow range needs are missing. + + The selected range already names its tools, so a missing tool is a + preflight failure before any manifest registration or workspace + creation — not a discovery made mid-creation. + """ + from chipcompiler.cli.inspection import env_probe + from chipcompiler.rtl2gds import build_flow_range + + try: + steps = build_flow_range(flow_config["start_step"], flow_config["end_step"]) + except ValueError: + # Range spellings are validated where they are declared (CLI ranges + # during argument handling, manifest ranges at load time); an + # unresolvable range here degrades to no preflight, never a new + # failure mode in front of the run. + return None + probes = env_probe.probe_environment(env_probe.probe_components_for_steps(steps)) + return _preflight_failures(probes, project, None) + + +def _preflight_failures(probes, project: str | None, preset: str | None) -> CommandResult | None: + """Map failed probes to env_not_ready; None means ready.""" + from chipcompiler.cli.inspection import env_probe + failures = [p for p in probes if p.status == env_probe.FAIL] if not failures: return None @@ -452,10 +484,17 @@ def error(kind: str, **fields) -> CommandResult: ] ) - # Manifest entries may define only a start/end range. They have no named - # preset to probe; EngineFlow uses the persisted range for that case. - if fresh_target and flow_config is None and effective_preset: - preflight = _preflight_environment(effective_preset, project) + # Fresh targets preflight before any mutation. Named presets probe the + # tools their builder needs; manifest/CLI flow ranges derive the same + # probe set from the selected chain, so a missing tool fails fast + # instead of surfacing mid-creation. + if fresh_target: + if flow_config is not None: + preflight = _preflight_flow_range(flow_config, project) + elif effective_preset: + preflight = _preflight_environment(effective_preset, project) + else: + preflight = None if preflight is not None: return preflight @@ -506,4 +545,8 @@ def error(kind: str, **fields) -> CommandResult: from chipcompiler.cli.project import run_workspace - return run_workspace.execute_workspace_run(command_input, ctx.run_dir, ctx.run_id) + # Only manifest projects carry a project.json status to write back. + project_dir = ctx.project_dir if ctx.project_state == "manifest" else None + return run_workspace.execute_workspace_run( + command_input, ctx.run_dir, ctx.run_id, project_dir=project_dir + ) diff --git a/chipcompiler/cli/command_handlers/project_config.py b/chipcompiler/cli/command_handlers/project_config.py index c1c0887e3..7f1716c60 100644 --- a/chipcompiler/cli/command_handlers/project_config.py +++ b/chipcompiler/cli/command_handlers/project_config.py @@ -7,6 +7,7 @@ from chipcompiler.cli.core.types import CommandContext, CommandResult from chipcompiler.cli.project.config_fields import lookup_project_field, parse_project_field_values from chipcompiler.cli.project.toml_edit import remove_scoped_key, set_scoped_key +from chipcompiler.utility.file import write_text_atomic def project_set(args, ctx: CommandContext) -> CommandResult: @@ -22,7 +23,9 @@ def project_set(args, ctx: CommandContext) -> CommandResult: config_path, missing = _config_path_or_error(ctx) if missing is not None: return missing - _set_value(config_path, field, value) + error = _set_value(config_path, field, value) + if error is not None: + return error return CommandResult.ok([_record(field.key, value, "set")]) @@ -33,12 +36,17 @@ def project_unset(args, ctx: CommandContext) -> CommandResult: config_path, missing = _config_path_or_error(ctx) if missing is not None: return missing - with open(config_path) as file: - changed = remove_scoped_key(file.read(), field.table, field.name) + try: + with open(config_path) as file: + changed = remove_scoped_key(file.read(), field.table, field.name) + except (OSError, UnicodeDecodeError) as exc: + return CommandResult.err([_io_error(config_path, exc)]) if changed is None: return CommandResult.ok([_record(field.key, None, "no_value")]) - with open(config_path, "w") as file: - file.write(changed) + try: + write_text_atomic(config_path, changed) + except OSError as exc: + return CommandResult.err([_io_error(config_path, exc)]) return CommandResult.ok([_record(field.key, None, "unset")]) @@ -57,8 +65,8 @@ def project_show(args, ctx: CommandContext) -> CommandResult: try: with open(config_path, "rb") as file: data = tomllib.load(file) - except tomllib.TOMLDecodeError as exc: - return CommandResult.err([error_record("invalid_project_config", reason=str(exc))]) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + return CommandResult.err([_io_error(config_path, exc)]) if args.key is not None: field, error = _field_or_error(args.key) if error is not None: @@ -110,8 +118,8 @@ def _change_rtl(args, ctx: CommandContext, *, add: bool) -> CommandResult: try: with open(config_path, "rb") as file: data = tomllib.load(file) - except tomllib.TOMLDecodeError as exc: - return CommandResult.err([error_record("invalid_project_config", reason=str(exc))]) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + return CommandResult.err([_io_error(config_path, exc)]) current = data.get("design", {}).get("rtl", []) if not isinstance(current, list) or not all(isinstance(value, str) for value in current): return CommandResult.err( @@ -123,15 +131,28 @@ def _change_rtl(args, ctx: CommandContext, *, add: bool) -> CommandResult: else: updated = [value for value in current if value not in values] status = "removed" - _set_value(config_path, field, updated) + error = _set_value(config_path, field, updated) + if error is not None: + return error return CommandResult.ok([_record(field.key, updated, status)]) -def _set_value(config_path: str, field, value: object) -> None: - with open(config_path) as file: - updated = set_scoped_key(file.read(), field.table, field.name, value) - with open(config_path, "w") as file: - file.write(updated) +def _set_value(config_path: str, field, value: object) -> CommandResult | None: + """Apply one field edit; returns a failed CommandResult when the edit fails.""" + try: + with open(config_path) as file: + updated = set_scoped_key(file.read(), field.table, field.name, value) + except (OSError, UnicodeDecodeError) as exc: + return CommandResult.err([_io_error(config_path, exc)]) + try: + write_text_atomic(config_path, updated) + except OSError as exc: + return CommandResult.err([_io_error(config_path, exc)]) + return None + + +def _io_error(config_path: str, exc: Exception) -> dict: + return error_record("config_error", path=config_path, reason=str(exc)) def _field_or_error(key: str): diff --git a/chipcompiler/cli/command_handlers/report.py b/chipcompiler/cli/command_handlers/report.py index b89f7ee32..2bf2d531f 100644 --- a/chipcompiler/cli/command_handlers/report.py +++ b/chipcompiler/cli/command_handlers/report.py @@ -13,16 +13,43 @@ ) +def _safe_report_filename(name: str) -> str: + """Reduce a design-derived filename to a safe basename. + + Design names reach the default report path, so path separators and + dot-prefixed escapes must never survive into the destination. + """ + cleaned = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in name) + cleaned = cleaned.strip("._") or "design" + return cleaned + + def _write_report(report_name, default_filename, content, command_input, ctx, extra): """Write the report file (default: /signoff/) and summarize.""" + from chipcompiler.utility.file import write_text_atomic + workspace_display_dir = workspace_display(command_input, ctx) if command_input.output_path is not None: destination = os.path.abspath(os.path.expanduser(command_input.output_path)) else: - destination = os.path.join(workspace_display_dir, "signoff", default_filename) - os.makedirs(os.path.dirname(destination), exist_ok=True) - with open(destination, "w", encoding="utf-8") as f: - f.write(content) + destination = os.path.join( + workspace_display_dir, "signoff", _safe_report_filename(default_filename) + ) + try: + os.makedirs(os.path.dirname(destination), exist_ok=True) + # An existing report must survive a failed write, so the replacement + # lands atomically instead of truncating in place. + write_text_atomic(destination, content) + except OSError as exc: + return CommandResult.err( + [ + error_record( + "report_write_failed", + path=destination, + reason=str(exc), + ) + ] + ) record = { "report": report_name, "path": destination, @@ -42,8 +69,11 @@ def qor(command_input, ctx: CommandContext) -> CommandResult: from chipcompiler.engine.qor_report import build_qor_report, generate_qor_report try: + # Build once and render that snapshot: a second traversal could read + # a changed workspace, so the record metadata would describe a + # different report than the one written. report = build_qor_report(workspace) - content = generate_qor_report(workspace) + content = generate_qor_report(workspace, report) except Exception as exc: return CommandResult.err([error_record("report_failed", reason=str(exc))]) @@ -84,7 +114,7 @@ def checklist(command_input, ctx: CommandContext) -> CommandResult: try: report = build_checklist_report(workspace) - content = generate_checklist_report(workspace) + content = generate_checklist_report(workspace, report) except Exception as exc: return CommandResult.err([error_record("report_failed", reason=str(exc))]) diff --git a/chipcompiler/cli/command_handlers/signoff.py b/chipcompiler/cli/command_handlers/signoff.py index 1eea5ec37..aa55c376e 100644 --- a/chipcompiler/cli/command_handlers/signoff.py +++ b/chipcompiler/cli/command_handlers/signoff.py @@ -76,6 +76,18 @@ def export(command_input, ctx: CommandContext) -> CommandResult: ) ] ) + except OSError as exc: + # An unwritable destination, a directory in place of the output + # path, or a full disk is a failed export, never a traceback. + return CommandResult.err( + [ + error_record( + "signoff_export_failed", + reason=str(exc), + inspect=disclosure_cmd("ecc signoff inspect", ctx.project, ctx.run_id), + ) + ] + ) return CommandResult.ok( [ { diff --git a/chipcompiler/cli/command_handlers/workspace_params.py b/chipcompiler/cli/command_handlers/workspace_params.py index 3902e95fa..8c576c77e 100644 --- a/chipcompiler/cli/command_handlers/workspace_params.py +++ b/chipcompiler/cli/command_handlers/workspace_params.py @@ -101,6 +101,60 @@ def param_diff(args, ctx: CommandContext) -> CommandResult: return CommandResult.ok(records or [{"diff_status": "clean", "workspace": ctx.run_id}]) +def _snapshot_transaction(workspace) -> dict: + """Capture every declared output file of the mutation sequence. + + The sequence commits three artifacts in turn (params.toml, the derived + config/*.json files, and the flow ledger); a failure after the first + commit must roll all of them back or the workspace keeps new parameters + paired with stale configs and a ledger that lets the next run no-op. + The declared config set is snapshotted even when a file does not exist + yet — refresh_workspace_config may create it, and the rollback must + remove it again. The auto-generated SDC is rewritten by the refresh + before config validation can fail, so it belongs to the same + transaction. Values are bytes, or None for not-yet-existing paths. + """ + from pathlib import Path + + from chipcompiler.data.workspace import workspace_config_paths + + workspace_dir = Path(workspace.directory) + paths = [workspace_dir / "home" / "params.toml", workspace_dir / "home" / "flow.json"] + paths.extend( + path for key, path in workspace_config_paths(workspace_dir).items() if key != "dir" + ) + sdc = getattr(getattr(workspace, "pdk", None), "sdc", None) + if sdc: + paths.append(Path(sdc)) + snapshot: dict = {} + for path in paths: + if not path.is_file(): + snapshot[path] = None + continue + try: + snapshot[path] = path.read_bytes() + except OSError as exc: + # A file that exists but cannot be read must abort the + # transaction before any mutation: restoring it as absent would + # delete a real configuration. + raise OSError(f"cannot snapshot {path}: {exc}") from exc + return snapshot + + +def _restore_transaction(snapshot: dict) -> list[str]: + """Write every snapshotted file back; returns human-readable failures.""" + failures: list[str] = [] + for path, content in snapshot.items(): + try: + if content is None: + path.unlink(missing_ok=True) + else: + path.write_bytes(content) + except OSError as exc: + failures.append(f"{path}: {exc}") + return failures + + def _mutate( ctx: CommandContext, schema, mutation, requested_value: object, status: str ) -> CommandResult: @@ -126,6 +180,18 @@ def _mutate( if result is None: return CommandResult.ok([_record(ctx, schema.param, None, "no_override")]) value, step = result + try: + snapshot = _snapshot_transaction(workspace) + except OSError as exc: + return CommandResult.err( + [ + error_record( + "workspace_param_refresh_failed", + param=schema.param, + reason=f"cannot snapshot workspace for rollback: {exc}", + ) + ] + ) if not save_parameter(workspace.parameters): return CommandResult.err( [error_record("workspace_param_save_failed", param=schema.param)] @@ -135,10 +201,14 @@ def _mutate( flow = EngineFlow(workspace=workspace) invalidated = rerun.invalidate_from(flow, step) except Exception as exc: + rollback_failures = _restore_transaction(snapshot) + reason = str(exc) + if rollback_failures: + reason += "; rollback incomplete: " + "; ".join(rollback_failures) return CommandResult.err( [ error_record( - "workspace_param_refresh_failed", param=schema.param, reason=str(exc) + "workspace_param_refresh_failed", param=schema.param, reason=reason ) ] ) diff --git a/chipcompiler/cli/commands/doc.py b/chipcompiler/cli/commands/doc.py index 6fc6d1e97..2eeeeb031 100644 --- a/chipcompiler/cli/commands/doc.py +++ b/chipcompiler/cli/commands/doc.py @@ -14,7 +14,6 @@ class DocTopic(str, Enum): config = "config" ug = "ug" tutorial = "tutorial" - dev = "dev" class DocLanguage(str, Enum): @@ -23,13 +22,17 @@ class DocLanguage(str, Enum): def register_doc_commands(app: typer.Typer) -> None: - app.command("doc", help="Show a bundled guide (config/ug/tutorial/dev) in the terminal")( - doc_cmd - ) + app.command( + "doc", + help="Show a bundled guide (config = config reference, ug = user guide, tutorial)", + )(doc_cmd) def doc_cmd( - topic: Annotated[DocTopic, typer.Argument(help="Guide to show")], + topic: Annotated[ + DocTopic, + typer.Argument(help="Guide to show: config reference, user guide (ug), or tutorial"), + ], *, lang: Annotated[DocLanguage, typer.Option("--lang", help="Guide language")] = DocLanguage.en, plain: Annotated[ diff --git a/chipcompiler/cli/commands/doctor.py b/chipcompiler/cli/commands/doctor.py index b66248c66..c0693058d 100644 --- a/chipcompiler/cli/commands/doctor.py +++ b/chipcompiler/cli/commands/doctor.py @@ -1,7 +1,7 @@ from chipcompiler.cli.command_handlers import doctor as doctor_handlers from chipcompiler.cli.core.inputs import DoctorInput, output_options, project_options from chipcompiler.cli.core.invocation import execute_command -from chipcompiler.cli.core.options import JsonlOption, JsonOption, PlainOption, ProjectOption +from chipcompiler.cli.core.options import PlainOption, ProjectOption def register_doctor_commands(app) -> None: @@ -11,12 +11,10 @@ def register_doctor_commands(app) -> None: def doctor_cmd( *, project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: command_input = DoctorInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), ) execute_command("doctor", command_input, doctor_handlers.doctor) diff --git a/chipcompiler/cli/commands/param.py b/chipcompiler/cli/commands/param.py index cfd014299..a975d724b 100644 --- a/chipcompiler/cli/commands/param.py +++ b/chipcompiler/cli/commands/param.py @@ -19,8 +19,6 @@ ) from chipcompiler.cli.core.invocation import CommandHandler, CommandInputT, execute_command from chipcompiler.cli.core.options import ( - JsonlOption, - JsonOption, PlainOption, ProjectOption, WorkspaceOption, @@ -44,8 +42,6 @@ def list_cmd( step: Annotated[str | None, typer.Option("--step")] = None, all_params: Annotated[bool, typer.Option("--all")] = False, workspace: WorkspaceOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: """List parameter overrides. @@ -63,7 +59,7 @@ def list_cmd( See 'ecc doc config' for the full reference. """ command_input = ParamListInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), step=step, all=all_params, @@ -78,8 +74,6 @@ def show_cmd( key: Annotated[str, typer.Argument()], project: ProjectOption = None, workspace: WorkspaceOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: """Show one parameter value. @@ -92,7 +86,7 @@ def show_cmd( See 'ecc doc config' for the full reference. """ command_input = ParamShowInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), key=key, workspace=workspace, @@ -107,8 +101,6 @@ def set_cmd( value: Annotated[str, typer.Argument()], project: ProjectOption = None, workspace: WorkspaceOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: """Set a parameter override. @@ -139,7 +131,7 @@ def set_cmd( See 'ecc doc config' for the full reference. """ command_input = ParamSetInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), key=key, value=value, @@ -154,8 +146,6 @@ def unset_cmd( key: Annotated[str, typer.Argument()], project: ProjectOption = None, workspace: WorkspaceOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: """Remove a parameter override. @@ -168,7 +158,7 @@ def unset_cmd( See 'ecc doc config' for the full reference. """ command_input = ParamUnsetInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), key=key, workspace=workspace, @@ -181,8 +171,6 @@ def diff_cmd( *, project: ProjectOption = None, workspace: WorkspaceOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: """Compare parameter overrides with defaults. @@ -194,7 +182,7 @@ def diff_cmd( See 'ecc doc config' for the full reference. """ command_input = ParamDiffInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), workspace=workspace, ) diff --git a/chipcompiler/cli/commands/pdk.py b/chipcompiler/cli/commands/pdk.py index 76cdda1d4..9725d4e14 100644 --- a/chipcompiler/cli/commands/pdk.py +++ b/chipcompiler/cli/commands/pdk.py @@ -6,7 +6,6 @@ from chipcompiler.cli.core.apps import create_app from chipcompiler.cli.core.inputs import ( PdkSetRootInput, - PdkSetupInput, PdkShowInput, PdkUnsetInput, output_options, @@ -14,8 +13,6 @@ ) from chipcompiler.cli.core.invocation import execute_command from chipcompiler.cli.core.options import ( - JsonlOption, - JsonOption, PlainOption, ProjectOption, ) @@ -27,28 +24,6 @@ def _finish(subcommand: str, command_input, handler) -> None: execute_command("pdk", command_input, handler, render_key=f"pdk:{subcommand}") -@pdk_app.command("setup", help="Clone + make unzip a PDK checkout, then set it as root") -def setup_cmd( - *, - path: Annotated[ - str | None, - typer.Argument( - help="PDK checkout path (default: ~/.local/icsprout55-pdk); cloned when missing", - ), - ] = None, - project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, - plain: PlainOption = False, -) -> None: - command_input = PdkSetupInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), - project=project_options(project), - path=path, - ) - _finish("setup", command_input, pdk_handlers.setup) - - @pdk_app.command("set-root") def set_root_cmd( *, @@ -57,8 +32,6 @@ def set_root_cmd( typer.Argument(help="Path to an icsprout55-pdk checkout (absolute after expansion)"), ], project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: """Set the [pdk] root path in ecc.toml. @@ -71,7 +44,7 @@ def set_root_cmd( See 'ecc doc config' for the full reference. """ command_input = PdkSetRootInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), path=path, ) @@ -82,12 +55,10 @@ def set_root_cmd( def show_cmd( *, project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: command_input = PdkShowInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), ) _finish("show", command_input, pdk_handlers.show) @@ -97,12 +68,10 @@ def show_cmd( def unset_cmd( *, project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: command_input = PdkUnsetInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), ) _finish("unset", command_input, pdk_handlers.unset) diff --git a/chipcompiler/cli/commands/project.py b/chipcompiler/cli/commands/project.py index 6a8c99cc1..ec51f8b65 100644 --- a/chipcompiler/cli/commands/project.py +++ b/chipcompiler/cli/commands/project.py @@ -17,8 +17,6 @@ ) from chipcompiler.cli.core.invocation import execute_command from chipcompiler.cli.core.options import ( - JsonlOption, - JsonOption, PlainOption, ProjectOption, WorkspaceOption, @@ -42,26 +40,20 @@ def register_project_commands(app: typer.Typer) -> None: def init_cmd( *, name: Annotated[str, typer.Argument()], - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: - command_input = InitInput( - name=name, output=output_options(json_output=json_output, jsonl=jsonl, plain=plain) - ) + command_input = InitInput(name=name, output=output_options(plain=plain)) execute_command("init", command_input, project_handlers.init) def check_cmd( *, project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, workspace: WorkspaceOption = None, ) -> None: command_input = CheckInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), workspace=workspace, ) @@ -103,8 +95,6 @@ def run_cmd( help="Flow preset for this run only, e.g. --preset syn_sta (does not edit ecc.toml)", ), ] = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, param_set: Annotated[ list[str] | None, typer.Option( @@ -131,7 +121,7 @@ def run_cmd( See 'ecc doc config' for the full reference. """ command_input = RunInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), overwrite=overwrite, param_set=tuple(param_set or ()), @@ -149,13 +139,11 @@ def run_cmd( def status_cmd( *, project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, workspace: WorkspaceOption = None, ) -> None: command_input = StatusInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), workspace=workspace, ) @@ -166,13 +154,11 @@ def log_cmd( *, step: Annotated[str | None, typer.Argument()] = None, project: ProjectOption = None, - json_output: JsonOption = False, plain: PlainOption = False, - jsonl: JsonlOption = False, workspace: WorkspaceOption = None, ) -> None: command_input = LogInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), step=step, workspace=workspace, @@ -187,12 +173,10 @@ def migrate_cmd( bool, typer.Option("--yes", help="Migrate without interactive confirmation"), ] = False, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: command_input = MigrateInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), yes=yes, ) @@ -203,8 +187,6 @@ def config_cmd( *, step: Annotated[str | None, typer.Argument()] = None, project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, workspace: WorkspaceOption = None, ) -> None: @@ -218,7 +200,7 @@ def config_cmd( See 'ecc doc config' for the full reference. """ command_input = ConfigInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), step=step, workspace=workspace, diff --git a/chipcompiler/cli/commands/project_config.py b/chipcompiler/cli/commands/project_config.py index f14c42ff4..637ab7b7c 100644 --- a/chipcompiler/cli/commands/project_config.py +++ b/chipcompiler/cli/commands/project_config.py @@ -15,7 +15,7 @@ project_options, ) from chipcompiler.cli.core.invocation import execute_command -from chipcompiler.cli.core.options import JsonlOption, JsonOption, PlainOption, ProjectOption +from chipcompiler.cli.core.options import PlainOption, ProjectOption project_app = create_app(help="Edit project declarations in ecc.toml") @@ -30,14 +30,12 @@ def set_cmd( key: Annotated[str, typer.Argument()], values: Annotated[list[str], typer.Argument()], project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: _finish( "set", ProjectSetInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), key=key, values=tuple(values), @@ -51,14 +49,12 @@ def unset_cmd( *, key: Annotated[str, typer.Argument()], project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: _finish( "unset", ProjectUnsetInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), key=key, ), @@ -72,14 +68,12 @@ def add_cmd( key: Annotated[str, typer.Argument()], values: Annotated[list[str], typer.Argument()], project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: _finish( "add", ProjectAddInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), key=key, values=tuple(values), @@ -94,14 +88,12 @@ def remove_cmd( key: Annotated[str, typer.Argument()], values: Annotated[list[str], typer.Argument()], project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: _finish( "remove", ProjectAddInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), key=key, values=tuple(values), @@ -115,14 +107,12 @@ def show_cmd( *, key: Annotated[str | None, typer.Argument()] = None, project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: _finish( "show", ProjectShowInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), key=key, ), diff --git a/chipcompiler/cli/commands/report.py b/chipcompiler/cli/commands/report.py index cfa873ad8..2aa6f3bfd 100644 --- a/chipcompiler/cli/commands/report.py +++ b/chipcompiler/cli/commands/report.py @@ -14,8 +14,6 @@ ) from chipcompiler.cli.core.invocation import execute_command from chipcompiler.cli.core.options import ( - JsonlOption, - JsonOption, PlainOption, ProjectOption, WorkspaceOption, @@ -39,12 +37,10 @@ def qor_cmd( output_path: OutputPathOption = None, project: ProjectOption = None, workspace: WorkspaceOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: command_input = ReportQorInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), workspace=workspace, output_path=output_path, @@ -58,12 +54,10 @@ def checklist_cmd( output_path: OutputPathOption = None, project: ProjectOption = None, workspace: WorkspaceOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: command_input = ReportChecklistInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), workspace=workspace, output_path=output_path, @@ -77,12 +71,10 @@ def summary_cmd( output_path: OutputPathOption = None, project: ProjectOption = None, workspace: WorkspaceOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: command_input = ReportSummaryInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), workspace=workspace, output_path=output_path, @@ -113,12 +105,10 @@ def step_cmd( sections: SectionOption = None, project: ProjectOption = None, workspace: WorkspaceOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: command_input = ReportStepInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), workspace=workspace, step=step, diff --git a/chipcompiler/cli/commands/signoff.py b/chipcompiler/cli/commands/signoff.py index 3dfcc4f5d..f3ef8b1a1 100644 --- a/chipcompiler/cli/commands/signoff.py +++ b/chipcompiler/cli/commands/signoff.py @@ -12,8 +12,6 @@ ) from chipcompiler.cli.core.invocation import execute_command from chipcompiler.cli.core.options import ( - JsonlOption, - JsonOption, PlainOption, ProjectOption, WorkspaceOption, @@ -31,12 +29,10 @@ def inspect_cmd( *, project: ProjectOption = None, workspace: WorkspaceOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: command_input = SignoffInspectInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), workspace=workspace, ) @@ -53,12 +49,10 @@ def export_cmd( ] = False, project: ProjectOption = None, workspace: WorkspaceOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: command_input = SignoffExportInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), workspace=workspace, output_path=output_path, diff --git a/chipcompiler/cli/commands/workspace.py b/chipcompiler/cli/commands/workspace.py index 2d5fb2b43..8805111be 100644 --- a/chipcompiler/cli/commands/workspace.py +++ b/chipcompiler/cli/commands/workspace.py @@ -8,7 +8,7 @@ from chipcompiler.cli.core.apps import create_app from chipcompiler.cli.core.inputs import WorkspaceRefreshInput, output_options, project_options from chipcompiler.cli.core.invocation import execute_command -from chipcompiler.cli.core.options import JsonlOption, JsonOption, PlainOption, ProjectOption +from chipcompiler.cli.core.options import PlainOption, ProjectOption workspace_app = create_app(help="Refresh managed workspaces from project configuration") @@ -18,8 +18,6 @@ def refresh_cmd( *, workspace: Annotated[str, typer.Argument(help="Declared workspace name")], project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, plain: PlainOption = False, ) -> None: """Recreate a workspace from ecc.toml without running it. @@ -33,7 +31,7 @@ def refresh_cmd( See 'ecc doc config' for the full reference. """ command_input = WorkspaceRefreshInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), + output=output_options(plain=plain), project=project_options(project), workspace=workspace, ) diff --git a/chipcompiler/cli/core/docs.py b/chipcompiler/cli/core/docs.py index f669731af..fa837d04f 100644 --- a/chipcompiler/cli/core/docs.py +++ b/chipcompiler/cli/core/docs.py @@ -6,10 +6,9 @@ from pathlib import Path GUIDE_STEMS = { - "config": "ecc-cli-config", - "ug": "ecc-cli-ug", - "tutorial": "ecc-cli-tutorial", - "dev": "ecc-cli-dev", + "config": "ecc-config-ref", + "ug": "ecc-user-guide", + "tutorial": "ecc-tutorial", } diff --git a/chipcompiler/cli/core/inputs.py b/chipcompiler/cli/core/inputs.py index dfda3221c..637f7407a 100644 --- a/chipcompiler/cli/core/inputs.py +++ b/chipcompiler/cli/core/inputs.py @@ -3,8 +3,6 @@ @dataclass(frozen=True) class OutputOptions: - json: bool = False - jsonl: bool = False plain: bool = False @@ -85,13 +83,6 @@ class PdkSetRootInput: path: str = "" -@dataclass(frozen=True) -class PdkSetupInput: - output: OutputOptions - project: ProjectOptions = ProjectOptions() - path: str | None = None - - @dataclass(frozen=True) class PdkShowInput: output: OutputOptions @@ -231,8 +222,8 @@ class WorkspaceRefreshInput: workspace: str -def output_options(*, json_output: bool, jsonl: bool, plain: bool) -> OutputOptions: - return OutputOptions(json=json_output, jsonl=jsonl, plain=plain) +def output_options(*, plain: bool) -> OutputOptions: + return OutputOptions(plain=plain) def project_options(project: str | None) -> ProjectOptions: diff --git a/chipcompiler/cli/core/invocation.py b/chipcompiler/cli/core/invocation.py index 7dc0aeed0..642736ee9 100644 --- a/chipcompiler/cli/core/invocation.py +++ b/chipcompiler/cli/core/invocation.py @@ -5,7 +5,7 @@ import typer -from chipcompiler.cli.core.inputs import OutputOptions, ProjectOptions +from chipcompiler.cli.core.inputs import OutputOptions, ProjectOptions, RunInput from chipcompiler.cli.core.types import CommandContext, CommandResult, OutputMode from chipcompiler.cli.project.config import ( ConfigUnreadableError, @@ -26,11 +26,7 @@ def project(self) -> ProjectOptions: ... CommandHandler = Callable[[CommandInputT, CommandContext], CommandResult] -def output_mode(*, json_output: bool, jsonl: bool, plain: bool) -> OutputMode: - if jsonl: - return OutputMode.JSONL - if json_output: - return OutputMode.JSON +def output_mode(*, plain: bool) -> OutputMode: if plain: return OutputMode.PLAIN return OutputMode.TEXT @@ -122,7 +118,7 @@ def build_context(command_input: CommandInput) -> CommandContext: run_dir, run_id, manifest_error = _resolve_manifest_workspace( project_dir, workspace_name, - allow_create=command_input.__class__.__name__ == "RunInput", + allow_create=isinstance(command_input, RunInput), ) except ManifestError as exc: run_dir, run_id = os.path.join(project_dir, "default"), workspace_name @@ -131,11 +127,7 @@ def build_context(command_input: CommandInput) -> CommandContext: workspace_id = workspace_name or "default" run_dir, run_id = os.path.join(project_dir, workspace_id), workspace_id - mode = output_mode( - json_output=command_input.output.json, - jsonl=command_input.output.jsonl, - plain=command_input.output.plain, - ) + mode = output_mode(plain=command_input.output.plain) return CommandContext( project_dir=project_dir, diff --git a/chipcompiler/cli/core/options.py b/chipcompiler/cli/core/options.py index e832320b0..6475b437d 100644 --- a/chipcompiler/cli/core/options.py +++ b/chipcompiler/cli/core/options.py @@ -3,8 +3,6 @@ import typer ProjectOption = Annotated[str | None, typer.Option("--project")] -JsonOption = Annotated[bool, typer.Option("--json")] -JsonlOption = Annotated[bool, typer.Option("--jsonl")] PlainOption = Annotated[bool, typer.Option("--plain")] WorkspaceOption = Annotated[ str | None, diff --git a/chipcompiler/cli/core/output.py b/chipcompiler/cli/core/output.py index 5b0f2c621..6546e52ee 100644 --- a/chipcompiler/cli/core/output.py +++ b/chipcompiler/cli/core/output.py @@ -30,7 +30,6 @@ def normalize_step_name(internal: str) -> str: def normalize_state(internal: str) -> str: mapping = { "Success": "success", - "Warning": "warning", "Incomplete": "incomplete", "Unstart": "unstart", "Ongoing": "ongoing", diff --git a/chipcompiler/cli/core/types.py b/chipcompiler/cli/core/types.py index 93cd1d235..4b2d744bf 100644 --- a/chipcompiler/cli/core/types.py +++ b/chipcompiler/cli/core/types.py @@ -7,8 +7,6 @@ class OutputMode(Enum): TEXT = "text" PLAIN = "plain" - JSON = "json" - JSONL = "jsonl" @dataclass(frozen=True) diff --git a/chipcompiler/cli/inspection/config_view.py b/chipcompiler/cli/inspection/config_view.py index a7b3252e0..8127835fd 100644 --- a/chipcompiler/cli/inspection/config_view.py +++ b/chipcompiler/cli/inspection/config_view.py @@ -80,7 +80,7 @@ def source_of(dotted: str) -> str: else: entries.append(("flow.preset", cfg.flow_preset, cfg.flow_preset, source_of("flow.preset"))) - inspect = disclosure_cmd("ecc config --json", project, run_id) + inspect = disclosure_cmd("ecc config", project, run_id) for key, value, resolved, source in entries: items.append( @@ -291,7 +291,7 @@ def build_step_config_items( "path": os.path.relpath(str(fpath), base_dir), "source": "workspace_config", "inspect_cmd": disclosure_cmd( - f"ecc config {requested_step_token} --json", project, run_id + f"ecc config {requested_step_token}", project, run_id ), } ) diff --git a/chipcompiler/cli/inspection/discovery.py b/chipcompiler/cli/inspection/discovery.py index 9d28da32b..f1a1ad557 100644 --- a/chipcompiler/cli/inspection/discovery.py +++ b/chipcompiler/cli/inspection/discovery.py @@ -57,13 +57,11 @@ def get_run_status(flow_data: dict) -> str: return "ongoing" if states & {"incomplete", "invalid"}: return "failed" - if "warning" in states and states <= {"success", "warning"}: - return "warning" if states == {"success"}: return "success" if states == {"unstart"}: return "unstart" - if states <= {"success", "warning", "unstart"}: + if states <= {"success", "unstart"}: # A bounded rerun (--only / --from / --to) leaves the executed # prefix success and the stale suffix unstart until the user # re-runs it: that is partial progress, not a failure. @@ -204,8 +202,21 @@ def resolve_command_workspace(workspace_arg, project, workspace_id, run_dir): def resolve_loaded_workspace(command_input, ctx: CommandContext): """Resolve and load the workspace for handlers that need a Workspace. - Returns (workspace, failure CommandResult-or-None). + Returns (workspace, failure CommandResult-or-None). A malformed manifest + is rejected first: silently falling through to ``/default`` + would report against (and mutate) the wrong workspace. """ + if ctx.manifest_error: + from chipcompiler.cli.core.records import error_record + + return None, CommandResult.err( + [ + error_record( + ctx.manifest_error.split(":", 1)[0], + reason=ctx.manifest_error, + ) + ] + ) workspace, error = resolve_command_workspace( command_input.workspace, ctx.project, ctx.run_id, ctx.run_dir ) diff --git a/chipcompiler/cli/inspection/env_probe.py b/chipcompiler/cli/inspection/env_probe.py index 42dadee7f..fcd0ef858 100644 --- a/chipcompiler/cli/inspection/env_probe.py +++ b/chipcompiler/cli/inspection/env_probe.py @@ -190,6 +190,17 @@ def probe_environment(components, *, cfg=None, include_slang=True) -> list[Probe return results +# Runtime component each step-tool identifier requires. Tools missing from +# the mapping need no host component. +_TOOL_COMPONENTS = { + "ecc": "ecc-tools", + "yosys": "yosys", + "yosys_lec": "yosys", + "dreamplace": "dreamplace", + "sizer": "sizer", +} + + def probe_components_for_preset(preset: str) -> tuple[str, ...]: """Components a flow preset needs at minimum before it can start. @@ -199,12 +210,19 @@ def probe_components_for_preset(preset: str) -> tuple[str, ...]: """ from chipcompiler import rtl2gds as rtl2gds_api - tools = {tool for _step, tool, _state in rtl2gds_api.get_flow_builders()[preset]()} - components = ["ecc-tools"] - if "yosys" in tools: - components.append("yosys") - if "dreamplace" in tools: - components.append("dreamplace") - if "sizer" in tools: - components.append("sizer") + return probe_components_for_steps(rtl2gds_api.get_flow_builders()[preset]()) + + +def probe_components_for_steps(steps) -> tuple[str, ...]: + """Components a concrete (step, tool, state) chain needs before it can start. + + Derived from the explicit tool-to-runtime mapping, so a Yosys-only + range preflights Yosys and an ECC-only range preflights ecc-tools — + each exactly once, in stable order. + """ + tools = {tool for _step, tool, _state in steps} + components = [] + for component in ("ecc-tools", "yosys", "dreamplace", "sizer"): + if component in {_TOOL_COMPONENTS.get(tool) for tool in tools}: + components.append(component) return tuple(components) diff --git a/chipcompiler/cli/project/config.py b/chipcompiler/cli/project/config.py index c11ca802a..4ec9e3c73 100644 --- a/chipcompiler/cli/project/config.py +++ b/chipcompiler/cli/project/config.py @@ -185,8 +185,9 @@ def load_run_config(project_dir: str) -> ProjectConfig | None: def _supported_flow_presets() -> set[str]: from chipcompiler import rtl2gds as rtl2gds_api + from chipcompiler.data.workspace_config import LEGACY_PRESET_RANGES - return set(rtl2gds_api.get_flow_builders()) + return set(rtl2gds_api.get_flow_builders()) | set(LEGACY_PRESET_RANGES) def validate_project_config(cfg: ProjectConfig) -> list[str]: @@ -289,22 +290,23 @@ def _resolve_pdk_root(cfg: ProjectConfig) -> str: def resolve_pdk_overrides( - cfg: ProjectConfig, additional_overrides: dict[str, object] | None = None + cfg: ProjectConfig, + additional_overrides: dict[str, object] | None = None, ) -> dict[str, object]: """Return pdk_overrides with path-field values resolved to absolute paths. - PDK-content paths (PDK_CONTENT_PATH_FIELDS) resolve against the PDK root; - design-data paths (sdc/spef) resolve against the project dir. Non-path - values such as dont_use glob patterns pass through untouched. + PDK-content paths (PDK_CONTENT_PATH_FIELDS) resolve against the configured + PDK root; design-data paths (sdc/spef) resolve against the project dir. + Non-path values such as dont_use glob patterns pass through untouched. """ from chipcompiler.data.pdk import PATH_LIST_FIELDS, PATH_SCALAR_FIELDS, PDK_CONTENT_PATH_FIELDS resolved = dict(cfg.pdk_overrides) if additional_overrides: resolved.update(additional_overrides) - pdk_root = _resolve_pdk_root(cfg) + base_root = _resolve_pdk_root(cfg) for key, value in resolved.items(): - base = pdk_root if key in PDK_CONTENT_PATH_FIELDS else cfg.project_dir + base = base_root if key in PDK_CONTENT_PATH_FIELDS else cfg.project_dir if key in PATH_SCALAR_FIELDS and isinstance(value, str): resolved[key] = _resolve_path(base, value) elif key in PATH_LIST_FIELDS and isinstance(value, list): diff --git a/chipcompiler/cli/project/design_inputs.py b/chipcompiler/cli/project/design_inputs.py index 87e9c6a34..de321fdc1 100644 --- a/chipcompiler/cli/project/design_inputs.py +++ b/chipcompiler/cli/project/design_inputs.py @@ -111,8 +111,11 @@ def validate_entry_inputs(cfg, entry_step: str | None) -> list[str]: def _validate_rtl_sources(paths: tuple[str, ...]) -> list[str]: errors: list[str] = [] for path in paths: - errors.extend(_validate_file("rtl", path)) - if errors: + # Per-path errors: one missing source must not hide independent + # suffix/filelist problems in the remaining declared sources. + path_errors = _validate_file("rtl", path) + errors.extend(path_errors) + if path_errors: continue suffix = os.path.splitext(path)[1].lower() if suffix in FILELIST_SUFFIXES: diff --git a/chipcompiler/cli/project/manifest.py b/chipcompiler/cli/project/manifest.py index 0b9b3cead..aaf329c85 100644 --- a/chipcompiler/cli/project/manifest.py +++ b/chipcompiler/cli/project/manifest.py @@ -3,10 +3,10 @@ """``project.json`` manifest support for the CLI. The manifest is the GUI's project descriptor (schema v1). The CLI reads it -for configuration layering and run discovery, generates it for virgin -projects, and writes back run status. All writes go through one -read-modify-write helper so status write-back and migration entry-append -share the same atomicity story. +for configuration layering and run discovery, and projects it into +configuration payloads. Write operations (generation, status write-back, +registration) live in chipcompiler.cli.project.manifest_write, which +routes every write through one read-modify-write helper. This module sits on the CLI startup path (imported by cli/core/invocation.py): keep module-level imports cheap — no @@ -14,18 +14,12 @@ """ import json -import logging import os import re -import tempfile -from copy import deepcopy from dataclasses import dataclass, field -from datetime import UTC, datetime from pathlib import Path from typing import Any -logger = logging.getLogger(__name__) - MANIFEST_FILENAME = "project.json" # GUI display names for the canonical rtl2gds chain. @@ -51,6 +45,10 @@ "syn_sta": ("Synth", "Synth"), "rtl2gds": ("Synth", "Harden"), "synthesis_lec": ("Synth", "LEC"), + # Legacy presets removed from the builder; keep resolving them so + # persisted projects still load. + "rcx": ("Synth", "STA"), + "harden": ("Synth", "Harden"), } _CANONICAL_TO_MANIFEST_STEP = { @@ -75,18 +73,6 @@ {"success", "failed", "running", "in_progress", "not_started", "archived"} ) -DEFAULT_OBJECTIVES = { - "primary": "timing", - "directions": { - "wns": "maximize", - "tns": "maximize", - "area": "minimize", - "drc_count": "minimize", - "lvs_count": "minimize", - "power": "minimize", - }, -} - class ManifestError(ValueError): """Raised when a project.json manifest cannot be used (manifest_invalid).""" @@ -432,302 +418,6 @@ def base_design_from_config(cfg, pdk_root: str) -> dict: } -def manifest_workspace_entry( - workspace_id: str, - *, - name: str, - workspace_path: str, - start_step: str, - end_step: str, - status: str, - now: str, -) -> dict: - """One complete schema-v1 workspaces[] entry, every field materialized. - - The single builder for generated manifests and migration previews, so - the previewed entry and the applied entry are the same object shape. - """ - return { - "workspace_id": workspace_id, - "name": name, - "workspace_path": workspace_path, - "source_workspace_id": None, - "branch_from": None, - "start_step": start_step, - "end_step": end_step, - "status": status, - "created_at": now, - "updated_at": now, - "parameter_patch": {}, - "metrics_summary": {}, - "step_metrics": {}, - } - - def _slugify(value: str) -> str: slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_") return slug or "project" - - -def _now_iso() -> str: - return datetime.now(UTC).isoformat() - - -def build_manifest_document( - project_dir: str, - *, - design_name: str, - base_design: dict, - workspace_id: str, - workspace_path: str, - start_step: str, - end_step: str, - status: str = "running", -) -> dict: - """Assemble a schema-v1 manifest for a virgin project's first run.""" - now = _now_iso() - name = os.path.basename(os.path.normpath(project_dir)) or "project" - document: dict[str, Any] = { - "schema_version": 1, - "project_id": f"proj_{_slugify(name)}", - "name": name, - "design_name": design_name, - "description": "", - "root_path": project_dir, - "created_at": now, - "updated_at": now, - "base_design": { - **{key: value for key, value in base_design.items() if key != "parameters" and value}, - "parameters": _record(base_design.get("parameters")), - "rtl_list": [ - item for item in base_design.get("rtl_list") or [] if isinstance(item, str) - ], - }, - "objectives": json.loads(json.dumps(DEFAULT_OBJECTIVES)), - "workspaces": [ - manifest_workspace_entry( - workspace_id, - name=design_name, - workspace_path=workspace_path, - start_step=start_step, - end_step=end_step, - status=status, - now=now, - ) - ], - "mpc": None, - "best_workspace": None, - "qor_baseline": {"workspace_id": workspace_id, "reason": "Default project QoR baseline"}, - } - return document - - -def write_manifest_if_absent(project_dir: str, document: dict) -> bool: - """Write the manifest only when it does not exist (virgin generation race). - - Fully written and fsynced at a temp path, then linked into place: - readers never see a partial file, and a concurrent creator wins the - link — ours is discarded and the caller continues read-only. - """ - path = os.path.join(project_dir, MANIFEST_FILENAME) - content = json.dumps(document, indent=2) + "\n" - tmp_path = None - try: - with tempfile.NamedTemporaryFile( - "w", - dir=project_dir, - delete=False, - prefix=f".{MANIFEST_FILENAME}.", - suffix=".tmp", - encoding="utf-8", - ) as f: - tmp_path = f.name - f.write(content) - f.flush() - os.fsync(f.fileno()) - # Mode stays the tempfile default (0600), matching json_write's - # convention for newly created state files. - os.link(tmp_path, path) - return True - except FileExistsError: - return False - except OSError as exc: - logger.warning("manifest write failed: %s: %s", path, exc) - return False - finally: - if tmp_path is not None: - Path(tmp_path).unlink(missing_ok=True) - - -def _read_manifest_document(path: str): - try: - with open(path, encoding="utf-8") as f: - document = json.load(f) - except (OSError, json.JSONDecodeError, UnicodeDecodeError): - return None - return document if isinstance(document, dict) else None - - -def update_manifest(project_dir: str, mutator) -> bool: - """Read-modify-write the manifest atomically (locked re-read + patch + replace). - - The whole read-modify-replace runs under ``.manifest.lock`` (flock): - two cooperating writers can no longer both complete the fresh read - before either replaces, so neither loses the other's update. The - mutator receives the parsed document and edits it in place. When an - unrelated change lands between the read and the write, the mutator is - re-applied to the freshest document instead of overwriting the change. - Project-level fields (including updated_at) are owned by the mutator. - Returns False (with a warning) when the manifest is missing, unreadable, - the lock cannot be taken, or the write fails — callers degrade to a - warning, never a run failure. - """ - from chipcompiler.cli.project.migrate_fs import flock_file - - path = os.path.join(project_dir, MANIFEST_FILENAME) - try: - with flock_file(os.path.join(project_dir, ".manifest.lock"), exclusive=True): - return _update_manifest_locked(path, mutator) - except OSError as exc: - # An untakeable lock (e.g. a directory at the lock path) degrades - # like any write failure: a warning, never an uncaught exception — - # the migration registration path relies on False to roll back. - logger.warning("manifest update failed: %s: %s", path, exc) - return False - - -def _update_manifest_locked(path: str, mutator) -> bool: - base = _read_manifest_document(path) - if base is None: - logger.warning("manifest update skipped (unreadable): %s", path) - return False - - document = deepcopy(base) - mutator(document) - - fresh = _read_manifest_document(path) - if fresh is not None and fresh != base: - # An unrelated edit landed after our read: re-apply the mutator to - # the freshest document so the interleaved change survives. - document = fresh - mutator(document) - - target = Path(path) - tmp_path = None - try: - with tempfile.NamedTemporaryFile( - "w", - dir=target.parent, - delete=False, - prefix=f".{target.name}.", - suffix=".tmp", - encoding="utf-8", - ) as f: - tmp_path = Path(f.name) - json.dump(document, f, indent=2) - f.write("\n") - f.flush() - os.fsync(f.fileno()) - os.replace(tmp_path, target) - return True - except OSError as exc: - logger.warning("manifest update failed: %s: %s", path, exc) - if tmp_path is not None: - tmp_path.unlink(missing_ok=True) - return False - - -def write_back_workspace_status(project_dir: str, workspace_id: str, status: str) -> bool: - """Update one workspace entry's status (and updated_at) after a run.""" - - def mutate(document: dict) -> None: - for entry in document.get("workspaces", []): - if isinstance(entry, dict) and entry.get("workspace_id") == workspace_id: - entry["status"] = status - entry["updated_at"] = _now_iso() - - return update_manifest(project_dir, mutate) - - -def manifest_range_for_flow(cfg, flow_config: dict | None) -> tuple[str, str]: - """Return the GUI manifest range for a workspace's effective target.""" - if isinstance(flow_config, dict) and flow_config.get("start_step"): - from chipcompiler.rtl2gds import normalize_flow_step - - start = normalize_flow_step(flow_config["start_step"]) - end = normalize_flow_step(flow_config.get("end_step") or start) - try: - return (_CANONICAL_TO_MANIFEST_STEP[start], _CANONICAL_TO_MANIFEST_STEP[end]) - except KeyError as exc: - raise ManifestError(f"unknown workspace flow step: {exc.args[0]}") from None - return PRESET_MANIFEST_RANGE.get(cfg.flow_preset, ("Synth", "Harden")) - - -def pre_register_workspace( - project_dir: str, - *, - cfg, - pdk_root: str, - workspace_id: str, - workspace_path: str, - flow_config: dict | None, -) -> str: - """Atomically register a fresh managed workspace before filesystem creation. - - Returns ``registered``, ``existing``, ``conflict``, or ``failed``. A - workspace entry intentionally contains no input snapshot: copied files and - the workspace config are the reproducibility boundary. - """ - try: - start_step, end_step = manifest_range_for_flow(cfg, flow_config) - except ManifestError: - return "failed" - now = _now_iso() - manifest_path = os.path.join(project_dir, MANIFEST_FILENAME) - if not os.path.lexists(manifest_path): - document = build_manifest_document( - project_dir, - design_name=cfg.design_name, - base_design=base_design_from_config(cfg, pdk_root), - workspace_id=workspace_id, - workspace_path=workspace_path, - start_step=start_step, - end_step=end_step, - status="not_started", - ) - return "registered" if write_manifest_if_absent(project_dir, document) else "failed" - - outcome = "registered" - - def mutate(document: dict) -> None: - nonlocal outcome - workspaces = document.get("workspaces") - if not isinstance(workspaces, list): - outcome = "failed" - return - for entry in workspaces: - if not isinstance(entry, dict) or entry.get("workspace_id") != workspace_id: - continue - if os.path.realpath(str(entry.get("workspace_path", ""))) == os.path.realpath( - workspace_path - ): - outcome = "existing" - else: - outcome = "conflict" - return - workspaces.append( - manifest_workspace_entry( - workspace_id, - name=cfg.design_name, - workspace_path=workspace_path, - start_step=start_step, - end_step=end_step, - status="not_started", - now=now, - ) - ) - document["updated_at"] = now - - if not update_manifest(project_dir, mutate): - return "failed" - return outcome diff --git a/chipcompiler/cli/project/manifest_write.py b/chipcompiler/cli/project/manifest_write.py new file mode 100644 index 000000000..c1d422dbf --- /dev/null +++ b/chipcompiler/cli/project/manifest_write.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python + +"""Manifest write, mutation, and registration operations for the CLI. + +All ``project.json`` writes go through one read-modify-write helper so +status write-back and migration entry-append share the same atomicity +story. Loading and normalization live in +chipcompiler.cli.project.manifest; this module imports from it, never +the reverse. + +Like manifest.py, this module sits on the CLI startup path (imported by +run dispatch and migration flows): keep module-level imports cheap — no +chipcompiler.data imports here. +""" + +import json +import logging +import os +import tempfile +from copy import deepcopy +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from chipcompiler.cli.project.manifest import ( + _CANONICAL_TO_MANIFEST_STEP, + MANIFEST_FILENAME, + PRESET_MANIFEST_RANGE, + ManifestError, + _record, + _slugify, + base_design_from_config, +) + +logger = logging.getLogger(__name__) + +DEFAULT_OBJECTIVES = { + "primary": "timing", + "directions": { + "wns": "maximize", + "tns": "maximize", + "area": "minimize", + "drc_count": "minimize", + "lvs_count": "minimize", + "power": "minimize", + }, +} + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat() + + +def manifest_workspace_entry( + workspace_id: str, + *, + name: str, + workspace_path: str, + start_step: str, + end_step: str, + status: str, + now: str, +) -> dict: + """One complete schema-v1 workspaces[] entry, every field materialized. + + The single builder for generated manifests and migration previews, so + the previewed entry and the applied entry are the same object shape. + """ + return { + "workspace_id": workspace_id, + "name": name, + "workspace_path": workspace_path, + "source_workspace_id": None, + "branch_from": None, + "start_step": start_step, + "end_step": end_step, + "status": status, + "created_at": now, + "updated_at": now, + "parameter_patch": {}, + "metrics_summary": {}, + "step_metrics": {}, + } + + +def build_manifest_document( + project_dir: str, + *, + design_name: str, + base_design: dict, + workspace_id: str, + workspace_path: str, + start_step: str, + end_step: str, + status: str = "running", +) -> dict: + """Assemble a schema-v1 manifest for a virgin project's first run.""" + now = _now_iso() + name = os.path.basename(os.path.normpath(project_dir)) or "project" + document: dict[str, Any] = { + "schema_version": 1, + "project_id": f"proj_{_slugify(name)}", + "name": name, + "design_name": design_name, + "description": "", + "root_path": project_dir, + "created_at": now, + "updated_at": now, + "base_design": { + **{key: value for key, value in base_design.items() if key != "parameters" and value}, + "parameters": _record(base_design.get("parameters")), + "rtl_list": [ + item for item in base_design.get("rtl_list") or [] if isinstance(item, str) + ], + }, + "objectives": json.loads(json.dumps(DEFAULT_OBJECTIVES)), + "workspaces": [ + manifest_workspace_entry( + workspace_id, + name=design_name, + workspace_path=workspace_path, + start_step=start_step, + end_step=end_step, + status=status, + now=now, + ) + ], + "mpc": None, + "best_workspace": None, + "qor_baseline": {"workspace_id": workspace_id, "reason": "Default project QoR baseline"}, + } + return document + + +def write_manifest_if_absent(project_dir: str, document: dict) -> bool: + """Write the manifest only when it does not exist (virgin generation race). + + Fully written and fsynced at a temp path, then linked into place: + readers never see a partial file, and a concurrent creator wins the + link — ours is discarded and the caller continues read-only. + """ + path = os.path.join(project_dir, MANIFEST_FILENAME) + content = json.dumps(document, indent=2) + "\n" + tmp_path = None + try: + with tempfile.NamedTemporaryFile( + "w", + dir=project_dir, + delete=False, + prefix=f".{MANIFEST_FILENAME}.", + suffix=".tmp", + encoding="utf-8", + ) as f: + tmp_path = f.name + f.write(content) + f.flush() + os.fsync(f.fileno()) + # Mode stays the tempfile default (0600), matching json_write's + # convention for newly created state files. + os.link(tmp_path, path) + return True + except FileExistsError: + return False + except OSError as exc: + logger.warning("manifest write failed: %s: %s", path, exc) + return False + finally: + if tmp_path is not None: + Path(tmp_path).unlink(missing_ok=True) + + +def _read_manifest_document(path: str): + try: + with open(path, encoding="utf-8") as f: + document = json.load(f) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return None + return document if isinstance(document, dict) else None + + +def update_manifest(project_dir: str, mutator) -> bool: + """Read-modify-write the manifest atomically (locked re-read + patch + replace). + + The whole read-modify-replace runs under ``.manifest.lock`` (flock): + two cooperating writers can no longer both complete the fresh read + before either replaces, so neither loses the other's update. The + mutator receives the parsed document and edits it in place. When an + unrelated change lands between the read and the write, the mutator is + re-applied to the freshest document instead of overwriting the change. + Project-level fields (including updated_at) are owned by the mutator. + Returns False (with a warning) when the manifest is missing, unreadable, + the lock cannot be taken, or the write fails — callers degrade to a + warning, never a run failure. + """ + from chipcompiler.cli.project.migrate_fs import flock_file + + path = os.path.join(project_dir, MANIFEST_FILENAME) + try: + with flock_file(os.path.join(project_dir, ".manifest.lock"), exclusive=True): + return _update_manifest_locked(path, mutator) + except OSError as exc: + # An untakeable lock (e.g. a directory at the lock path) degrades + # like any write failure: a warning, never an uncaught exception — + # the migration registration path relies on False to roll back. + logger.warning("manifest update failed: %s: %s", path, exc) + return False + + +def _update_manifest_locked(path: str, mutator) -> bool: + base = _read_manifest_document(path) + if base is None: + logger.warning("manifest update skipped (unreadable): %s", path) + return False + + document = deepcopy(base) + mutator(document) + + fresh = _read_manifest_document(path) + if fresh is not None and fresh != base: + # An unrelated edit landed after our read: re-apply the mutator to + # the freshest document so the interleaved change survives. + document = fresh + mutator(document) + + target = Path(path) + tmp_path = None + try: + with tempfile.NamedTemporaryFile( + "w", + dir=target.parent, + delete=False, + prefix=f".{target.name}.", + suffix=".tmp", + encoding="utf-8", + ) as f: + tmp_path = Path(f.name) + json.dump(document, f, indent=2) + f.write("\n") + f.flush() + os.fsync(f.fileno()) + # Preserve the existing manifest's permissions: mkstemp's 0600 must + # not silently narrow a shared project.json. + if target.exists(): + os.chmod(tmp_path, target.stat().st_mode & 0o7777) + os.replace(tmp_path, target) + return True + except OSError as exc: + logger.warning("manifest update failed: %s: %s", path, exc) + if tmp_path is not None: + tmp_path.unlink(missing_ok=True) + return False + + +def write_back_workspace_status(project_dir: str, workspace_id: str, status: str) -> bool: + """Update one workspace entry's status (and updated_at) after a run.""" + + def mutate(document: dict) -> None: + for entry in document.get("workspaces", []): + if isinstance(entry, dict) and entry.get("workspace_id") == workspace_id: + entry["status"] = status + entry["updated_at"] = _now_iso() + + return update_manifest(project_dir, mutate) + + +def remove_workspace_registration(project_dir: str, workspace_id: str) -> bool: + """Roll back a pre-registration: drop the freshly added entry. + + Used when an overwrite run against an undeclared workspace fails before + the replacement is constructed: the restored previous workspace must not + be shadowed by a stale ``not_started`` entry this invocation created. + """ + + def mutate(document: dict) -> None: + workspaces = document.get("workspaces") + if isinstance(workspaces, list): + document["workspaces"] = [ + entry + for entry in workspaces + if not (isinstance(entry, dict) and entry.get("workspace_id") == workspace_id) + ] + document["updated_at"] = _now_iso() + + return update_manifest(project_dir, mutate) + + +def manifest_range_for_flow(cfg, flow_config: dict | None) -> tuple[str, str]: + """Return the GUI manifest range for a workspace's effective target.""" + if isinstance(flow_config, dict) and flow_config.get("start_step"): + from chipcompiler.rtl2gds import normalize_flow_step + + start = normalize_flow_step(flow_config["start_step"]) + end = normalize_flow_step(flow_config.get("end_step") or start) + try: + return (_CANONICAL_TO_MANIFEST_STEP[start], _CANONICAL_TO_MANIFEST_STEP[end]) + except KeyError as exc: + raise ManifestError(f"unknown workspace flow step: {exc.args[0]}") from None + return PRESET_MANIFEST_RANGE.get(cfg.flow_preset, ("Synth", "Harden")) + + +def pre_register_workspace( + project_dir: str, + *, + cfg, + pdk_root: str, + workspace_id: str, + workspace_path: str, + flow_config: dict | None, +) -> str: + """Atomically register a fresh managed workspace before filesystem creation. + + Returns ``registered``, ``existing``, ``conflict``, or ``failed``. A + workspace entry intentionally contains no input snapshot: copied files and + the workspace config are the reproducibility boundary. + """ + try: + start_step, end_step = manifest_range_for_flow(cfg, flow_config) + except ManifestError: + return "failed" + now = _now_iso() + manifest_path = os.path.join(project_dir, MANIFEST_FILENAME) + if not os.path.lexists(manifest_path): + document = build_manifest_document( + project_dir, + design_name=cfg.design_name, + base_design=base_design_from_config(cfg, pdk_root), + workspace_id=workspace_id, + workspace_path=workspace_path, + start_step=start_step, + end_step=end_step, + status="not_started", + ) + if write_manifest_if_absent(project_dir, document): + return "registered" + # A concurrent creator won the link race: fall through and apply the + # same registration mutation under the manifest lock instead of + # aborting this run. A genuine I/O failure fails again below. + + outcome = "registered" + + def mutate(document: dict) -> None: + nonlocal outcome + workspaces = document.get("workspaces") + if not isinstance(workspaces, list): + outcome = "failed" + return + for entry in workspaces: + if not isinstance(entry, dict) or entry.get("workspace_id") != workspace_id: + continue + if os.path.realpath(str(entry.get("workspace_path", ""))) == os.path.realpath( + workspace_path + ): + outcome = "existing" + else: + outcome = "conflict" + return + workspaces.append( + manifest_workspace_entry( + workspace_id, + name=cfg.design_name, + workspace_path=workspace_path, + start_step=start_step, + end_step=end_step, + status="not_started", + now=now, + ) + ) + document["updated_at"] = now + + if not update_manifest(project_dir, mutate): + return "failed" + return outcome diff --git a/chipcompiler/cli/project/migrate.py b/chipcompiler/cli/project/migrate.py index 07892598a..c1267fadc 100644 --- a/chipcompiler/cli/project/migrate.py +++ b/chipcompiler/cli/project/migrate.py @@ -16,7 +16,8 @@ from typing_extensions import deprecated -from chipcompiler.cli.project.manifest import find_manifest, load_manifest, update_manifest +from chipcompiler.cli.project.manifest import find_manifest, load_manifest +from chipcompiler.cli.project.manifest_write import update_manifest from chipcompiler.cli.project.migrate_plan import ( MigrationEntry, MigrationPreview, @@ -91,7 +92,7 @@ def execute_migration(project_dir: str, preview: MigrationPreview) -> tuple[list that actually moved. """ from chipcompiler.cli.project import migrate_fs - from chipcompiler.cli.project.manifest import write_manifest_if_absent + from chipcompiler.cli.project.manifest_write import write_manifest_if_absent plan = preview.plan records: list[dict] = [] @@ -495,8 +496,10 @@ def _migrate_project_impl(command_input, ctx): problems.append("flow.preset is required") else: from chipcompiler import rtl2gds as rtl2gds_api + from chipcompiler.data.workspace_config import LEGACY_PRESET_RANGES - if cfg.flow_preset not in rtl2gds_api.get_flow_builders(): + supported = set(rtl2gds_api.get_flow_builders()) | set(LEGACY_PRESET_RANGES) + if cfg.flow_preset not in supported: problems.append(f"unsupported flow.preset: {cfg.flow_preset}") if problems: return CommandResult.err( diff --git a/chipcompiler/cli/project/migrate_fs.py b/chipcompiler/cli/project/migrate_fs.py index f9c7ece5e..cae71830e 100644 --- a/chipcompiler/cli/project/migrate_fs.py +++ b/chipcompiler/cli/project/migrate_fs.py @@ -279,6 +279,41 @@ def rebase(value): raise OSError(f"failed to write rebased home.json: {home_path}") +@deprecated( + "legacy runs/ -> manifest layout migration machinery; slated for removal " + "after the transition period", + category=None, +) +def _rebase_flow_step_info(workspace_dir: str, old_prefix: str, new_prefix: str) -> None: + """Rewrite flow.json step-info paths (golden netlist, SPEF) to the new location.""" + flow_path = os.path.join(workspace_dir, "home", "flow.json") + if not os.path.exists(flow_path): + return + with open(flow_path, encoding="utf-8") as f: + data = json.load(f) + steps = data.get("steps") if isinstance(data, dict) else None + if not isinstance(steps, list): + return + changed = False + for step in steps: + if not isinstance(step, dict): + continue + info = step.get("info") + if not isinstance(info, dict): + continue + for key in ("golden_verilog", "spef"): + value = info.get(key) + if isinstance(value, str) and value.startswith(old_prefix + os.sep): + info[key] = new_prefix + value[len(old_prefix) :] + changed = True + if not changed: + return + from chipcompiler.utility import json_write + + if not json_write(flow_path, data): + raise OSError(f"failed to write rebased flow.json: {flow_path}") + + @deprecated( "legacy runs/ -> manifest layout migration machinery; slated for removal " "after the transition period", @@ -308,6 +343,7 @@ def _rollback_workspace(entry, container_fd: int, project_fd: int) -> bool: # all-or-nothing covers the legacy "PDK Config" pointer too. _pre_rebase_legacy_config_paths(entry.source, entry.target, entry.source) _rebase_home_pointers(entry.source, entry.target, entry.source) + _rebase_flow_step_info(entry.source, entry.target, entry.source) except (OSError, ValueError): # ValueError covers JSONDecodeError/UnicodeDecodeError and the # not-an-object guard: a malformed state file only downgrades the @@ -451,6 +487,7 @@ def _move_workspace(entry, container_fd: int, project_fd: int) -> tuple[str, str if workspace is None: raise ValueError(f"moved workspace fails to load: {entry.target}") _rebase_home_pointers(entry.target, entry.source, entry.target) + _rebase_flow_step_info(entry.target, entry.source, entry.target) workspace = load_workspace(entry.target) refresh_workspace_config(workspace) except Exception as exc: diff --git a/chipcompiler/cli/project/migrate_plan.py b/chipcompiler/cli/project/migrate_plan.py index 9ca7cd688..d097420c6 100644 --- a/chipcompiler/cli/project/migrate_plan.py +++ b/chipcompiler/cli/project/migrate_plan.py @@ -20,9 +20,11 @@ from chipcompiler.cli.project.manifest import ( base_design_from_config, - build_manifest_document, find_manifest, load_manifest, +) +from chipcompiler.cli.project.manifest_write import ( + build_manifest_document, manifest_workspace_entry, ) @@ -112,7 +114,7 @@ def _flow_status(steps: list[dict]) -> str: return "failed" if states & {"Ongoing", "Pending"}: return "in_progress" - if states and states <= {"Success", "Warning"}: + if states == {"Success"}: return "success" return "not_started" diff --git a/chipcompiler/cli/project/run_dispatch.py b/chipcompiler/cli/project/run_dispatch.py index d692eb3a0..fc84189a9 100644 --- a/chipcompiler/cli/project/run_dispatch.py +++ b/chipcompiler/cli/project/run_dispatch.py @@ -13,6 +13,7 @@ imports cheap. """ +import contextlib import os from pathlib import Path @@ -85,24 +86,30 @@ def _existing_target_guard(run_dir: str, project_dir: str, run_name: str) -> Com return None -def _prepare_run_target(command_input, ctx, run_dir: str, run_name: str): - """Overwrite-delete + atomic create of the run target (the caller holds +def _prepare_run_target(command_input, ctx, run_dir: str, run_name: str, ws_locks): + """Overwrite-backup + atomic create of the run target (the caller holds the shared project lock). - Returns owns_target when the run may proceed, or a CommandResult - error (overwrite_refused / run_exists). Only the process that - atomically creates the target may proceed or clean up a failed - create_workspace: an existing target (pre-existing or won by a - concurrent run) is never written into or removed by this invocation. - create_workspace re-attempts the creation, so any other error - surfaces from there. + Enters the sibling workspace lock into *ws_locks* BEFORE the rename and + leaves it held: the caller keeps it through the replacement's + construction, so two concurrent overwrite runs serialize end-to-end and + one's failure cleanup can never destroy the other's completed + workspace. + + Returns (owns_target, backup_path) when the run may proceed, or a + CommandResult error (overwrite_refused / run_exists). Only the process + that atomically creates the target may proceed, restore a backup, or + clean up a failed create_workspace: an existing target (pre-existing or + won by a concurrent run) is never written into or removed by this + invocation. The previous workspace is renamed to a sibling backup + instead of deleted, so a failed creation can put it back; the backup is + discarded once the replacement is fully constructed. """ - import shutil - from chipcompiler.cli.core.records import error_record from chipcompiler.engine.reconcile import _workspace_lock project_dir = ctx.project_dir + backup_path = None if command_input.overwrite and os.path.lexists(run_dir): if not _resolves_as_spelled(run_dir, project_dir) or not _is_ecc_run_dir(run_dir): return CommandResult.err( @@ -115,28 +122,24 @@ def _prepare_run_target(command_input, ctx, run_dir: str, run_name: str): ) ] ) - # Serialize the deletion with an active execution of this workspace: + # Serialize the rename with an active execution of this workspace: # flock blocks until the running engine releases the sibling lock - # (.lock, which survives the rmtree), and the fresh engine + # (.lock, which survives the rename), and the fresh engine # re-acquires it on the recreated tree, so two runs never execute - # against the same paths. - with _workspace_lock(Path(run_dir)): - for root, dirs, files in os.walk(run_dir): - for d in dirs: - dp = os.path.join(root, d) - if not os.path.islink(dp): - os.chmod(dp, 0o755) - for f in files: - fp = os.path.join(root, f) - if not os.path.islink(fp): - os.chmod(fp, 0o644) - os.chmod(run_dir, 0o755) - shutil.rmtree(run_dir) + # against the same paths. The lock stays entered in *ws_locks* until + # the replacement is built. + ws_locks.enter_context(_workspace_lock(Path(run_dir))) + backup_path = f"{run_dir}.overwritten-{os.getpid()}" + # An atomic rename, not a delete: until the replacement is fully + # constructed the old tree stays on disk and recoverable. + os.replace(run_dir, backup_path) try: os.makedirs(run_dir) - return True + return True, backup_path except FileExistsError: + if backup_path is not None: + os.replace(backup_path, run_dir) return CommandResult.err( [ error_record( @@ -147,8 +150,33 @@ def _prepare_run_target(command_input, ctx, run_dir: str, run_name: str): ) ] ) - except OSError: - return False + except OSError as exc: + if backup_path is not None: + with contextlib.suppress(OSError): + os.replace(backup_path, run_dir) + return CommandResult.err( + [ + error_record( + "workspace_create_failed", + workspace_id=run_name, + workspace=run_dir, + reason=str(exc), + ) + ] + ) + + +def _abandon_prepared_target(backup_path: str | None, run_dir: str, *, owns_target: bool) -> None: + """Undo a prepared-but-unregistered target: remove the empty directory + and put a renamed-aside previous workspace back.""" + import shutil + + if not owns_target: + return + shutil.rmtree(run_dir, ignore_errors=True) + if backup_path is not None: + with contextlib.suppress(OSError): + os.replace(backup_path, run_dir) def _stale_project_state(project_dir: str, expected: str) -> CommandResult | None: @@ -212,7 +240,13 @@ def existing_workspace_run() -> CommandResult: workspace_registered=workspace_registered, ) - def fresh_run(*, owns_target: bool) -> CommandResult: + def fresh_run( + *, + owns_target: bool, + backup_path: str | None = None, + ws_locks=None, + registration_created: bool = False, + ) -> CommandResult: return execute_fresh_run( command_input, ctx, @@ -225,6 +259,9 @@ def fresh_run(*, owns_target: bool) -> CommandResult: warning_records, workspace_registered=workspace_registered, owns_target=owns_target, + backup_path=backup_path, + ws_locks=ws_locks, + registration_created=registration_created, execute_flow=execute_flow, ) @@ -235,73 +272,95 @@ def fresh_run(*, owns_target: bool) -> CommandResult: # Legacy runs create inside runs/ — the very paths a migration # moves — so state revalidation, the existing/fresh decision, # creation, AND the engine all hold the shared project lock. + ws_locks = contextlib.ExitStack() + try: + with migrate_fs.project_migrate_lock(project_dir, exclusive=False): + stale = _stale_project_state(project_dir, "legacy") + if stale is not None: + return stale + if os.path.exists(flow_json) and not command_input.overwrite: + unsafe = _existing_target_guard(run_dir, project_dir, run_name) + if unsafe is not None: + return unsafe + return existing_workspace_run() + prepared = _prepare_run_target(command_input, ctx, run_dir, run_name, ws_locks) + if isinstance(prepared, CommandResult): + return prepared + return fresh_run( + owns_target=prepared[0], backup_path=prepared[1], ws_locks=ws_locks + ) + finally: + ws_locks.close() + + # The ownership decision (overwrite rename + create) runs inside the + # shared project lock, and the workspace lock taken for the rename stays + # held until the replacement is fully built: two concurrent overwrite + # runs can no longer interleave so that one's failure cleanup destroys + # the other's completed workspace. The engine still runs outside the + # project-wide lock so a run never holds it for minutes. + owns_target = False + backup_path = None + created_registration = False + ws_locks = contextlib.ExitStack() + try: with migrate_fs.project_migrate_lock(project_dir, exclusive=False): - stale = _stale_project_state(project_dir, "legacy") - if stale is not None: - return stale - if os.path.exists(flow_json) and not command_input.overwrite: + existing = os.path.exists(flow_json) and not command_input.overwrite + if existing: unsafe = _existing_target_guard(run_dir, project_dir, run_name) if unsafe is not None: return unsafe - return existing_workspace_run() - prepared = _prepare_run_target(command_input, ctx, run_dir, run_name) - if isinstance(prepared, CommandResult): - return prepared - return fresh_run(owns_target=prepared) - - # The overwrite-delete + atomic create run inside the shared project - # lock: an `ecc migrate` holding the exclusive lock sees them as one - # serialized section instead of racing the target's appearance. The - # engine runs outside the lock so a run never holds it for minutes. - owns_target = False - with migrate_fs.project_migrate_lock(project_dir, exclusive=False): - existing = os.path.exists(flow_json) and not command_input.overwrite - if existing: - unsafe = _existing_target_guard(run_dir, project_dir, run_name) - if unsafe is not None: - return unsafe - else: - if not workspace_registered: - from chipcompiler.cli.core.records import error_record - from chipcompiler.cli.project.config import resolve_pdk_root - from chipcompiler.cli.project.manifest import pre_register_workspace - - registration = pre_register_workspace( - project_dir, - cfg=cfg, - pdk_root=resolve_pdk_root(cfg), - workspace_id=run_name, - workspace_path=run_dir, - flow_config=flow_config, - ) - if registration == "conflict": - return CommandResult.err( - [ - error_record( - "workspace_conflict", - workspace_id=run_name, - workspace=run_dir, - ) - ] - ) - if registration != "registered": - return CommandResult.err( - [ - error_record( - "workspace_registration_failed", - workspace_id=run_name, - workspace=run_dir, - ) - ] + else: + prepared = _prepare_run_target(command_input, ctx, run_dir, run_name, ws_locks) + if isinstance(prepared, CommandResult): + return prepared + owns_target, backup_path = prepared + if not workspace_registered: + from chipcompiler.cli.core.records import error_record + from chipcompiler.cli.project.config import resolve_pdk_root + from chipcompiler.cli.project.manifest_write import pre_register_workspace + + registration = pre_register_workspace( + project_dir, + cfg=cfg, + pdk_root=resolve_pdk_root(cfg), + workspace_id=run_name, + workspace_path=run_dir, + flow_config=flow_config, ) - workspace_registered = True - prepared = _prepare_run_target(command_input, ctx, run_dir, run_name) - if isinstance(prepared, CommandResult): - return prepared - owns_target = prepared - if existing: - # Manifest workspaces live outside runs/ — migration never moves - # them, so the engine must not pin the shared lock for its whole - # execution the way the legacy branch intentionally does. - return existing_workspace_run() - return fresh_run(owns_target=owns_target) + if registration == "conflict": + _abandon_prepared_target(backup_path, run_dir, owns_target=owns_target) + return CommandResult.err( + [ + error_record( + "workspace_conflict", + workspace_id=run_name, + workspace=run_dir, + ) + ] + ) + if registration != "registered": + _abandon_prepared_target(backup_path, run_dir, owns_target=owns_target) + return CommandResult.err( + [ + error_record( + "workspace_registration_failed", + workspace_id=run_name, + workspace=run_dir, + ) + ] + ) + workspace_registered = True + created_registration = True + if existing: + # Manifest workspaces live outside runs/ — migration never moves + # them, so the engine must not pin the shared lock for its whole + # execution the way the legacy branch intentionally does. + return existing_workspace_run() + return fresh_run( + owns_target=owns_target, + backup_path=backup_path, + ws_locks=ws_locks, + registration_created=created_registration, + ) + finally: + ws_locks.close() diff --git a/chipcompiler/cli/project/run_existing.py b/chipcompiler/cli/project/run_existing.py index e7cfc7d6d..afeda0b3f 100644 --- a/chipcompiler/cli/project/run_existing.py +++ b/chipcompiler/cli/project/run_existing.py @@ -14,6 +14,7 @@ from chipcompiler.cli.core.output import disclosure_cmd from chipcompiler.cli.core.types import CommandResult from chipcompiler.cli.project.run_prepare import _write_back_status +from chipcompiler.data import is_finished_step_state def run_existing_workspace( @@ -203,7 +204,7 @@ def mismatch_error(reason: str) -> CommandResult: for step in flow_data.get("steps", []) if isinstance(step, dict) and isinstance(step.get("name"), str) - and step.get("state") != "Success" + and not is_finished_step_state(step.get("state")) and step["name"] in target_names } engine_flow.create_step_workspaces(executable_steps=executable) diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index 709cbfb22..bed0caf21 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -118,10 +118,31 @@ def _workspace_failed_result(run_name: str, run_dir: str, reason: str | None) -> return CommandResult.err([record]) +def _fresh_entry_step_name(cfg, flow_config) -> str | None: + """The canonical first step a fresh workspace target will execute. + + An explicit start step (CLI range or manifest range) wins; otherwise the + preset builder's first step. None when the target declares neither. + """ + if isinstance(flow_config, dict) and flow_config.get("start_step"): + from chipcompiler.rtl2gds import normalize_flow_step + + return normalize_flow_step(flow_config["start_step"]) + from chipcompiler import rtl2gds as rtl2gds_api + + builders = rtl2gds_api.get_flow_builders() + if cfg.flow_preset in builders: + first = next(iter(builders[cfg.flow_preset]()), None) + if first is not None: + step = first[0] + return step.value if hasattr(step, "value") else str(step) + return None + + def _write_back_status(project_dir: str, run_name: str, status: str, warning_records: list) -> None: """Best-effort manifest status write-back; degrades to a warning.""" from chipcompiler.cli.core.records import warning_record - from chipcompiler.cli.project.manifest import write_back_workspace_status + from chipcompiler.cli.project.manifest_write import write_back_workspace_status if not write_back_workspace_status(project_dir, run_name, status): warning_records.append( @@ -191,13 +212,21 @@ def execute_fresh_run( *, workspace_registered: bool, owns_target: bool, + backup_path: str | None = None, + ws_locks=None, + registration_created: bool = False, execute_flow: bool = True, ) -> CommandResult: """Create the workspace, seed it, execute the flow, and map the result. Fresh-run preparation and execution for a project run: parameter assembly, workspace creation, flow target seeding, virgin manifest - generation, engine execution, and status write-back. + generation, engine execution, and status write-back. When *backup_path* + is set the invocation overwrote an existing workspace by renaming it + aside: a failure before the replacement is fully constructed restores + the backup, and only a verified construction discards it. *ws_locks* is + the caller's lock stack already holding the workspace lock (taken before + the overwrite rename); when None this function takes the lock itself. """ import shutil @@ -216,6 +245,38 @@ def execute_fresh_run( project = ctx.project project_dir = ctx.project_dir + # Commit point: once the replacement is verified and the backup is + # discarded, execution failures are a normal failed run — the new tree + # stays, and cleanup must no longer touch it. + committed_state = {"value": False} + + def commit_replacement(): + committed_state["value"] = True + if backup_path is not None: + shutil.rmtree(backup_path, ignore_errors=True) + + def terminal_failure() -> bool: + """A failure marks the entry failed when the target stays; a + restored backup keeps its prior manifest status.""" + return committed_state["value"] or backup_path is None + + def cleanup_failed_target() -> list[str]: + """Remove a partially created target and put a renamed-aside + workspace back, so the previous artifacts survive the failure. + Returns problems encountered while rolling back.""" + problems: list[str] = [] + if not owns_target or committed_state["value"]: + return problems + shutil.rmtree(run_dir, ignore_errors=True) + if os.path.lexists(run_dir): + problems.append(f"partial target could not be removed: {run_dir}") + if backup_path is not None: + try: + os.replace(backup_path, run_dir) + except OSError as exc: + problems.append(f"previous workspace left at {backup_path}: {exc}") + return problems + base = None if cfg.manifest_parameters: # Manifest base layer: ecc.toml/--set values overlay it, not the @@ -235,8 +296,7 @@ def execute_fresh_run( skip_params=effective_override_keys(cfg, cli_overrides), ) if coerce_errors: - if owns_target: - shutil.rmtree(run_dir, ignore_errors=True) + cleanup_failed_target() return CommandResult.err( [ { @@ -248,9 +308,23 @@ def execute_fresh_run( ] ) - def failed_workspace(reason: str | None) -> CommandResult: - if workspace_registered: + def rollback_failed_registration() -> None: + if terminal_failure() and workspace_registered: + # The target is genuinely gone: mark the entry failed. A restored + # backup keeps its prior status — the refresh never happened. _write_back_status(project_dir, run_name, "failed", warning_records) + elif registration_created and backup_path is not None: + # This invocation pre-registered an undeclared workspace and then + # restored the previous tree: the fresh entry must not shadow it. + from chipcompiler.cli.project.manifest_write import remove_workspace_registration + + remove_workspace_registration(project_dir, run_name) + + def failed_workspace(reason: str | None) -> CommandResult: + rollback_problems = cleanup_failed_target() + if rollback_problems: + reason = f"{reason}; rollback incomplete: {'; '.join(rollback_problems)}" + rollback_failed_registration() return _workspace_failed_result(run_name, run_dir, reason) from chipcompiler.cli.project.design_inputs import resolve_design_inputs @@ -258,19 +332,23 @@ def failed_workspace(reason: str | None) -> CommandResult: inputs = resolve_design_inputs(cfg) _, origin_verilog, input_filelist = resolve_rtl(cfg) origin_def = inputs.def_ or cfg.manifest_origin_def - if inputs.netlist: + + # The declared netlist is the synthesis OUTPUT consumed by a + # post-synthesis entry step. A flow entering at Synthesis must use the + # declared RTL, even when a netlist is also present in ecc.toml. + entry_step = _fresh_entry_step_name(cfg, flow_config) + uses_netlist_input = bool(inputs.netlist) and entry_step != "Synthesis" + if uses_netlist_input: origin_verilog = inputs.netlist input_filelist = "" generated_filelist = None - if len(cfg.design_rtl) > 1 and not inputs.netlist: + if len(cfg.design_rtl) > 1 and not uses_netlist_input: # Manifest-backed projects may declare several RTL sources; # materialize them as one generated filelist for creation. A failure # here must not strand a partial run target for the next run. try: generated_filelist = _materialize_rtl_filelist(cfg) except Exception as exc: - if owns_target: - shutil.rmtree(run_dir, ignore_errors=True) return failed_workspace(str(exc)) input_filelist = generated_filelist origin_verilog = "" @@ -312,11 +390,16 @@ def failed_workspace(reason: str | None) -> CommandResult: # creation. It stays held through seeding and engine execution below — # the same execution ownership as the existing-run path — while the # migration lock is released right after creation so a run never pins - # project-wide migration for minutes. - ws_locks = contextlib.ExitStack() + # project-wide migration for minutes. The caller's stack already holds + # the lock when it took the overwrite rename; entering here would be a + # no-op on the same path, so only a self-owned stack enters it. + caller_locks = ws_locks is not None + if ws_locks is None: + ws_locks = contextlib.ExitStack() try: with migrate_fs.project_migrate_lock(project_dir, exclusive=False): - ws_locks.enter_context(_workspace_lock(Path(run_dir))) + if not caller_locks: + ws_locks.enter_context(_workspace_lock(Path(run_dir))) try: workspace = create_workspace( directory=run_dir, @@ -333,8 +416,6 @@ def failed_workspace(reason: str | None) -> CommandResult: golden_verilog=inputs.golden_netlist, ) except Exception as exc: - if owns_target: - shutil.rmtree(run_dir, ignore_errors=True) return failed_workspace(str(exc)) finally: if generated_filelist is not None: @@ -342,27 +423,29 @@ def failed_workspace(reason: str | None) -> CommandResult: shutil.rmtree(os.path.dirname(generated_filelist)) if workspace is None: - if owns_target: - shutil.rmtree(run_dir, ignore_errors=True) return failed_workspace(None) - if cli_overrides: - import json - - provenance_path = os.path.join(run_dir, "home", "cli-param-overrides.json") - os.makedirs(os.path.dirname(provenance_path), exist_ok=True) - with open(provenance_path, "w") as _f: - json.dump(cli_overrides, _f) - - if flow_config is None: - # CLI-born workspaces persist the named prefix chain as their target. - workspace_parameters = getattr(workspace, "parameters", None) - if workspace_parameters is not None: - workspace_parameters.data["_flow"] = {"preset": cfg.flow_preset} - save_parameter(workspace_parameters) - - if workspace_registered and execute_flow: - _write_back_status(project_dir, run_name, "running", warning_records) + # Seeding writes are part of the replacement construction: they run + # before the commit point inside the recovery boundary, so a write + # failure restores the renamed-aside previous workspace. + try: + if cli_overrides: + import json + + provenance_path = os.path.join(run_dir, "home", "cli-param-overrides.json") + os.makedirs(os.path.dirname(provenance_path), exist_ok=True) + with open(provenance_path, "w") as _f: + json.dump(cli_overrides, _f) + + if flow_config is None: + # CLI-born workspaces persist the named prefix chain as their target. + workspace_parameters = getattr(workspace, "parameters", None) + if workspace_parameters is not None: + workspace_parameters.data["_flow"] = {"preset": cfg.flow_preset} + if not save_parameter(workspace_parameters): + return failed_workspace("failed to persist the flow target in params.toml") + except Exception as exc: + return failed_workspace(str(exc)) # Engine execution still holds the workspace lock taken before # creation: a second `ecc run` taking the existing-workspace path @@ -376,6 +459,25 @@ def failed_workspace(reason: str | None) -> CommandResult: engine_flow.create_step_workspaces() + # create_step_workspaces marks a failed dependency Incomplete and + # stops: a ranged workspace can end up only partially constructed, + # which is a failed run/refresh, never a success. The ledger read + # tolerates stub flows so test doubles stay minimal. + flow_ledger = getattr(getattr(engine_flow, "workspace", None), "flow", None) + persisted_steps = flow_ledger.data.get("steps", []) if flow_ledger else [] + created_steps = getattr(engine_flow, "workspace_steps", None) or [] + if len(created_steps) < len(persisted_steps): + missing = persisted_steps[len(created_steps)].get("name") + return failed_workspace(f"step workspace creation failed at {missing}") + + # The replacement is fully constructed and verified: commit it. + # The previous workspace's backup is obsolete, the new tree owns + # the target, and later failures are a normal failed run. + commit_replacement() + + if workspace_registered and execute_flow: + _write_back_status(project_dir, run_name, "running", warning_records) + if not execute_flow: if workspace_registered: _write_back_status(project_dir, run_name, "not_started", warning_records) @@ -417,8 +519,11 @@ def failed_workspace(reason: str | None) -> CommandResult: except Exception as exc: from chipcompiler.cli.core.records import error_record - if workspace_registered: - _write_back_status(project_dir, run_name, "failed", warning_records) + rollback_problems = cleanup_failed_target() + reason = str(exc) + if rollback_problems: + reason = f"{reason}; rollback incomplete: {'; '.join(rollback_problems)}" + rollback_failed_registration() return CommandResult.err( warning_records + [ @@ -426,12 +531,15 @@ def failed_workspace(reason: str | None) -> CommandResult: "flow_failed", workspace_id=run_name, workspace=run_dir, - reason=str(exc), + reason=reason, ) ] ) finally: - ws_locks.close() + # A caller-owned stack outlives this function: the dispatcher closes + # it after the engine run, so only a self-owned stack closes here. + if not caller_locks: + ws_locks.close() if workspace_registered: _write_back_status(project_dir, run_name, "success", warning_records) diff --git a/chipcompiler/cli/project/run_workspace.py b/chipcompiler/cli/project/run_workspace.py index 9352a50e3..d80cb2324 100644 --- a/chipcompiler/cli/project/run_workspace.py +++ b/chipcompiler/cli/project/run_workspace.py @@ -11,21 +11,30 @@ lock. Imported lazily by the run handler; keep module-level imports cheap. """ +import logging import os import shlex from pathlib import Path from chipcompiler.cli.core.types import CommandResult +logger = logging.getLogger(__name__) + def execute_workspace_run( - command_input, workspace_path: str, workspace_id: str | None = None + command_input, + workspace_path: str, + workspace_id: str | None = None, + *, + project_dir: str | None = None, ) -> CommandResult: """Reconcile and execute a registered project workspace. Selector validity was already checked by the handler. Explicit selectors (--from/--only) re-execute on request; the default resume - runs only within the reconciled target range. + runs only within the reconciled target range. When *project_dir* names + a manifest project, the run lifecycle is written back to project.json + (running, then the terminal status) so the GUI never reads a stale one. """ from chipcompiler.data import load_workspace from chipcompiler.data.workspace_config import ( @@ -42,6 +51,17 @@ def execute_workspace_run( def error(kind: str, **fields) -> CommandResult: return CommandResult.err([{"kind": "error", "error": kind, **fields}]) + def write_status(status: str) -> None: + """Best-effort manifest status write-back; degrades to a log.""" + from chipcompiler.cli.project.manifest_write import write_back_workspace_status + + if project_dir is None or not workspace_id: + return + if not write_back_workspace_status(project_dir, workspace_id, status): + logger.warning( + "manifest write-back failed: %s: %s -> %s", project_dir, workspace_id, status + ) + workspace_path = os.path.abspath(workspace_path) def mismatch_error(reason: str) -> CommandResult: @@ -97,9 +117,12 @@ def mismatch_error(reason: str) -> CommandResult: and command_input.from_step is None and command_input.only is None ): - # The persisted flow already covers the target and succeeded; + # The persisted flow already covers the target and finished; # resume has nothing to do. Explicit selectors (--from/--only) - # still re-execute on request. + # still re-execute on request. The flow is complete, so the + # manifest entry reads success even if a previous failed state + # is stale. + write_status("success") return CommandResult.ok( [ { @@ -112,12 +135,20 @@ def mismatch_error(reason: str) -> CommandResult: ] ) + write_status("running") + + def run_failed(kind: str, reason: str | None = None) -> CommandResult: + """A failure after the running marker must leave a terminal + status, never a workspace stuck as running.""" + write_status("failed") + return error(kind, workspace=workspace_path, **({"reason": reason} if reason else {})) + try: engine_flow = EngineFlow(workspace=workspace) except Exception as exc: - return error("invalid_workspace", workspace=workspace_path, reason=str(exc)) + return run_failed("invalid_workspace", str(exc)) if not engine_flow.has_init(): - return error("missing_flow", workspace=workspace_path) + return run_failed("missing_flow") try: selected = rerun.selected_step_names( @@ -135,7 +166,7 @@ def mismatch_error(reason: str) -> CommandResult: if target_names: selected = rerun.bounded_resume_names(engine_flow, target_names[-1]) except ValueError as exc: - return error("unknown_step", workspace=workspace_path, reason=str(exc)) + return run_failed("unknown_step", str(exc)) from chipcompiler.cli.rendering.progress import preserve_cli_stdio @@ -158,10 +189,13 @@ def mismatch_error(reason: str) -> CommandResult: else: result = rerun.run_resume(engine_flow) except ValueError as exc: - return error("step_unavailable", workspace=workspace_path, reason=str(exc)) + return run_failed("step_unavailable", str(exc)) except Exception as exc: - return error("flow_failed", workspace=workspace_path, reason=str(exc)) + return run_failed("flow_failed", str(exc)) + # Written inside the workspace lock: a second run acquiring the lock + # afterwards must observe this terminal status, not overwrite it. + write_status("success" if result.ok else "failed") record = { "workspace_id": workspace_id or "default", "status": "success" if result.ok else "failed", diff --git a/chipcompiler/cli/project/toml_edit.py b/chipcompiler/cli/project/toml_edit.py index 6abd726de..d42cbe1cd 100644 --- a/chipcompiler/cli/project/toml_edit.py +++ b/chipcompiler/cli/project/toml_edit.py @@ -8,56 +8,242 @@ _TABLE_HEADER_RE = re.compile(r"^[ \t]*\[([^\]]+)\][ \t]*(?:#.*)?$", re.MULTILINE) +# Generic assignment line; the key span may be blanked by string masking, so +# callers compare the ORIGINAL text's key token, not the masked match. +# Group 1 is the line indent. +_ASSIGNMENT_LINE_RE = re.compile(r"^([ \t]*)[^=\n]+=[^\n]*$", re.MULTILINE) + + +def _split_dotted_key(raw: str) -> list[str]: + """Semantic segments of a TOML dotted key (quotes stripped, dots in quotes kept).""" + segments = [] + current = [] + quote = None + for ch in raw.strip(): + if quote is not None: + if ch == quote: + quote = None + else: + current.append(ch) + elif ch in "\"'": + quote = ch + elif ch == ".": + segments.append("".join(current).strip()) + current = [] + else: + current.append(ch) + segments.append("".join(current).strip()) + return segments + + +def _header_matches(text: str, match: re.Match, table_name: str) -> bool: + """Compare the ORIGINAL header text at a masked match position semantically.""" + raw_header = text[match.start(1) : match.end(1)] + return _split_dotted_key(raw_header) == table_name.split(".") + + +def _find_assignment(masked_body: str, original_body: str, name: str) -> re.Match | None: + """Locate ``name = ...`` in a masked section body, matching the key semantically. + + String masking blanks quoted keys, so candidate lines come from the masked + body while the key token is read from the original text at the same offsets. + """ + for m in _ASSIGNMENT_LINE_RE.finditer(masked_body): + key_token = original_body[m.start() : m.end()].split("=", 1)[0] + segments = _split_dotted_key(key_token) + if len(segments) == 1 and segments[0] == name: + return m + return None + + +def _mask_strings_and_comments(text: str) -> str: + """Return a same-length text with string and comment contents blanked. + + Table-header discovery must not mistake bracket text inside a multiline + string for a real ``[table]`` header; masking preserves every index and + newline so matches map back onto the original text. + """ + chars = list(text) + pos = 0 + n = len(text) + while pos < n: + ch = text[pos] + if ch == "#": + nl = text.find("\n", pos) + end = n if nl == -1 else nl + for i in range(pos, end): + chars[i] = " " + pos = end + continue + if ch in ('"', "'"): + triple = text[pos : pos + 3] + if triple in ('"""', "'''"): + end = pos + 3 + while end < n: + if text.startswith(triple, end): + end += 3 + break + end += 1 + else: + end = pos + 1 + while end < n: + if ch == '"' and text.startswith("\\", end): + end += 2 + continue + if text[end] == ch: + end += 1 + break + end += 1 + for i in range(pos, min(end, n)): + if chars[i] != "\n": + chars[i] = " " + pos = max(end, pos + 1) + continue + pos += 1 + return "".join(chars) + def find_table_span(text: str, table_name: str) -> tuple[int, int] | None: """Return (body_start, body_end) for a TOML table, or None.""" - for m in _TABLE_HEADER_RE.finditer(text): - if m.group(1).strip() == table_name: + masked = _mask_strings_and_comments(text) + for m in _TABLE_HEADER_RE.finditer(masked): + if _header_matches(text, m, table_name): header_end = m.end() nl = text.find("\n", header_end) body_start = len(text) if nl == -1 else nl + 1 - next_header = _TABLE_HEADER_RE.search(text, body_start) + next_header = _TABLE_HEADER_RE.search(masked, body_start) body_end = next_header.start() if next_header else len(text) return body_start, body_end return None -def _extend_multiline_value(text: str, match_end: int) -> int: - """Extend match end past continuation lines for multiline TOML values. +def _skip_string(segment: str, start: int) -> int: + """Return the index just past the TOML string starting at segment[start].""" + quote = segment[start] + if segment[start : start + 3] in ('"""', "'''"): + end = start + 3 + while end < len(segment): + if quote == '"' and segment.startswith("\\", end): + end += 2 + continue + if segment.startswith(segment[start : start + 3], end): + return end + 3 + end += 1 + return len(segment) + end = start + 1 + while end < len(segment): + if quote == '"' and segment[end] == "\\": + end += 2 + continue + if segment[end] == quote: + return end + 1 + end += 1 + return len(segment) + - After matching `key = ...` on one line, consume subsequent lines if the - value has unclosed brackets (arrays or inline tables). +def _toml_code_depth(segment: str) -> int: + """Net []/{} depth of a TOML fragment, skipping strings and comments. + + Bracket characters inside quoted strings, triple-quoted strings, or + comments are content, not structure: counting them would make a value + like "alu[rev" look multiline and swallow following keys. """ - line_start = text.rfind("\n", 0, match_end) + 1 - matched_line = text[line_start:match_end] + depth = 0 + i = 0 + while i < len(segment): + ch = segment[i] + if ch == "#": + nl = segment.find("\n", i) + if nl == -1: + break + i = nl + 1 + elif ch in ('"', "'"): + i = _skip_string(segment, i) + elif ch in "[{": + depth += 1 + i += 1 + elif ch in "]}": + depth -= 1 + i += 1 + else: + i += 1 + return depth + +def _extend_multiline_value(text: str, match_end: int) -> int: + """Return the end of the (possibly multiline) TOML value at the match. + + A small tokenizer walks the value from its line start: multiline basic + and literal strings (``\"\"\"...\"\"\"`` / ``'''...'''``), bracket + collections, escapes, and comments each terminate the value correctly. + The naive first-line match would leave the tail of a multiline string + behind as unparsable text. + """ + pos = text.rfind("\n", 0, match_end) + 1 + n = len(text) depth = 0 - eq_pos = matched_line.find("=") - if eq_pos >= 0: - for ch in matched_line[eq_pos + 1 :]: - if ch in ("[", "{"): - depth += 1 - elif ch in ("]", "}"): - depth -= 1 - - if depth <= 0: - return match_end - - pos = match_end - while pos < len(text) and depth > 0: + state = None # None, or the opening quote: '"' | "'" | '"""' | "'''" + while pos < n: ch = text[pos] - if ch in ("[", "{"): + if state in ('"', "'"): + if ch == "\\" and state == '"': + pos += 2 + continue + if ch == state: + state = None + pos += 1 + continue + if state in ('"""', "'''"): + if text.startswith(state, pos): + state = None + pos += 3 + else: + pos += 1 + continue + if ch == "#": + # A comment ends the value only at the top level; inside a + # bracket collection it decorates the line and the collection + # continues on the next line. + if depth <= 0: + nl = text.find("\n", pos) + if nl == -1: + return n + return nl + 1 + nl = text.find("\n", pos) + if nl == -1: + return n + pos = nl + 1 + continue + if ch in ('"', "'"): + triple = text[pos : pos + 3] + if triple in ('"""', "'''"): + state = triple + pos += 3 + else: + state = ch + pos += 1 + continue + if ch == "\n" and depth <= 0: + # Inside a bracket collection a line break is insignificant; at + # the top level the value ends with this physical line. + return pos + 1 + if ch in "[{": depth += 1 - elif ch in ("]", "}"): + elif ch in "]}": depth -= 1 pos += 1 + return n - while pos < len(text) and text[pos] in (" ", "\t"): - pos += 1 - if pos < len(text) and text[pos] == "\n": - pos += 1 - return pos +_ESCAPES = { + '"': '\\"', + "\\": "\\\\", + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", +} def format_toml_value(val: object) -> str: @@ -66,15 +252,26 @@ def format_toml_value(val: object) -> str: if isinstance(val, (int, float)): return str(val) if isinstance(val, str): - escaped = val.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' + out = [] + for ch in val: + escaped = _ESCAPES.get(ch) + if escaped: + out.append(escaped) + elif ord(ch) < 0x20 or ch == "\x7f": + out.append(f"\\u{ord(ch):04X}") + else: + out.append(ch) + return f'"{"".join(out)}"' if isinstance(val, (list, tuple)): items = ", ".join(format_toml_value(v) for v in val) return f"[{items}]" if isinstance(val, dict): - items = ", ".join(f'"{key}" = {format_toml_value(value)}' for key, value in val.items()) + items = ", ".join( + f"{format_toml_value(str(key))} = {format_toml_value(value)}" + for key, value in val.items() + ) return f"{{{items}}}" - return str(val) + raise ValueError(f"value has no TOML representation: {val!r}") # TODO: Move ecc.toml parameter editing into chipcompiler.data.project_config_edit @@ -90,7 +287,7 @@ def set_scoped_key(text: str, target_table: str, name: str, value: object) -> st return text.rstrip() + f"\n\n[{target_table}]\n{name} = {value_str}\n" body_start, body_end = params_span insert = f"\n\n[{target_table}]\n{name} = {value_str}" - next_header = _TABLE_HEADER_RE.search(text, body_start) + next_header = _TABLE_HEADER_RE.search(_mask_strings_and_comments(text), body_start) if next_header: pos = next_header.start() return text[:pos] + insert + "\n" + text[pos:] @@ -98,8 +295,12 @@ def set_scoped_key(text: str, target_table: str, name: str, value: object) -> st body_start, body_end = span section_body = text[body_start:body_end] - key_pattern = re.compile(rf"^(\s*){re.escape(name)}\s*=[^\n]*$", re.MULTILINE) - key_match = key_pattern.search(section_body) + # Match assignments on the masked body: key-like text inside a multiline + # string must never be edited as if it were a real assignment. The key + # token itself is compared semantically on the original text so quoted + # keys ("timeout" = ...) are recognized. + masked_body = _mask_strings_and_comments(section_body) + key_match = _find_assignment(masked_body, section_body, name) if key_match: indent = key_match.group(1) @@ -121,21 +322,22 @@ def remove_scoped_key(text: str, target_table: str, name: str) -> str | None: body_start, body_end = span section_body = text[body_start:body_end] - key_pattern = re.compile(rf"^\s*{re.escape(name)}\s*=[^\n]*\n?", re.MULTILINE) - key_match = key_pattern.search(section_body) + # Match only the value's first line; _extend_multiline_value walks to the + # true end of a multiline value, including its terminating newline. + # Assignments are located on the masked body (see set_scoped_key). + masked_body = _mask_strings_and_comments(section_body) + key_match = _find_assignment(masked_body, section_body, name) if not key_match: return None end = _extend_multiline_value(section_body, key_match.end()) - # Consume trailing newline after multiline value - if section_body[end : end + 1] == "\n": - end += 1 new_body = section_body[: key_match.start()] + section_body[end:] remaining_keys = [line for line in new_body.strip().split("\n") if line.strip()] if not remaining_keys: + masked = _mask_strings_and_comments(text) header_match = None - for m in _TABLE_HEADER_RE.finditer(text): - if m.group(1).strip() == target_table: + for m in _TABLE_HEADER_RE.finditer(masked): + if _header_matches(text, m, target_table): header_match = m break if header_match is None: @@ -149,20 +351,24 @@ def remove_scoped_key(text: str, target_table: str, name: str) -> str | None: def set_pdk_root(text: str, value: str) -> str: """Set `root = ""` under the existing [pdk] table, preserving layout.""" + value_str = format_toml_value(value) span = find_table_span(text, "pdk") if span is None: - return text.rstrip("\n") + f'\n\n[pdk]\nroot = "{value}"\n' + return text.rstrip("\n") + f"\n\n[pdk]\nroot = {value_str}\n" body_start, body_end = span section = text[body_start:body_end] - key_pattern = re.compile(r"^(\s*)root\s*=[^\n]*$", re.MULTILINE) - key_match = key_pattern.search(section) + masked_section = _mask_strings_and_comments(section) + key_match = _find_assignment(masked_section, section, "root") if key_match: - new_section = ( - section[: key_match.start()] - + f'{key_match.group(1)}root = "{value}"' - + section[key_match.end() :] - ) + # Same value-range logic as set_scoped_key: a multiline value must + # be replaced whole, never leaving its tail behind. + end = _extend_multiline_value(section, key_match.end()) + indent = key_match.group(1) + new_line = f"{indent}root = {value_str}" + if end > key_match.end(): + new_line += "\n" + new_section = section[: key_match.start()] + new_line + section[end:] else: - new_section = f'root = "{value}"\n' + section + new_section = f"root = {value_str}\n" + section return text[:body_start] + new_section + text[body_end:] diff --git a/chipcompiler/cli/rendering/pretty.py b/chipcompiler/cli/rendering/pretty.py index b56a147d7..811d14cc0 100644 --- a/chipcompiler/cli/rendering/pretty.py +++ b/chipcompiler/cli/rendering/pretty.py @@ -417,18 +417,18 @@ def render_signoff_inspect_text(records, file=None): print(f" report : {summary['report']}", file=target) groups = [r for r in records[1:] if "group" in r] if groups: - print() - print(" groups:") + print(file=target) + print(" groups:", file=target) for group in groups: counts = "" if group.get("available") is not None: counts = f" ({group['available']}/{group['expected']})" - print(f" {group['group']:14s} {group['status']:9s}{counts}") + print(f" {group['group']:14s} {group['status']:9s}{counts}", file=target) risks = [r for r in records[1:] if "risk" in r] if risks: - print() - print(" risks:") + print(file=target) + print(" risks:", file=target) for risk in risks: - print(f" [{risk['risk']:7s}] {risk['title']}") + print(f" [{risk['risk']:7s}] {risk['title']}", file=target) if risk.get("reason"): - print(f" {risk['reason']}") + print(f" {risk['reason']}", file=target) diff --git a/chipcompiler/cli/rendering/progress.py b/chipcompiler/cli/rendering/progress.py index 6bfc33cf7..a33f9be24 100644 --- a/chipcompiler/cli/rendering/progress.py +++ b/chipcompiler/cli/rendering/progress.py @@ -16,9 +16,9 @@ LineKind, extract_error_context, ) -from chipcompiler.cli.rendering.pretty import BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW +from chipcompiler.cli.rendering.pretty import BOLD, CYAN, DIM, GREEN, RED, RESET from chipcompiler.cli.rendering.pretty import style as _style -from chipcompiler.data import StateEnum, is_non_blocking_step, log_flow +from chipcompiler.data import StateEnum, log_flow from chipcompiler.utility.log import flush_cstdio, redirect_stdio_to_file @@ -338,16 +338,10 @@ def start_step(self, step, tool): self._stream.flush() self._step_started = True - def finish_step( - self, step, tool, status, runtime, log_path, inspect_cmd, success, *, warning=False - ): + def finish_step(self, step, tool, status, runtime, log_path, inspect_cmd, success): self.clear() if success: line = style(f"✓ {step} ({tool}) {runtime}", GREEN, self._color) - elif warning: - sym = style("!", YELLOW, self._color) - status_styled = style("warning", YELLOW, self._color) - line = f"{sym} {step} ({tool}) {status_styled} {runtime}" else: sym = style("✗", RED, self._color) status_styled = style(status, RED, self._color) @@ -510,29 +504,8 @@ def run_flow_with_progress(engine_flow, ctx, project, stderr): inspect = disclosure_cmd(f"ecc log {step_token}", project, ctx.run_id) is_success = state == StateEnum.Success - is_warning = state != StateEnum.Success and ( - state == StateEnum.Warning or is_non_blocking_step(workspace_step) - ) - renderer.finish_step( - step_token, - tool, - status, - runtime, - rel_log, - inspect, - is_success, - warning=is_warning, - ) + renderer.finish_step(step_token, tool, status, runtime, rel_log, inspect, is_success) - if is_warning: - engine_flow.workspace.logger.warning( - "[WARNING] %s %s; continuing flow", - workspace_step.name, - "did not prove equivalence" - if is_non_blocking_step(workspace_step) - else "completed with warnings", - ) - continue if not is_success: _maybe_render_failure_context( renderer, log_path, rel_log, step_token, project, ctx.run_id, color diff --git a/chipcompiler/cli/rendering/render.py b/chipcompiler/cli/rendering/render.py index fbf0d480d..b00d03dfb 100644 --- a/chipcompiler/cli/rendering/render.py +++ b/chipcompiler/cli/rendering/render.py @@ -1,4 +1,3 @@ -import json import os import sys @@ -21,17 +20,6 @@ def render_text(records: tuple[dict, ...], file=None) -> None: print(" ".join(parts), file=target) -def render_json(result: CommandResult, file=None) -> None: - target = file or sys.stdout - print(json.dumps({"records": list(result.records)}, ensure_ascii=False), file=target) - - -def render_jsonl(result: CommandResult, file=None) -> None: - target = file or sys.stdout - for record in result.records: - print(json.dumps(record, ensure_ascii=False), file=target) - - def render_plain(records: tuple[dict, ...], file=None) -> None: target = file or sys.stdout for record in records: @@ -80,11 +68,7 @@ def render_markdown(text: str, file=None, *, color: bool, pager: bool = False) - def render_result( result: CommandResult, mode: OutputMode, file=None, command=None, *, color=True ) -> None: - if mode == OutputMode.JSON: - render_json(result, file=file) - elif mode == OutputMode.JSONL: - render_jsonl(result, file=file) - elif mode == OutputMode.PLAIN: + if mode == OutputMode.PLAIN: render_plain(result.records, file=file) elif mode == OutputMode.TEXT: _render_pretty(result, file=file, command=command, color=color) diff --git a/chipcompiler/data/__init__.py b/chipcompiler/data/__init__.py index f1a019882..11b3e9b10 100644 --- a/chipcompiler/data/__init__.py +++ b/chipcompiler/data/__init__.py @@ -11,10 +11,11 @@ ) from .pdk import PDK, get_pdk from .step import ( + FINISHED_STEP_STATES, StateEnum, StepEnum, StepMetrics, - is_non_blocking_step, + is_finished_step_state, load_metrics, save_metrics, ) @@ -96,7 +97,8 @@ "YosysReport", "YosysLecReport", "EccReport", - "is_non_blocking_step", + "FINISHED_STEP_STATES", + "is_finished_step_state", "LogPaths", "ScriptPaths", "EccScript", diff --git a/chipcompiler/data/config_params/coverage.py b/chipcompiler/data/config_params/coverage.py deleted file mode 100644 index 66696f126..000000000 --- a/chipcompiler/data/config_params/coverage.py +++ /dev/null @@ -1,99 +0,0 @@ -import json -from pathlib import Path - -from . import CONFIG_PARAM_SCHEMAS - -_PACKAGE_ROOT = Path(__file__).resolve().parents[2] -TEMPLATES = { - "db": _PACKAGE_ROOT / "tools/ecc/configs/db_ecc.json", - "CTS": _PACKAGE_ROOT / "tools/ecc/configs/cts_ecc.json", - "Floorplan": _PACKAGE_ROOT / "tools/ecc/configs/floorplan_ecc.json", - "dreamplace": _PACKAGE_ROOT / "tools/ecc_dreamplace/configs/dreamplace_ecc.json", - "route": _PACKAGE_ROOT / "tools/ecc/configs/route_ecc.json", - "filler": _PACKAGE_ROOT / "tools/ecc/configs/filler_ecc.json", - "RCX": _PACKAGE_ROOT / "tools/ecc/configs/rcx_ecc.json", - "sta": _PACKAGE_ROOT / "tools/ecc/configs/sta_ecc.json", -} -# drc_ecc.json is empty today; add it here once it grows static fields so the -# coverage test starts pinning them. - -LEGACY_FIELDS = { - "db": {("LayerSettings", "routing_layer_1st")}, - "CTS": {("max_fanout",)}, - "Floorplan": { - ("die_builder", "die_util", "utilization"), - ("die_builder", "die_util", "aspect_ratio"), - ("die_builder", "margin", "left_micron"), - ("die_builder", "margin", "right_micron"), - ("die_builder", "margin", "top_micron"), - ("die_builder", "margin", "bottom_micron"), - }, - "dreamplace": { - ("target_density",), - ("stop_overflow",), - ("cell_padding_x",), - ("routability_opt_flag",), - }, - "route": { - ("RT", "-bottom_routing_layer"), - ("RT", "-top_routing_layer"), - }, -} - -PROTECTED_FIELDS = { - "db": { - ("INPUT", "tech_lef_path"), - ("INPUT", "lef_paths"), - ("INPUT", "def_path"), - ("INPUT", "verilog_path"), - ("INPUT", "lib_path"), - ("INPUT", "sdc_path"), - ("OUTPUT", "output_dir_path"), - }, - "Floorplan": { - ("ifp", "temp_directory_path"), - ("macro_placer", "macro_location_path"), - }, - "dreamplace": { - ("aux_input",), - ("base_design_name",), - ("def_input",), - ("lef_input",), - ("result_dir",), - ("verilog_input",), - }, - "route": {("RT", "-temp_directory_path")}, - "RCX": {("output",)}, - "sta": {("liberty",)}, -} - - -def template_fields() -> dict[str, set[tuple[str, ...]]]: - return {key: _config_fields(path) for key, path in TEMPLATES.items()} - - -def covered_fields() -> dict[str, set[tuple[str, ...]]]: - fields: dict[str, set[tuple[str, ...]]] = { - key: set(value) for key, value in LEGACY_FIELDS.items() - } - for schema in CONFIG_PARAM_SCHEMAS: - target = schema.config_target - if target is not None: - fields.setdefault(target.config_key, set()).add(target.json_path) - for key, protected in PROTECTED_FIELDS.items(): - fields.setdefault(key, set()).update(protected) - return fields - - -def _config_fields(path: Path) -> set[tuple[str, ...]]: - with path.open(encoding="utf-8") as file: - data = json.load(file) - return set(_iter_fields(data)) - - -def _iter_fields(value: object, path: tuple[str, ...] = ()): # noqa: ANN001 - if isinstance(value, dict): - for key, child in value.items(): - yield from _iter_fields(child, (*path, key)) - return - yield path diff --git a/chipcompiler/data/config_params/dreamplace.py b/chipcompiler/data/config_params/dreamplace.py index aa5fc85b6..c47902b17 100644 --- a/chipcompiler/data/config_params/dreamplace.py +++ b/chipcompiler/data/config_params/dreamplace.py @@ -1,3 +1,9 @@ +# The descriptions below are a vendored mirror of the upstream DreamPlace +# template metadata (chipcompiler/thirdparty/ecc-dreamplace/dreamplace/params.json, +# excluded from the wheel). They are copied rather than derived at runtime +# because deployed packages do not ship the thirdparty tree; +# test_descriptions.match_upstream_metadata fails the build when the copy +# drifts from the canonical template. from .common import config_param DREAMPLACE_PARAMETER_DESCRIPTIONS = { diff --git a/chipcompiler/data/step.py b/chipcompiler/data/step.py index f55a3c4e5..e4bcbd4b5 100644 --- a/chipcompiler/data/step.py +++ b/chipcompiler/data/step.py @@ -39,16 +39,21 @@ class StateEnum(Enum): Ongoing = "Ongoing" # step is running Pending = "Pending" # step is pending Imcomplete = "Incomplete" # step is failed - Warning = "Warning" # step completed with a non-blocking check warning # Ignored = "Ignored" # step result do not affect flow step -def is_non_blocking_step(step) -> bool: - """Return whether a failed step may be recorded as a warning.""" - return ( - getattr(step, "name", None) == StepEnum.LEC.value - and getattr(step, "tool", None) == "yosys_lec" - ) +FINISHED_STEP_STATES = frozenset({StateEnum.Success.value}) + + +def is_finished_step_state(state: object) -> bool: + """Whether a persisted step state counts as done for selection and skipping. + + Incomplete/Invalid steps are unfinished: resume and rerun selectors + re-execute them. A legacy ``Warning`` state (removed terminal state for + the synthesis LEC) is not finished and is normalized to Unstart on + resume. + """ + return state in FINISHED_STEP_STATES ########################################################################### diff --git a/chipcompiler/data/step_dirs.py b/chipcompiler/data/step_dirs.py new file mode 100644 index 000000000..19125c287 --- /dev/null +++ b/chipcompiler/data/step_dirs.py @@ -0,0 +1,29 @@ +"""Canonical workspace step-directory names. + +Workspace creation names each step directory ``_`` along the +canonical rtl2gds chain (``Synthesis_yosys``, ``place_dreamplace``, ...). +Checklists, signoff packages, QoR scoring, and the design reports all +resolve per-step artifacts through that naming, so the mapping lives here +once instead of as hand-maintained tables per consumer. Timing +optimization is on the canonical chain but owns no artifact directory any +consumer reads through these tables. +""" + +from chipcompiler.data.step import StepEnum + +STEP_DIRECTORIES = { + StepEnum.SYNTHESIS.value: "Synthesis_yosys", + StepEnum.LEC.value: "lec_yosys_lec", + StepEnum.FLOORPLAN.value: "Floorplan_ecc", + StepEnum.PLACEMENT.value: "place_dreamplace", + StepEnum.CTS.value: "CTS_ecc", + StepEnum.LEGALIZATION.value: "legalization_dreamplace", + StepEnum.ROUTING.value: "route_ecc", + StepEnum.FILLER.value: "filler_ecc", + StepEnum.RCX.value: "RCX_ecc", + StepEnum.STA.value: "sta_ecc", + StepEnum.LVS.value: "lvs_ecc", + StepEnum.POST_ROUTE_LEC.value: "postRouteLec_yosys_lec", + StepEnum.DRC.value: "drc_ecc", + StepEnum.HARDEN.value: "Harden_ecc", +} diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py index 9d4cc5424..d75f5d3d9 100644 --- a/chipcompiler/data/workspace/__init__.py +++ b/chipcompiler/data/workspace/__init__.py @@ -28,8 +28,10 @@ from ..workspace_config import ( workspace_config_path as workspace_config_toml_path, ) -from .filelist_copy import copy_filelist_with_sources +from .filelist_copy import copy_filelist_with_sources as copy_filelist_with_sources from .layout import EccData, WorkspaceStepBase +from .sdc import create_default_sdc as create_default_sdc +from .sdc import refresh_generated_sdc # The shared step type used as the annotation/constructor across the codebase. WorkspaceStep = WorkspaceStepBase @@ -697,7 +699,7 @@ def refresh_workspace_config(workspace: Workspace) -> None: if not workspace.config: workspace.config = build_workspace_config_paths(workspace) - _refresh_generated_sdc(workspace) + refresh_generated_sdc(workspace) db = json_read(workspace.config["db"]) if "INPUT" not in db or "LayerSettings" not in db: @@ -1103,62 +1105,17 @@ def create_workspace( # update orign files to workspace origin folder origin_dir.mkdir(parents=True, exist_ok=True) workspace.config["dir"].mkdir(parents=True, exist_ok=True) - origin_def_path = Path(origin_def) if origin_def else None - if origin_def_path and origin_def_path.exists(): - target = origin_dir / origin_def_path.name - shutil.copy(origin_def_path, target) - workspace.design.origin_def = target - else: - workspace.design.origin_def = origin_dir / f"{workspace.design.name}.def" - - origin_verilog_path = Path(origin_verilog) if origin_verilog else None - if origin_verilog_path and origin_verilog_path.exists(): - target = origin_dir / origin_verilog_path.name - shutil.copy(origin_verilog_path, target) - workspace.design.origin_verilog = target - else: - workspace.design.origin_verilog = origin_dir / f"{workspace.design.name}.v" - - golden_verilog_path = Path(golden_verilog) if golden_verilog else None - if golden_verilog_path and golden_verilog_path.exists(): - target = origin_dir / f"golden_{golden_verilog_path.name}" - shutil.copy(golden_verilog_path, target) - workspace.design.golden_verilog = target - - # Copy filelist and all referenced source files - input_filelist_path = Path(input_filelist) if input_filelist else None - if input_filelist_path and input_filelist_path.exists(): - try: - # Use new copy_filelist_with_sources to copy filelist + all RTL files - workspace.design.input_filelist = Path( - copy_filelist_with_sources( - input_filelist=str(input_filelist_path), - workspace_dir=str(workspace_dir), - logger=workspace.logger, - ) - ) - except Exception as e: - workspace.logger.error(f"Failed to copy filelist sources: {e}") - workspace.logger.warning("Falling back to copying only filelist file") - # Fallback: copy only filelist file (backward compatibility) - target = origin_dir / input_filelist_path.name - shutil.copy(input_filelist_path, target) - workspace.design.input_filelist = target - - if workspace.pdk.sdc and workspace.pdk.sdc.exists(): - sdc_target = origin_dir / workspace.pdk.sdc.name - shutil.copy(workspace.pdk.sdc, sdc_target) - workspace.pdk.sdc = sdc_target - else: - # create default sdc file - workspace.pdk.sdc = origin_dir / f"{workspace.design.name}.sdc" - create_default_sdc(workspace) - - if workspace.pdk.spef and workspace.pdk.spef.exists(): - spef_target = origin_dir / workspace.pdk.spef.name - shutil.copy(workspace.pdk.spef, spef_target) - workspace.pdk.spef = spef_target - + from .inputs import persist_origin_inputs + + persist_origin_inputs( + workspace, + origin_dir, + workspace_dir, + origin_def=origin_def, + origin_verilog=origin_verilog, + input_filelist=input_filelist, + golden_verilog=golden_verilog, + ) init_workspace_config(workspace) # set home data @@ -1206,6 +1163,32 @@ def create_workspace( return workspace +def _persisted_golden_verilog(workspace_dir: Path) -> tuple[Path | None, bool]: + """Golden netlist recorded at workspace creation. + + Returns ``(path, True)`` when the flow ledger declares a golden netlist, + ``(None, True)`` when a current-format ledger declares none, and + ``(None, False)`` for legacy ledgers without step info, where the + ``golden_*`` filename convention still applies. + """ + from chipcompiler.utility import json_read + + flow_data = json_read(workspace_dir / "home" / "flow.json") + steps = flow_data.get("steps", []) if isinstance(flow_data, dict) else [] + if not steps or not isinstance(steps[0], dict): + return None, False + if not isinstance(steps[0].get("info"), dict): + return None, False + for step in steps: + if not isinstance(step, dict): + continue + info = step.get("info") + golden = info.get("golden_verilog") if isinstance(info, dict) else None + if golden and Path(golden).is_file(): + return Path(golden), True + return None, True + + def load_workspace(directory: str | Path) -> Workspace: workspace_dir = Path(directory).expanduser().resolve() origin_dir = workspace_dir / "origin" @@ -1278,18 +1261,26 @@ def load_workspace(directory: str | Path) -> Workspace: if len(def_gz_path) > 0: workspace.design.origin_def = def_gz_path[0] - verilog_path = [path for path in origin_dir.rglob("*.v") if not path.name.startswith("golden_")] - verilog_gz_path = [ - path for path in origin_dir.rglob("*.v.gz") if not path.name.startswith("golden_") - ] + # The golden netlist path is persisted in the first flow step's info at + # creation; trust it over the golden_* filename convention so a primary + # netlist whose name merely starts with "golden_" keeps its role. Only + # legacy ledgers without step info fall back to the filename convention. + golden, golden_declared = _persisted_golden_verilog(workspace_dir) + if golden is None and not golden_declared: + golden_paths = list(origin_dir.rglob("golden_*.v")) + list( + origin_dir.rglob("golden_*.v.gz") + ) + golden = golden_paths[0] if golden_paths else None + + verilog_path = [path for path in origin_dir.rglob("*.v") if path != golden] + verilog_gz_path = [path for path in origin_dir.rglob("*.v.gz") if path != golden] if len(verilog_path) > 0: workspace.design.origin_verilog = verilog_path[0] if len(verilog_gz_path) > 0: workspace.design.origin_verilog = verilog_gz_path[0] - golden_paths = list(origin_dir.rglob("golden_*.v")) + list(origin_dir.rglob("golden_*.v.gz")) - if golden_paths: - workspace.design.golden_verilog = golden_paths[0] + if golden is not None: + workspace.design.golden_verilog = golden filelist_path = origin_dir / "filelist" if filelist_path.exists(): @@ -1358,40 +1349,3 @@ def format_string(text: str, len=20) -> str: format_string(step.get("state", "")), format_string(step.get("runtime", "")), ) - - -def create_default_sdc(workspace: Workspace): - """ - Create SDC file based on PDK and workspace parameters. - """ - sdc_content = [] - sdc_content.append("# Auto-generated SDC file\n") - sdc_content.append("\n") - sdc_content.append("set clk_name {} \n".format(workspace.parameters.data.get("clock", ""))) - sdc_content.append("set clk_port_name {}\n".format(workspace.parameters.data.get("clock", ""))) - sdc_content.append( - "set clk_freq_mhz {}\n".format(workspace.parameters.data.get("frequency_max", 100)) - ) - sdc_content.append("set clk_period [expr 1000.0 / $clk_freq_mhz]\n") - sdc_content.append("set clk_io_pct 0.2\n") - sdc_content.append("set clk_port [get_ports $clk_port_name]\n") - sdc_content.append("create_clock -name $clk_name -period $clk_period $clk_port\n") - - with open(workspace.pdk.sdc, "w") as file: - file.writelines(sdc_content) - - -def _refresh_generated_sdc(workspace: Workspace) -> None: - """Refresh an existing SDC created by ECC while preserving user SDC files.""" - sdc_path = workspace.pdk.sdc - if sdc_path is None or not sdc_path.is_file(): - return - - try: - with sdc_path.open(encoding="utf-8") as file: - if file.readline().strip() != "# Auto-generated SDC file": - return - except (OSError, UnicodeError): - return - - create_default_sdc(workspace) diff --git a/chipcompiler/data/workspace/config_overrides.py b/chipcompiler/data/workspace/config_overrides.py index 1164a14c0..39a91fcd5 100644 --- a/chipcompiler/data/workspace/config_overrides.py +++ b/chipcompiler/data/workspace/config_overrides.py @@ -1,8 +1,15 @@ -"""Replay validated direct tool-configuration overrides into workspace JSON files.""" +"""Replay validated direct tool-configuration overrides into workspace JSON files. -from pathlib import Path +Overrides are validated and staged against strict reads of the current +configurations, then committed atomically per file (sibling temp + +replace) with rollback of already-written files if a later commit fails, +so a partial override set can never replace a real tool configuration. +""" -from chipcompiler.utility import json_read, json_write +import json +import os +import tempfile +from pathlib import Path CONFIG_OVERRIDES_KEY = "config_overrides" _LEGACY_CONFIG_OVERRIDES_KEY = "Config Overrides" @@ -15,16 +22,77 @@ def apply_config_overrides(config_paths: dict[str, Path], parameters: dict) -> N if not isinstance(overrides, dict): return + staged: list[tuple[Path, dict, bytes | None]] = [] for config_key, patch in overrides.items(): config_path = _config_path_for_key(config_paths, config_key) if config_path is None: raise ValueError(f"unknown config override target: {config_key}") if not isinstance(patch, dict): raise ValueError(f"config override patch must be an object: {config_key}") - config = json_read(config_path) + # Strict read: json_read's tolerant {} fallback would replace a real + # tool configuration with only the patch on a corrupt or missing + # file. An unreadable target must abort the whole override set. + config = _read_json_strict(config_path) _merge_config_patch(config, patch) - if not json_write(config_path, config): - raise OSError(f"Failed to write config override: {config_path}") + original = config_path.read_bytes() if config_path.is_file() else None + staged.append((config_path, config, original)) + + written: list[tuple[Path, bytes | None]] = [] + try: + for config_path, config, original in staged: + _write_json_strict(config_path, config) + written.append((config_path, original)) + except OSError: + # Roll back with the same atomic replace discipline as the forward + # writes: a truncated non-atomic restore is its own data loss. + for config_path, original in reversed(written): + if original is None: + config_path.unlink(missing_ok=True) + else: + _atomic_write_bytes(config_path, original) + raise + + +def _read_json_strict(path: Path) -> dict: + try: + with open(path, encoding="utf-8") as file: + data = json.load(file) + except FileNotFoundError as exc: + raise ValueError(f"config override target does not exist: {path}") from exc + except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ValueError(f"config override target is unreadable or corrupt: {path}: {exc}") from exc + if not isinstance(data, dict): + # A valid JSON scalar/array is not a tool configuration: overwriting + # it with only the patch would be destructive data loss. + raise ValueError(f"config override target must be a JSON object: {path}") + return data + + +def _atomic_write_bytes(path: Path, content: bytes) -> None: + fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "wb") as file: + file.write(content) + file.flush() + os.fsync(file.fileno()) + os.replace(tmp_path, path) + except BaseException: + os.unlink(tmp_path) + raise + + +def _write_json_strict(path: Path, config: dict) -> None: + fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as file: + json.dump(config, file, indent=2) + file.write("\n") + file.flush() + os.fsync(file.fileno()) + os.replace(tmp_path, path) + except BaseException: + os.unlink(tmp_path) + raise def _config_path_for_key(config_paths: dict[str, Path], config_key: object) -> Path | None: diff --git a/chipcompiler/data/workspace/inputs.py b/chipcompiler/data/workspace/inputs.py new file mode 100644 index 000000000..aff8b65fe --- /dev/null +++ b/chipcompiler/data/workspace/inputs.py @@ -0,0 +1,91 @@ +"""Persist external design inputs into a new workspace. + +Copies the declared origin inputs (DEF, RTL or netlist, optional golden +netlist, filelist with referenced sources, SDC, and SPEF) into +``origin/`` and rebases the workspace's design fields onto the copied +files. Extracted from create_workspace so the workspace package root +stays focused on the workspace model. +""" + +import shutil +from pathlib import Path + +from .filelist_copy import copy_filelist_with_sources +from .sdc import create_default_sdc + + +def persist_origin_inputs( + workspace, + origin_dir: Path, + workspace_dir: Path, + *, + origin_def, + origin_verilog, + input_filelist, + golden_verilog, +) -> None: + """Copy every declared input into origin/ and rebind design paths.""" + origin_def_path = Path(origin_def) if origin_def else None + if origin_def_path and origin_def_path.exists(): + target = origin_dir / origin_def_path.name + shutil.copy(origin_def_path, target) + workspace.design.origin_def = target + else: + workspace.design.origin_def = origin_dir / f"{workspace.design.name}.def" + + origin_verilog_path = Path(origin_verilog) if origin_verilog else None + if origin_verilog_path and origin_verilog_path.exists(): + target = origin_dir / origin_verilog_path.name + shutil.copy(origin_verilog_path, target) + workspace.design.origin_verilog = target + else: + workspace.design.origin_verilog = origin_dir / f"{workspace.design.name}.v" + + # Copy filelist and all referenced source files BEFORE the golden + # netlist: the bulk copy picks destination names by relative path, so + # running it first lets the golden collision check below refuse an + # ambiguous layout instead of the copy silently overwriting the golden + # file with RTL source content. + input_filelist_path = Path(input_filelist) if input_filelist else None + if input_filelist_path and input_filelist_path.exists(): + try: + # Use new copy_filelist_with_sources to copy filelist + all RTL files + workspace.design.input_filelist = Path( + copy_filelist_with_sources( + input_filelist=str(input_filelist_path), + workspace_dir=str(workspace_dir), + logger=workspace.logger, + ) + ) + except Exception as e: + workspace.logger.error(f"Failed to copy filelist sources: {e}") + workspace.logger.warning("Falling back to copying only filelist file") + # Fallback: copy only filelist file (backward compatibility) + target = origin_dir / input_filelist_path.name + shutil.copy(input_filelist_path, target) + workspace.design.input_filelist = target + + golden_verilog_path = Path(golden_verilog) if golden_verilog else None + if golden_verilog_path and golden_verilog_path.exists(): + target = origin_dir / f"golden_{golden_verilog_path.name}" + if target == workspace.design.origin_verilog or target.exists(): + # The generated golden name collides with the primary netlist + # (or another input): copying would silently overwrite a real + # input and could make LEC compare a file with itself. + raise ValueError(f"golden netlist name collides with an existing input: {target}") + shutil.copy(golden_verilog_path, target) + workspace.design.golden_verilog = target + + if workspace.pdk.sdc and workspace.pdk.sdc.exists(): + sdc_target = origin_dir / workspace.pdk.sdc.name + shutil.copy(workspace.pdk.sdc, sdc_target) + workspace.pdk.sdc = sdc_target + else: + # create default sdc file + workspace.pdk.sdc = origin_dir / f"{workspace.design.name}.sdc" + create_default_sdc(workspace) + + if workspace.pdk.spef and workspace.pdk.spef.exists(): + spef_target = origin_dir / workspace.pdk.spef.name + shutil.copy(workspace.pdk.spef, spef_target) + workspace.pdk.spef = spef_target diff --git a/chipcompiler/data/workspace/sdc.py b/chipcompiler/data/workspace/sdc.py new file mode 100644 index 000000000..e0460fced --- /dev/null +++ b/chipcompiler/data/workspace/sdc.py @@ -0,0 +1,49 @@ +"""Workspace SDC generation and refresh. + +``create_default_sdc`` writes the auto-generated clock constraint file +from the workspace parameters; ``refresh_generated_sdc`` rewrites only +files ECC generated (marker-checked), so a user-provided SDC survives +parameter refreshes untouched. +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from chipcompiler.data import Workspace + + +def create_default_sdc(workspace: "Workspace") -> None: + """ + Create SDC file based on PDK and workspace parameters. + """ + sdc_content = [] + sdc_content.append("# Auto-generated SDC file\n") + sdc_content.append("\n") + sdc_content.append("set clk_name {} \n".format(workspace.parameters.data.get("clock", ""))) + sdc_content.append("set clk_port_name {}\n".format(workspace.parameters.data.get("clock", ""))) + sdc_content.append( + "set clk_freq_mhz {}\n".format(workspace.parameters.data.get("frequency_max", 100)) + ) + sdc_content.append("set clk_period [expr 1000.0 / $clk_freq_mhz]\n") + sdc_content.append("set clk_io_pct 0.2\n") + sdc_content.append("set clk_port [get_ports $clk_port_name]\n") + sdc_content.append("create_clock -name $clk_name -period $clk_period $clk_port\n") + + with open(workspace.pdk.sdc, "w") as file: + file.writelines(sdc_content) + + +def refresh_generated_sdc(workspace: "Workspace") -> None: + """Refresh an existing SDC created by ECC while preserving user SDC files.""" + sdc_path = workspace.pdk.sdc + if sdc_path is None or not sdc_path.is_file(): + return + + try: + with sdc_path.open(encoding="utf-8") as file: + if file.readline().strip() != "# Auto-generated SDC file": + return + except (OSError, UnicodeError): + return + + create_default_sdc(workspace) diff --git a/chipcompiler/data/workspace_config.py b/chipcompiler/data/workspace_config.py index 2b852d599..97df94111 100644 --- a/chipcompiler/data/workspace_config.py +++ b/chipcompiler/data/workspace_config.py @@ -47,6 +47,13 @@ "pdk_config": "config", } +# Ranges for presets removed when RCX/STA/Harden folded into the canonical +# rtl2gds chain. Persisted configs may still name them. +LEGACY_PRESET_RANGES = { + "rcx": ("Synthesis", "sta"), + "harden": ("Synthesis", "Harden"), +} + class WorkspaceConfigError(ValueError): """Invalid ``home/params.toml`` content (parse failure or rule violation).""" @@ -157,7 +164,12 @@ def flow_range_for_preset(preset: str) -> tuple[str, str]: builder = rtl2gds_api.get_flow_builders().get(preset) if builder is None: - raise WorkspaceFlowTargetError(f"unknown flow preset: {preset}") + # Presets removed when RCX/STA/Harden folded into the rtl2gds chain + # still resolve so existing ecc.toml/params.toml files keep working. + legacy = LEGACY_PRESET_RANGES.get(preset) + if legacy is None: + raise WorkspaceFlowTargetError(f"unknown flow preset: {preset}") + return legacy steps = builder() if not steps: raise WorkspaceFlowTargetError(f"flow preset has no steps: {preset}") diff --git a/chipcompiler/docs/ecc-cli-dev.cn.md b/chipcompiler/docs/ecc-cli-dev.cn.md deleted file mode 100644 index cb17028f4..000000000 --- a/chipcompiler/docs/ecc-cli-dev.cn.md +++ /dev/null @@ -1,263 +0,0 @@ -# ECC CLI 命令扩展开发指南 - -本文面向需要在 `ecc` CLI 中新增/修改命令的开发者,基于 `ecc/` 子模块当前源码(`chipcompiler` 包,v0.1.0-alpha.11)整理。代码路径均相对 `ecc/` 子模块根目录。 - -相关文档:[architecture.md](../../docs/architecture.md)(架构)、[development.md](../../docs/development.md)(开发工作流)、[workspace-cli.md](../../docs/workspace-cli.md)(RPC sidecar 协议)、[../../CLAUDE.md](../../CLAUDE.md)(仓库约定)。 - -## 1. 入口与整体结构 - -``` -pyproject.toml # scripts.ecc = "chipcompiler.cli.main:main" -chipcompiler/cli/main.py # run(argv) / main(),仅做薄封装 -chipcompiler/cli/app.py # 根 typer app;invoke_typer_app() 统一执行与退出码;version / layout-image 两命令直接注册于此 -chipcompiler/cli/commands/ # typer 命令定义层(薄) - ├── project.py # init/check/run/status/log/config/migrate 的注册与参数声明 - ├── doctor.py # doctor 顶层命令(环境体检) - ├── param.py # param 子应用(list/show/set/unset/diff) - ├── pdk.py # pdk 子应用(setup/set-root/show/unset) - ├── project_config.py # project 子应用(set/unset/add/remove/show) - ├── workspace.py # workspace 子应用(refresh) - ├── signoff.py # signoff 子应用(inspect/export) - ├── report.py # report 子应用(summary/qor/checklist/step) - └── rpc.py # rpc 子应用(serve) -chipcompiler/cli/command_handlers/ # 业务处理层(唯一的处理器包,有状态/重逻辑) - ├── project.py # init / check / run / migrate / workspace refresh(含 preset 解析与环境预检) - ├── inspect.py # status / log / config - ├── doctor.py # doctor(组装 env_probe 结果为 records) - ├── param.py # param 五子命令(校验 + 经 cli/project/toml_edit.py 做 TOML 定点改写) - ├── pdk.py # pdk 四子命令(TOML 定点改写 + root 来源解析) - ├── project_config.py # project 五子命令(声明 schema + 经 cli/project/config_fields.py 做 TOML 定点改写) - ├── workspace_params.py # workspace 局部 param set/unset/list/diff(改 home/params.toml + 失效后缀步骤) - ├── signoff.py # signoff inspect/export - └── report.py # report summary/qor/checklist/step 的处理器 -chipcompiler/cli/core/ # 框架层 - ├── inputs.py # 各命令的 frozen dataclass 输入模型 - ├── invocation.py # execute_command():上下文构建→handler→渲染→退出码 - ├── options.py # 共享 Annotated 选项别名 - ├── output.py # disclosure_cmd() / step 名与状态归一化 - ├── records.py # error_record() - ├── types.py # CommandContext / CommandResult / OutputMode - └── version_info.py # version 命令的包元数据版本(环境工具版本见 inspection/tool_versions.py) -chipcompiler/cli/inspection/ # 只读探查逻辑 - ├── discovery.py / config_view.py / log_view.py - ├── env_probe.py # doctor/run 预检的环境探查(ProbeResult 体系) - └── tool_versions.py # ecc version 的环境工具版本(yosys/sizer/klayout) -chipcompiler/cli/project/ # config.py(ecc.toml 解析校验)/ config_fields.py(`ecc project` 的项目声明 schema)/ params.py(参数注册表)/ workspace_params.py(workspace 局部覆盖记录)/ manifest.py(项目形态分类)/ effective_config.py / config_params/(直配参数 schema)/ migrate*.py(旧布局迁移)/ run_*.py(run 目标解析与分发) -chipcompiler/cli/rendering/ # 输出渲染(render / renderers / pretty / progress) -chipcompiler/engine/signoff/ # 签核收集器 + 设计/checklist 报告(包,见 §5.4) -chipcompiler/engine/qor_report.py # QoR 总分计分(GUI 规则移植,见 §5.5) -``` - -模块归属由 `test/cli/test_cli_module_layout.py` 强制:核心框架必须在 `cli/core/`、命令注册在 `cli/commands/`、全部处理器在唯一的 `cli/command_handlers/` 包、只读探查在 `cli/inspection/`、渲染在 `cli/rendering/`;旧的 `chipcompiler/cli/*.py` 平铺模块必须不可导入。新增文件时放进对应子包,不要在 `cli/` 根下新建模块。 - -公开命令的归属必须严格:`ecc signoff` 只负责签核包就绪度与归档导出(`inspect`、`export`);`ecc report` 统一承载报告输出(`summary`、`qor`、`checklist`、`step`)。`ecc config [STEP]` 始终返回解析后的数据,因此不提供 `--resolved` 开关。不要在错误的命令组中增加别名,也不要添加没有行为分支的选项。 - -## 2. 一次命令调用的完整链路 - -以 `ecc check --project gcd --json` 为例: - -1. `main.py::run()` 把 `sys.argv[1:]` 交给 `app.py::invoke_typer_app(raw)`(`cli/app.py`)。 -2. typer 解析参数,命中 `commands/project.py::check_cmd`(`cli/commands/project.py`)。命令函数只做一件事:把 typer 参数装进 frozen dataclass `CheckInput`(定义在 `cli/core/inputs.py`),然后调用: - ```python - execute_command("check", command_input, project_handlers.check) - ``` -3. `core/invocation.py::execute_command()`(`cli/core/invocation.py`)依次: - - `build_context()`:解析项目目录(`--project`,缺省为 cwd)→ 读项目唯一的 `ecc.toml`(不可读时记入 `config_error`)→ `cli/project/manifest.py::classify_project()` 判定项目形态(manifest / legacy / virgin)。manifest 项目只从 `project.json` workspace 表解析 `--workspace NAME`:唯一活跃 workspace 自动选中,多个时必须选择;新的 `ecc run --workspace NAME` 会在创建文件前登记。`--workspace` 是项目内单路径段名称,不是直接路径。legacy 项目必须先迁移才能 `ecc run`;清单损坏为 `manifest_invalid`。随后由 `--json/--jsonl/--plain` 推导 `OutputMode`,组装成带 `project_state` / `manifest_error` 字段的 `CommandContext`(`cli/core/types.py`)。 - - 调 handler:`handler(command_input, ctx) -> CommandResult`。 - - handler 返回后按需追加记录(`_with_legacy_hint` / `_with_config_shadow_hint`):legacy 项目的 `run/check/status` 附加迁移提示(指向 `ecc migrate`);workspace 的 `home/` 同时存在 `params.toml` 与旧 `parameters.json` 时打 `workspace_config_shadowed` 警告(旧 JSON 已失效)。 - - 渲染:`rendering/renderers.py::render_command_result()` 先查 `RENDERERS[(render_key, output_mode)]` 定制渲染器,没有则落到通用 `rendering/render.py::render_result()`。 - - `raise typer.Exit(code=result.exit_code)` 把退出码透传给 `invoke_typer_app`。 -4. `invoke_typer_app` 以 `standalone_mode=False` 运行 click 命令,捕获 `click.exceptions.Exit` / `ClickException` 并转换成进程退出码,保证测试里 `cli_main.run([...])` 能拿到返回值。 - -## 3. 输出约定(records 模型) - -经 `execute_command()` 分发的命令统一使用「记录列表」: - -- handler 返回 `CommandResult.ok(records)` / `CommandResult.err(records, exit_code=1)`(`cli/core/types.py`);`records` 是 `tuple[dict, ...]`,每个 dict 是一行结构化记录。 -- 四种输出模式(优先级 jsonl > json > plain > text,见 `cli/core/invocation.py`): - - `--json`:`{"records": [...]}` 单个 JSON 对象; - - `--jsonl`:每条记录一行 JSON; - - `--plain`:`key=value` 逐行(含空格的值会加引号),面向脚本 grep; - - 默认 TEXT:走 pretty 渲染;无定制渲染器时打印 `key=value`,键名去掉 `_cmd` 后缀。 -- 错误记录用 `core/records.py::error_record(...)`,产出 `{"kind": "error", "error": "<机器可读错误码>", ...}`;TEXT 模式下由 `render_error` 打成 `[error]` 块。错误码是稳定契约(如 `missing_config`、`run_exists`、`unknown_parameter`、`invalid_value`),测试会对它们断言。 -- 给用户的「下一步」提示统一用 `core/output.py::disclosure_cmd("ecc status", project, run_id)` 生成可复制的完整命令,记录里放在 `inspect` / `log_cmd` / `run` 等字段。 - -`ecc version` 直接格式化版本元数据,但也支持 `--json`、`--jsonl` 和 `--plain`,使用版本专用 schema。`ecc rpc serve` 与 `ecc layout-image` 有意不使用 records 渲染器输出模式。 - -## 4. 新增一个顶层命令(Step by Step) - -以新增 `ecc check` 这样的命令为例,共 5 步(前 3 步必须,后 2 步按需): - -### 4.1 定义输入模型 - -在 `cli/core/inputs.py` 增加 frozen dataclass,必须满足 `CommandInput` 协议(`cli/core/invocation.py`)——即带 `output: OutputOptions` 与 `project: ProjectOptions` 两个字段: - -```python -@dataclass(frozen=True) -class CheckInput: - output: OutputOptions - project: ProjectOptions - # 命令私有字段放这里 -``` - -### 4.2 编写 handler - -放在 `cli/command_handlers/`,签名固定: - -```python -def check(command_input: CheckInput, ctx: CommandContext) -> CommandResult: - if ctx.config is None: - return CommandResult.err([error_record("missing_config", path=...)]) - ... - return CommandResult.ok([{...}, ...]) -``` - -约定:handler 不直接 print、不解析命令行字符串;重逻辑延迟导入(现有代码普遍在函数体内 `from chipcompiler... import ...`,保持该风格以缩短 CLI 启动时间);处理只读探查的逻辑放 `cli/inspection/`,handler 做记录拼装。 - -### 4.3 注册 typer 命令 - -在 `cli/commands/project.py`(或新模块)声明命令函数并注册,共享选项直接用 `cli/core/options.py` 的别名: - -```python -from chipcompiler.cli.core.options import JsonlOption, JsonOption, PlainOption, ProjectOption - -def register_project_commands(app: typer.Typer) -> None: - app.command("check", help="Validate the current project setup")(check_cmd) - -def check_cmd( - *, - project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, - plain: PlainOption = False, -) -> None: - command_input = CheckInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), - project=project_options(project), - ) - execute_command("check", command_input, project_handlers.check) -``` - -顶层单命令直接 `app.command(...)`(现成范例:`cli/commands/doctor.py`,全链路最短);命令组则新建 `xxx_app = typer.Typer(...)` 再在 `app.py` 里 `app.add_typer(xxx_app, name="xxx")`(现成范例:`cli/commands/signoff.py`,含子命令经 `execute_command(..., render_key=f"signoff:{sub}")` 复用同一 handler 模块)。注意 `app.py` 构建的根 app 设置了 `add_completion=False, no_args_is_help=True`。 - -### 4.4 (可选)定制 TEXT 渲染 - -默认 TEXT 是 `key=value`。若要更友好的输出: - -- 单命令:在 `cli/rendering/pretty.py` 的 `get_pretty_renderer()` 注册表加一个渲染函数(现有 `init/check/run/status/config` 即此路径); -- 子命令组:在 `cli/rendering/renderers.py` 的 `RENDERERS` 字典加 `(render_key, OutputMode)` 条目,`render_key` 通过 `execute_command(..., render_key="param:show")` 传入(param 即此路径)。 - -JSON/JSONL/PLAIN 无需任何定制。 - -### 4.5 补测试 - -CLI 测试全部位于 `ecc/test/cli/`,目录按所有权划分(仓库 CLAUDE.md 第 5 节): - -- 命令行为 → `test/cli/commands/test_.py`;param → `test/cli/params/`;只读探查 → `test/cli/inspect/`;渲染 → `test/cli/rendering/`。 -- 测试直接调 Python 入口而非子进程: - ```python - from chipcompiler.cli import main as cli_main - - rc = cli_main.run(["check", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - ``` -- 复用 `test/cli/conftest.py` 的 fixture:`create_cli_project`(生成带 `ecc.toml` 的临时项目)、`create_flow_json`(伪造 `runs//home/flow.json`)、`create_step_dir`、`create_workspace_config`、`mock_pdk_validation` 等。**注意 autouse 的 `_stub_run_preflight`**:它把 `env_probe.probe_environment` 打桩为空,保证 CLI 测试不依赖宿主工具(doctor/预检相关测试自行覆盖该补丁即可覆盖生效)。 -- 引擎层报告/签核的测试放顶层 `test/`(如 `test/test_signoff_report.py`、`test/test_qor_report.py`、`test/test_signoff_package.py`),伪造 workspace 复用其 fixture。 -- 新命令别忘了在 `test/cli/test_typer_cli.py::test_root_help_returns_zero_and_lists_commands` 与 `test/cli/test_cli_module_layout.py`(commands 元组)里登记。 - -运行方式(ecc 目录下): - -```bash -nix develop # 可选 -uv sync --no-build-isolation-package ecc-dreamplace --no-build-isolation-package ecc-tools-bin -.venv/bin/python -m pytest test/cli -q -``` - -## 5. 常见扩展场景 - -### 5.1 新增可调参数(param 体系) - -旧的语义参数仍在 `cli/project/params.py::_LEGACY_PARAM_REGISTRY`。工具 JSON 的直配字段按 owner 分别放在 `data/config_params/`(`cts.py`、`floorplan.py`、`dreamplace.py` 等),每项都必须人工审核。`ParamSchema` 只能拥有一种目标:旧的 `maps_to`、JSON `config_target` 或白名单 PDK `pdk_target`。 - -已审核的静态模板字段使用 `config_param()` 声明(`description` 为必填关键字参数,逐参数人工撰写,`test/data/test_descriptions.py` 会校验): - -```python -# chipcompiler/data/config_params/cts.py -config_param( - "cts.skew_bound", - "cts", - ("skew_bound",), - "0.08", - applies="cts", - description="Allowed clock skew upper bound in ns.", -) -``` - -该声明会同时启用 `ecc param list/show/set/unset/diff`、重复的 `ecc run --set key=value`,以及嵌套 `[params.*]` TOML 的读写与校验。默认 `ecc param list` 保持简明;用 `--step ` 或 `--all` 查看直配 schema。命令行列表和对象值使用 JSON 字面量。 - -项目 run 创建时,非默认 `config_target` 会以结构化 `config_overrides` 存入 `home/params.toml`;每次刷新 workspace 配置后由 `data.workspace.config_overrides` 重放。PDK 路径 schema 在 `config_params/pdk.py`,写入 `[pdk.overrides]`;`pdk.root` 始终使用 `ecc pdk set-root`。不得将 workspace 的输入、输出、临时、生成产物或 STA 多 corner liberty 路径暴露为 CLI 参数。 - -`config_params/coverage.py` 会把每个 JSON 模板字段与唯一一个直配 schema、旧映射或受保护路径清单比对。模板变化时必须同步更新该清单和 `test/cli/params/test_config_coverage.py`。解析和定点 TOML 编辑仍在 `params.py`,命令测试仍放在 `test/cli/params/`。 - -### 5.2 扩展 `ecc run` - -`run` 有两条互斥路径(`cli/command_handlers/project.py` 的 `run()` / `_run_workspace()`): - -- **新建 workspace**:解析 `[design]` 输入声明、PDK、参数与请求入口步骤;只校验入口步骤所需文件;先原子登记受管名称到 `project.json`(`not_started`);预检工具;在 `/` 调用 `create_workspace`。`create_workspace` 将输入复制到 `origin/` 并产出全部步骤配置,CLI 后续不改写配置。正常新建 flow 用 preset;`--from A --to B` 改用 `rtl2gds.build_flow_range(A, B)` 动态构建包含式规范范围。新范围不能与 `--preset`、`--overwrite`、`--resume`、`--only`、`--force` 组合。 -- **已有 workspace**:先由 `chipcompiler/engine/reconcile.py` 把持久化 flow 与目标对齐(前缀 → 追加扩展;超集且全成 → `no_op`;分叉 → `flow_mismatch`),再 `load_workspace` 后由 `chipcompiler.engine.rerun` 的 `run_resume`、`run_from` 或 `run_only` 原地复跑。`--from A --to B` 是已有 flow 的包含式范围,会将其后的步骤状态失效但保留其输出文件。已有 workspace 不会重新预检输入,也不会改写已复制输入或配置。 - -项目 preset 的步骤序列定义在 `chipcompiler/rtl2gds/builder.py`(`build_*_flow()` / `get_flow_builders()`),不在 CLI 层。`build_flow_range()` 对规范的 `build_rtl2gds_flow()` 结果切片,步骤别名和顺序只有一份来源。修改序列时须同步引擎默认 flow、`StepEnum` 与 manifest 范围映射;CLI 只负责参数解析、输入契约、进度渲染选择与结果映射。 - -### 5.3 扩展环境探查(doctor / 预检) - -`cli/inspection/env_probe.py` 是唯一的探查层:`ProbeResult(component, status, required, detail, remediation)` + 每组件一个 probe 函数(yosys / yosys-slang / ecc-tools / dreamplace / klayout / sizer / pdk)。新增组件 = 加一个 probe 函数并登记进 `_PROBES`/`ALL_COMPONENTS`;`probe_environment()` 对异常兜底(探查失败计为 fail 而非崩溃)。`probe_components_for_preset()` 决定当前 run 预检范围(始终 ecc-tools,yosys↔含 Synthesis,dreamplace↔含 place/legalization,sizer↔含 Timing optimization)。PDK 由配置校验覆盖,slang 留给综合步骤;Sizer 也是 doctor 的必需组件。 - -### 5.4 扩展签核(`ecc signoff inspect/export`) - -- **CLI 层**:`cli/commands/signoff.py` + `cli/command_handlers/signoff.py`。`inspection/discovery.py::resolve_loaded_workspace()` 在选定项目中解析受管 `--workspace NAME`(或唯一活跃 workspace)。inspect 复用 `runtime/signoff_export.py::inspect_signoff_package`(blocked 也 rc=0);export 复用 `export_signoff_package_archive`(`RuntimeApiError` → `signoff_incomplete`)。 -- **引擎层**:`chipcompiler/engine/signoff/` 包负责签核收集器 `SignoffPackageCollector`,以及就绪度检查和归档导出所使用的包级 API。 - -### 5.5 扩展报告(`ecc report summary/qor/checklist/step`) - -- **设计总结**:`ecc report summary` 调用 `chipcompiler.engine.signoff.generate_text_report`。其实现按职责分模块(`report.py` 编排 / `report_data.py` 数据契约 / `report_extract.py` 解析器+workspace 收集 / `report_sections.py` 分区抽取 / `report_timing.py` timing 链 / `report_text.py` 格式化),全部经包 `__init__` 对外暴露。新增报告分区时,在 `report_sections.py`(或 timing 链)增加 `_extract_(q)`,并在 `report.py` 编排处注册。 -- `engine/qor_report.py`:GUI `projectQorTrend.ts` 的单 workspace 移植——常量表(`METRIC_FAIL_VALUES`/`DIMENSION_WEIGHTS`/`QOR_SCORE_THRESHOLD`)+ 归一化 + 项目级记录选择(role 优先级 final>gate>trend、area_cost 只取最后成功的 area 步)+ `score_record` 计分公式 + 维度加权(不重归一化)。新增可计分指标 = 在 GUI 与 `METRIC_FAIL_VALUES` 同步加阈值。 -- `engine/signoff/report_checklist.py`:只读渲染 `home/checklist.json`(不合法时报 unavailable,绝不回写文件)。 -- CLI:`cli/commands/report.py` + `cli/command_handlers/report.py`;workspace 解析复用 `inspection/discovery.py`(`resolve_workspace_path` 是无副作用核心,`resolve_command_workspace` 是核心加 `load_workspace`;signoff、report 与只读的 status/log/config 共用)。 - -### 5.6 扩展 RPC(`ecc rpc serve`) - -`rpc serve --stdio` 启动 JSON-RPC 2.0 sidecar(`chipcompiler/runtime/stdio_server.py`)。方法在 `chipcompiler/runtime/methods.py::RUNTIME_METHODS` 声明(`method_name` + pydantic `request_model` + `handler_name`),handler 实现在 `chipcompiler/runtime/workspace_api.py`,由 `runtime/server.py` 统一挂载;协议细节见 [workspace-cli.md](../../docs/workspace-cli.md)。新增方法 = 加一个 `RuntimeMethodSpec` + 对应 API 方法 + 请求模型,无需改 CLI 层。 - -### 5.7 扩展项目声明(`ecc project *` / `ecc workspace refresh`) - -- `ecc project set/unset/add/remove/show` 的可编辑键在 `cli/project/config_fields.py::PROJECT_FIELDS` 声明(`key` / TOML 表 / 字段名 / 类型 / `list_value`)。加一个字段五个子命令自动生效;`add`/`remove` 硬性只支持 `design.rtl`(其余键报 `unsupported_project_collection`)。 -- `ecc workspace refresh` 的实现等价于 run 路径的 `overwrite=True, execute_flow=False`(`cli/command_handlers/project.py::refresh_workspace`),因此它和新建 run 一样做环境预检(`ecc.toml` 的 `preset: rtl2gds` 要求全套工具就绪,即使并不真正执行步骤);非 manifest 项目报 `workspace_refresh_requires_managed_workspace`。 -- workspace 局部 `param set/unset/list/diff --workspace NAME` 经 `cli/command_handlers/workspace_params.py` 修改 `home/params.toml`(记录到 `workspace_param_overrides`,经 `chipcompiler.engine.rerun` 失效后缀步骤);项目级 `param` 走 `cli/command_handlers/param.py`。 - -## 6. 构建 CLI 安装包(改完代码的实测环节) - -命令本体可直接用 `.venv/bin/ecc` 或 `uv run ecc` 验证;要测安装版行为(PATH/软链/env 不变的真实体验)则重打 PyInstaller 包——与官方 release 完全同流程(`ecc.spec` + `.github/actions/build-pyinstaller-bundle`): - -```bash -cd ecc -ECOS_PYINSTALLER_MODE=onedir uv run --no-sync --managed-python \ - pyinstaller ecc.spec --clean --noconfirm -# 重建 dist/ecc/(onedir,~3.6G;首跑会触发 dreamplace 的 cmake 安装,属正常) - -# 安装到本机(覆盖现有安装位,如 ~/.local/ecc;PATH 中指向它的软链无需改动) -rm -rf ~/.local/ecc && mkdir -p ~/.local/ecc && cp -a dist/ecc/. ~/.local/ecc/ -ecc --help # 验证 doctor / signoff / report 已列出 -``` - -回退官方发行版:重新运行 [README](../../README.cn.md#安装) 的安装脚本(`curl -fsSL http://release.openecos.com/installers/ecc/latest/ecc-installer.sh | sh`)。 - -## 7. 约束与注意事项(来自仓库约定) - -- **模块体积**:文件超过约 800 LoC 时新功能放新模块,不要继续堆([../../CLAUDE.md](../../CLAUDE.md) 第 6 节)。 -- **Python 3+**:不用 `__future__`;最低版本看 `pyproject.toml` 的 `requires-python`。 -- **测试放置**按所有权边界;优先整对象比较;不为静态定义的值写测试;不为已删除的逻辑保留负向测试。 -- **代码评审**必须执行 [review-guidelines.md](../../docs/review-guidelines.md) 的附加标准。 -- `uv.lock` 是依赖事实源;`requirements_lock.txt` 自动生成且被 gitignore。 -- ECC-Tools 在代码里的工具标识是 `"ecc"`(不是 `"ecc-tools"`);每个工具模块需实现 `is_eda_exist / build_step / run_step`;步骤在 `multiprocessing.Process` 中执行,状态持久化在 `workspace.flow.json`。 -- 依赖安装后 `ecc` 以 editable 方式生效,改源码下次导入即生效,无需重装。 diff --git a/chipcompiler/docs/ecc-cli-dev.en.md b/chipcompiler/docs/ecc-cli-dev.en.md deleted file mode 100644 index 52282a412..000000000 --- a/chipcompiler/docs/ecc-cli-dev.en.md +++ /dev/null @@ -1,263 +0,0 @@ -# ECC CLI Command Extension Developer Guide - -This guide is for developers who need to add or modify commands in the `ecc` CLI. It is based on the current source tree (the `chipcompiler` package, v0.1.0-alpha.11). All code paths are relative to the `ecc` repository root. - -Related documents: [architecture.md](../../docs/architecture.md) (architecture), [development.md](../../docs/development.md) (development workflow), [workspace-cli.md](../../docs/workspace-cli.md) (RPC sidecar protocol), [../../CLAUDE.md](../../CLAUDE.md) (repository conventions). - -## 1. Entry point and overall structure - -``` -pyproject.toml # scripts.ecc = "chipcompiler.cli.main:main" -chipcompiler/cli/main.py # run(argv) / main(), a thin wrapper -chipcompiler/cli/app.py # root typer app; invoke_typer_app() owns execution and exit codes; the version / layout-image commands are registered here directly -chipcompiler/cli/commands/ # typer command definition layer (thin) - ├── project.py # registration and option declarations for init/check/run/status/log/config/migrate - ├── doctor.py # doctor top-level command (environment check) - ├── param.py # param sub-app (list/show/set/unset/diff) - ├── pdk.py # pdk sub-app (setup/set-root/show/unset) - ├── project_config.py # project sub-app (set/unset/add/remove/show) - ├── workspace.py # workspace sub-app (refresh) - ├── signoff.py # signoff sub-app (inspect/export) - ├── report.py # report sub-app (summary/qor/checklist/step) - └── rpc.py # rpc sub-app (serve) -chipcompiler/cli/command_handlers/ # business logic layer (stateful / heavy) - ├── project.py # init / check / run / migrate / workspace refresh (preset resolution and environment preflight) - ├── inspect.py # status / log / config - ├── doctor.py # doctor (assembles env_probe results into records) - ├── param.py # the five param subcommands (validation + TOML edits via cli/project/toml_edit.py) - ├── pdk.py # the four pdk subcommands (surgical TOML edit + root source resolution) - ├── project_config.py # the five project subcommands (declaration schema + TOML edits via cli/project/config_fields.py) - ├── workspace_params.py # workspace-scoped param set/unset/list/diff (home/params.toml mutation + step invalidation) - ├── signoff.py # signoff inspect/export - └── report.py # the four report subcommands (file writing + record summary) -chipcompiler/cli/core/ # framework layer - ├── inputs.py # frozen dataclass input models per command - ├── invocation.py # execute_command(): context build → handler → rendering → exit code - ├── options.py # shared Annotated option aliases - ├── output.py # disclosure_cmd() / step-name and state normalization - ├── records.py # error_record() - ├── types.py # CommandContext / CommandResult / OutputMode - └── version_info.py # package-metadata versions for the version command (environment tool versions live in inspection/tool_versions.py) -chipcompiler/cli/inspection/ # read-only probing logic - ├── discovery.py / config_view.py / log_view.py - ├── env_probe.py # environment probes for doctor/run preflight (the ProbeResult model) - └── tool_versions.py # environment tool versions for ecc version (yosys/sizer/klayout) -chipcompiler/cli/project/ # config.py (ecc.toml parsing and validation) / config_fields.py (project declaration schema for `ecc project`) / params.py (parameter registry) / workspace_params.py (workspace-local override records) / manifest.py (project-state classification) / effective_config.py / config_params/ (direct-config schemas) / migrate*.py (legacy-layout migration) / run_*.py (workspace target resolution and dispatch) -chipcompiler/cli/rendering/ # output rendering (render / renderers / pretty / progress) -chipcompiler/engine/signoff/ # signoff collector + design/checklist reports (package, see §5.4) -chipcompiler/engine/qor_report.py # overall QoR scoring (port of the GUI rules, see §5.5) -``` - -Module placement is enforced by `test/cli/test_cli_module_layout.py`: the core framework must live under `cli/core/`, command registration under `cli/commands/`, all handlers under the single `cli/command_handlers/` package, read-only probing under `cli/inspection/`, and rendering under `cli/rendering/`; the old flat `chipcompiler/cli/*.py` modules must not be importable. Put new files in the matching subpackage — do not create modules at the `cli/` root. - -Public command ownership is strict: `ecc signoff` owns package readiness and archive export (`inspect`, `export`); `ecc report` owns all report output (`summary`, `qor`, `checklist`, `step`). `ecc config [STEP]` always returns resolved data, so it has no `--resolved` switch. Do not introduce an alias in the wrong group or an option that does not change behavior. - -## 2. The full path of one command invocation - -Using `ecc check --project gcd --json` as the example: - -1. `main.py::run()` hands `sys.argv[1:]` to `app.py::invoke_typer_app(raw)` (`cli/app.py`). -2. typer parses the arguments and dispatches to `commands/project.py::check_cmd` (`cli/commands/project.py`). The command function does exactly one thing: it packs the typer parameters into the frozen dataclass `CheckInput` (defined in `cli/core/inputs.py`) and calls: - ```python - execute_command("check", command_input, project_handlers.check) - ``` -3. `core/invocation.py::execute_command()` (`cli/core/invocation.py`) then: - - `build_context()`: resolves the project directory (`--project`, defaulting to cwd) → reads its sole `ecc.toml` (an unreadable file is recorded in `config_error`) → classifies the project state via `cli/project/manifest.py::classify_project()` (manifest / legacy / virgin). Manifest projects resolve `--workspace NAME` only through the `project.json` workspaces table: one active workspace auto-selects, multiple ones require the selector, and a new `ecc run --workspace NAME` target is registered before files are created. `--workspace` is a single project-local name, never a direct path. A legacy project must migrate before `ecc run`; a corrupt manifest yields `manifest_invalid`. The context derives `OutputMode` from `--json/--jsonl/--plain` and carries `project_state` / `manifest_error` (`cli/core/types.py`). - - Calls the handler: `handler(command_input, ctx) -> CommandResult`. - - After the handler, records are appended as needed (`_with_legacy_hint` / `_with_config_shadow_hint`): `run/check/status` on a legacy project carry a migration hint (pointing at `ecc migrate`); when a workspace's `home/` holds both `params.toml` and the legacy `parameters.json`, a `workspace_config_shadowed` warning is emitted (the JSON is inert). - - Renders: `rendering/renderers.py::render_command_result()` first looks up a custom renderer in `RENDERERS[(render_key, output_mode)]`, falling back to the generic `rendering/render.py::render_result()`. - - `raise typer.Exit(code=result.exit_code)` passes the exit code through to `invoke_typer_app`. -4. `invoke_typer_app` runs the click command with `standalone_mode=False`, catching `click.exceptions.Exit` / `ClickException` and converting them into a process exit code, so tests can read the return value of `cli_main.run([...])`. - -## 3. Output conventions (the records model) - -Commands dispatched through `execute_command()` use a "list of records": - -- The handler returns `CommandResult.ok(records)` / `CommandResult.err(records, exit_code=1)` (`cli/core/types.py`); `records` is a `tuple[dict, ...]` where each dict is one structured record. -- Four output modes (priority jsonl > json > plain > text, see `cli/core/invocation.py`): - - `--json`: a single JSON object `{"records": [...]}`; - - `--jsonl`: one JSON record per line; - - `--plain`: `key=value` per line (values containing whitespace are quoted), for scripting/grep; - - TEXT by default: pretty rendering; without a custom renderer it prints `key=value` with the `_cmd` suffix stripped from key names. -- Error records use `core/records.py::error_record(...)`, producing `{"kind": "error", "error": "", ...}`; in TEXT mode `render_error` prints them as an `[error]` block. Error codes are a stable contract (e.g. `missing_config`, `run_exists`, `unknown_parameter`, `invalid_value`) and tests assert against them. -- "Next step" hints for users are uniformly generated by `core/output.py::disclosure_cmd("ecc status", project, run_id)` as a copy-pasteable full command, stored in record fields such as `inspect` / `log_cmd` / `run`. - -`ecc version` formats version metadata directly, but supports `--json`, `--jsonl`, and `--plain` with its version-specific schema. `ecc rpc serve` and `ecc layout-image` intentionally do not use record-renderer output modes. - -## 4. Adding a new top-level command (step by step) - -Using a command like `ecc check` as the example, there are 5 steps (the first 3 are mandatory, the last 2 as needed): - -### 4.1 Define the input model - -Add a frozen dataclass in `cli/core/inputs.py`. It must satisfy the `CommandInput` protocol (`cli/core/invocation.py`) — i.e. carry the two fields `output: OutputOptions` and `project: ProjectOptions`: - -```python -@dataclass(frozen=True) -class CheckInput: - output: OutputOptions - project: ProjectOptions - # command-private fields go here -``` - -### 4.2 Write the handler - -Put it in `cli/command_handlers/`, with a fixed signature: - -```python -def check(command_input: CheckInput, ctx: CommandContext) -> CommandResult: - if ctx.config is None: - return CommandResult.err([error_record("missing_config", path=...)]) - ... - return CommandResult.ok([{...}, ...]) -``` - -Conventions: handlers do not print directly and do not parse command-line strings; heavy logic is imported lazily (existing code routinely does `from chipcompiler... import ...` inside function bodies — keep that style to shorten CLI startup); read-only probing logic goes in `cli/inspection/`, while the handler assembles records. - -### 4.3 Register the typer command - -Declare the command function in `cli/commands/project.py` (or a new module) and register it; shared options use the aliases from `cli/core/options.py` directly: - -```python -from chipcompiler.cli.core.options import JsonlOption, JsonOption, PlainOption, ProjectOption - -def register_project_commands(app: typer.Typer) -> None: - app.command("check", help="Validate the current project setup")(check_cmd) - -def check_cmd( - *, - project: ProjectOption = None, - json_output: JsonOption = False, - jsonl: JsonlOption = False, - plain: PlainOption = False, -) -> None: - command_input = CheckInput( - output=output_options(json_output=json_output, jsonl=jsonl, plain=plain), - project=project_options(project), - ) - execute_command("check", command_input, project_handlers.check) -``` - -For a top-level single command use `app.command(...)` directly (working example: `cli/commands/doctor.py`, the shortest full chain); for a command group create `xxx_app = typer.Typer(...)` and add it in `app.py` with `app.add_typer(xxx_app, name="xxx")` (working example: `cli/commands/signoff.py`, whose subcommands reuse one handler module via `execute_command(..., render_key=f"signoff:{sub}")`). Note that the root app built in `app.py` sets `add_completion=False, no_args_is_help=True`. - -### 4.4 (Optional) Customize TEXT rendering - -The default TEXT output is `key=value`. For friendlier output: - -- Single commands: add a renderer function to the `get_pretty_renderer()` registry in `cli/rendering/pretty.py` (the existing `init/check/run/status/config` commands take this path); -- Subcommand groups: add a `(render_key, OutputMode)` entry to the `RENDERERS` dict in `cli/rendering/renderers.py`, passing `render_key` via `execute_command(..., render_key="param:show")` (the param group takes this path). - -JSON/JSONL/PLAIN need no customization at all. - -### 4.5 Add tests - -All CLI tests live under `ecc/test/cli/`, organized by ownership (repository CLAUDE.md section 5): - -- Command behavior → `test/cli/commands/test_.py`; param → `test/cli/params/`; read-only probing → `test/cli/inspect/`; rendering → `test/cli/rendering/`. -- Tests call the Python entry point directly, not a subprocess: - ```python - from chipcompiler.cli import main as cli_main - - rc = cli_main.run(["check", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - ``` -- Reuse the fixtures in `test/cli/conftest.py`: `create_cli_project` (creates a temporary project with `ecc.toml`), `create_flow_json` (fabricates `runs//home/flow.json`), `create_step_dir`, `create_workspace_config`, `mock_pdk_validation`, and others. **Note the autouse `_stub_run_preflight`**: it stubs `env_probe.probe_environment` to return nothing, so CLI tests never depend on host tools (doctor/preflight tests override that stub themselves, which takes precedence). -- Tests for engine-layer reports/signoff go in the top-level `test/` (e.g. `test/test_signoff_report.py`, `test/test_qor_report.py`, `test/test_signoff_package.py`), reusing their fixtures to fabricate workspaces. -- Don't forget to register new commands in both `test/cli/test_typer_cli.py::test_root_help_returns_zero_and_lists_commands` and `test/cli/test_cli_module_layout.py` (the commands tuple). - -How to run (from the `ecc` repository root): - -```bash -nix develop # optional -uv sync --no-build-isolation-package ecc-dreamplace --no-build-isolation-package ecc-tools-bin -.venv/bin/python -m pytest test/cli -q -``` - -## 5. Common extension scenarios - -### 5.1 Adding a tunable parameter (the param system) - -Legacy semantic parameters remain in `cli/project/params.py::_LEGACY_PARAM_REGISTRY`. Direct tool configuration belongs in one reviewed module per owner under `data/config_params/` (`cts.py`, `floorplan.py`, `dreamplace.py`, and so on). `ParamSchema` has one target: legacy `maps_to`, a JSON `config_target`, or a whitelisted PDK `pdk_target`. - -Use `config_param()` for a reviewed static template field (`description` is a required keyword argument, written per parameter by a human reviewer and enforced by `test/data/test_descriptions.py`): - -```python -# chipcompiler/data/config_params/cts.py -config_param( - "cts.skew_bound", - "cts", - ("skew_bound",), - "0.08", - applies="cts", - description="Allowed clock skew upper bound in ns.", -) -``` - -This enables `ecc param list/show/set/unset/diff`, repeated `ecc run --set key=value`, and recursive `[params.*]` TOML parsing/writing. `ecc param list` stays concise; use `--step ` or `--all` to enumerate direct schemas. List and object values use JSON literals on the command line. - -At project-run creation, non-default `config_target` values are saved as structured `config_overrides` in `home/params.toml`; `data.workspace.config_overrides` replays them after every workspace configuration refresh. PDK path schemas live in `config_params/pdk.py` and write `[pdk.overrides]`; keep `pdk.root` on `ecc pdk set-root`. Never add workspace input, output, temporary, generated-artifact, or STA multi-corner liberty paths as CLI parameters. - -`config_params/coverage.py` compares each JSON template field with exactly one direct schema, legacy mapping, or protected-path entry. Update that manifest and `test/cli/params/test_config_coverage.py` whenever a template changes. Parsing and surgical TOML editing remain in `params.py`; command tests remain in `test/cli/params/`. - -### 5.2 Extending `ecc run` - -`run` has two mutually exclusive paths (`run()` / `_run_workspace()` in `cli/command_handlers/project.py`): - -- **Fresh workspace**: resolve `[design]` input declarations, PDK, parameters, and the requested entry step; validate only that entry step's required files; atomically pre-register the managed name in `project.json` as `not_started`; preflight tools; call `create_workspace` at `/`. `create_workspace` copies inputs to `origin/` and produces all step configs; the CLI never rewrites those configs afterwards. A normal fresh flow uses a preset. `--from A --to B` instead calls `rtl2gds.build_flow_range(A, B)` to construct the inclusive canonical range. New ranges cannot combine with `--preset`, `--overwrite`, `--resume`, `--only`, or `--force`. -- **Existing workspace**: first reconcile the persisted flow against the target via `chipcompiler/engine/reconcile.py` (proper prefix → append/extend; superset with all steps successful → `no_op`; divergent → `flow_mismatch`), then after `load_workspace`, re-run in place via `run_resume`, `run_from`, or `run_only` from `chipcompiler.engine.rerun`. `--from A --to B` is an inclusive persisted range and invalidates its downstream suffix while retaining downstream output files. Existing workspaces neither preflight fresh inputs nor rewrite copied inputs/configuration. - -Project preset sequences are defined in `chipcompiler/rtl2gds/builder.py` (`build_*_flow()` / `get_flow_builders()`), not in the CLI layer. `build_flow_range()` slices the canonical `build_rtl2gds_flow()` result, so step aliases and ordering have one source of truth. Keep a sequence change coordinated with the engine's default flow, `StepEnum`, and manifest range mappings; the CLI only handles argument parsing, input contracts, progress-renderer selection, and result mapping. - -### 5.3 Extending environment probing (doctor / preflight) - -`cli/inspection/env_probe.py` is the single probing layer: `ProbeResult(component, status, required, detail, remediation)` plus one probe function per component (yosys / yosys-slang / ecc-tools / dreamplace / klayout / sizer / pdk). Adding a component = adding a probe function and registering it in `_PROBES`/`ALL_COMPONENTS`; `probe_environment()` guards against exceptions (a crashing probe counts as a fail rather than aborting the sweep). `probe_components_for_preset()` decides the current run-preflight scope (ecc-tools always, yosys ↔ contains Synthesis, dreamplace ↔ contains place/legalization, sizer ↔ contains Timing optimization). The PDK is covered by configuration validation, slang is left to synthesis, and Sizer is also required by doctor. - -### 5.4 Extending signoff (`ecc signoff inspect/export`) - -- **CLI layer**: `cli/commands/signoff.py` + `cli/command_handlers/signoff.py`. `inspection/discovery.py::resolve_loaded_workspace()` resolves a managed `--workspace NAME` in the selected project (or its sole active workspace). inspect reuses `runtime/signoff_export.py::inspect_signoff_package` (blocked still exits 0); export reuses `export_signoff_package_archive` (`RuntimeApiError` → `signoff_incomplete`). -- **Engine layer**: the `chipcompiler/engine/signoff/` package owns the signoff collector `SignoffPackageCollector` and the package-export APIs used by readiness inspection and archive generation. - -### 5.5 Extending reports (`ecc report summary/qor/checklist/step`) - -- **Design summary**: `ecc report summary` calls `chipcompiler.engine.signoff.generate_text_report`. Its implementation is split by responsibility (`report.py` orchestration / `report_data.py` data contract / `report_extract.py` parsers + workspace collection / `report_sections.py` section extraction / `report_timing.py` the timing chain / `report_text.py` formatting), all exposed through the package `__init__`. Add a report section through an `_extract_(q)` in `report_sections.py` (or the timing chain) and register it from `report.py`. -- `engine/qor_report.py`: the single-workspace port of the GUI's `projectQorTrend.ts` — constant tables (`METRIC_FAIL_VALUES`/`DIMENSION_WEIGHTS`/`QOR_SCORE_THRESHOLD`) + normalization + project-level record selection (role priority final>gate>trend; area_cost only from the last successful area step) + the `score_record` formulas + dimension weighting (no renormalization). Adding a scoreable metric = adding its threshold here and in the GUI. -- `engine/signoff/report_checklist.py`: read-only rendering of `home/checklist.json` (reports unavailable on an invalid file; never writes back). -- CLI: `cli/commands/report.py` + `cli/command_handlers/report.py`; workspace resolution reuses `inspection/discovery.py` (`resolve_workspace_path` = side-effect-free core, `resolve_command_workspace` = core + `load_workspace`; shared by signoff, report, and the read-only status/log/config commands). - -### 5.6 Extending the RPC (`ecc rpc serve`) - -`rpc serve --stdio` starts the JSON-RPC 2.0 sidecar (`chipcompiler/runtime/stdio_server.py`). Methods are declared in `chipcompiler/runtime/methods.py::RUNTIME_METHODS` (`method_name` + a pydantic `request_model` + `handler_name`), handler implementations live in `chipcompiler/runtime/workspace_api.py`, and `runtime/server.py` mounts them uniformly; protocol details in [workspace-cli.md](../../docs/workspace-cli.md). Adding a method = one `RuntimeMethodSpec` + the matching API method + a request model; no CLI-layer changes needed. - -### 5.7 Extending project declarations (`ecc project *` / `ecc workspace refresh`) - -- The editable keys of `ecc project set/unset/add/remove/show` are declared in `cli/project/config_fields.py::PROJECT_FIELDS` (`key` / TOML table / name / type / `list_value`). Add a field there and the subcommands pick it up; `add`/`remove` are hard-restricted to `design.rtl` (`unsupported_project_collection` otherwise). -- `ecc workspace refresh` is implemented as the run path with `overwrite=True, execute_flow=False` (`cli/command_handlers/project.py::refresh_workspace`), which is why it runs the same environment preflight as a fresh run (`preset: rtl2gds` from `ecc.toml` means the full tool set must be ready, even though no step executes). On a non-manifest project it reports `workspace_refresh_requires_managed_workspace`. -- Workspace-scoped `param set/unset/list/diff --workspace NAME` mutate `home/params.toml` through `cli/command_handlers/workspace_params.py` (records in `workspace_param_overrides`, suffix invalidation via `chipcompiler.engine.rerun`), and project-scoped `param` goes through `cli/command_handlers/param.py`. - -## 6. Building the CLI bundle (testing installed behavior after code changes) - -The command itself can be verified directly with `.venv/bin/ecc` or `uv run ecc`; to test installed-bundle behavior (the real experience with unchanged PATH/symlinks/env), rebuild the PyInstaller bundle — exactly the official release pipeline (`ecc.spec` + `.github/actions/build-pyinstaller-bundle`): - -```bash -cd ecc -ECOS_PYINSTALLER_MODE=onedir uv run --no-sync --managed-python \ - pyinstaller ecc.spec --clean --noconfirm -# rebuilds dist/ecc/ (onedir, ~3.6G; the first run triggers dreamplace's cmake install, which is normal) - -# Install locally (overwrite your install location, e.g. ~/.local/ecc; a PATH symlink pointing at it needs no change) -rm -rf ~/.local/ecc && mkdir -p ~/.local/ecc && cp -a dist/ecc/. ~/.local/ecc/ -ecc --help # verify doctor / signoff / report are listed -``` - -To roll back to the official release, re-run the [README](../../README.md#installation) installer (`curl -fsSL http://release.openecos.com/installers/ecc/latest/ecc-installer.sh | sh`). - -## 7. Constraints and caveats (from the repository conventions) - -- **Module size**: once a file exceeds roughly 800 LoC, put new functionality in a new module instead of growing it (repository CLAUDE.md section 6). -- **Python 3+**: do not use `__future__`; check `requires-python` in `pyproject.toml` for the minimum version. -- **Test placement** follows ownership boundaries; prefer whole-object comparisons; do not write tests for statically defined values; do not keep negative tests for removed logic. -- **Code review** must enforce the additional standards in [review-guidelines.md](../../docs/review-guidelines.md). -- `uv.lock` is the source of truth for dependencies; `requirements_lock.txt` is auto-generated and gitignored. -- ECC-Tools' tool identifier in code is `"ecc"` (not `"ecc-tools"`); every tool module must implement `is_eda_exist / build_step / run_step`; steps execute in `multiprocessing.Process` and state persists in `workspace.flow.json`. -- After installing dependencies, `ecc` is editable — source changes take effect on the next import, no reinstall needed. diff --git a/chipcompiler/docs/ecc-cli-config.cn.md b/chipcompiler/docs/ecc-config-ref.cn.md similarity index 95% rename from chipcompiler/docs/ecc-cli-config.cn.md rename to chipcompiler/docs/ecc-config-ref.cn.md index 1114bda03..b638c571a 100644 --- a/chipcompiler/docs/ecc-cli-config.cn.md +++ b/chipcompiler/docs/ecc-config-ref.cn.md @@ -1,8 +1,8 @@ # ECC Flow 工具配置参考(按步骤) -本文整理 ECC RTL-to-Harden 流程中**每一步实际使用的工具配置文件、全部参数及其含义**。配置取值与生成逻辑均核对自 v0.1.0-alpha.11 源码(rebase main 之后;模板位于 [chipcompiler/tools/*/configs/](../../chipcompiler/tools/ecc/configs/))与一次真实的 gcd@ics55 harden 运行。 +本文整理 ECC RTL-to-Harden 流程中**每一步实际使用的工具配置文件、全部参数及其含义**。配置取值与生成逻辑均核对自 v0.1.0-alpha.11 源码(rebase main 之后;模板位于 [chipcompiler/tools/*/configs/](https://github.com/openecos-projects/ecc/tree/main/chipcompiler/tools/ecc/configs/))与一次真实的 gcd@ics55 harden 运行。 -- 想了解命令用法 → [ECC CLI 用户指南](ecc-cli-ug.cn.md);从零上手 → [入门教程](ecc-cli-tutorial.cn.md) +- 想了解命令用法 → [ECC CLI 用户指南](ecc-user-guide.cn.md)(终端:`ecc doc ug --lang cn`);从零上手 → [入门教程](ecc-tutorial.cn.md)(终端:`ecc doc tutorial --lang cn`) - 配置查看命令:`ecc config `(列出该步骤实际生效的配置文件);参数查看与修改命令:`ecc param`(见 §1.4) ## 0. 配置体系总览 @@ -61,12 +61,12 @@ graph LR ### 0.3 每个步骤用到哪些配置 -`ecc config ` 的真实输出归纳(映射源码 `_STEP_CONFIG_KEYS`,位于 [chipcompiler/data/workspace/__init__.py](../../chipcompiler/data/workspace/__init__.py)): +`ecc config ` 的真实输出归纳(映射源码 `_STEP_CONFIG_KEYS`,位于 [chipcompiler/data/workspace/__init__.py](https://github.com/openecos-projects/ecc/blob/main/chipcompiler/data/workspace/__init__.py)): | 步骤 | db_ecc | 专属配置 | 说明 | |---|---|---|---| | synthesis | — | `global_var.tcl`(Tcl) | Yosys 用 Tcl 变量驱动,不走 JSON | -| lec | — | 无(Tcl) | 综合级 Yosys LEC;比较综合网表与 golden 网表;未证明时记录为 Warning,不阻断后续物理流程 | +| lec | — | 无(Tcl) | 综合级 Yosys LEC;比较综合网表与 golden 网表;未证明时步骤失败并终止后续流程 | | floorplan | ✓ | `floorplan_ecc.json` | | | placement | — | `dreamplace_ecc.json` | 与 legalization 共用一个文件 | | cts | ✓ | `cts_ecc.json` | | @@ -85,7 +85,7 @@ graph LR ### 1.1 旧语义参数(13 个) -来源:[chipcompiler/cli/project/params.py](../../chipcompiler/cli/project/params.py) 的 `_LEGACY_PARAM_REGISTRY`(`PARAM_REGISTRY` 的兼容段;直配参数见 §1.2 的 `config_params/` schema)。这些参数保持兼容;优先级:`--set` > `ecc.toml [params]` > 默认值。「写入位置」列为该参数最终落到的工具配置字段。 +来源:[chipcompiler/cli/project/params.py](https://github.com/openecos-projects/ecc/blob/main/chipcompiler/cli/project/params.py) 的 `_LEGACY_PARAM_REGISTRY`(`PARAM_REGISTRY` 的兼容段;直配参数见 §1.2 的 `config_params/` schema)。这些参数保持兼容;优先级:`--set` > `ecc.toml [params]` > 默认值。「写入位置」列为该参数最终落到的工具配置字段。 | 参数 | 类型 / 范围 | 默认 | 写入位置(config 字段) | 含义 | |---|---|---|---|---| @@ -152,7 +152,7 @@ tech = "prtech/techLEF/N551P6M_ecos.lef" ### 1.4 参数配置 CLI 命令(`ecc param`) -参数的查看与修改统一走 `ecc param` 子命令(子命令定义:[chipcompiler/cli/commands/param.py](../../chipcompiler/cli/commands/param.py);项目作用域实现:[chipcompiler/cli/command_handlers/param.py](../../chipcompiler/cli/command_handlers/param.py),workspace 作用域实现:[chipcompiler/cli/command_handlers/workspace_params.py](../../chipcompiler/cli/command_handlers/workspace_params.py)): +参数的查看与修改统一走 `ecc param` 子命令(子命令定义:[chipcompiler/cli/commands/param.py](https://github.com/openecos-projects/ecc/blob/main/chipcompiler/cli/commands/param.py);项目作用域实现:[chipcompiler/cli/command_handlers/param.py](https://github.com/openecos-projects/ecc/blob/main/chipcompiler/cli/command_handlers/param.py),workspace 作用域实现:[chipcompiler/cli/command_handlers/workspace_params.py](https://github.com/openecos-projects/ecc/blob/main/chipcompiler/cli/command_handlers/workspace_params.py)): | 命令 | 作用 | |---|---| @@ -168,7 +168,7 @@ tech = "prtech/techLEF/N551P6M_ecos.lef" |---|---| | `--project DIR` | 指定项目目录(缺省为当前目录) | | `--workspace NAME` | 切换到 workspace 作用域:操作 project.json 清单中声明的指定 workspace,而不是项目 `ecc.toml` | -| `--json` / `--jsonl` / `--plain` | 结构化输出:JSON 记录数组 / 每行一条 JSON / `key=value`(便于脚本解析);缺省为人类可读文本 | +| `--plain` | 机器可读的 `key=value` 输出(便于脚本解析);缺省为人类可读文本 | 两种作用域的行为差异: @@ -184,7 +184,7 @@ tech = "prtech/techLEF/N551P6M_ecos.lef" 一次性覆盖用 `ecc run --set KEY=VALUE`:仅在**新建**(含 `--overwrite`)workspace 时生效并记录到 `home/cli-param-overrides.json`;对已有 workspace 使用会报 `set_requires_fresh_run`,此时应改用 `ecc param set KEY VALUE --workspace NAME` 或 `--overwrite` 重建。 -完整命令输出示例见 [ECC CLI 用户指南 §9](ecc-cli-ug.cn.md)。 +完整命令输出示例见 [ECC CLI 用户指南 §9](ecc-user-guide.cn.md)(终端:`ecc doc ug --lang cn`)。 ## 2. 公共配置:db_ecc.json diff --git a/chipcompiler/docs/ecc-cli-config.en.md b/chipcompiler/docs/ecc-config-ref.en.md similarity index 96% rename from chipcompiler/docs/ecc-cli-config.en.md rename to chipcompiler/docs/ecc-config-ref.en.md index d9dcc2750..c3c790ce1 100644 --- a/chipcompiler/docs/ecc-cli-config.en.md +++ b/chipcompiler/docs/ecc-config-ref.en.md @@ -1,8 +1,8 @@ # ECC Flow Tool Configuration Reference (by step) -This document consolidates **the tool configuration files actually used by each step of the ECC RTL-to-Harden flow, all of their parameters, and what each parameter means**. Configuration values and generation logic were verified against the v0.1.0-alpha.11 source (after the rebase onto main; templates live in [chipcompiler/tools/*/configs/](../../chipcompiler/tools/ecc/configs/)) and a real gcd@ics55 harden run. +This document consolidates **the tool configuration files actually used by each step of the ECC RTL-to-Harden flow, all of their parameters, and what each parameter means**. Configuration values and generation logic were verified against the v0.1.0-alpha.11 source (after the rebase onto main; templates live in [chipcompiler/tools/*/configs/](https://github.com/openecos-projects/ecc/tree/main/chipcompiler/tools/ecc/configs/)) and a real gcd@ics55 harden run. -- For command usage, see the [ECC CLI User Guide](ecc-cli-ug.en.md); to get started from scratch, see the [Tutorial](ecc-cli-tutorial.en.md) +- For command usage, see the [ECC CLI User Guide](ecc-user-guide.en.md) (`ecc doc ug`); to get started from scratch, see the [Tutorial](ecc-tutorial.en.md) (`ecc doc tutorial`) - Config inspection command: `ecc config ` (lists the configuration files actually in effect for that step); parameter inspection/modification command: `ecc param` (see §1.4) ## 0. Configuration System Overview @@ -61,12 +61,12 @@ graph LR ### 0.3 Which configurations each step uses -Distilled from real `ecc config ` output (maps to the source `_STEP_CONFIG_KEYS` in [chipcompiler/data/workspace/__init__.py](../../chipcompiler/data/workspace/__init__.py)): +Distilled from real `ecc config ` output (maps to the source `_STEP_CONFIG_KEYS` in [chipcompiler/data/workspace/__init__.py](https://github.com/openecos-projects/ecc/blob/main/chipcompiler/data/workspace/__init__.py)): | Step | db_ecc | Step-specific config | Notes | |---|---|---|---| | synthesis | — | `global_var.tcl` (Tcl) | Yosys is driven by Tcl variables, not JSON | -| lec | — | none (Tcl) | Synthesis-level Yosys LEC; compares the mapped and golden synthesis netlists; an unproven result is recorded as Warning and does not block the physical flow | +| lec | — | none (Tcl) | Synthesis-level Yosys LEC; compares the mapped and golden synthesis netlists; an unproven result fails the step and stops the flow | | floorplan | ✓ | `floorplan_ecc.json` | | | placement | — | `dreamplace_ecc.json` | shares one file with legalization | | cts | ✓ | `cts_ecc.json` | | @@ -85,7 +85,7 @@ Distilled from real `ecc config ` output (maps to the source `_STEP_CONFIG ### 1.1 Legacy-semantic parameters (13) -Source: `_LEGACY_PARAM_REGISTRY` in [chipcompiler/cli/project/params.py](../../chipcompiler/cli/project/params.py) (the compatibility section of `PARAM_REGISTRY`; the direct-config parameters are the `config_params/` schemas in §1.2). These parameters are kept for compatibility; precedence: `--set` > `ecc.toml [params]` > defaults. The "Written to" column shows the tool configuration field each parameter ultimately lands in. +Source: `_LEGACY_PARAM_REGISTRY` in [chipcompiler/cli/project/params.py](https://github.com/openecos-projects/ecc/blob/main/chipcompiler/cli/project/params.py) (the compatibility section of `PARAM_REGISTRY`; the direct-config parameters are the `config_params/` schemas in §1.2). These parameters are kept for compatibility; precedence: `--set` > `ecc.toml [params]` > defaults. The "Written to" column shows the tool configuration field each parameter ultimately lands in. | Parameter | Type / range | Default | Written to (config field) | Meaning | |---|---|---|---|---| @@ -150,7 +150,7 @@ Workspace-local overrides written by `ecc param set KEY VALUE --workspace NAME` ### 1.4 Parameter-configuration CLI commands (`ecc param`) -Parameter inspection and modification go through the `ecc param` subcommands (subcommand definitions: [chipcompiler/cli/commands/param.py](../../chipcompiler/cli/commands/param.py); project-scope implementation: [chipcompiler/cli/command_handlers/param.py](../../chipcompiler/cli/command_handlers/param.py), workspace-scope implementation: [chipcompiler/cli/command_handlers/workspace_params.py](../../chipcompiler/cli/command_handlers/workspace_params.py)): +Parameter inspection and modification go through the `ecc param` subcommands (subcommand definitions: [chipcompiler/cli/commands/param.py](https://github.com/openecos-projects/ecc/blob/main/chipcompiler/cli/commands/param.py); project-scope implementation: [chipcompiler/cli/command_handlers/param.py](https://github.com/openecos-projects/ecc/blob/main/chipcompiler/cli/command_handlers/param.py), workspace-scope implementation: [chipcompiler/cli/command_handlers/workspace_params.py](https://github.com/openecos-projects/ecc/blob/main/chipcompiler/cli/command_handlers/workspace_params.py)): | Command | What it does | |---|---| @@ -166,7 +166,7 @@ Options shared by every subcommand: |---|---| | `--project DIR` | Select the project directory (defaults to the current directory) | | `--workspace NAME` | Switch to workspace scope: operate on the named workspace declared in the project.json manifest instead of the project `ecc.toml` | -| `--json` / `--jsonl` / `--plain` | Structured output: a JSON record array / one JSON object per line / `key=value` (script-friendly); the default is human-readable text | +| `--plain` | Machine-readable `key=value` output (script-friendly); the default is human-readable text | Behavior differences between the two scopes: @@ -182,7 +182,7 @@ Value parsing: scalars are parsed according to the schema type; list and object For one-off overrides use `ecc run --set KEY=VALUE`: it applies only when the workspace is **freshly created** (including `--overwrite`) and is recorded in `home/cli-param-overrides.json`; on an existing workspace it fails with `set_requires_fresh_run` — use `ecc param set KEY VALUE --workspace NAME` or rebuild with `--overwrite` instead. -For full command output examples, see [ECC CLI User Guide §9](ecc-cli-ug.en.md). +For full command output examples, see [ECC CLI User Guide §9](ecc-user-guide.en.md) (`ecc doc ug`). ## 2. Shared configuration: db_ecc.json diff --git a/chipcompiler/docs/ecc-cli-tutorial.cn.md b/chipcompiler/docs/ecc-tutorial.cn.md similarity index 90% rename from chipcompiler/docs/ecc-cli-tutorial.cn.md rename to chipcompiler/docs/ecc-tutorial.cn.md index 572dadfd5..dc843b356 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.cn.md +++ b/chipcompiler/docs/ecc-tutorial.cn.md @@ -1,6 +1,6 @@ # ECC CLI 入门教程:从零跑通 RTL → Harden 并产出签核包 -本教程面向第一次接触 ECC 的用户:从一台只有 Linux 系统的机器开始,安装 `ecc` 命令行工具,把一个 Verilog RTL 设计([gcd](../../docs/examples/gcd/gcd.v),最大公约数计算单元)一路跑完 **综合 → 布局布线 → 物理验证 → 逻辑等价性检查(LEC)→ 时序签核 → Harden** 全流程,最终拿到: +本教程面向第一次接触 ECC 的用户:从一台只有 Linux 系统的机器开始,安装 `ecc` 命令行工具,把一个 Verilog RTL 设计([gcd](https://github.com/openecos-projects/ecc/blob/main/docs/examples/gcd/gcd.v),最大公约数计算单元)一路跑完 **综合 → 布局布线 → 物理验证 → 逻辑等价性检查(LEC)→ 时序签核 → Harden** 全流程,最终拿到: - **Harden 交付物**:GDS 版图、抽象 LEF、时序 LIB、版图快照 PNG; - **签核包** `gcd_signoff_package.tar.gz`(含 RTL/配置/交付物/LEC 证明/报告等 300+ 文件); @@ -29,7 +29,7 @@ graph LR |---|---| | 操作系统 | Linux x86_64(其他架构需自行交叉验证) | | 基础命令 | `bash`、`curl` 或 `wget`、`tar`、`git`、`make`、`bzip2` | -| 磁盘空间 | ≥ 10 GB 空闲(安装后实测:ecc CLI ≈ 3.6 GB + OSS CAD Suite ≈ 2.9 GB + PDK ≈ 1.9 GB) | +| 磁盘空间 | ≥ 10 GB 空闲(安装后实测:ecc CLI ≈ 0.9 GB + OSS CAD Suite ≈ 2.9 GB + PDK ≈ 1.9 GB) | | 网络 | 能访问 release.openecos.com(安装脚本)与 GitHub(PDK / OSS CAD Suite) | | Python / 依赖 | **无需**。ecc-tools、DreamPlace 等已捆绑在 CLI 包内 | @@ -37,7 +37,9 @@ graph LR ### 2.1 一键安装(推荐) -使用官方安装脚本安装 `ecc` CLI(Linux x86_64,glibc 2.34+): +使用官方安装脚本安装 `ecc` CLI(Linux x86_64,glibc 2.34+,fontconfig): + +> 本教程随 v0.1.0-alpha.12 版本发布可用:其中用到的命令(`ecc doctor`、`ecc doc`、`signoff`/`report` 命令组、`run` 的 workspace/范围选择器)不在更早的 Release 中。在 alpha.12 发布前,请按 [development.cn.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md#扩展-cli) 从源码运行。 ```bash curl -fsSL http://release.openecos.com/installers/ecc/latest/ecc-installer.sh | sh @@ -59,7 +61,7 @@ export PATH="$HOME/.local/bin:$PATH" ### 2.2 从源码运行(可选) -按 [README](../../README.cn.md#源码构建) 带 `--recursive` 克隆仓库(`chipcompiler/thirdparty/` 会拉取 `ecc-tools` 和 `ecc-dreamplace`),再参照 [开发指南](../../docs/development.md) 配置 `uv` 工作区: +按 [README](https://github.com/openecos-projects/ecc/blob/main/README.cn.md#源码构建) 带 `--recursive` 克隆仓库(`chipcompiler/thirdparty/` 会拉取 `ecc-tools` 和 `ecc-dreamplace`),再参照 [开发指南](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md) 配置 `uv` 工作区: ```bash git clone --recursive https://github.com/openecos-projects/ecc.git @@ -91,10 +93,9 @@ tar -xzf oss-cad-suite-*.tgz -C ~/.local && mv ~/.local/oss-cad-suite* ~/.local/ export CHIPCOMPILER_OSS_CAD_DIR=~/.local/oss-cad-suite ``` -也可以在建项目后用 CLI 自带的 PDK 子命令接入(二选一): +也可以在建项目后用 CLI 自带的 PDK 子命令接入已就绪的 PDK: ```bash -ecc pdk setup # clone + make unzip + 接入,一条到位 ecc pdk set-root ~/pdk/icsprout55-pdk # 已就绪的 PDK 直接接入(写入 ecc.toml) ecc pdk show # 查看生效的 PDK root 与来源 ecc pdk unset # 清除 ecc.toml 的 pdk.root,回落到环境变量/仓库默认 @@ -140,7 +141,7 @@ $ ecc doctor ... ``` -必需项(yosys、yosys-slang、ecc-tools、dreamplace、sizer、pdk)全部 `pass` 后,`ecc doctor` 才会成功。就绪的 Sizer 同时需要可执行文件和 runtime root。完整 `rtl2gds` 流包含 Timing optimization 步骤;新建或 `--overwrite` 的 `rtl2gds` 会在启动预检中检查 Sizer,缺失时以 `env_not_ready` 失败。已有 workspace 或 `--workspace` 重跑不预检,缺 Sizer 时仍可能在流中段失败。缺组件时按 `ecc doctor` 的 remediation 提示补齐(如 `ecc pdk setup`,或重新运行 §2.1 安装脚本加 `--with-toolchain`)。 +必需项(yosys、yosys-slang、ecc-tools、dreamplace、sizer、pdk)全部 `pass` 后,`ecc doctor` 才会成功。就绪的 Sizer 同时需要可执行文件和 runtime root。完整 `rtl2gds` 流包含 Timing optimization 步骤;新建或 `--overwrite` 的 `rtl2gds` 会在启动预检中检查 Sizer,缺失时以 `env_not_ready` 失败。已有 workspace 或 `--workspace` 重跑不预检,缺 Sizer 时仍可能在流中段失败。缺组件时按 `ecc doctor` 的 remediation 提示补齐(如重新运行 §2.1 安装脚本加 `--with-toolchain`)。 ## 3. 创建第一个项目 @@ -184,7 +185,7 @@ curl -fL -o rtl/gcd.v \ # cp /path/to/ecc/docs/examples/gcd/gcd.v rtl/ ``` -多文件设计请改用 filelist(`rtl = ["rtl/filelist.f"]`),语法见 [examples/gcd/README.md](../../docs/examples/gcd/README.md#using-filelist) 与 [filelist 语法](../../docs/specification/filelist-grammar.md)。 +多文件设计请改用 filelist(`rtl = ["rtl/filelist.f"]`),语法见 [examples/gcd/README.md](https://github.com/openecos-projects/ecc/blob/main/docs/examples/gcd/README.md#using-filelist) 与 [filelist 语法](https://github.com/openecos-projects/ecc/blob/main/docs/specification/filelist-grammar.md)。 ### 3.3 认识 ecc.toml @@ -213,7 +214,7 @@ preset = "rtl2gds" # 本教程使用的完整 RTL-to-Harden 流程 对 gcd 示例来说,`init` 生成的默认值恰好全部正确(顶层就叫 `gcd`,时钟端口 `clk`),**一个字都不用改**。换你自己的设计时,需要核对 `top`、`rtl`、`clock_port`、`frequency_mhz` 四项。 -除了用编辑器改 `ecc.toml`,也可以用 `ecc project` 命令组直接改声明(写入 `ecc.toml`,保留注释;详见[用户指南 §8.5](ecc-cli-ug.cn.md#85-project--workspace--编辑项目资源与刷新-workspace)): +除了用编辑器改 `ecc.toml`,也可以用 `ecc project` 命令组直接改声明(写入 `ecc.toml`,保留注释;详见[用户指南 §8.5](ecc-user-guide.cn.md#85-project--workspace--编辑项目资源与刷新-workspace)(终端:`ecc doc ug --lang cn`)): ```bash ecc project set design.top my_chip # 设置一条声明 @@ -242,7 +243,7 @@ $ ecc check run: ecc run rtl: pass path: rtl/gcd.v - inspect: ecc check --json + inspect: ecc check rc=0 ``` @@ -593,7 +594,7 @@ ecc param diff --workspace exp1 # 与 exp1 创建时 ecc param unset place.target_density --workspace exp1 # 恢复 exp1 的原值 ``` -常用旧参数:`design.frequency_mhz`、`floorplan.core_util`、`place.target_density`、`route.top_layer`、`sta.max_paths`。其余静态工具字段通过每步 schema 提供,用 `--step` / `--all` 查找。workspace 的输入、输出、临时和生成路径不允许修改;PDK 路径参数可用 `ecc param set KEY VALUE` 设置:`pdk.tech`、`pdk.lefs`、`pdk.libs`、`pdk.mapping_file` 相对 `pdk.root` 解析,`pdk.sdc`/`pdk.spef` 是设计数据、相对项目目录解析,`pdk.root` 使用 `ecc pdk set-root`。完整说明见[用户指南 §9](ecc-cli-ug.cn.md#9-param--参数管理)。 +常用旧参数:`design.frequency_mhz`、`floorplan.core_util`、`place.target_density`、`route.top_layer`、`sta.max_paths`。其余静态工具字段通过每步 schema 提供,用 `--step` / `--all` 查找。workspace 的输入、输出、临时和生成路径不允许修改;PDK 路径参数可用 `ecc param set KEY VALUE` 设置:`pdk.tech`、`pdk.lefs`、`pdk.libs`、`pdk.mapping_file` 相对 `pdk.root` 解析,`pdk.sdc`/`pdk.spef` 是设计数据、相对项目目录解析,`pdk.root` 使用 `ecc pdk set-root`。完整说明见[用户指南 §9](ecc-user-guide.cn.md#9-param--参数管理)(终端:`ecc doc ug --lang cn`)。 `--workspace` 局部设置会把参数所属步骤及其后缀标记为待执行,下一次 `ecc run --workspace exp1` 只重跑这一段——只想微调一个参数时,比 `--overwrite` 整体重建便宜得多。注意只支持已审核参数(`ecc param list --all`),且参数所属步骤必须存在于该 workspace 的 flow 中。 @@ -628,7 +629,7 @@ ecc run --workspace default --overwrite # 重建 default(有安全 ecc run --workspace default --overwrite --set place.target_density=0.55 ``` -同样的 `--overwrite` 重跑也是已有 workspace 吸收**入口输入、PDK 路径、`flow.preset`** 变更的方式——这些改动会改变 workspace 的输入快照或 flow 结构。若只想按当前 `ecc.toml` 重建 workspace 而**不执行**,用专用命令(适合批量运行前准备,或当前机器缺少所需工具时): +同样的 `--overwrite` 重跑也是已有 workspace 吸收**入口输入、PDK 路径、`flow.preset`** 变更的方式——这些改动会改变 workspace 的输入快照或 flow 结构。若只想按当前 `ecc.toml` 重建 workspace 而**不执行**,用专用命令(适合批量运行前准备)。与所有新建 workspace 一样,refresh 仍会对所选流程范围执行启动工具预检,请先安装好对应工具: ```bash ecc workspace refresh default # 按 ecc.toml 重建输入/配置,但不运行 @@ -675,12 +676,12 @@ $ ecc run --from cts --to route rc=1 ``` -> **步骤名怎么写**:`ecc status`/`ecc log` 展示的是小写展示名(如 `placement`、`timing_optimization`);而 **已有** workspace 上的 `--from`/`--only`/`--to` 要用 `home/flow.json` 里的持久化名(如 `place`、`CTS`、`Timing optimization`);只有**新建**范围(`--from A --to B` 成对出现)接受小写别名。记不住没关系——拼错时会报 `unknown_step` 并列出全部可用名,照抄即可: +> **步骤名怎么写**:`ecc status`/`ecc log` 展示的是小写展示名(如 `placement`、`timing_optimization`);`--from`/`--only`/`--to` 选择器同时接受 `home/flow.json` 里的持久化名(如 `place`、`CTS`、`Timing optimization`)和小写别名(如 `placement`、`routing`)。记不住没关系——两者都不匹配时报 `unknown_step` 并列出全部可用名,照抄即可: > > ```console -> $ ecc run --workspace default --only placement # 持久化名是 "place" +> $ ecc run --workspace default --only placemen # 拼错了:既不是持久化名也不是别名 > [error] -> unknown_step unknown step 'placement'; available steps: Synthesis, lec, Floorplan, +> unknown_step unknown step 'placemen'; available steps: Synthesis, lec, Floorplan, > place, CTS, legalization, Timing optimization, route, filler, RCX, sta, lvs, > postRouteLec, drc, Harden > ``` @@ -700,11 +701,11 @@ ecc config --plain # 项目级配置(键值 + 解析后绝对路径) | `[error] env_not_ready`(run 时) | preset 必需工具缺失 | 按 `ecc doctor` 输出补齐;通常是 yosys/slang,重新运行 §2.1 安装脚本加 `--with-toolchain` | | `[error] run_exists` | workspace 目录已存在但不是有效 ECC workspace | `ecc run --overwrite`,或换 `--workspace NAME`。注意:**跑完再执行 `ecc run` 不会报这个错**——已成功时是 no_op,中断时自动续跑 | | `[error] workspace_required` | 项目里有多个活跃 workspace,没指明用哪个 | 按报错列出的名称传 `--workspace NAME` | -| `[error] unknown_step` | `--from`/`--only` 的步骤名拼写与 `home/flow.json` 持久化名不符(如写了 `placement`,持久化名是 `place`) | 照抄报错列出的可用步骤名;详见 §6.3 的「步骤名怎么写」 | +| `[error] unknown_step` | `--from`/`--only` 的步骤名既不匹配 `home/flow.json` 持久化名也不匹配别名(如把 `place` 写成 `placemen`) | 照抄报错列出的可用步骤名;详见 §6.3 的「步骤名怎么写」 | | `[error] set_requires_fresh_run` | 对已有 workspace 用 `--set` | `--set` 只在新建时生效;改用 `--overwrite` 或新 `--workspace` | | run 汇总带 `warning: ecc.toml values override different project.json base values`(`config_layer_diverged`) | `ecc.toml` 与首次运行记录到 `project.json` 的基线实际不一致:`pdk.root` 解析到了与首次运行不同的 PDK(如环境变量改指向),或 `flow.preset` 与 workspace 声明的范围不一致(如用 `--preset synthesis_lec` 建的 workspace 配 `rtl2gds` 的 ecc.toml) | 不影响执行结果,可忽略;对齐两边即消失(`ecc pdk set-root` 或修正 `flow.preset`) | | `[error] signoff_incomplete`(export 时) | 必需交付物缺失(如某步失败) | `ecc signoff inspect` 看 blocked 项;`ecc status`/`ecc log` 排查失败步骤后重跑 | -| `ecc check` 报 `pdk.root is required` | 未找到 PDK | `ecc pdk setup` 或 `ecc pdk set-root <路径>`,或设 `CHIPCOMPILER_ICS55_PDK_ROOT` | +| `ecc check` 报 `pdk.root is required` | 未找到 PDK | `ecc pdk set-root <路径>` 或设 `CHIPCOMPILER_ICS55_PDK_ROOT` | | PDK liberty 缺失 | 只 clone 了 PDK 没下数据 | `make -C ~/.local/icsprout55-pdk unzip`(可加 `USE_PROXY=true GH_PROXY=...`) | | 下载超时 | 网络受限 | 重试安装脚本;或按 §2.3 手动安装(PDK 的 `make unzip` 支持 `USE_PROXY=true GH_PROXY=...`) | | doctor 显示 `sizer: fail` | 必需的 Sizer 组件未安装 | `ecc doctor` 返回非零。完整 `rtl2gds` 链含 Timing optimization 步骤,运行前应安装 Sizer。按 remediation 提示源码构建 | @@ -713,10 +714,10 @@ ecc config --plain # 项目级配置(键值 + 解析后绝对路径) ## 8. 下一步 -- 换你自己的设计:改 `ecc.toml` 的 `top`/`rtl`/`clock_port`/`frequency_mhz`,多文件用 [filelist](../../docs/examples/gcd/README.md#using-filelist); +- 换你自己的设计:改 `ecc.toml` 的 `top`/`rtl`/`clock_port`/`frequency_mhz`,多文件用 [filelist](https://github.com/openecos-projects/ecc/blob/main/docs/examples/gcd/README.md#using-filelist); - 了解 preset 差异:`rtl2gds`(完整 15 步综合到 Harden 链,含综合级 LEC)、`syn_sta`(仅综合)、`synthesis_lec`(综合 + LEC,两步); -- 全部命令细节见 **[ECC CLI 用户指南](ecc-cli-ug.cn.md)**;CLI 扩展开发见 [ecc-cli-dev.cn.md](ecc-cli-dev.cn.md); -- 用 Python API 直接编排 flow(`EngineFlow`)见 [examples/gcd/ics55flow.py](../../docs/examples/gcd/ics55flow.py)。 +- 全部命令细节见 **[ECC CLI 用户指南](ecc-user-guide.cn.md)**(终端:`ecc doc ug --lang cn`);CLI 扩展开发见 [development.cn.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md#扩展-cli); +- 用 Python API 直接编排 flow(`EngineFlow`)见 [examples/gcd/ics55flow.py](https://github.com/openecos-projects/ecc/blob/main/docs/examples/gcd/ics55flow.py)。 --- diff --git a/chipcompiler/docs/ecc-cli-tutorial.en.md b/chipcompiler/docs/ecc-tutorial.en.md similarity index 91% rename from chipcompiler/docs/ecc-cli-tutorial.en.md rename to chipcompiler/docs/ecc-tutorial.en.md index 4f7568760..89f2d70e3 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.en.md +++ b/chipcompiler/docs/ecc-tutorial.en.md @@ -1,6 +1,6 @@ # ECC CLI Tutorial: From Zero to RTL → Harden with a Signoff Package -This tutorial is for first-time ECC users: starting from a bare Linux machine, install the `ecc` command-line tool and drive a Verilog RTL design ([gcd](../../docs/examples/gcd/gcd.v), a greatest-common-divisor unit) through the full **synthesis → place & route → physical verification → logic equivalence check (LEC) → timing signoff → Harden** flow, ending up with: +This tutorial is for first-time ECC users: starting from a bare Linux machine, install the `ecc` command-line tool and drive a Verilog RTL design ([gcd](https://github.com/openecos-projects/ecc/blob/main/docs/examples/gcd/gcd.v), a greatest-common-divisor unit) through the full **synthesis → place & route → physical verification → logic equivalence check (LEC) → timing signoff → Harden** flow, ending up with: - **Harden deliverables**: GDS layout, abstract LEF, timing LIB, and a layout snapshot PNG; - A **signoff package** `gcd_signoff_package.tar.gz` (300+ files: RTL / configs / deliverables / LEC proof / reports); @@ -29,7 +29,7 @@ graph LR |---|---| | OS | Linux x86_64 (other architectures are untested) | | Basic commands | `bash`, `curl` or `wget`, `tar`, `git`, `make`, `bzip2` | -| Disk | ≥ 10 GB free (measured after install: ecc CLI ≈ 3.6 GB + OSS CAD Suite ≈ 2.9 GB + PDK ≈ 1.9 GB) | +| Disk | ≥ 10 GB free (measured after install: ecc CLI ≈ 0.9 GB + OSS CAD Suite ≈ 2.9 GB + PDK ≈ 1.9 GB) | | Network | Access to release.openecos.com (installer) and GitHub (PDK / OSS CAD Suite) | | Python / deps | **None**. ecc-tools, DreamPlace, etc. are bundled inside the CLI package | @@ -37,7 +37,9 @@ graph LR ### 2.1 One-shot installer (recommended) -Install the `ecc` CLI (Linux x86_64, glibc 2.34+) with the official installer: +Install the `ecc` CLI (Linux x86_64, glibc 2.34+, fontconfig) with the official installer: + +> This tutorial ships with release v0.1.0-alpha.12: the commands it uses (`ecc doctor`, `ecc doc`, the `signoff`/`report` groups, and the `run` workspace/range selectors) are not in earlier releases. Until alpha.12 is out, run from source per [development.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.md#extending-the-cli). ```bash curl -fsSL http://release.openecos.com/installers/ecc/latest/ecc-installer.sh | sh @@ -59,7 +61,7 @@ The `--with-toolchain` wrapper exports `CHIPCOMPILER_OSS_CAD_DIR` and `CHIPCOMPI ### 2.2 Running from source (optional) -Clone the repository with `--recursive` as the [README](../../README.md#build-from-source) describes (`chipcompiler/thirdparty/` pulls in `ecc-tools` and `ecc-dreamplace`), then set up the `uv` workspace per the [development guide](../../docs/development.md): +Clone the repository with `--recursive` as the [README](https://github.com/openecos-projects/ecc/blob/main/README.md#build-from-source) describes (`chipcompiler/thirdparty/` pulls in `ecc-tools` and `ecc-dreamplace`), then set up the `uv` workspace per the [development guide](https://github.com/openecos-projects/ecc/blob/main/docs/development.md): ```bash git clone --recursive https://github.com/openecos-projects/ecc.git @@ -91,10 +93,9 @@ tar -xzf oss-cad-suite-*.tgz -C ~/.local && mv ~/.local/oss-cad-suite* ~/.local/ export CHIPCOMPILER_OSS_CAD_DIR=~/.local/oss-cad-suite ``` -Alternatively, hook the PDK up with the CLI's own `pdk` subcommands after creating a project: +Alternatively, wire a provisioned PDK into a project with the CLI's own `pdk` subcommands: ```bash -ecc pdk setup # clone + make unzip + wire up, all in one ecc pdk set-root ~/pdk/icsprout55-pdk # attach an already-provisioned PDK (written to ecc.toml) ecc pdk show # show the effective PDK root and where it came from ecc pdk unset # clear pdk.root in ecc.toml (falls back to env vars / repo default) @@ -140,7 +141,7 @@ $ ecc doctor ... ``` -All required components (yosys, yosys-slang, ecc-tools, dreamplace, sizer, and pdk) must `pass` before `ecc doctor` succeeds. A ready Sizer has both its executable and runtime root. The complete `rtl2gds` flow contains Timing optimization; fresh or `--overwrite` `rtl2gds` targets check Sizer during startup preflight and return `env_not_ready` when it is missing. Existing workspaces and `--workspace` reruns skip preflight, so a missing Sizer can still fail mid-flow. When a component is missing, follow the `ecc doctor` remediation hint (e.g. `ecc pdk setup`, or re-run the §2.1 installer with `--with-toolchain`). +All required components (yosys, yosys-slang, ecc-tools, dreamplace, sizer, and pdk) must `pass` before `ecc doctor` succeeds. A ready Sizer has both its executable and runtime root. The complete `rtl2gds` flow contains Timing optimization; fresh or `--overwrite` `rtl2gds` targets check Sizer during startup preflight and return `env_not_ready` when it is missing. Existing workspaces and `--workspace` reruns skip preflight, so a missing Sizer can still fail mid-flow. When a component is missing, follow the `ecc doctor` remediation hint (e.g. re-run the §2.1 installer with `--with-toolchain`). ## 3. Creating Your First Project @@ -185,7 +186,7 @@ curl -fL -o rtl/gcd.v \ # cp /path/to/ecc/docs/examples/gcd/gcd.v rtl/ ``` -For multi-file designs, switch to a filelist (`rtl = ["rtl/filelist.f"]`); see [examples/gcd/README.md](../../docs/examples/gcd/README.md#using-filelist) and the [filelist grammar](../../docs/specification/filelist-grammar.md). +For multi-file designs, switch to a filelist (`rtl = ["rtl/filelist.f"]`); see [examples/gcd/README.md](https://github.com/openecos-projects/ecc/blob/main/docs/examples/gcd/README.md#using-filelist) and the [filelist grammar](https://github.com/openecos-projects/ecc/blob/main/docs/specification/filelist-grammar.md). ### 3.3 Understanding ecc.toml @@ -214,7 +215,7 @@ preset = "rtl2gds" # the complete RTL-to-Harden flow used in this tutorial For the gcd example, the defaults produced by `init` happen to be exactly right (the top module is literally `gcd`, the clock port is `clk`) — **you don't need to change a single character**. For your own design, check the four fields `top`, `rtl`, `clock_port`, and `frequency_mhz`. -You can edit `ecc.toml` in an editor, or set the same declarations from the command line with the `ecc project` group (writes `ecc.toml`, comments preserved; see [User Guide §8.5](ecc-cli-ug.en.md#85-project--workspace--edit-project-declarations-and-refresh-workspaces)): +You can edit `ecc.toml` in an editor, or set the same declarations from the command line with the `ecc project` group (writes `ecc.toml`, comments preserved; see [User Guide §8.5](ecc-user-guide.en.md#85-project--workspace--edit-project-declarations-and-refresh-workspaces) (`ecc doc ug`)): ```bash ecc project set design.top my_chip # set one declaration @@ -243,7 +244,7 @@ $ ecc check run: ecc run rtl: pass path: rtl/gcd.v - inspect: ecc check --json + inspect: ecc check rc=0 ``` @@ -594,7 +595,7 @@ ecc param diff --workspace exp1 # vs. the values exp1 ecc param unset place.target_density --workspace exp1 # restore exp1's original value ``` -Frequently used legacy parameters are `design.frequency_mhz`, `floorplan.core_util`, `place.target_density`, `route.top_layer`, and `sta.max_paths`. Other static tool fields are supplied by per-step schemas; find them with `--step` or `--all`. Workspace input, output, temporary, and generated paths cannot be changed. PDK path parameters use `ecc param set KEY VALUE`: `pdk.tech`, `pdk.lefs`, `pdk.libs`, and `pdk.mapping_file` resolve against `pdk.root`, while `pdk.sdc`/`pdk.spef` are design data resolved against the project directory; keep `pdk.root` on `ecc pdk set-root`. See [User Guide §9](ecc-cli-ug.en.md#9-param--parameter-management) for the full contract. +Frequently used legacy parameters are `design.frequency_mhz`, `floorplan.core_util`, `place.target_density`, `route.top_layer`, and `sta.max_paths`. Other static tool fields are supplied by per-step schemas; find them with `--step` or `--all`. Workspace input, output, temporary, and generated paths cannot be changed. PDK path parameters use `ecc param set KEY VALUE`: `pdk.tech`, `pdk.lefs`, `pdk.libs`, and `pdk.mapping_file` resolve against `pdk.root`, while `pdk.sdc`/`pdk.spef` are design data resolved against the project directory; keep `pdk.root` on `ecc pdk set-root`. See [User Guide §9](ecc-user-guide.en.md#9-param--parameter-management) (`ecc doc ug`) for the full contract. A `--workspace` override marks the parameter's owning step (and everything after it) as pending, so the next `ecc run --workspace exp1` re-runs just that suffix — cheaper than an `--overwrite` rebuild when you only want to tweak one knob. It only works for reviewed parameters (`ecc param list --all`) whose owning step exists in that workspace's flow. @@ -629,7 +630,7 @@ ecc run --workspace default --overwrite # rebuild default (with safet ecc run --workspace default --overwrite --set place.target_density=0.55 ``` -The same `--overwrite` rerun is also how an existing workspace picks up changes to its **entry inputs, PDK paths, or `flow.preset`** — those alter the workspace's input snapshot or flow structure. To rebuild the workspace from the current `ecc.toml` *without* running it, use the dedicated command (handy before a batch of runs, or when the required tools aren't on the current machine): +The same `--overwrite` rerun is also how an existing workspace picks up changes to its **entry inputs, PDK paths, or `flow.preset`** — those alter the workspace's input snapshot or flow structure. To rebuild the workspace from the current `ecc.toml` *without* running it, use the dedicated command (handy before a batch of runs). Like every fresh workspace target, refresh still runs the startup tool preflight for the selected range, so the flow's tools must be installed first: ```bash ecc workspace refresh default # rebuild inputs/config from ecc.toml, do not run @@ -676,12 +677,12 @@ $ ecc run --from cts --to route rc=1 ``` -> **How to spell step names**: `ecc status`/`ecc log` show lowercase display names (e.g. `placement`, `timing_optimization`), while `--from`/`--only`/`--to` on an **existing** workspace must use the persisted names from `home/flow.json` (e.g. `place`, `CTS`, `Timing optimization`); only **creating** a new range (`--from A --to B` given as a pair) accepts the lowercase aliases. Don't worry about memorizing this — a misspelled name fails with `unknown_step` and lists every accepted name, so just copy one: +> **How to spell step names**: `ecc status`/`ecc log` show lowercase display names (e.g. `placement`, `timing_optimization`); the `--from`/`--only`/`--to` selectors accept the persisted names from `home/flow.json` (e.g. `place`, `CTS`, `Timing optimization`) and the lowercase aliases (e.g. `placement`, `routing`) alike. Don't worry about memorizing this — a name matching neither fails with `unknown_step` and lists every accepted name, so just copy one: > > ```console -> $ ecc run --workspace default --only placement # the persisted name is "place" +> $ ecc run --workspace default --only placemen # typo: neither a persisted name nor an alias > [error] -> unknown_step unknown step 'placement'; available steps: Synthesis, lec, Floorplan, +> unknown_step unknown step 'placemen'; available steps: Synthesis, lec, Floorplan, > place, CTS, legalization, Timing optimization, route, filler, RCX, sta, lvs, > postRouteLec, drc, Harden > ``` @@ -701,11 +702,11 @@ ecc config --plain # project-level config (key=value + resolved absolute pa | `[error] env_not_ready` (at run) | tools required by the preset are missing | Follow `ecc doctor`; usually yosys/slang — re-run the §2.1 installer with `--with-toolchain` | | `[error] run_exists` | the workspace directory already exists but is not a valid ECC workspace | `ecc run --overwrite`, or select a different `--workspace NAME`. Note: **running `ecc run` again after the flow completed does NOT raise this error** — it no-ops when everything succeeded, and auto-resumes after an interruption | | `[error] workspace_required` | the project has multiple active workspaces and none was specified | pass `--workspace NAME` with one of the names listed in the error | -| `[error] unknown_step` | a step name passed to `--from`/`--only` doesn't match the persisted names in `home/flow.json` (e.g. you wrote `placement`; the persisted name is `place`) | copy one of the available step names listed in the error; see the "How to spell step names" note in §6.3 | +| `[error] unknown_step` | a step name passed to `--from`/`--only` matches neither a persisted name in `home/flow.json` nor an alias (e.g. you wrote `placemen` for `place`) | copy one of the available step names listed in the error; see the "How to spell step names" note in §6.3 | | `[error] set_requires_fresh_run` | `--set` used on an existing workspace | `--set` applies only at creation; use `--overwrite` or a new `--workspace` instead | | run summary carries `warning: ecc.toml values override different project.json base values` (`config_layer_diverged`) | `ecc.toml` effectively disagrees with the baseline the first run recorded in `project.json`: `pdk.root` resolves to a different PDK than the first run used (e.g. the env var was repointed), or `flow.preset` differs from the workspace's declared range (e.g. a workspace created with `--preset synthesis_lec` under an `rtl2gds` ecc.toml) | does not affect the run result — safe to ignore; aligning the two sides makes it go away (`ecc pdk set-root`, or fix `flow.preset`) | | `[error] signoff_incomplete` (at export) | required deliverables missing (e.g. a failed step) | `ecc signoff inspect` for blocked items; debug with `ecc status`/`ecc log`, then rerun | -| `ecc check` reports `pdk.root is required` | no PDK found | `ecc pdk setup` or `ecc pdk set-root `, or set `CHIPCOMPILER_ICS55_PDK_ROOT` | +| `ecc check` reports `pdk.root is required` | no PDK found | `ecc pdk set-root ` or set `CHIPCOMPILER_ICS55_PDK_ROOT` | | PDK liberty missing | PDK cloned without data files | `make -C ~/.local/icsprout55-pdk unzip` (add `USE_PROXY=true GH_PROXY=...` if needed) | | Downloads time out | restricted network | retry the installer; or install manually per §2.3 (the PDK's `make unzip` supports `USE_PROXY=true GH_PROXY=...`) | | doctor shows `sizer: fail` | required Sizer component not installed | `ecc doctor` exits non-zero. The complete `rtl2gds` chain contains Timing optimization, so install Sizer before running it. Build ecc-sizer per the remediation hint | @@ -714,10 +715,10 @@ ecc config --plain # project-level config (key=value + resolved absolute pa ## 8. Next Steps -- Try your own design: edit `top`/`rtl`/`clock_port`/`frequency_mhz` in `ecc.toml`; use a [filelist](../../docs/examples/gcd/README.md#using-filelist) for multi-file designs; +- Try your own design: edit `top`/`rtl`/`clock_port`/`frequency_mhz` in `ecc.toml`; use a [filelist](https://github.com/openecos-projects/ecc/blob/main/docs/examples/gcd/README.md#using-filelist) for multi-file designs; - Preset differences: `rtl2gds` (the complete 15-step synthesis-to-Harden chain, including synthesis-level LEC), `syn_sta` (synthesis only), and `synthesis_lec` (synthesis + LEC, two steps); -- Full command details in the **[ECC CLI User Guide](ecc-cli-ug.en.md)**; extending the CLI is covered in [ecc-cli-dev.en.md](ecc-cli-dev.en.md); -- Driving the flow directly via the Python API (`EngineFlow`): [examples/gcd/ics55flow.py](../../docs/examples/gcd/ics55flow.py). +- Full command details in the **[ECC CLI User Guide](ecc-user-guide.en.md)** (`ecc doc ug`); extending the CLI is covered in [development.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.md#extending-the-cli); +- Driving the flow directly via the Python API (`EngineFlow`): [examples/gcd/ics55flow.py](https://github.com/openecos-projects/ecc/blob/main/docs/examples/gcd/ics55flow.py). --- diff --git a/chipcompiler/docs/ecc-cli-ug.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md similarity index 89% rename from chipcompiler/docs/ecc-cli-ug.cn.md rename to chipcompiler/docs/ecc-user-guide.cn.md index 69c8e11d2..4cc5c1a1a 100644 --- a/chipcompiler/docs/ecc-cli-ug.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -2,9 +2,9 @@ `ecc` 是 ECOS Chip Compiler 的项目制命令行入口,覆盖 RTL-to-GDS 流水的建项、校验、运行、状态/日志/配置查询、参数管理、签核与报告。本文基于 `ecc/` 子模块当前源码(v0.1.0-alpha.11)整理,所有示例输出均为真实执行结果(示例中的 run 状态为手工构造的演示数据)。 -- 源码位置:[chipcompiler/cli/](../../chipcompiler/cli/) -- 命令扩展开发方式见同目录 [ecc-cli-dev.cn.md](ecc-cli-dev.cn.md) -- RPC sidecar 协议详见 [workspace-cli.md](../../docs/workspace-cli.md) +- 源码位置:[chipcompiler/cli/](https://github.com/openecos-projects/ecc/tree/main/chipcompiler/cli/) +- 命令扩展开发方式见 [development.cn.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md#扩展-cli) +- RPC sidecar 协议详见 [rpc-guide.md](https://github.com/openecos-projects/ecc/blob/main/docs/rpc-guide.md) ## 0. 调用方式 @@ -63,7 +63,7 @@ which ecc && ecc --version # 任意目录下应输出 ecc <版本号> # 升级 = 用新包覆盖解压目录内容;方式 B/C 的软链接无需改动 ``` -> 官方最新 Release(v0.1.0-alpha.11)已包含本文全部命令,含 `doctor`/`signoff`/`report` 与 `run` 的 workspace/范围选择器。当源码领先于最近一次 Release 时(两次发布之间的新行为),按 [ecc-cli-dev.cn.md](ecc-cli-dev.cn.md) 的源码开发方式用 `uv run ecc` 即可体验(editable 安装,改源码下次导入即生效);重新运行安装脚本会装回官方发行版,未发布的新行为随之消失,属预期回退。 +> 本文及其记载的功能——内置 `ecc doc` 文档、`doctor`/`signoff`/`report` 命令组、`run` 的 workspace/范围选择器——随 v0.1.0-alpha.12 版本发布可用;更早的 Release(截至 v0.1.0-alpha.9)不包含它们。在 alpha.12 发布前,按 [development.cn.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md#扩展-cli) 的源码开发方式用 `uv run ecc` 即可体验(editable 安装,改源码下次导入即生效)。重新运行安装脚本会装回官方发行版,未发布的新行为随之消失,属预期回退。 > 注:`ecc` 的项目定位默认取当前目录(`ecc.toml` 所在处),所以「任意文件夹启动」是常态用法;在其他目录操作项目时加 `--project ` 即可。 @@ -81,7 +81,7 @@ uv run ecc --help - 全局:`ecc --version`(单行版本号)、`ecc --help`。 - 项目定位:项目级命令接受 `--project `(缺省为当前目录)。`--workspace <名称>` 是项目内受管的、非空单路径段名称,不能传文件系统路径。新项目裸执行 `ecc run` 创建 `default`;只有一个活跃 workspace 时自动选择,多个活跃 workspace 时必须指定 `--workspace`。命名 workspace 会在创建文件前登记到 `project.json`。遗留的 `runs/` 项目必须先执行 `ecc migrate`。每个项目只有一个 `ecc.toml`;创建时会把声明的输入复制到各 workspace 的 `origin/`。 -- 结构化输出:`init`、`check`、`run`、`status`、`log`、`config`、`migrate`、`doctor`、`param`、`pdk`、`project`、`workspace`、`signoff`、`report` 都支持 `--json`(`{"records":[...]}`)、`--jsonl`(每行一条记录)和 `--plain`(`key=value`,便于脚本解析),缺省为人类可读 TEXT。`ecc version` 也支持这三种选项,但使用版本专用 schema;`rpc serve` 和 `layout-image` 使用各自的协议。 +- 结构化输出:`init`、`check`、`run`、`status`、`log`、`config`、`migrate`、`doctor`、`param`、`pdk`、`project`、`workspace`、`signoff`、`report` 都支持 `--plain`(`key=value`,便于脚本解析),缺省为人类可读 TEXT。`rpc serve` 和 `layout-image` 使用各自的协议。 - 退出码:成功 0;业务失败 1(错误记录形如 `[error] error=<机器可读错误码>`)。 - 步骤名(step token)有三套写法,按场景区分: - **展示名**(`ecc status` / `ecc log` / `ecc report step` 的输出与入参,统一小写/下划线):`synthesis / lec / floorplan / placement / cts / legalization / timing_optimization / routing / filler / rcx / sta / lvs / postroutelec / drc / harden`; @@ -95,6 +95,7 @@ uv run ecc --help $ ecc --help Commands: version Show ECC runtime, component, and installed tool versions + doc Show a bundled guide (config = config reference, ug = user guide, tutorial) layout-image Render a GDS file into a layout image init Create a new ECC project check Validate the current project setup @@ -104,7 +105,6 @@ Commands: config Show resolved project or step configuration migrate Migrate a legacy runs/ project to the manifest layout doctor Check host environment: PDK, tools, and components - doc Show a bundled guide (config/ug/tutorial/dev) in the terminal param Manage EDA parameters pdk Show and configure the PDK path used by this project project Edit project declarations in ecc.toml @@ -124,7 +124,7 @@ ecc doc ug --lang cn # 本指南的中文版 ecc doc config --plain # 原始 markdown,逐字节输出 ``` -- 主题:`config`、`ug`、`tutorial`、`dev`;`--lang` 选择 `en`(默认)或 `cn`。 +- 主题:`config`(配置参考)、`ug`(用户指南,即 user guide)、`tutorial`(教程);`--lang` 选择 `en`(默认)或 `cn`。 - 终端下渲染输出带高亮并进入分页器翻阅(`$PAGER`,回退到 `less`/`more`;未设置 `LESS` 时默认 `LESS=FRX`,保证 `less` 下颜色生效);管道场景全量直出、不带颜色。 - 非法的主题/语言取值由参数校验拒绝(退出码 2)。 - 管道输出保留 unicode 渲染版式;`--plain` 原样输出原始 markdown,适合脚本处理。 @@ -144,9 +144,6 @@ eval "$(ecc --show-completion)" # 自动探测当前 shell ```bash ecc version # 文本 -ecc version --json # JSON(含 schema_version/ecc/dreamplace/ecc_tools/tools) -ecc version --jsonl # 每行一个 {"component", "version"} 对象 -ecc version --plain # 一条 key=value 记录 ecc --version # 仅一行 ecc 版本 ``` @@ -163,15 +160,12 @@ runtime ECC CLI yosys 0.68+132 sizer 0.1.0-alpha klayout 0.30.2 - -$ ecc version --json -{"schema_version": 1, "runtime": "ECC CLI", "ecc": "0.1.0a11", "dreamplace": "0.1.0a7", "ecc_tools": "0.1.0a12", "tools": {"yosys": "0.68+132", "sizer": "not installed", "klayout": "0.30.2"}} ``` ## 3. init — 创建项目 ```bash -ecc init [--json | --jsonl | --plain] +ecc init [--plain] ``` 在 `NAME/` 下生成 `ecc.toml`、`rtl/`、`constraints/` 骨架(workspace 由首个 `ecc run` 创建): @@ -214,16 +208,16 @@ preset = "rtl2gds" ## 4. check — 校验项目配置 ```bash -ecc check [--project DIR] [--json | --jsonl | --plain] +ecc check [--project DIR] [--plain] ``` -校验 `ecc.toml` 必填项(design/pdk/flow)、PDK 名称与内容(tech LEF/LEF/liberty);声明了多个 RTL 源的 manifest 项目还会逐一校验每个源。单个 RTL 源文件的存在性在 `ecc run` 创建 workspace 时按入口步骤校验(报 `step_input_missing`): +校验 `ecc.toml` 必填项(design/pdk/flow)、PDK 名称与内容(tech LEF/LEF/liberty),并逐一校验每个声明的 RTL 源文件(源文件缺失即失败): ```console $ ecc check # PDK 未就绪时 [check] fail pdk.root is required - inspect: ecc check --json + inspect: ecc check rc=1 $ ecc check # 全部就绪后 @@ -235,7 +229,7 @@ $ ecc check # 全部就绪后 run: ecc run rtl: pass path: rtl/gcd.v - inspect: ecc check --json + inspect: ecc check rc=0 ``` @@ -244,7 +238,7 @@ rc=0 `ecc doctor` 一条命令体检全部依赖(PDK、yosys 含 slang 前端、随包捆绑的 ecc-tools/dreamplace、必需的 Sizer,以及可选的 KLayout),每项给出 pass/fail/skip 与修复建议;只有**必需项**失败才返回非零: ```bash -ecc doctor [--project DIR] [--json | --jsonl | --plain] +ecc doctor [--project DIR] [--plain] ``` ```console @@ -286,7 +280,7 @@ rc=1 ### 手动排查清单(无 doctor 时备用) -`ecc check` 只覆盖「项目配置(design/pdk/flow 必填项)+ **PDK 内容**(tech LEF / LEF / liberty)」,**不检查外部工具**,也不检查单个 RTL 源文件的存在性(后者在 `ecc run` 创建 workspace 时校验)。手动逐项确认: +`ecc check` 只覆盖「项目配置(design/pdk/flow 必填项)+ **PDK 内容**(tech LEF / LEF / liberty)+ 每个声明的 RTL 源文件」,**不检查外部工具**。手动逐项确认: | 依赖 | 检查命令 | 就绪标志 | |---|---|---| @@ -330,7 +324,7 @@ ecc run [OPTIONS] --preset TEXT 本次运行的 flow preset 覆盖(不写回 ecc.toml),如 --preset syn_sta --overwrite 覆盖已存在的 workspace(仅删除真正的 ECC workspace 目录,含安全校验) --set KEY=VALUE 参数覆盖,可重复(如 --set place.target_density=0.65),会记录到 run 的 provenance - --json / --jsonl / --plain + --plain 面向脚本的 key=value 输出 ``` 新建或 `--overwrite` 的 workspace 会按以下流程执行:读 `ecc.toml` → 只解析入口步骤所需的设计文件以及 PDK/参数 → 预检所需工具 → 先写入 `project.json` 登记 → 在 `/` 创建 workspace → 将声明的设计输入复制到 `origin/`、写入对应步骤配置并运行 flow。workspace 不会存放第二份项目输入清单。已有 workspace 按持久化 flow 续跑,不会改写已有输入或步骤配置。`rtl2gds` 是完整 15 步链(Synthesis→LEC(Yosys 等价性检查)→Floorplan→place→CTS→legalization→Timing optimization(sizer)→route→filler→RCX→sta→LVS→postRouteLec(Yosys 等价性检查)→DRC→Harden,Harden 产出 GDS + 抽象 LEF + 时序 LIB)。 @@ -355,7 +349,7 @@ $ ecc run --workspace default # 已全部成功时再跑一次 → 无 log: ecc log --workspace default ``` -`--json` 输出会带 `no_op: true`(用 `--resume`/`--only` 等选择器时还会带 `executed_steps` 列表)。若 `ecc.toml` 与 `project.json` 记录的基线值实际不一致(如 `pdk.root` 解析到了与首次运行记录不同的 PDK,或 `flow.preset` 与 workspace 声明的范围不一致),汇总块前会多一行 `warning: ...` 提示(`config_layer_diverged`),不影响执行结果。 +run 汇总记录会带 `no_op: true`(`--plain` 输出可见;用 `--resume`/`--only` 等选择器时还会带 `executed_steps` 列表)。若 `ecc.toml` 与 `project.json` 记录的基线值实际不一致(如 `pdk.root` 解析到了与首次运行记录不同的 PDK,或 `flow.preset` 与 workspace 声明的范围不一致),汇总块前会多一行 `warning: ...` 提示(`config_layer_diverged`),不影响执行结果。 **已有 workspace 的目标对齐(reconcile)**:再次 `ecc run` 时,CLI 会把 workspace 已持久化的 flow 与当前目标(`ecc.toml` 的 `flow.preset`,或 `project.json` 中该 workspace 声明的 start/end 范围)对齐: @@ -501,7 +495,7 @@ $ ecc run --workspace a/b # workspace 必须是单段名称,不能是路 ### 5.4 migrate — 旧布局迁移(过渡期命令) ```bash -ecc migrate [--project DIR] [--yes] [--json | --jsonl | --plain] +ecc migrate [--project DIR] [--yes] [--plain] ``` 把 legacy `runs/` 布局项目迁移到 manifest 布局:每个安全的 `runs/` workspace 都会移动到 `/`,重写 workspace 内部路径,并登记到新建或更新的 `project.json`。缺省先输出迁移计划,确认后才执行;`--yes` 跳过确认。该命令为过渡期保留(代码标注 deprecated),存量项目迁完即可弃用。`run`/`check`/`status` 在 legacy 项目上会自动附带迁移提示记录。 @@ -509,7 +503,7 @@ ecc migrate [--project DIR] [--yes] [--json | --jsonl | --plain] ## 6. status — 查看 run 与步骤状态 ```bash -ecc status [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] +ecc status [--project DIR] [--workspace NAME] [--plain] ``` `status` 是轻量进度速查;完整的分步证据报告用 `ecc report step`(§12.4)。 @@ -537,19 +531,19 @@ $ ecc status log: ecc log cts --workspace default ... -$ ecc status --jsonl -{"workspace_id": "default", "status": "failed", "workspace": "/tmp/gcd/default", "inspect_cmd": "ecc status --workspace default", "log_cmd": "ecc log --workspace default"} -{"step": "synthesis", "tool": "yosys", "status": "success", "runtime": "0:0:17", "log_cmd": "ecc log synthesis --workspace default"} -{"step": "lec", "tool": "yosys_lec", "status": "success", "runtime": "0:0:1", "log_cmd": "ecc log lec --workspace default"} +$ ecc status --plain +workspace_id=default status=failed workspace=/tmp/gcd/default inspect_cmd="ecc status --workspace default" log_cmd="ecc log --workspace default" +step=synthesis tool=yosys status=success runtime=0:0:17 log_cmd="ecc log synthesis --workspace default" +step=lec tool=yosys_lec status=success runtime=0:0:1 log_cmd="ecc log lec --workspace default" ... ``` -run 级状态取全部步骤的聚合:`success / warning / failed / ongoing / unstart`(flow.json 缺失/损坏时为 `missing / corrupt`);步骤级状态为 `success / warning / incomplete / unstart / ongoing / pending / invalid`。综合级 LEC 未证明时为 `warning`,但仍保留 LEC 证据并继续物理流程。 +run 级状态取全部步骤的聚合:`success / partial / failed / ongoing / unstart`(flow.json 缺失/损坏时为 `missing / corrupt`;`partial` 表示范围重跑后成功与未开始步骤并存);步骤级状态为 `success / incomplete / unstart / ongoing / pending / invalid`。综合级 LEC 未证明时步骤失败并终止流程。 ## 7. log — 查看日志 ```bash -ecc log [STEP] [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] +ecc log [STEP] [--project DIR] [--workspace NAME] [--plain] ``` 不带 STEP 列出全部日志文件(run 级 flow 日志 + 各步骤日志,含尾部预览);带 STEP 打印该步骤日志内容(TEXT 模式高亮 ERROR/WARNING 行)。STEP 接受展示名(`synthesis`)与持久化名(`Synthesis`)两种写法;步骤尚未运行(日志不存在)时报 `log status: missing`,名字拼错报 `unknown_step`。 @@ -586,7 +580,7 @@ rc=1 ## 8. config — 查看解析后的配置 ```bash -ecc config [STEP] [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] +ecc config [STEP] [--project DIR] [--workspace NAME] [--plain] ``` `--workspace` 将步骤视图限定到指定的受管 workspace;项目级视图仍以项目为作用域,读取项目目录 @@ -606,10 +600,10 @@ $ ecc config floorplan # 步骤级 step: db_ecc.json (config) path: default/config/db_ecc.json - inspect: ecc config floorplan --json + inspect: ecc config floorplan floorplan_ecc.json (config) path: default/config/floorplan_ecc.json - inspect: ecc config floorplan --json + inspect: ecc config floorplan ``` ## 8.5. project / workspace — 编辑项目资源与刷新 workspace @@ -658,7 +652,7 @@ ecc param diff # 只显示与默认值不同的参数 ecc param set KEY VALUE --workspace NAME # 仅修改指定 workspace,不写 ecc.toml ``` -通用选项:`--project DIR`、`--json / --jsonl / --plain`。`list`、`show`、`set`、`unset` 和 `diff` 还接受 `--workspace NAME`。此时参数写入该 workspace 的 `home/params.toml`(不写 `ecc.toml`),刷新其生成配置,并将参数所属步骤及其后缀标记为待执行;后续 `ecc run --workspace NAME` 从该步骤继续。workspace 局部设置会记录到 `workspace_param_overrides`(含修改前的 `baseline`)——`param diff --workspace NAME` 与该 baseline 对比,`param unset KEY --workspace NAME` 恢复 baseline。只有 `ecc param list --all` 中的已审核参数可局部设置,且参数所属步骤必须存在于该 workspace 的持久化 flow 中(否则报 `workspace_param_refresh_failed`,例如对只有综合的 workspace 设 `place.*`);`pdk.*` 路径字段仍需修改 `ecc.toml` 后执行 `ecc workspace refresh`: +通用选项:`--project DIR`、`--plain`。`list`、`show`、`set`、`unset` 和 `diff` 还接受 `--workspace NAME`。此时参数写入该 workspace 的 `home/params.toml`(不写 `ecc.toml`),刷新其生成配置,并将参数所属步骤及其后缀标记为待执行;后续 `ecc run --workspace NAME` 从该步骤继续。workspace 局部设置会记录到 `workspace_param_overrides`(含修改前的 `baseline`)——`param diff --workspace NAME` 与该 baseline 对比,`param unset KEY --workspace NAME` 恢复 baseline。只有 `ecc param list --all` 中的已审核参数可局部设置,且参数所属步骤必须存在于该 workspace 的持久化 flow 中(否则报 `workspace_param_refresh_failed`,例如对只有综合的 workspace 设 `place.*`);`pdk.*` 路径字段仍需修改 `ecc.toml` 后执行 `ecc workspace refresh`: ```console $ ecc param set design.frequency_mhz 150 --workspace default --plain @@ -769,13 +763,12 @@ tech = "prtech/techLEF/N551P6M_ecos.lef" ## 10. pdk — PDK 路径配置 -接入 PDK 有两条路:`ecc pdk setup` 一条到位(自动 clone + `make unzip`,已就绪的目录则跳过下载只接入),或对已就绪的 PDK 用 `ecc pdk set-root` 直接接入(写入 `ecc.toml` 的 `[pdk] root`,自动展开为绝对路径;目录必须已存在)。内容不完整(如还没 `make unzip`)不阻断设置,会给出提示: +PDK 本体由安装脚本(`--with-toolchain`,见教程)或手动 clone 获取。已就绪的 PDK 用 `ecc pdk set-root` 接入(写入 `ecc.toml` 的 `[pdk] root`,自动展开为绝对路径;目录必须已存在)。内容不完整(如还没 `make unzip`)不阻断设置,会给出提示: -全部 `pdk` 子命令都支持 `--project DIR` 和 `--json/--jsonl/--plain`。 +全部 `pdk` 子命令都支持 `--project DIR` 和 `--plain`。 ```bash -ecc pdk setup [~/pdk/icsprout55-pdk] # 一条到位:clone(缺时)→ make unzip(缺 liberty 时,支持 GH_PROXY+重试)→ 接入;缺省装到 ~/.local/icsprout55-pdk -ecc pdk set-root ~/pdk/icsprout55-pdk # 仅设置(已就绪的 PDK) +ecc pdk set-root ~/pdk/icsprout55-pdk # 接入已就绪的 PDK checkout ecc pdk show # 查看生效 root 与来源(ecc.toml / 环境变量 / 仓库默认)及内容校验 ecc pdk unset # 清空 root,回落环境变量 / 仓库默认 ecc pdk set-root /bad/path # → [error] invalid_pdk_path(目录不存在) @@ -795,12 +788,12 @@ $ ecc pdk set-root ~/pdk/icsprout55-pdk ## 11. signoff — 签核包 -`ecc signoff export` 需要就绪的 Harden 签核包。`ecc signoff inspect` 可审阅尚未完成的 workspace。两个子命令都接受 `--project DIR` 与可选的受管 `--workspace NAME`,以及 `--json/--jsonl/--plain`。 +`ecc signoff export` 需要就绪的 Harden 签核包。`ecc signoff inspect` 可审阅尚未完成的 workspace。两个子命令都接受 `--project DIR` 与可选的受管 `--workspace NAME`,以及 `--plain`。 ### 11.1 inspect — 就绪度审阅 ```bash -ecc signoff inspect [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] +ecc signoff inspect [--project DIR] [--workspace NAME] [--plain] ``` 刷新已完成步骤的 analysis 与 `home/checklist.json` 后,输出签核包的就绪状态(`ready / attention / blocked`)、七个分组(initial/config/harden/final_design/sta/spef/reports)与风险清单。**blocked 也返回 rc=0**(检查是建议性的,门禁在 export): @@ -828,7 +821,7 @@ $ ecc signoff inspect --workspace default ### 11.2 export — 导出签核包 tar.gz(有门禁) ```bash -ecc signoff export -o .tar.gz [--include-debug] [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] +ecc signoff export -o .tar.gz [--include-debug] [--project DIR] [--workspace NAME] [--plain] ``` 直接指定已有 workspace 时,例如: @@ -856,8 +849,8 @@ $ ecc signoff export -o gcd.tar.gz --project gcd # 就绪后 ## 12. report — 设计总结、QoR 总分、checklist 与单步证据 ```bash -ecc report summary [-o PATH] [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] -ecc report qor [-o PATH] [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] +ecc report summary [-o PATH] [--project DIR] [--workspace NAME] [--plain] +ecc report qor [-o PATH] [--project DIR] [--workspace NAME] [--plain] ecc report checklist [-o PATH] [同样的 selector 与输出选项] ecc report step [STEP] [--section feature|analysis|checklist]... [同样的 selector 与输出选项] ``` @@ -971,7 +964,7 @@ $ ecc report step drc --section analysis ecc rpc serve --stdio [--persistent-db] ``` -供 GUI 等前端使用的 JSON-RPC 2.0 服务,`Content-Length` 帧封装于 stdio。`--persistent-db` 额外开放 `db.ensure` / `db.release` 与 `layout.edit.*` / `floorplan.edit.*` 系列方法。握手与调用示例(完整方法列表和参数见 [workspace-cli.md](../../docs/workspace-cli.md)): +供 GUI 等前端使用的 JSON-RPC 2.0 服务,`Content-Length` 帧封装于 stdio。`--persistent-db` 额外开放 `db.ensure` / `db.release` 与 `layout.edit.*` / `floorplan.edit.*` 系列方法。握手与调用示例(完整方法列表和参数见 [rpc-guide.md](https://github.com/openecos-projects/ecc/blob/main/docs/rpc-guide.md)): ```console → {"jsonrpc":"2.0","method":"rpc.hello","params":{"version":1},"id":"hello-1"} diff --git a/chipcompiler/docs/ecc-cli-ug.en.md b/chipcompiler/docs/ecc-user-guide.en.md similarity index 89% rename from chipcompiler/docs/ecc-cli-ug.en.md rename to chipcompiler/docs/ecc-user-guide.en.md index 660df3c50..16abb4c33 100644 --- a/chipcompiler/docs/ecc-cli-ug.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -2,9 +2,9 @@ `ecc` is the project-oriented command-line entry point of ECOS Chip Compiler, covering the full RTL-to-GDS flow: project creation, validation, execution, status/log/config inspection, parameter management, signoff, and reporting. This guide is based on the current source tree (v0.1.0-alpha.11); all example outputs are real execution results (run states in the examples are hand-crafted demo data). -- Source code: [chipcompiler/cli/](../../chipcompiler/cli/) -- For how to extend the CLI with new commands, see [ecc-cli-dev.en.md](ecc-cli-dev.en.md) -- RPC sidecar protocol: [workspace-cli.md](../../docs/workspace-cli.md) +- Source code: [chipcompiler/cli/](https://github.com/openecos-projects/ecc/tree/main/chipcompiler/cli/) +- For how to extend the CLI with new commands, see [development.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.md#extending-the-cli) +- RPC sidecar protocol: [rpc-guide.md](https://github.com/openecos-projects/ecc/blob/main/docs/rpc-guide.md) ## 0. Invocation @@ -63,7 +63,7 @@ which ecc && ecc --version # from any directory, should print ecc The latest official release (v0.1.0-alpha.11) already ships every command in this guide, including `doctor`/`signoff`/`report` and the `run` workspace/range selectors. When the source tree is ahead of the last release (behavior added between releases), run from source with `uv run ecc` as described in [ecc-cli-dev.en.md](ecc-cli-dev.en.md) (editable install — source changes take effect on the next import); re-running the installer reinstalls the official release, and unreleased behavior disappears with it — the expected rollback. +> This guide and the features it documents — the bundled `ecc doc` guides, the `doctor`/`signoff`/`report` command groups, and the `run` workspace/range selectors — ship with release v0.1.0-alpha.12; earlier releases (up to v0.1.0-alpha.9) do not include them. Until alpha.12 is out, run from source with `uv run ecc` as described in [development.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.md#extending-the-cli) (editable install — source changes take effect on the next import). Re-running the installer reinstalls the official release, and unreleased behavior disappears with it — the expected rollback. > `ecc` resolves the project from the current directory by default (wherever `ecc.toml` lives), so "launch from any folder" is the normal usage; to operate on a project from elsewhere, add `--project `. @@ -81,7 +81,7 @@ uv run ecc --help - Global: `ecc --version` (single version line), `ecc --help`. - Project location: project-scoped commands accept `--project ` (defaults to the current directory). `--workspace ` is a managed, non-empty single path segment in that project, never a filesystem path. A fresh project creates `default` on bare `ecc run`; a project with one active workspace auto-selects it, while one with multiple active workspaces requires `--workspace`. A named workspace is created and registered in `project.json` before its files are created. Legacy `runs/` projects must be upgraded with `ecc migrate` before running a flow. Each project has one `ecc.toml`; workspace inputs are copied to its own `origin/` directory at creation time. -- Structured output: `init`, `check`, `run`, `status`, `log`, `config`, `migrate`, `doctor`, `param`, `pdk`, `project`, `workspace`, `signoff`, and `report` accept `--json` (`{"records":[...]}`), `--jsonl` (one JSON record per line), and `--plain` (`key=value`, for scripting), with human-readable TEXT by default. `ecc version` supports the same flags with its version-specific schema; `rpc serve` and `layout-image` use their own protocols instead. +- Structured output: `init`, `check`, `run`, `status`, `log`, `config`, `migrate`, `doctor`, `param`, `pdk`, `project`, `workspace`, `signoff`, and `report` accept `--plain` (`key=value`, for scripting), with human-readable TEXT by default. `rpc serve` and `layout-image` use their own protocols instead. - Exit codes: 0 on success; 1 on business failure (error records look like `[error] error=`). - Step tokens come in three vocabularies, distinguished by context: - **display names** (output and input of `ecc status` / `ecc log` / `ecc report step`, uniformly lowercase/underscore): `synthesis / lec / floorplan / placement / cts / legalization / timing_optimization / routing / filler / rcx / sta / lvs / postroutelec / drc / harden`; @@ -95,6 +95,7 @@ Command overview: $ ecc --help Commands: version Show ECC runtime, component, and installed tool versions + doc Show a bundled guide (config = config reference, ug = user guide, tutorial) layout-image Render a GDS file into a layout image init Create a new ECC project check Validate the current project setup @@ -104,7 +105,6 @@ Commands: config Show resolved project or step configuration migrate Migrate a legacy runs/ project to the manifest layout doctor Check host environment: PDK, tools, and components - doc Show a bundled guide (config/ug/tutorial/dev) in the terminal param Manage EDA parameters pdk Show and configure the PDK path used by this project project Edit project declarations in ecc.toml @@ -124,7 +124,7 @@ ecc doc ug --lang cn # this guide, Chinese edition ecc doc config --plain # raw markdown, byte-for-byte ``` -- Topics: `config`, `ug`, `tutorial`, `dev`; `--lang` selects `en` (default) or `cn`. +- Topics: `config` (config reference), `ug` (user guide), `tutorial`; `--lang` selects `en` (default) or `cn`. - On a terminal the rendered guide opens in a pager with highlighting (`$PAGER`, falling back to `less`/`more`; `LESS=FRX` is defaulted when unset so colors survive `less`). When piped it prints in full without colors. - Invalid topic/language values are rejected by argument validation (exit 2). - Default output keeps the rendered unicode layout even when piped; `--plain` prints the raw markdown unchanged (script-friendly). @@ -144,9 +144,6 @@ eval "$(ecc --show-completion)" # auto-detects the current shell ```bash ecc version # text -ecc version --json # JSON (schema_version/ecc/dreamplace/ecc_tools/tools) -ecc version --jsonl # one {"component", "version"} object per line -ecc version --plain # one key=value record ecc --version # single ecc version line ``` @@ -164,15 +161,12 @@ runtime ECC CLI yosys 0.68+132 sizer 0.1.0-alpha klayout 0.30.2 - -$ ecc version --json -{"schema_version": 1, "runtime": "ECC CLI", "ecc": "0.1.0a11", "dreamplace": "0.1.0a7", "ecc_tools": "0.1.0a12", "tools": {"yosys": "0.68+132", "sizer": "not installed", "klayout": "0.30.2"}} ``` ## 3. init — create a project ```bash -ecc init [--json | --jsonl | --plain] +ecc init [--plain] ``` Creates an `ecc.toml`, `rtl/`, and `constraints/` skeleton under `NAME/` (the workspace is created by the first `ecc run`): @@ -215,16 +209,16 @@ preset = "rtl2gds" ## 4. check — validate the project configuration ```bash -ecc check [--project DIR] [--json | --jsonl | --plain] +ecc check [--project DIR] [--plain] ``` -Validates required `ecc.toml` fields (design/pdk/flow), the PDK name and contents (tech LEF/LEF/liberty); manifest projects declaring multiple RTL sources also validate every source. Existence of a single RTL source file is validated by `ecc run` per the entry step when it creates the workspace (reported as `step_input_missing`): +Validates required `ecc.toml` fields (design/pdk/flow), the PDK name and contents (tech LEF/LEF/liberty), and every declared RTL source file (missing sources fail the check): ```console $ ecc check # PDK not ready [check] fail pdk.root is required - inspect: ecc check --json + inspect: ecc check rc=1 $ ecc check # once everything is ready @@ -236,7 +230,7 @@ $ ecc check # once everything is ready run: ecc run rtl: pass path: rtl/gcd.v - inspect: ecc check --json + inspect: ecc check rc=0 ``` @@ -245,7 +239,7 @@ rc=0 `ecc doctor` checks every dependency in one command (PDK, yosys including the slang frontend, bundled ecc-tools/dreamplace, required Sizer, and optional KLayout), reporting pass/fail/skip per component with remediation hints; only **required** failures produce a non-zero exit: ```bash -ecc doctor [--project DIR] [--json | --jsonl | --plain] +ecc doctor [--project DIR] [--plain] ``` ```console @@ -287,7 +281,7 @@ Notes: ### Manual checklist (fallback when doctor is unavailable) -`ecc check` covers only "project config (required design/pdk/flow fields) + **PDK contents** (tech LEF / LEF / liberty)"; it **does not check external tools**, nor the existence of a single RTL source file (that is validated by `ecc run` when it creates the workspace). Verify manually: +`ecc check` covers only "project config (required design/pdk/flow fields) + **PDK contents** (tech LEF / LEF / liberty) + every declared RTL source"; it **does not check external tools**. Verify manually: | Dependency | Check command | Ready when | |---|---|---| @@ -331,7 +325,7 @@ ecc run [OPTIONS] --preset TEXT flow preset override for this run only (not written back to ecc.toml), e.g. --preset syn_sta --overwrite overwrite an existing run (only deletes genuine ECC run directories, with safety checks) --set KEY=VALUE parameter override, repeatable (e.g. --set place.target_density=0.65), recorded in the run provenance - --json / --jsonl / --plain + --plain key=value output for scripting ``` For a fresh or `--overwrite` workspace, the pipeline reads `ecc.toml` → resolves only the design files required by the entry step plus PDK/parameters → preflights bundled ecc-tools plus the selected tools → records the workspace in `project.json` → creates it under `/` → copies its declared design inputs to `origin/`, writes the resulting step configuration, and executes the selected flow. A workspace never stores a second project input manifest. Existing workspaces resume their persisted flow without rewriting its inputs or step configuration. `rtl2gds` is the full 15-step chain (Synthesis→LEC (Yosys equivalence check)→Floorplan→place→CTS→legalization→Timing optimization (sizer)→route→filler→RCX→sta→LVS→postRouteLec (Yosys equivalence check)→DRC→Harden; Harden emits GDS + abstract LEF + timing LIB). @@ -356,7 +350,7 @@ $ ecc run --workspace default # everything already succeeded → nothin log: ecc log --workspace default ``` -The `--json` output carries `no_op: true` (the `--resume`/`--only` selector paths also carry an `executed_steps` list). If `ecc.toml` and the baseline values recorded in `project.json` effectively disagree (for example `pdk.root` resolving to a different PDK than the one recorded by the first run, or `flow.preset` differing from the workspace's declared range), a `warning: ...` line (`config_layer_diverged`) is prepended to the summary block; it does not affect the execution result. +The run summary record carries `no_op: true` (visible in `--plain` output; the `--resume`/`--only` selector paths also carry an `executed_steps` list). If `ecc.toml` and the baseline values recorded in `project.json` effectively disagree (for example `pdk.root` resolving to a different PDK than the one recorded by the first run, or `flow.preset` differing from the workspace's declared range), a `warning: ...` line (`config_layer_diverged`) is prepended to the summary block; it does not affect the execution result. **Target reconciliation on an existing workspace**: on a repeat `ecc run`, the CLI aligns the workspace's persisted flow with the current target (the `flow.preset` from `ecc.toml`, or the start/end range declared for that workspace in `project.json`): @@ -502,7 +496,7 @@ $ ecc run --workspace a/b # a workspace must be a single name, never a path ### 5.4 migrate — legacy-layout migration (transitional command) ```bash -ecc migrate [--project DIR] [--yes] [--json | --jsonl | --plain] +ecc migrate [--project DIR] [--yes] [--plain] ``` Migrates a legacy `runs/`-layout project to the manifest layout: each safe `runs/` workspace is moved to `/`, its workspace-internal paths are rebased, and it is registered in a generated or updated `project.json`. By default it prints the migration plan and asks for confirmation; `--yes` skips the prompt. The command is kept for the transition period (marked deprecated in code) and can be retired once existing projects have migrated. `run`/`check`/`status` attach a migration-hint record to their output on legacy projects. @@ -510,7 +504,7 @@ Migrates a legacy `runs/`-layout project to the manifest layout: each safe `runs ## 6. status — show run and step status ```bash -ecc status [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] +ecc status [--project DIR] [--workspace NAME] [--plain] ``` `status` is the lightweight progress check; for the full per-step evidence @@ -539,19 +533,19 @@ $ ecc status log: ecc log cts --workspace default ... -$ ecc status --jsonl -{"workspace_id": "default", "status": "failed", "workspace": "/tmp/gcd/default", "inspect_cmd": "ecc status --workspace default", "log_cmd": "ecc log --workspace default"} -{"step": "synthesis", "tool": "yosys", "status": "success", "runtime": "0:0:17", "log_cmd": "ecc log synthesis --workspace default"} -{"step": "lec", "tool": "yosys_lec", "status": "success", "runtime": "0:0:1", "log_cmd": "ecc log lec --workspace default"} +$ ecc status --plain +workspace_id=default status=failed workspace=/tmp/gcd/default inspect_cmd="ecc status --workspace default" log_cmd="ecc log --workspace default" +step=synthesis tool=yosys status=success runtime=0:0:17 log_cmd="ecc log synthesis --workspace default" +step=lec tool=yosys_lec status=success runtime=0:0:1 log_cmd="ecc log lec --workspace default" ... ``` -The run-level status aggregates all steps: `success / warning / failed / ongoing / unstart` (`missing / corrupt` when flow.json is absent or damaged); the step-level states are `success / warning / incomplete / unstart / ongoing / pending / invalid`. An unproven synthesis-level LEC is reported as `warning`, while its evidence is retained and the physical flow continues. +The run-level status aggregates all steps: `success / partial / failed / ongoing / unstart` (`missing / corrupt` when flow.json is absent or damaged; `partial` when a bounded rerun leaves a mix of successful and unstarted steps); the step-level states are `success / incomplete / unstart / ongoing / pending / invalid`. An unproven synthesis-level LEC fails the step and stops the flow. ## 7. log — view logs ```bash -ecc log [STEP] [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] +ecc log [STEP] [--project DIR] [--workspace NAME] [--plain] ``` Without STEP it lists all log files (the run-level flow log plus each step's log, with tail previews); with STEP it prints that step's log content (TEXT mode highlights ERROR/WARNING lines). STEP accepts both the display name (`synthesis`) and the persisted name (`Synthesis`); a step that has not run yet (no log file) reports `log status: missing`, and a misspelled name reports `unknown_step`. @@ -588,7 +582,7 @@ rc=1 ## 8. config — view the resolved configuration ```bash -ecc config [STEP] [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] +ecc config [STEP] [--project DIR] [--workspace NAME] [--plain] ``` `--workspace` scopes the step view to an existing managed workspace; the project-level view remains @@ -608,10 +602,10 @@ $ ecc config floorplan # step level step: db_ecc.json (config) path: default/config/db_ecc.json - inspect: ecc config floorplan --json + inspect: ecc config floorplan floorplan_ecc.json (config) path: default/config/floorplan_ecc.json - inspect: ecc config floorplan --json + inspect: ecc config floorplan ``` ## 8.5. project / workspace — edit project declarations and refresh workspaces @@ -626,7 +620,7 @@ ecc project remove design.rtl # remove RTL sources (design.rtl only) ecc project show [KEY] # show declarations stored in ecc.toml ``` -All subcommands accept `--project DIR` and `--json/--jsonl/--plain`. Real outputs: +All subcommands accept `--project DIR` and `--plain`. Real outputs: ```console $ ecc project set design.def inputs/gcd.def @@ -700,7 +694,7 @@ ecc param diff # show only parameters that differ from thei ecc param set KEY VALUE --workspace NAME # workspace-local override; ecc.toml is not touched ``` -Common options: `--project DIR`, `--json / --jsonl / --plain`. `list`, `show`, `set`, `unset`, and `diff` also accept `--workspace NAME`. With the selector, the value is written to that workspace's `home/params.toml` (not `ecc.toml`), its generated step configuration is refreshed, and the owning step plus its suffix are marked for re-run; a later `ecc run --workspace NAME` continues from that step. Workspace-local values are recorded in `workspace_param_overrides` with the pre-edit `baseline` — `param diff --workspace NAME` compares against that baseline and `param unset KEY --workspace NAME` restores it. Only reviewed parameters from `ecc param list --all` can be set locally, the parameter's owning step must exist in the workspace's persisted flow (otherwise `workspace_param_refresh_failed` — e.g. `place.*` on a synthesis-only workspace), and `pdk.*` path fields still require changing `ecc.toml` plus `ecc workspace refresh`: +Common options: `--project DIR`, `--plain`. `list`, `show`, `set`, `unset`, and `diff` also accept `--workspace NAME`. With the selector, the value is written to that workspace's `home/params.toml` (not `ecc.toml`), its generated step configuration is refreshed, and the owning step plus its suffix are marked for re-run; a later `ecc run --workspace NAME` continues from that step. Workspace-local values are recorded in `workspace_param_overrides` with the pre-edit `baseline` — `param diff --workspace NAME` compares against that baseline and `param unset KEY --workspace NAME` restores it. Only reviewed parameters from `ecc param list --all` can be set locally, the parameter's owning step must exist in the workspace's persisted flow (otherwise `workspace_param_refresh_failed` — e.g. `place.*` on a synthesis-only workspace), and `pdk.*` path fields still require changing `ecc.toml` plus `ecc workspace refresh`: ```console $ ecc param set design.frequency_mhz 150 --workspace default --plain @@ -811,17 +805,16 @@ Priority: CLI `--set` > `ecc.toml` `[params.*]` > template defaults. `pdk.*` pat ## 10. pdk — PDK path configuration -Two ways to attach a PDK: `ecc pdk setup` does everything (auto clone + `make unzip`, -skipping downloads for an already-complete checkout), or `ecc pdk set-root` wires in -a ready-made checkout directly — `[pdk] root` in `ecc.toml` (the path is expanded to +The PDK itself comes from the install script (`--with-toolchain`; see the +tutorial) or a manual clone. Wire a ready checkout into the project with +`ecc pdk set-root` — `[pdk] root` in `ecc.toml` (the path is expanded to absolute form; the directory must already exist). Incomplete contents (e.g. `make unzip` not run yet) do not block the setting — a hint is emitted instead: -All `pdk` subcommands accept `--project DIR` and `--json/--jsonl/--plain`. +All `pdk` subcommands accept `--project DIR` and `--plain`. ```bash -ecc pdk setup [~/pdk/icsprout55-pdk] # all-in-one: clone (if missing) -> make unzip (if liberty missing, honors GH_PROXY + retries) -> wire in; defaults to ~/.local/icsprout55-pdk -ecc pdk set-root ~/pdk/icsprout55-pdk # wire in only (for an already-ready PDK) +ecc pdk set-root ~/pdk/icsprout55-pdk # wire in a ready PDK checkout ecc pdk show # effective root, its source (ecc.toml / env / repo default), contents check ecc pdk unset # clear root; falls back to env vars / repo default ecc pdk set-root /bad/path # -> [error] invalid_pdk_path (not a directory) @@ -841,12 +834,12 @@ The resolution priority is unchanged: `ecc.toml [pdk] root` > `CHIPCOMPILER_ICS5 ## 11. signoff — signoff package -`ecc signoff export` requires a ready Harden signoff package. `ecc signoff inspect` can assess a partially completed workspace. Both subcommands accept `--project DIR` and an optional managed `--workspace NAME`, plus `--json/--jsonl/--plain`. +`ecc signoff export` requires a ready Harden signoff package. `ecc signoff inspect` can assess a partially completed workspace. Both subcommands accept `--project DIR` and an optional managed `--workspace NAME`, plus `--plain`. ### 11.1 inspect — readiness review ```bash -ecc signoff inspect [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] +ecc signoff inspect [--project DIR] [--workspace NAME] [--plain] ``` Refreshes completed-step analysis and `home/checklist.json`, then prints the signoff package readiness status (`ready / attention / blocked`), the seven groups (initial/config/harden/final_design/sta/spef/reports), and the risk list. **blocked still exits with rc=0** (inspection is advisory; the gate lives in export): @@ -874,7 +867,7 @@ $ ecc signoff inspect --workspace default ### 11.2 export — export the signoff package tar.gz (gated) ```bash -ecc signoff export -o .tar.gz [--include-debug] [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] +ecc signoff export -o .tar.gz [--include-debug] [--project DIR] [--workspace NAME] [--plain] ``` For an existing workspace, for example: @@ -902,8 +895,8 @@ $ ecc signoff export -o gcd.tar.gz --project gcd # once ready ## 12. report — design summary, QoR score, checklist, and step evidence ```bash -ecc report summary [-o PATH] [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] -ecc report qor [-o PATH] [--project DIR] [--workspace NAME] [--json | --jsonl | --plain] +ecc report summary [-o PATH] [--project DIR] [--workspace NAME] [--plain] +ecc report qor [-o PATH] [--project DIR] [--workspace NAME] [--plain] ecc report checklist [-o PATH] [same selector and output options] ecc report step [STEP] [--section feature|analysis|checklist]... [same selector and output options] ``` @@ -1019,7 +1012,7 @@ $ ecc report step drc --section analysis ecc rpc serve --stdio [--persistent-db] ``` -A JSON-RPC 2.0 service for front ends such as the GUI, framed with `Content-Length` over stdio. `--persistent-db` additionally exposes `db.ensure` / `db.release` plus the `layout.edit.*` / `floorplan.edit.*` method families. Handshake and call examples (full method list and parameters in [workspace-cli.md](../../docs/workspace-cli.md)): +A JSON-RPC 2.0 service for front ends such as the GUI, framed with `Content-Length` over stdio. `--persistent-db` additionally exposes `db.ensure` / `db.release` plus the `layout.edit.*` / `floorplan.edit.*` method families. Handshake and call examples (full method list and parameters in [rpc-guide.md](https://github.com/openecos-projects/ecc/blob/main/docs/rpc-guide.md)): ```console → {"jsonrpc":"2.0","method":"rpc.hello","params":{"version":1},"id":"hello-1"} diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 8982c3fd4..1f61036f8 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -12,7 +12,7 @@ StepEnum, Workspace, WorkspaceStep, - is_non_blocking_step, + is_finished_step_state, log_flow, ) from chipcompiler.engine import EngineDB @@ -35,17 +35,14 @@ StateEnum.Unstart.value: { StateEnum.Ongoing.value, StateEnum.Imcomplete.value, - StateEnum.Warning.value, }, StateEnum.Pending.value: { StateEnum.Ongoing.value, StateEnum.Imcomplete.value, - StateEnum.Warning.value, }, StateEnum.Ongoing.value: { StateEnum.Success.value, StateEnum.Imcomplete.value, - StateEnum.Warning.value, StateEnum.Invalid.value, }, # Terminal states — no outgoing lifecycle transitions. @@ -53,7 +50,6 @@ # can assign any state directly, including Unstart for terminal states. StateEnum.Success.value: set(), StateEnum.Imcomplete.value: set(), - StateEnum.Warning.value: set(), StateEnum.Invalid.value: set(), } @@ -105,7 +101,13 @@ def build_default_steps(self): steps = [] steps.append(self.init_flow_step(StepEnum.SYNTHESIS, "yosys", StateEnum.Unstart)) - steps.append(self.init_flow_step(StepEnum.LEC, "yosys_lec", StateEnum.Unstart)) + # Persist the golden netlist on the LEC step so reloads do not have + # to guess roles from the golden_* filename convention. + golden = getattr(self.workspace.design, "golden_verilog", None) + lec_info = {"golden_verilog": str(golden)} if golden else None + steps.append( + self.init_flow_step(StepEnum.LEC, "yosys_lec", StateEnum.Unstart, info=lec_info) + ) steps.append(self.init_flow_step(StepEnum.FLOORPLAN, "ecc", StateEnum.Unstart)) steps.append(self.init_flow_step(StepEnum.PLACEMENT, "dreamplace", StateEnum.Unstart)) steps.append(self.init_flow_step(StepEnum.CTS, "ecc", StateEnum.Unstart)) @@ -429,11 +431,13 @@ def init_db_engine(self) -> bool: if self.engine_db.has_init(): return True - # init engine step by last workpsace step data if all step run success + # init engine step by last workpsace step data if all steps finished workspace_step = None for ws_step in self.workspace_steps: - if not self.check_state(name=ws_step.name, tool=ws_step.tool, state=StateEnum.Success): - # use the first unsuccess step to setup db engine + step = self.get_step(name=ws_step.name, tool=ws_step.tool) + state = step.get("state") if step is not None else None + if not is_finished_step_state(state): + # use the first unfinished step to setup db engine workspace_step = ws_step break @@ -497,8 +501,6 @@ def save_step_flow_facts( payload["constraints"] = {"sdc": timing_constraints} return json_write(file_path=feature_path, data=payload) - return True - def run_steps( self, *, rerun: bool = False, observer=None, require_full_ledger: bool = True ) -> bool: @@ -534,19 +536,9 @@ def run_steps( case StateEnum.Unstart: return False case StateEnum.Imcomplete: - if is_non_blocking_step(workspace_step): - self.workspace.logger.warning( - "[WARNING] %s did not prove equivalence; continuing flow", - workspace_step.name, - ) - continue + # An Incomplete step is an infrastructure or check + # failure: it blocks the flow. return False - case StateEnum.Warning: - self.workspace.logger.warning( - "[WARNING] %s completed with warnings; continuing flow", - workspace_step.name, - ) - continue case StateEnum.Pending: return False case StateEnum.Ongoing: @@ -564,11 +556,12 @@ def run_steps( return True def _normalize_legacy_terminal_state(self, workspace_step, step_tag): - """Reset terminal states from pre-guard workspaces to Unstart. + """Reset stuck terminal states from pre-guard workspaces to Unstart. - Pre-guard workspaces may have steps stuck in Incomplete/Warning/Invalid - from earlier runs. Batch resets (_invalidate_suffix, clear_states) - handle rerun paths; this handles the rerun=False resume path. + Pre-guard workspaces may have steps stuck in Incomplete/Invalid from + earlier runs, or in the removed terminal Warning state of the + synthesis LEC. Batch resets (_invalidate_suffix, clear_states) handle + rerun paths; this handles the rerun=False resume path. """ old_step = self.get_step(name=workspace_step.name, tool=workspace_step.tool) if old_step is None: @@ -576,8 +569,8 @@ def _normalize_legacy_terminal_state(self, workspace_step, step_tag): persisted = old_step.get("state") if persisted in { StateEnum.Imcomplete.value, - StateEnum.Warning.value, StateEnum.Invalid.value, + "Warning", }: logger.warning( "Normalizing legacy %s state '%s' → Unstart before rerun", @@ -684,17 +677,13 @@ def run_step( # Run fallible post-success work BEFORE the terminal commit: a # failure here must still transition Ongoing -> a terminal failure - # state; a synthesis LEC failure is normalized to Warning below. - # after a persisted Success the transition table forbids the - # rollback and the ledger would claim a failed step succeeded. + # state; after a persisted Success the transition table forbids + # the rollback and the ledger would claim a failed step succeeded. if state == StateEnum.Success: from chipcompiler.tools import save_layout_image save_layout_image(workspace=self.workspace, step=workspace_step) - if is_non_blocking_step(workspace_step) and state == StateEnum.Imcomplete: - state = StateEnum.Warning - if flow_step is not None and not self.set_state( name=workspace_step.name, tool=workspace_step.tool, @@ -753,8 +742,6 @@ def run_step( runtime, peak_memory_mb, ) - if is_non_blocking_step(workspace_step): - state = StateEnum.Warning if flow_step is not None and not self.set_state( name=workspace_step.name, tool=workspace_step.tool, @@ -804,6 +791,11 @@ def init_db_engine_for_step(self, workspace_step: WorkspaceStep) -> bool: elif self.engine_db.has_init(): return True + if workspace_step.tool == "yosys_lec": + # LEC is a netlist comparison step with no ECC DB input; the + # batch path (init_db_engine) skips it the same way. + return True + return self.engine_db.create_db_engine(step=workspace_step) diff --git a/chipcompiler/engine/qor_report.py b/chipcompiler/engine/qor_report.py index 380bfc36a..9e73ef92e 100644 --- a/chipcompiler/engine/qor_report.py +++ b/chipcompiler/engine/qor_report.py @@ -16,25 +16,36 @@ """ import dataclasses +import math from pathlib import Path from chipcompiler.data import StateEnum, StepEnum +from chipcompiler.data.step_dirs import STEP_DIRECTORIES from chipcompiler.utility.json import json_read -# GUI FlowStep labels in flow order, mapped to workspace step directories. +# GUI FlowStep label for each canonical step that owns a scored directory. +_STEP_ENUM_TO_LABEL = { + StepEnum.SYNTHESIS.value: "Synth", + StepEnum.FLOORPLAN.value: "Floor", + StepEnum.PLACEMENT.value: "Place", + StepEnum.CTS.value: "CTS", + StepEnum.LEGALIZATION.value: "Legal", + StepEnum.ROUTING.value: "Route", + StepEnum.DRC.value: "DRC", + StepEnum.LVS.value: "LVS", + StepEnum.FILLER.value: "Filler", + StepEnum.RCX.value: "RCX", + StepEnum.STA.value: "STA", + StepEnum.HARDEN.value: "Harden", +} + +# GUI FlowStep labels in flow order, derived from the canonical +# step->directory mapping (lec/postRouteLec and the label-less TimingOpt +# step carry no scored directory, so they drop out). FLOW_STEP_DIRS = { - "Synth": "Synthesis_yosys", - "Floor": "Floorplan_ecc", - "Place": "place_dreamplace", - "CTS": "CTS_ecc", - "Legal": "legalization_dreamplace", - "Route": "route_ecc", - "DRC": "drc_ecc", - "LVS": "lvs_ecc", - "Filler": "filler_ecc", - "RCX": "RCX_ecc", - "STA": "sta_ecc", - "Harden": "Harden_ecc", + _STEP_ENUM_TO_LABEL[step]: directory + for step, directory in STEP_DIRECTORIES.items() + if step in _STEP_ENUM_TO_LABEL } FLOW_STEPS = tuple(FLOW_STEP_DIRS) @@ -154,15 +165,18 @@ class QorScoreReport: def _flexible_number(value): + """Parse a finite metric number; NaN/Infinity are invalid, not extreme.""" if isinstance(value, bool): return None if isinstance(value, (int, float)): - return float(value) + number = float(value) + return number if math.isfinite(number) else None if isinstance(value, str) and value.strip(): try: - return float(value.replace(",", "").strip()) + number = float(value.replace(",", "").strip()) except ValueError: return None + return number if math.isfinite(number) else None return None @@ -309,6 +323,9 @@ def _resolve_area_scoring_step(records, flow_steps_by_label) -> str | None: def _gate_status(flow_steps_by_label) -> str: + # A pass verdict requires every gate step to be present and successful: + # a successful DRC with LVS/RCX/STA absent is partial evidence, not a + # pass. known = [step for step in GATE_STEPS if step in flow_steps_by_label] if not known: return "unavailable" @@ -317,9 +334,32 @@ def _gate_status(flow_steps_by_label) -> str: return "blocked" if states - {StateEnum.Success.value}: return "incomplete" + if len(known) < len(GATE_STEPS): + return "incomplete" return "pass" +def _flow_completion_state(states) -> str: + """Classify a workspace's step-state set explicitly. + + Only an all-Success ledger completes a flow. + """ + from chipcompiler.data.step import FINISHED_STEP_STATES + + values = list(states) + if any(state in (StateEnum.Imcomplete.value, StateEnum.Invalid.value) for state in values): + return "failed" + if not values: + return "not_started" + if all(state in FINISHED_STEP_STATES for state in values): + return "complete" + if any(state == StateEnum.Ongoing.value for state in values): + return "running" + if all(state == StateEnum.Unstart.value for state in values): + return "not_started" + return "in_progress" + + def _workspace_status(flow_state: str, score: float | None, gate: str) -> str: if flow_state == "failed": return "Red" @@ -384,22 +424,6 @@ def _workspace_parameters(workspace, workspace_root: Path) -> dict: return legacy if isinstance(legacy, dict) else {} -_STEP_ENUM_TO_LABEL = { - StepEnum.SYNTHESIS.value: "Synth", - StepEnum.FLOORPLAN.value: "Floor", - StepEnum.PLACEMENT.value: "Place", - StepEnum.CTS.value: "CTS", - StepEnum.LEGALIZATION.value: "Legal", - StepEnum.ROUTING.value: "Route", - StepEnum.DRC.value: "DRC", - StepEnum.LVS.value: "LVS", - StepEnum.FILLER.value: "Filler", - StepEnum.RCX.value: "RCX", - StepEnum.STA.value: "STA", - StepEnum.HARDEN.value: "Harden", -} - - def build_qor_report(workspace) -> QorScoreReport: """Score one workspace's current analysis outputs the way the GUI does.""" workspace_root = Path(workspace.directory or "") @@ -411,6 +435,11 @@ def build_qor_report(workspace) -> QorScoreReport: records: list[QorMetricRecord] = [] analyzed_steps = [] for step, dir_name in FLOW_STEP_DIRS.items(): + # Only currently successful steps score: invalidation keeps a step's + # analysis outputs on disk, so without this gate a stale suffix would + # report its obsolete metrics as current. + if flow_steps_by_label.get(step) != StateEnum.Success.value: + continue payload = json_read(workspace_root / dir_name / "analysis" / "qor_metrics.json") if not payload: continue @@ -438,14 +467,7 @@ def build_qor_report(workspace) -> QorScoreReport: overall_score = _round_score(overall) if overall is not None else None gate = _gate_status(flow_steps_by_label) - flow_state = ( - "failed" - if any( - state in (StateEnum.Imcomplete.value, StateEnum.Invalid.value) - for state in flow_steps_by_label.values() - ) - else ("complete" if flow_steps_by_label else "not_started") - ) + flow_state = _flow_completion_state(flow_steps_by_label.values()) parameters = _workspace_parameters(workspace, workspace_root) workspace_design = getattr(workspace, "design", None) @@ -504,9 +526,13 @@ def _fmt(value, unit: str = "") -> str: return f"{text} {unit}".rstrip() if unit else text -def generate_qor_report(workspace) -> str: - """Render the overall QoR score report as GUI-parity text.""" - report = build_qor_report(workspace) +def generate_qor_report(workspace, report=None) -> str: + """Render the overall QoR score report as GUI-parity text. + + Pass a prebuilt *report* to render the exact snapshot the caller + already collected instead of re-traversing the workspace. + """ + report = report if report is not None else build_qor_report(workspace) lines: list[str] = [] score_text = f"{report.overall_score:g}" if report.overall_score is not None else "—" verdict = ( diff --git a/chipcompiler/engine/reconcile.py b/chipcompiler/engine/reconcile.py index e825a4401..3aad977a1 100644 --- a/chipcompiler/engine/reconcile.py +++ b/chipcompiler/engine/reconcile.py @@ -17,9 +17,9 @@ Outcomes: - ``no_op``: persisted == target (or target is a prefix of persisted) and - every step succeeded. -- ``resume``: same shape, but some step is not Success — resume from the - first non-Success step. + every step finished (Success). +- ``resume``: same shape, but some step is not finished — resume from the + first unfinished step. - ``extended``: persisted was a proper prefix of the target; the missing suffix was appended as Unstart and the target adopted into ``[flow]``. - ``repaired``: shapes matched but ``[flow]`` was stale (e.g. a crash @@ -27,6 +27,12 @@ - ``mismatch``: divergent flows — validation is pure-read, nothing was written; the caller surfaces flow_mismatch. +Ledgers persisted in the pre-reorder chain order (DRC/LVS before filler, +RCX/STA/Harden as preset suffixes) are migrated onto the canonical chain by +step name instead of rejected: records up to ``route`` keep their states, +everything after restarts from Unstart, and the outcome falls back to +``repaired``/``resume``. + :func:`classify_workspace` exposes the pure-read phase to callers that must reject a mismatch before loading (and thereby initializing or migrating) the workspace; it additionally returns ``pending_mutation`` @@ -95,6 +101,46 @@ def _is_legacy_missing_synthesis_lec( ) +# The pre-reorder canonical chain: DRC/LVS ran before filler, and RCX/STA/ +# Harden were preset-only suffixes. Ledgers persisted in this shape map onto +# the current chain by step name. +_LEGACY_RTL2GDS_CHAIN = ( + ("Synthesis", "yosys"), + ("lec", "yosys_lec"), + ("Floorplan", "ecc"), + ("place", "dreamplace"), + ("CTS", "ecc"), + ("legalization", "dreamplace"), + ("Timing optimization", "sizer"), + ("route", "ecc"), + ("drc", "ecc"), + ("lvs", "ecc"), + ("filler", "ecc"), + ("postRouteLec", "yosys_lec"), + ("RCX", "ecc"), + ("sta", "ecc"), + ("Harden", "ecc"), +) + + +def _is_legacy_reordered_chain(persisted: list[tuple[str, str]]) -> bool: + """Whether a ledger is an order-preserving slice of the pre-reorder chain. + + Legacy presets always began at Synthesis, so a ledger starting anywhere + else is a foreign shape, not a legacy one. + """ + if not persisted or persisted[0] != _LEGACY_RTL2GDS_CHAIN[0]: + return False + positions = {entry: index for index, entry in enumerate(_LEGACY_RTL2GDS_CHAIN)} + taken = [] + for entry in persisted: + position = positions.get(entry) + if position is None: + return False + taken.append(position) + return taken == sorted(set(taken)) and len(taken) == len(set(taken)) + + def _target_entries(flow_section: dict) -> list[tuple[str, str]]: """(name, tool) entries for a [flow] section, over the canonical chain.""" from chipcompiler.data.workspace import _canonical_rtl2gds_flow_entries @@ -199,6 +245,8 @@ def _probe_workspace(workspace_dir: Path, target_section: dict | None): relation = compare_flows(persisted, target) if relation == "divergent" and _is_legacy_missing_synthesis_lec(persisted, target): relation = "legacy_missing_synthesis_lec" + if relation == "divergent" and _is_legacy_reordered_chain(persisted): + relation = "legacy_reordered_chain" if relation == "divergent": return ( ReconcileResult( @@ -214,7 +262,10 @@ def _probe_workspace(workspace_dir: Path, target_section: dict | None): stale = flow_range_of(workspace_flow) != flow_range_of(target_section) else: stale = bool(target_section) - if relation in {"proper_prefix", "legacy_missing_synthesis_lec"} or stale: + if ( + relation in {"proper_prefix", "legacy_missing_synthesis_lec", "legacy_reordered_chain"} + or stale + ): context = { "flow_data": flow_data, "persisted": persisted, @@ -234,9 +285,11 @@ def _probe_workspace(workspace_dir: Path, target_section: dict | None): if relation == "target_prefix": # The persisted flow already covers the target: no-op only when - # every step WITHIN the requested target range succeeded; a - # non-Success step inside the target resumes. Steps beyond the - # target are never the run's business. + # every step WITHIN the requested target range finished; an + # unfinished one resumes. Steps beyond the target are never the + # run's business. + from chipcompiler.data.step import FINISHED_STEP_STATES + target_states = { str(step.get("state", "")) for step in flow_data.get("steps", [])[: len(target)] @@ -244,17 +297,19 @@ def _probe_workspace(workspace_dir: Path, target_section: dict | None): } return ( ReconcileResult( - outcome="no_op" if target_states == {"Success"} else "resume", + outcome="no_op" if target_states <= FINISHED_STEP_STATES else "resume", persisted=_entry_names(persisted), target=_entry_names(target), ), {}, ) + from chipcompiler.data.step import FINISHED_STEP_STATES + states = { str(step.get("state", "")) for step in flow_data.get("steps", []) if isinstance(step, dict) } - outcome = "no_op" if states == {"Success"} else "resume" + outcome = "no_op" if states <= FINISHED_STEP_STATES else "resume" return ( ReconcileResult( outcome=outcome, @@ -379,6 +434,40 @@ def _apply_mutation(workspace_dir: Path, probe: ReconcileResult, context: dict) ) adopted_flow = dict(target_section) outcome = "extended" + elif relation == "legacy_reordered_chain": + # Map a pre-reorder ledger onto the canonical chain by step name: + # the Synthesis..route prefix is identical in both orders and keeps + # its records; every post-route step ran with pre-reorder inputs + # (DRC/LVS before filler), so it restarts from Unstart. + import copy + + from chipcompiler.data.workspace import ( + _canonical_rtl2gds_flow_entries, + _flow_step_template, + ) + + context["flow_data_original"] = copy.deepcopy(flow_data) + persisted_by_name = { + step.get("name"): step for step in flow_data.get("steps", []) if isinstance(step, dict) + } + chain_names = [name for name, _tool, _state in _canonical_rtl2gds_flow_entries()] + route_position = chain_names.index("route") + steps = [] + for name, tool in target: + old = persisted_by_name.get(name) + if old is not None and chain_names.index(name) <= route_position: + steps.append(old) + else: + steps.append(_flow_step_template(name, tool, "Unstart")) + if name not in persisted_by_name: + appended.append(name) + flow_data["steps"] = steps + if not json_write(workspace_dir / "home" / "flow.json", flow_data): + return ReconcileResult( + outcome="mismatch", + error=f"failed to rewrite flow steps into {workspace_dir / 'home' / 'flow.json'}", + ) + adopted_flow = dict(target_section) else: # Adopt the effective target when the persisted [flow] is stale # (crash between append and adopt, a hand-edited file, or an @@ -395,7 +484,9 @@ def _apply_mutation(workspace_dir: Path, probe: ReconcileResult, context: dict) # failure is an error, not a tolerated partial state. Roll the # ledger back too — leaving the appended suffix behind would # report failure while the persisted flow is wider than the target. - if appended: + # The reordered-chain migration can rewrite the ledger without + # appending a single name, so the rollback keys on the snapshot. + if "flow_data_original" in context: json_write(workspace_dir / "home" / "flow.json", context["flow_data_original"]) return ReconcileResult( outcome="mismatch", @@ -408,16 +499,18 @@ def _apply_mutation(workspace_dir: Path, probe: ReconcileResult, context: dict) ) if outcome is None: + from chipcompiler.data.step import FINISHED_STEP_STATES + if relation == "target_prefix": # The persisted flow already covers the target: no-op only - # when every step within the requested target range succeeded. + # when every step within the requested target range finished. flow_data = _persisted_flow_data(workspace_dir, json_read) target_states = { str(step.get("state", "")) for step in flow_data.get("steps", [])[: len(target)] if isinstance(step, dict) } - outcome = "no_op" if target_states == {"Success"} else "resume" + outcome = "no_op" if target_states <= FINISHED_STEP_STATES else "resume" else: flow_data = _persisted_flow_data(workspace_dir, json_read) states = { @@ -425,7 +518,7 @@ def _apply_mutation(workspace_dir: Path, probe: ReconcileResult, context: dict) for step in flow_data.get("steps", []) if isinstance(step, dict) } - outcome = "repaired" if states == {"Success"} else "resume" + outcome = "repaired" if states <= FINISHED_STEP_STATES else "resume" return ReconcileResult( outcome=outcome, diff --git a/chipcompiler/engine/rerun.py b/chipcompiler/engine/rerun.py index ccc8cdc35..00c425ce8 100644 --- a/chipcompiler/engine/rerun.py +++ b/chipcompiler/engine/rerun.py @@ -13,7 +13,13 @@ from pathlib import Path from typing import TYPE_CHECKING, NamedTuple -from chipcompiler.data import StateEnum, Workspace, WorkspaceStep, is_non_blocking_step, log_flow +from chipcompiler.data import ( + StateEnum, + Workspace, + WorkspaceStep, + is_finished_step_state, + log_flow, +) from chipcompiler.utility.log import redirect_stdio_to_file from chipcompiler.utility.path import path_is_within @@ -41,7 +47,7 @@ def selected_step_names( steps = flow.workspace.flow.data.get("steps", []) if only is not None: index = _require_step_index(flow, only) - if not force and steps[index].get("state") == StateEnum.Success.value: + if not force and is_finished_step_state(steps[index].get("state")): return [] return [steps[index]["name"]] if from_step is not None: @@ -51,7 +57,7 @@ def selected_step_names( raise ValueError(f"step '{through}' is before '{from_step}'") return [step["name"] for step in steps[first : last + 1]] for index, step in enumerate(steps): - if step.get("state") != StateEnum.Success.value: + if not is_finished_step_state(step.get("state")): return [step["name"] for step in steps[index:]] return [] @@ -61,7 +67,7 @@ def bounded_resume_names(flow: "EngineFlow", through: str) -> list[str]: steps = flow.workspace.flow.data.get("steps", []) last_index = _require_step_index(flow, through) for index, step in enumerate(steps): - if step.get("state") != StateEnum.Success.value: + if not is_finished_step_state(step.get("state")): if index > last_index: return [] return [step["name"] for step in steps[index : last_index + 1]] @@ -69,14 +75,14 @@ def bounded_resume_names(flow: "EngineFlow", through: str) -> list[str]: def run_resume(flow: "EngineFlow", *, through: str | None = None) -> StepRunResult: - """Resume from the first non-successful step, re-executing the persisted suffix. + """Resume from the first non-finished step, re-executing the persisted suffix. *through* bounds the resume to the reconciled target's last step: a persisted ledger wider than the target is neither re-executed nor invalidated past it. """ for step in flow.workspace.flow.data.get("steps", []): - if step.get("state") != StateEnum.Success.value: + if not is_finished_step_state(step.get("state")): return run_from(flow, step["name"], through=through) return StepRunResult(ok=True, executed=()) @@ -107,10 +113,10 @@ def invalidate_from(flow: "EngineFlow", name: str) -> list[str]: def run_only(flow: "EngineFlow", name: str, *, force: bool = False) -> StepRunResult: - """Run exactly one persisted step; a successful step is re-run only with force.""" + """Run exactly one persisted step; a finished step is re-run only with force.""" steps = flow.workspace.flow.data.get("steps", []) index = _require_step_index(flow, name) - if not force and steps[index].get("state") == StateEnum.Success.value: + if not force and is_finished_step_state(steps[index].get("state")): return StepRunResult(ok=True, executed=()) _require_steps_available(flow, index) workspace_step = flow.get_workspace_step(name) @@ -189,18 +195,9 @@ def _run_selected(flow: "EngineFlow", selected: list[tuple[WorkspaceStep, Path]] flow.workspace.logger.log_section( f"{workspace_step.tool} - end step - {workspace_step.name}" ) - if state not in {StateEnum.Success, StateEnum.Warning} and not is_non_blocking_step( - workspace_step - ): + if state is not StateEnum.Success: + # A persisted Incomplete blocks the rerun. return StepRunResult(ok=False, executed=tuple(executed), failed=workspace_step.name) - if state != StateEnum.Success: - flow.workspace.logger.warning( - "[WARNING] %s %s; continuing flow", - workspace_step.name, - "did not prove equivalence" - if is_non_blocking_step(workspace_step) - else "completed with warnings", - ) executed.append(workspace_step.name) return StepRunResult(ok=True, executed=tuple(executed)) diff --git a/chipcompiler/engine/signoff/__init__.py b/chipcompiler/engine/signoff/__init__.py index f1a1f20b2..c706ae777 100644 --- a/chipcompiler/engine/signoff/__init__.py +++ b/chipcompiler/engine/signoff/__init__.py @@ -1,1319 +1,26 @@ -import glob -import importlib -import json -import os -import shutil -import tarfile -import time -from dataclasses import dataclass, field -from pathlib import Path - -from chipcompiler.data import StateEnum, StepEnum, Workspace -from chipcompiler.tools.ecc.sta_qor import ( - STA_POWER_REPORT_FILENAME, - STA_QOR_SUMMARY_FILENAME, - STA_REPORT_FILENAMES, - STA_TIMING_PATHS_FILENAME, - sta_artifact_directory, -) -from chipcompiler.utility import file_digest -from chipcompiler.utility.filelist import ( - FILELIST_SUFFIXES, - parse_filelist, - resolve_initial_rtl, - rewrite_absolute_entries, +"""Signoff package collection and design-summary reporting. + +Public surface: :class:`SignoffPackageCollector` and its option/result +model (see collector.py and models.py), plus the GUI-parity text design +summary implemented in engine.signoff.report*. +""" + +from chipcompiler.engine.signoff.collector import SignoffPackageCollector +from chipcompiler.engine.signoff.models import ( + SIGNOFF_REQUIRED_QOR_STEPS, + SignoffPackageIssue, + SignoffPackageOptions, + SignoffPackageResult, ) -SIGNOFF_REQUIRED_QOR_STEPS = { - StepEnum.HARDEN.value, - StepEnum.RCX.value, - StepEnum.STA.value, - StepEnum.DRC.value, - StepEnum.LVS.value, - StepEnum.FILLER.value, - StepEnum.ROUTING.value, -} - - -@dataclass(frozen=True) -class SignoffPackageOptions: - output_dir: str | None = None - archive: bool = True - include_debug: bool = False - allow_incomplete: bool = False - materialize: bool = True - refresh_analysis: bool = False - - -@dataclass -class SignoffPackageResult: - ok: bool - package_dir: str - archive_path: str | None = None - manifest_path: str | None = None - summary_path: str | None = None - copied: list[dict] = field(default_factory=list) - missing_required: list[str] = field(default_factory=list) - missing_optional: list[str] = field(default_factory=list) - warnings: list[str] = field(default_factory=list) - issues: list["SignoffPackageIssue"] = field(default_factory=list) - - -@dataclass(frozen=True) -class SignoffPackageIssue: - kind: str - label: str - location: str - reason: str - required: bool - destination: str - - -class SignoffPackageCollector: - def __init__(self, workspace: Workspace): - self.workspace = workspace - - def text_report(self) -> str: - """GUI-parity text design summary for this workspace. - - The implementation lives in engine.signoff.report to keep this module - from growing further; re-exported below as the public entry point. - """ - return generate_text_report(self.workspace) - - def collect( - self, - options: SignoffPackageOptions | None = None, - ) -> SignoffPackageResult: - options = options or SignoffPackageOptions() - if self.workspace is None or not self.workspace.directory: - raise FileNotFoundError("workspace is not configured") - - workspace_dir = Path(self.workspace.directory) - if not workspace_dir.exists(): - raise FileNotFoundError(f"workspace does not exist: {workspace_dir}") - - refresh_issues = ( - self._refresh_workspace_analysis(workspace_dir) if options.refresh_analysis else [] - ) - - from chipcompiler.data.workspace_config import workspace_config_path - - parameters = self._read_parameters(workspace_config_path(workspace_dir)) - design = ( - self.workspace.design.name - or parameters.get("design", "") - or self._design_from_outputs(workspace_dir) - ) - top_module = self.workspace.design.top_module or parameters.get("top_module", "") or design - pdk_name = getattr(self.workspace.pdk, "name", "") or parameters.get("pdk", "") - if not design: - raise ValueError("cannot determine design name for signoff package") - - package_root = Path(options.output_dir) if options.output_dir else workspace_dir / "signoff" - package_dir = package_root / f"{design}_signoff_package" - if options.materialize: - if package_dir.exists(): - shutil.rmtree(package_dir) - package_dir.mkdir(parents=True, exist_ok=True) - - copied: list[dict] = [] - missing_required: list[str] = [] - missing_optional: list[str] = [] - warnings: list[str] = [] - issues: list[SignoffPackageIssue] = [] - - def add_file( - role: str, - source: Path | None, - destination: str, - *, - required: bool = False, - content: str | None = None, - ) -> None: - self._add_file( - workspace_dir=workspace_dir, - package_dir=package_dir, - role=role, - source=source, - destination=destination, - required=required, - copied=copied, - missing_required=missing_required, - missing_optional=missing_optional, - issues=issues, - materialize=options.materialize, - content=content, - ) - - flow_path = workspace_dir / "home" / "flow.json" - checklist_path = workspace_dir / "home" / "checklist.json" - if self.workspace.flow.path is None: - self.workspace.flow.path = flow_path - checklist_data = self._read_json(checklist_path) - - has_synthesis = self.workspace.flow.has_step(StepEnum.SYNTHESIS) - synthesis_verilog = self._synthesis_output_verilog() if has_synthesis else None - lec_golden = synthesis_verilog or getattr(self.workspace.design, "origin_verilog", None) - filler_verilog = workspace_dir / "filler_ecc" / "output" / f"{design}_filler.v.gz" - require_lec = self._requires_post_route_lec(lec_golden, filler_verilog) - required_steps = self._required_step_states(require_lec=require_lec) - for step_name, state in required_steps.items(): - if state != StateEnum.Success.value: - missing_required.append(f"flow step {step_name} is {state or 'missing'}") - issues.append( - SignoffPackageIssue( - kind="flow", - label=f"{step_name} flow step", - location=step_name, - reason=f"State is {state or 'missing'}", - required=True, - destination=f"flow step {step_name}", - ) - ) - - config_dir = workspace_dir / "config" - required_configs = { - "db_ecc.json", - "rcx_ecc.json", - "sta_ecc.json", - } - if not config_dir.is_dir(): - missing_required.append("config directory") - issues.append( - SignoffPackageIssue( - kind="resource", - label="Config directory", - location="config", - reason="Required directory does not exist", - required=True, - destination="config directory", - ) - ) - else: - for config_file in sorted(path for path in config_dir.rglob("*") if path.is_file()): - rel = config_file.relative_to(config_dir).as_posix() - add_file( - role=f"config.{config_file.stem}", - source=config_file, - destination=f"config/{rel}", - required=config_file.name in required_configs, - ) - for config_name in sorted(required_configs): - if not (config_dir / config_name).is_file(): - missing_required.append(f"config/{config_name}") - issues.append( - SignoffPackageIssue( - kind="resource", - label=f"Config {config_name}", - location=f"config/{config_name}", - reason="Required file is missing or empty", - required=True, - destination=f"config/{config_name}", - ) - ) - - db_config = self._read_json(config_dir / "db_ecc.json") - configured_filelist = ( - None if not has_synthesis else getattr(self.workspace.design, "input_filelist", None) - ) - origin_rtl = resolve_initial_rtl( - configured_filelist, - getattr(self.workspace.design, "origin_verilog", None), - workspace_dir / "origin", - ) - if origin_rtl is not None: - rtl_suffix = ".v.gz" if origin_rtl.name.endswith(".v.gz") else origin_rtl.suffix.lower() - rtl_destination = f"initial/{design}{rtl_suffix}" - else: - rtl_destination = f"initial/{design}.v" - if origin_rtl is None or not origin_rtl.is_file(): - missing_required.append("origin RTL") - issues.append( - SignoffPackageIssue( - kind="resource", - label="Origin RTL", - location=self._review_source_path(workspace_dir, origin_rtl, "origin"), - reason="Required file is missing or empty", - required=True, - destination=rtl_destination, - ) - ) - origin_sdc = self._path_from_config( - workspace_dir, - db_config.get("INPUT", {}).get("sdc_path", ""), - ) - if origin_sdc is None: - origin_sdc, origin_sdc_reason = self._find_one( - workspace_dir / "origin", - preferred_name=f"{design}.sdc", - pattern="*.sdc", - ) - if origin_sdc is None: - missing_required.append("origin SDC") - issues.append( - SignoffPackageIssue( - kind="resource", - label="Origin SDC", - location=f"origin/{design}.sdc", - reason=origin_sdc_reason, - required=True, - destination=f"initial/{design}.sdc", - ) - ) - - add_file( - role="harden.gds", - source=workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.gds", - destination=f"harden/{design}.gds", - required=True, - ) - add_file( - role="harden.lef", - source=workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.lef", - destination=f"harden/{design}.lef", - required=True, - ) - add_file( - role="harden.lib", - source=workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.lib", - destination=f"harden/{design}.lib", - required=True, - ) - add_file( - role="harden.image", - source=workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.png", - destination=f"harden/{design}.png", - ) - - if origin_rtl is not None and origin_rtl.is_file(): - # Like synthesis, a configured input_filelist is always a filelist; - # runtime-created ones are suffixless (origin/filelist), so the - # suffix check alone would miss them. - is_filelist = ( - origin_rtl.suffix.lower() in FILELIST_SUFFIXES - or origin_rtl.name == "filelist" - or (configured_filelist is not None and origin_rtl == Path(configured_filelist)) - ) - if is_filelist: - # Workspace creation copies filelist sources into origin/ keeping - # each entry's filelist-relative path (absolute entries land at - # their basename); bundle them the same way and rewrite absolute - # entries to those basenames so the packaged filelist stays - # resolvable. +incdir header trees are not bundled. - try: - rtl_entries = parse_filelist(str(origin_rtl)) - filelist_text = rewrite_absolute_entries(origin_rtl.read_text(encoding="utf-8")) - except (OSError, ValueError) as error: - # Without the entries the packaged filelist would dangle, so - # block the export instead of shipping an incomplete package. - issues.append( - SignoffPackageIssue( - kind="resource", - label="Origin RTL sources", - location=self._review_source_path(workspace_dir, origin_rtl, "origin"), - reason=f"Could not parse origin filelist for packaging: {error}", - required=True, - destination=rtl_destination, - ) - ) - rtl_entries = [] - else: - add_file( - "initial.filelist", - origin_rtl, - rtl_destination, - required=True, - content=filelist_text, - ) - packaged_entries = set() - escaped_entries = [] - for rtl_entry in rtl_entries: - relative = ( - os.path.basename(rtl_entry) if os.path.isabs(rtl_entry) else rtl_entry - ) - # Parent-relative entries would escape initial/ (and already - # escaped origin/ at creation); bundling them corrupts the - # package layout, so they block the export instead. - normalized = os.path.normpath(relative) - if normalized == ".." or normalized.startswith(f"..{os.sep}"): - escaped_entries.append(rtl_entry) - continue - if normalized in packaged_entries: - continue - packaged_entries.add(normalized) - add_file( - "initial.verilog", - workspace_dir / "origin" / normalized, - f"initial/{normalized}", - required=True, - ) - if escaped_entries: - issues.append( - SignoffPackageIssue( - kind="resource", - label="Origin RTL sources", - location=self._review_source_path(workspace_dir, origin_rtl, "origin"), - reason=( - "Filelist entries escape the package layout: " - + ", ".join(escaped_entries) - ), - required=True, - destination=rtl_destination, - ) - ) - else: - add_file("initial.verilog", origin_rtl, rtl_destination, required=True) - if origin_sdc is not None: - add_file("initial.sdc", origin_sdc, f"initial/{design}.sdc", required=True) - from chipcompiler.data.workspace_config import workspace_config_path - - parameters_config = workspace_config_path(workspace_dir) - if not parameters_config.exists(): - # A read-only legacy workspace (TOML migration deferred) runs on - # its parameters.json — package the file it actually runs on. - parameters_config = workspace_dir / "home" / "parameters.json" - add_file( - "initial.parameters", - parameters_config, - f"initial/{parameters_config.name}", - required=True, - ) - - if has_synthesis: - add_file( - role="synthesis.verilog", - source=synthesis_verilog, - destination=f"synthesis/{design}.v.gz", - required=True, - ) - - lec_dir = workspace_dir / self._step_dirs()[StepEnum.POST_ROUTE_LEC.value] - lec_result = lec_dir / "output" / f"{design}_{StepEnum.POST_ROUTE_LEC.value}_result.json" - if require_lec: - add_file( - role="lec.result", - source=lec_result, - destination="final/reports/postRouteLec/result.json", - required=True, - ) - add_file( - role="lec.equiv_status", - source=lec_dir / "report" / "equiv_status.rpt", - destination="final/reports/postRouteLec/report/equiv_status.rpt", - required=True, - ) - add_file( - role="lec.status_report", - source=lec_dir / "report" / "run_lec_status.rpt", - destination="final/reports/postRouteLec/report/run_lec_status.rpt", - required=True, - ) - add_file( - role="lec.failed_rtlil", - source=lec_dir / "report" / "equiv_failed.il", - destination="final/reports/postRouteLec/report/equiv_failed.il", - ) - add_file( - role="lec.failed_verilog", - source=lec_dir / "report" / "equiv_failed.v", - destination="final/reports/postRouteLec/report/equiv_failed.v", - ) - from chipcompiler.tools.yosys_lec.utility import lec_result_status - - lec_status = lec_result_status( - lec_result, - golden_verilog=lec_golden, - gate_verilog=filler_verilog, - ) - if lec_result.is_file() and lec_status != "proven": - missing_required.append("final/reports/postRouteLec/result.json") - issues.append( - SignoffPackageIssue( - kind="resource", - label="lec.result", - location=self._review_source_path( - workspace_dir, - lec_result, - "final/reports/postRouteLec/result.json", - ), - reason=( - "Yosys LEC proof is stale; golden or gate netlist changed" - if lec_status == "stale" - else "Yosys LEC did not prove equivalence" - ), - required=True, - destination="final/reports/postRouteLec/result.json", - ) - ) - - add_file( - role="final.design.verilog", - source=filler_verilog, - destination=f"final/design/{design}.v.gz", - required=True, - ) - add_file( - role="final.design.def", - source=workspace_dir / "filler_ecc" / "output" / f"{design}_filler.def.gz", - destination=f"final/design/{design}.def.gz", - required=True, - ) - add_file( - role="final.design.gds", - source=workspace_dir / "filler_ecc" / "output" / f"{design}_filler.gds", - destination=f"final/design/{design}.gds", - required=True, - ) - add_file( - role="final.design.image", - source=workspace_dir / "filler_ecc" / "output" / f"{design}_filler.png", - destination=f"final/design/{design}.png", - ) - - sta_config = self._read_json(config_dir / "sta_ecc.json") - sta_matrix = self._sta_matrix(sta_config) - expected_spefs = set() - for item in sta_matrix: - expected_spefs.add( - f"{top_module}_{item['rcx_corner']}_{self._temperature_token(item['temperature'])}C.spef" - ) - report_dir = sta_artifact_directory( - workspace_dir / "sta_ecc" / "report", - item["lib_corner"], - item["temperature"], - item["rcx_corner"], - ) - feature_dir = sta_artifact_directory( - workspace_dir / "sta_ecc" / "feature", - item["lib_corner"], - item["temperature"], - item["rcx_corner"], - ) - report_dest = ( - f"final/timing/sta/{item['lib_corner']}_" - f"{self._temperature_token(item['temperature'])}/" - f"{item['rcx_corner']}/report" - ) - for report_name in STA_REPORT_FILENAMES: - add_file( - role="final.sta_report", - source=report_dir / report_name, - destination=f"{report_dest}/{report_name}", - required=True, - ) - # Optional: workspaces whose STA ran before power collection have - # no per-corner power report; package it when present. - add_file( - role="final.sta_report", - source=report_dir / STA_POWER_REPORT_FILENAME, - destination=f"{report_dest}/{STA_POWER_REPORT_FILENAME}", - ) - item["report"] = f"{report_dest}/qor_summary.rpt" - feature_dest = report_dest.removesuffix("/report") + "/feature" - add_file( - role="final.sta_qor_summary", - source=feature_dir / STA_QOR_SUMMARY_FILENAME, - destination=f"{feature_dest}/{STA_QOR_SUMMARY_FILENAME}", - required=True, - ) - add_file( - role="final.sta_timing_paths", - source=feature_dir / STA_TIMING_PATHS_FILENAME, - destination=f"{feature_dest}/{STA_TIMING_PATHS_FILENAME}", - required=True, - ) - item["qor_summary"] = f"{feature_dest}/{STA_QOR_SUMMARY_FILENAME}" - item["timing_paths"] = f"{feature_dest}/{STA_TIMING_PATHS_FILENAME}" - - rcx_output_dir = workspace_dir / "RCX_ecc" / "output" - spef_paths = sorted(rcx_output_dir.glob("*.spef")) if rcx_output_dir.is_dir() else [] - if expected_spefs: - for spef_name in sorted(expected_spefs): - add_file( - role="final.spef", - source=rcx_output_dir / spef_name, - destination=f"final/timing/spef/{spef_name}", - required=True, - ) - for spef_path in spef_paths: - if spef_path.name not in expected_spefs: - add_file( - role="final.spef", - source=spef_path, - destination=f"final/timing/spef/{spef_path.name}", - ) - elif spef_paths: - for spef_path in spef_paths: - add_file( - role="final.spef", - source=spef_path, - destination=f"final/timing/spef/{spef_path.name}", - required=True, - ) - else: - missing_required.append("RCX SPEF files") - issues.append( - SignoffPackageIssue( - kind="resource", - label="RCX SPEF files", - location="RCX_ecc/output", - reason="No SPEF files were found", - required=True, - destination="RCX SPEF files", - ) - ) - - add_file("status.flow", flow_path, "final/reports/flow.json", required=True) - - for step_name, step_dir in self._step_dirs().items(): - if step_name == StepEnum.POST_ROUTE_LEC.value: - continue - for kind in ("analysis", "report"): - self._copy_tree_files( - workspace_dir=workspace_dir, - package_dir=package_dir, - source_dir=workspace_dir / step_dir / kind, - destination_dir=f"final/reports/{step_name}/{kind}", - role=f"report.{kind}", - copied=copied, - missing_optional=missing_optional, - issues=issues, - materialize=options.materialize, - ) - - if options.include_debug: - self._collect_debug_files( - workspace_dir=workspace_dir, - package_dir=package_dir, - copied=copied, - missing_optional=missing_optional, - issues=issues, - materialize=options.materialize, - ) - - # Resource collection finds package evidence, but the refreshed home - # checklist is the single authority for signoff readiness and export. - from chipcompiler.tools.ecc.signoff_checklist import rebuild_home_checklist - - analysis_issues = refresh_issues - checklist_data = rebuild_home_checklist( - self.workspace, - resource_issues=[*issues, *analysis_issues], - ) - add_file( - "status.checklist", - checklist_path, - "final/reports/checklist.json", - required=True, - ) - checklist_counts = checklist_data.get("summary", {}) - checklist_items = checklist_data.get("checklist", []) - blocked_items = [ - item - for item in checklist_items - if isinstance(item, dict) and item.get("blocked") is True - ] - attention_items = [ - item - for item in checklist_items - if isinstance(item, dict) and item.get("state") == "warning" - ] - missing_required = [str(item.get("id")) for item in blocked_items] - missing_optional = [str(item.get("id")) for item in attention_items] - if blocked_items or attention_items: - warnings.append("home checklist requires attention; see final/reports/checklist.json") - - qor_metrics = self._read_json(workspace_dir / "drc_ecc" / "analysis" / "qor_metrics.json") - ok = len(blocked_items) == 0 - flow_success = all(state == StateEnum.Success.value for state in required_steps.values()) - summary = { - "schema_version": 1, - "status": "ok" if ok else "incomplete", - "design": design, - "top_module": top_module, - "pdk": pdk_name, - "required_steps": required_steps, - "checks": { - "flow": "passed" if flow_success else "failed", - "home_checklist": checklist_counts, - "qor_analysis_issue_count": len(analysis_issues), - }, - "initial": { - "verilog": rtl_destination, - "sdc": f"initial/{design}.sdc", - "parameters": f"initial/{parameters_config.name}", - }, - "config": "config/", - "harden": { - "gds": f"harden/{design}.gds", - "lef": f"harden/{design}.lef", - "lib": f"harden/{design}.lib", - }, - "final": { - "verilog": f"final/design/{design}.v.gz", - "def": f"final/design/{design}.def.gz", - "gds": f"final/design/{design}.gds", - "image": f"final/design/{design}.png", - }, - "qor_metrics": qor_metrics, - "sta_matrix": sta_matrix, - "missing_required": missing_required, - "missing_optional": missing_optional, - "warnings": warnings, - } - if require_lec: - lec_payload = self._read_json(lec_result) - summary["lec"] = { - "status": lec_payload.get("status", ""), - "result": "final/reports/postRouteLec/result.json", - "equiv_status": "final/reports/postRouteLec/report/equiv_status.rpt", - "status_report": "final/reports/postRouteLec/report/run_lec_status.rpt", - "golden_verilog": lec_payload.get("golden_verilog", ""), - "gate_verilog": lec_payload.get("gate_verilog", ""), - } - if has_synthesis: - summary["synthesis"] = {"verilog": f"synthesis/{design}.v.gz"} - summary_path = package_dir / "summary.json" - - manifest = { - "schema_version": 1, - "created_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), - "workspace": str(workspace_dir.resolve()), - "design": design, - "top_module": top_module, - "pdk": pdk_name, - "flow": { - "source": "home/flow.json", - "all_required_steps_success": flow_success, - }, - "files": copied, - "missing_required": missing_required, - "missing_optional": missing_optional, - "warnings": warnings, - } - manifest_path = package_dir / "manifest.json" - - archive_path = None - if options.materialize: - summary_path.write_text(json.dumps(summary, indent=2)) - manifest_path.write_text(json.dumps(manifest, indent=2)) - - readme_path = package_dir / "README.md" - input_verilog_description = "- Mapped synthesis netlist is under `synthesis/`.\n" - if not has_synthesis: - input_verilog_description = ( - "- Original imported netlist is under `initial/` because this flow " - "has no Synthesis step.\n" - ) - readme_path.write_text( - f"# {design} Signoff Package\n\n" - + f"- Workspace: {workspace_dir.resolve()}\n" - + f"- Status: {summary['status']}\n" - + input_verilog_description - + "- Harden outputs are under `harden/`.\n" - + "- Final physical resources are under `final/`.\n" - + "- Post-route LEC evidence is under `final/reports/postRouteLec/`.\n" - ) - - if options.archive and (ok or options.allow_incomplete): - archive_path = str(package_dir.with_suffix(".tar.gz")) - archive_file = Path(archive_path) - if archive_file.exists(): - archive_file.unlink() - with tarfile.open(archive_file, "w:gz") as archive: - archive.add(package_dir, arcname=package_dir.name) - - return SignoffPackageResult( - ok=ok, - package_dir=str(package_dir), - archive_path=archive_path, - manifest_path=str(manifest_path), - summary_path=str(summary_path), - copied=copied, - missing_required=missing_required, - missing_optional=missing_optional, - warnings=warnings, - issues=issues, - ) - - def _add_file( - self, - workspace_dir: Path, - package_dir: Path, - role: str, - source: Path | None, - destination: str, - *, - required: bool, - copied: list[dict], - missing_required: list[str], - missing_optional: list[str], - issues: list[SignoffPackageIssue], - materialize: bool, - content: str | None = None, - ) -> None: - if content is not None: - missing = not content - else: - missing = source is None or not source.is_file() or source.stat().st_size <= 0 - if missing: - if required: - missing_required.append(destination) - else: - missing_optional.append(destination) - issues.append( - SignoffPackageIssue( - kind="resource", - label=role, - location=self._review_source_path(workspace_dir, source, destination), - reason=( - "Required file is missing or empty" - if required - else "Optional file is missing or empty" - ), - required=required, - destination=destination, - ) - ) - return - - if materialize: - target = package_dir / destination - target.parent.mkdir(parents=True, exist_ok=True) - if content is None: - shutil.copy2(source, target) - else: - target.write_text(content, encoding="utf-8") - size_bytes = target.stat().st_size - digest = file_digest(target) - sha256 = digest[0] if digest else None - else: - size_bytes = len(content.encode()) if content is not None else source.stat().st_size - sha256 = None - copied.append( - { - "role": role, - "required": required, - "source": self._source_path(workspace_dir, source), - "destination": destination, - "size_bytes": size_bytes, - "sha256": sha256, - } - ) - - def _copy_tree_files( - self, - workspace_dir: Path, - package_dir: Path, - source_dir: Path, - destination_dir: str, - role: str, - copied: list[dict], - missing_optional: list[str], - issues: list[SignoffPackageIssue], - *, - materialize: bool, - ) -> None: - if not source_dir.is_dir(): - return - for source in sorted(path for path in source_dir.rglob("*") if path.is_file()): - relative = source.relative_to(source_dir).as_posix() - self._add_file( - workspace_dir=workspace_dir, - package_dir=package_dir, - role=role, - source=source, - destination=f"{destination_dir}/{relative}", - required=False, - copied=copied, - missing_required=[], - missing_optional=missing_optional, - issues=issues, - materialize=materialize, - ) - - def _collect_debug_files( - self, - workspace_dir: Path, - package_dir: Path, - copied: list[dict], - missing_optional: list[str], - issues: list[SignoffPackageIssue], - *, - materialize: bool, - ) -> None: - patterns = [ - "*_ecc/feature/**/*", - "*_ecc/subflow.json", - ] - for pattern in patterns: - for path_text in sorted(glob.glob(str(workspace_dir / pattern), recursive=True)): - source = Path(path_text) - if not source.is_file(): - continue - destination = f"debug/{source.relative_to(workspace_dir).as_posix()}" - self._add_file( - workspace_dir=workspace_dir, - package_dir=package_dir, - role="debug", - source=source, - destination=destination, - required=False, - copied=copied, - missing_required=[], - missing_optional=missing_optional, - issues=issues, - materialize=materialize, - ) - output_db_dirs = sorted(workspace_dir.glob("*_ecc/output/*_db")) - output_view_dirs = sorted(workspace_dir.glob("*_ecc/output/*_view")) - for source in output_db_dirs + output_view_dirs: - if not source.is_dir(): - continue - self._copy_tree_files( - workspace_dir=workspace_dir, - package_dir=package_dir, - source_dir=source, - destination_dir=f"debug/{source.relative_to(workspace_dir).as_posix()}", - role="debug", - copied=copied, - missing_optional=missing_optional, - issues=issues, - materialize=materialize, - ) - - def _read_json(self, path: Path) -> dict: - try: - with open(path, encoding="utf-8") as file: - data = json.load(file) - except (OSError, json.JSONDecodeError, UnicodeDecodeError): - return {} - return data if isinstance(data, dict) else {} - - def _read_parameters(self, path: Path) -> dict: - """Read the workspace configuration's [params] section; {} when unreadable.""" - import tomllib - - try: - with open(path, "rb") as file: - data = tomllib.load(file) - except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError): - return {} - params = data.get("params", {}) - return params if isinstance(params, dict) else {} - - def _path_from_config(self, workspace_dir: Path, path_text: str) -> Path | None: - if not path_text: - return None - path = Path(path_text) - if not path.is_absolute(): - path = workspace_dir / path - return path if path.is_file() else None - - def _source_path(self, workspace_dir: Path, source: Path) -> str: - try: - return source.relative_to(workspace_dir).as_posix() - except ValueError: - return str(source) - - def _review_source_path( - self, - workspace_dir: Path, - source: Path | None, - fallback: str, - ) -> str: - if source is None: - return fallback - try: - return source.relative_to(workspace_dir).as_posix() - except ValueError: - return source.name - - def _find_one( - self, - directory: Path, - preferred_name: str, - pattern: str, - ) -> tuple[Path | None, str]: - preferred = directory / preferred_name - if preferred.is_file(): - return preferred, "" - matches = sorted(directory.glob(pattern)) if directory.is_dir() else [] - if len(matches) == 1: - return matches[0], "" - if len(matches) > 1: - return None, "Multiple matching files found" - return None, "Required file is missing or empty" - - def _design_from_outputs(self, workspace_dir: Path) -> str: - for pattern, suffix in ( - ("Harden_ecc/output/*_Harden.gds", "_Harden.gds"), - ("filler_ecc/output/*_filler.v.gz", "_filler.v.gz"), - ): - matches = sorted(workspace_dir.glob(pattern)) - if matches: - name = matches[0].name - if name.endswith(suffix): - return name[: -len(suffix)] - return "" - - def _synthesis_output_verilog(self) -> Path | None: - """Resolve the netlist from the Yosys step's declared output contract.""" - synthesis_step = self._build_workspace_step( - {"name": StepEnum.SYNTHESIS.value, "tool": "yosys"}, - previous_step=None, - ) - output = getattr(synthesis_step, "output", None) - verilog = getattr(output, "verilog", None) - return Path(verilog) if verilog else None - - def _required_step_states(self, *, require_lec: bool) -> dict: - required = [ - StepEnum.HARDEN.value, - StepEnum.RCX.value, - StepEnum.STA.value, - StepEnum.DRC.value, - StepEnum.LVS.value, - StepEnum.FILLER.value, - StepEnum.ROUTING.value, - ] - if require_lec: - required.append(StepEnum.POST_ROUTE_LEC.value) - states = {} - for step in required: - entry = self.workspace.flow.get_step(step) - states[step] = entry.get("state", "") if entry else "" - return states - - def _requires_post_route_lec( - self, - golden_verilog: Path | None, - filler_verilog: Path | None, - ) -> bool: - if golden_verilog is None or not Path(golden_verilog).is_file(): - return False - return bool(filler_verilog and Path(filler_verilog).is_file()) - - def _refresh_workspace_analysis(self, workspace_dir: Path) -> list[SignoffPackageIssue]: - """Rebuild current V3 analysis and checklist snapshots for completed steps.""" - if self.workspace.flow.path is None: - self.workspace.flow.path = workspace_dir / "home" / "flow.json" - issues: list[SignoffPackageIssue] = [] - previous_step = None - - for flow_step in self.workspace.flow.steps(): - step_name = str(flow_step.get("name", "")) - tool = str(flow_step.get("tool", "")) - if not step_name or not tool: - continue - - try: - workspace_step = self._build_workspace_step(flow_step, previous_step) - except (ImportError, OSError, TypeError, ValueError): - workspace_step = None - if workspace_step is None: - issues.append( - self._analysis_issue( - step_name=step_name, - required=step_name in SIGNOFF_REQUIRED_QOR_STEPS, - reason=f"Could not construct the current {tool} step definition", - kind="freshness", - ) - ) - continue - - if ( - previous_step is not None - and previous_step.name == StepEnum.RCX.value - and workspace_step.name == StepEnum.STA.value - ): - workspace_step.output.spef = previous_step.output.spef - - if tool != "yosys_lec": - previous_step = workspace_step - if flow_step.get("state") != StateEnum.Success.value: - continue - if tool == "yosys_lec": - continue - - try: - self._refresh_step_analysis(workspace_step) - except Exception as error: - issues.append( - self._analysis_issue( - step_name=step_name, - required=step_name in SIGNOFF_REQUIRED_QOR_STEPS, - reason=f"Current-output analysis refresh failed: {error}", - kind="freshness", - ) - ) - - return issues - - def _build_workspace_step(self, flow_step: dict, previous_step): - step_name = str(flow_step.get("name", "")) - tool = str(flow_step.get("tool", "")) - module_alias = { - "klayout": "klayout_tool", - "dreamplace": "ecc_dreamplace", - "sizer": "ecc_sizer", - } - try: - builder = importlib.import_module( - f"chipcompiler.tools.{module_alias.get(tool, tool)}.builder" - ) - except ImportError: - return None - - build_step = getattr(builder, "build_step", None) - if not callable(build_step): - return None - - if previous_step is None: - input_def = self.workspace.design.origin_def - input_verilog = self.workspace.design.origin_verilog - input_db = None - else: - input_def = previous_step.output.def_ - input_verilog = previous_step.output.verilog - input_db = previous_step.output.db - return build_step( - workspace=self.workspace, - step_name=step_name, - input_def=input_def, - input_verilog=input_verilog, - input_db=input_db, - ) - - def _refresh_step_analysis(self, step) -> None: - if step.tool == "yosys": - from chipcompiler.tools.yosys.checklist import YosysChecklist - from chipcompiler.tools.yosys.metrics import build_step_metrics - - checker_class = YosysChecklist - elif step.tool == "dreamplace": - from chipcompiler.tools.ecc.metrics import build_step_metrics - from chipcompiler.tools.ecc_dreamplace.checklist import DreamplaceChecklist - - checker_class = DreamplaceChecklist - else: - from chipcompiler.tools.ecc.checklist import EccChecklist - from chipcompiler.tools.ecc.metrics import build_step_metrics - - checker_class = EccChecklist - - if build_step_metrics(workspace=self.workspace, step=step) is None: - raise RuntimeError("no current metrics could be built") - checker = checker_class(workspace=self.workspace, workspace_step=step) - checker.check() - - def _qor_summary_issues( - self, workspace_dir: Path, flow_data: dict - ) -> list[SignoffPackageIssue]: - issues: list[SignoffPackageIssue] = [] - for flow_step in flow_data.get("steps", []): - if not isinstance(flow_step, dict) or flow_step.get("state") != StateEnum.Success.value: - continue - step_name = str(flow_step.get("name", "")) - step_dir = self._step_dirs().get(step_name) - if not step_name or not step_dir: - continue - summary_path = workspace_dir / step_dir / "analysis" / "qor_summary.json" - summary = self._read_json(summary_path) - required = step_name in SIGNOFF_REQUIRED_QOR_STEPS - if summary.get("schema_version") != 3: - issues.append( - self._analysis_issue( - step_name=step_name, - required=required, - reason=( - "qor_summary.json is missing or does not use the current V3 contract" - ), - kind="freshness", - ) - ) - continue - - if not summary.get("analysis_revision"): - issues.append( - self._analysis_issue( - step_name=step_name, - required=required, - reason="qor_summary.json has no current analysis revision", - kind="freshness", - ) - ) - - blocking_issues = summary.get("blocking_issues", []) - for blocking_issue in blocking_issues if isinstance(blocking_issues, list) else []: - if not isinstance(blocking_issue, dict): - continue - metric_id = str(blocking_issue.get("metric_id", "QoR blocking issue")) - reason = str(blocking_issue.get("reason", "Current QoR analysis blocked signoff.")) - value = blocking_issue.get("value") - if value is not None: - reason = f"{reason} actual={value}" - issues.append( - self._analysis_issue( - step_name=step_name, - required=required, - label=metric_id, - reason=reason, - kind="analysis", - ) - ) - - hard_gates = summary.get("hard_gates", []) - for gate in hard_gates if isinstance(hard_gates, list) else []: - if not isinstance(gate, dict) or gate.get("passed") is not False: - continue - gate_id = str(gate.get("id", "QoR hard gate")) - reason = ( - f"{gate.get('metric', gate_id)} actual={gate.get('actual')} " - f"does not satisfy {gate.get('threshold')}" - ) - issues.append( - self._analysis_issue( - step_name=step_name, - required=required, - label=gate_id, - reason=reason, - kind="analysis", - ) - ) - - missing_metrics = summary.get("missing_metrics", []) - for missing_metric in missing_metrics if isinstance(missing_metrics, list) else []: - if not isinstance(missing_metric, dict): - continue - issues.append( - self._analysis_issue( - step_name=step_name, - required=False, - label=str(missing_metric.get("metric_id", "QoR metric")), - reason=str( - missing_metric.get( - "reason", "The required current QoR metric is unavailable." - ) - ), - kind="analysis", - ) - ) - return issues - - def _analysis_issue( - self, - step_name: str, - *, - required: bool, - reason: str, - kind: str, - label: str | None = None, - ) -> SignoffPackageIssue: - step_dir = self._step_dirs().get(step_name, step_name) - return SignoffPackageIssue( - kind=kind, - label=label or f"{step_name} QoR analysis", - location=f"{step_dir}/analysis/qor_summary.json", - reason=reason, - required=required, - destination=f"analysis/{step_name}/qor_summary.json", - ) - - def _checklist_counts(self, checklist_data: dict) -> dict: - counts = {"passed": 0, "warning": 0, "failed": 0} - for item in checklist_data.get("checklist", []): - if not isinstance(item, dict): - continue - state = str(item.get("state", "")).lower() - if state == "passed": - counts["passed"] += 1 - elif state == "warning": - counts["warning"] += 1 - elif state == "failed": - counts["failed"] += 1 - return counts - - def _checklist_issues(self, checklist_data: dict) -> list[SignoffPackageIssue]: - issues = [] - for item in checklist_data.get("checklist", []): - if not isinstance(item, dict): - continue - state = str(item.get("state", "")).strip() - normalized_state = state.lower() - if normalized_state not in {"warning", "failed"}: - continue - scope = " / ".join( - str(item.get(key, "")).strip() - for key in ("step", "type", "item") - if str(item.get(key, "")).strip() - ) - info = str(item.get("info", "")).strip() - issues.append( - SignoffPackageIssue( - kind="checklist", - label=str(item.get("item", "Checklist item")).strip() or "Checklist item", - location=scope or "home/checklist.json", - reason=f"{state or normalized_state.title()}{f': {info}' if info else ''}", - required=False, - destination="final/reports/checklist.json", - ) - ) - return issues - - def _sta_matrix(self, sta_config: dict) -> list[dict]: - liberty_by_corner = { - item.get("corner"): item - for item in sta_config.get("liberty", []) - if isinstance(item, dict) - } - matrix = [] - for signoff_group in sta_config.get("signoff", []): - if not isinstance(signoff_group, dict): - continue - for lib_corner, rcx_corners in signoff_group.items(): - liberty = liberty_by_corner.get(lib_corner, {}) - if isinstance(rcx_corners, str): - rcx_corners = [rcx_corners] - for rcx_corner in rcx_corners: - matrix.append( - { - "lib_corner": lib_corner, - "temperature": liberty.get("temperature", ""), - "rcx_corner": rcx_corner, - } - ) - return matrix - - def _temperature_token(self, temperature) -> str: - try: - numeric = float(temperature) - if numeric.is_integer(): - temperature = int(numeric) - except (TypeError, ValueError): - pass - return str(temperature).replace("-", "m").replace(".", "p") - - def _step_dirs(self) -> dict[str, str]: - return { - StepEnum.SYNTHESIS.value: "Synthesis_yosys", - StepEnum.LEC.value: "lec_yosys_lec", - StepEnum.FLOORPLAN.value: "Floorplan_ecc", - StepEnum.PLACEMENT.value: "place_dreamplace", - StepEnum.CTS.value: "CTS_ecc", - StepEnum.LEGALIZATION.value: "legalization_dreamplace", - StepEnum.ROUTING.value: "route_ecc", - StepEnum.DRC.value: "drc_ecc", - StepEnum.LVS.value: "lvs_ecc", - StepEnum.FILLER.value: "filler_ecc", - StepEnum.POST_ROUTE_LEC.value: "postRouteLec_yosys_lec", - StepEnum.RCX.value: "RCX_ecc", - StepEnum.STA.value: "sta_ecc", - StepEnum.HARDEN.value: "Harden_ecc", - } +__all__ = [ + "SIGNOFF_REQUIRED_QOR_STEPS", + "SignoffPackageCollector", + "SignoffPackageIssue", + "SignoffPackageOptions", + "SignoffPackageResult", +] -# Public entry point for the text design summary; the implementation lives in # the engine.signoff.report* sibling modules, re-exported here as the API. from chipcompiler.engine.signoff.report import generate_text_report # noqa: E402,F401 diff --git a/chipcompiler/engine/signoff/analysis.py b/chipcompiler/engine/signoff/analysis.py new file mode 100644 index 000000000..03d2f9fde --- /dev/null +++ b/chipcompiler/engine/signoff/analysis.py @@ -0,0 +1,327 @@ +"""Analysis refresh and validation checks for the signoff package collector. + +Split from the collector core: these mixin methods rebuild current V3 +analysis and checklist snapshots for completed steps and turn stale or +missing evidence into package issues. Artifact discovery, lookup, and +materialization stay in collector.py and discovery.py; shared helpers +(``_step_dirs``, ``_read_json``) resolve through the composed collector. +""" + +import importlib +from pathlib import Path + +from chipcompiler.data import EccOutput, StateEnum, StepEnum +from chipcompiler.engine.signoff.models import SIGNOFF_REQUIRED_QOR_STEPS, SignoffPackageIssue + + +class CollectorAnalysisMixin: + def _refresh_workspace_analysis(self, workspace_dir: Path) -> list[SignoffPackageIssue]: + """Rebuild current V3 analysis and checklist snapshots for completed steps.""" + if self.workspace.flow.path is None: + self.workspace.flow.path = workspace_dir / "home" / "flow.json" + issues: list[SignoffPackageIssue] = [] + previous_step = None + + for flow_step in self.workspace.flow.steps(): + step_name = str(flow_step.get("name", "")) + tool = str(flow_step.get("tool", "")) + if not step_name or not tool: + continue + + try: + workspace_step = self._build_workspace_step(flow_step, previous_step) + except (ImportError, OSError, TypeError, ValueError): + workspace_step = None + if workspace_step is None: + issues.append( + self._analysis_issue( + step_name=step_name, + required=step_name in SIGNOFF_REQUIRED_QOR_STEPS, + reason=f"Could not construct the current {tool} step definition", + kind="freshness", + ) + ) + continue + + if ( + previous_step is not None + and previous_step.name == StepEnum.RCX.value + and workspace_step.name == StepEnum.STA.value + ): + workspace_step.output.spef = previous_step.output.spef + + if tool != "yosys_lec": + previous_step = workspace_step + if flow_step.get("state") != StateEnum.Success.value: + continue + if tool == "yosys_lec": + continue + + try: + self._refresh_step_analysis(workspace_step) + except Exception as error: + issues.append( + self._analysis_issue( + step_name=step_name, + required=step_name in SIGNOFF_REQUIRED_QOR_STEPS, + reason=f"Current-output analysis refresh failed: {error}", + kind="freshness", + ) + ) + + return issues + + def _build_workspace_step(self, flow_step: dict, previous_step): + step_name = str(flow_step.get("name", "")) + tool = str(flow_step.get("tool", "")) + module_alias = { + "klayout": "klayout_tool", + "dreamplace": "ecc_dreamplace", + "sizer": "ecc_sizer", + } + try: + builder = importlib.import_module( + f"chipcompiler.tools.{module_alias.get(tool, tool)}.builder" + ) + except ImportError: + return None + + build_step = getattr(builder, "build_step", None) + if not callable(build_step): + return None + + if previous_step is None: + input_def = self.workspace.design.origin_def + input_verilog = self.workspace.design.origin_verilog + input_db = None + else: + input_def = previous_step.output.def_ + input_verilog = previous_step.output.verilog + input_db = previous_step.output.db + workspace_step = build_step( + workspace=self.workspace, + step_name=step_name, + input_def=input_def, + input_verilog=input_verilog, + input_db=input_db, + ) + if workspace_step is not None: + # Mirror the execution-side projection (engine/flow.py): a + # persisted info.spef overrides the STA step's chained SPEFs. + step_info = flow_step.get("info") or {} + if ( + step_name == StepEnum.STA.value + and step_info.get("spef") + and isinstance(workspace_step.output, EccOutput) + ): + workspace_step.output.spef = [Path(step_info["spef"])] + return workspace_step + + def _refresh_step_analysis(self, step) -> None: + if step.tool == "yosys": + from chipcompiler.tools.yosys.checklist import YosysChecklist + from chipcompiler.tools.yosys.metrics import build_step_metrics + + checker_class = YosysChecklist + elif step.tool == "dreamplace": + from chipcompiler.tools.ecc.metrics import build_step_metrics + from chipcompiler.tools.ecc_dreamplace.checklist import DreamplaceChecklist + + checker_class = DreamplaceChecklist + else: + from chipcompiler.tools.ecc.checklist import EccChecklist + from chipcompiler.tools.ecc.metrics import build_step_metrics + + checker_class = EccChecklist + + if build_step_metrics(workspace=self.workspace, step=step) is None: + raise RuntimeError("no current metrics could be built") + checker = checker_class(workspace=self.workspace, workspace_step=step) + checker.check() + + def _qor_summary_issues( + self, workspace_dir: Path, flow_data: dict + ) -> list[SignoffPackageIssue]: + issues: list[SignoffPackageIssue] = [] + for flow_step in flow_data.get("steps", []): + if not isinstance(flow_step, dict) or flow_step.get("state") != StateEnum.Success.value: + continue + step_name = str(flow_step.get("name", "")) + step_dir = self._step_dirs().get(step_name) + if not step_name or not step_dir: + continue + summary_path = workspace_dir / step_dir / "analysis" / "qor_summary.json" + summary = self._read_json(summary_path) + required = step_name in SIGNOFF_REQUIRED_QOR_STEPS + if summary.get("schema_version") != 3: + issues.append( + self._analysis_issue( + step_name=step_name, + required=required, + reason=( + "qor_summary.json is missing or does not use the current V3 contract" + ), + kind="freshness", + ) + ) + continue + + if not summary.get("analysis_revision"): + issues.append( + self._analysis_issue( + step_name=step_name, + required=required, + reason="qor_summary.json has no current analysis revision", + kind="freshness", + ) + ) + + blocking_issues = summary.get("blocking_issues", []) + for blocking_issue in blocking_issues if isinstance(blocking_issues, list) else []: + if not isinstance(blocking_issue, dict): + continue + metric_id = str(blocking_issue.get("metric_id", "QoR blocking issue")) + reason = str(blocking_issue.get("reason", "Current QoR analysis blocked signoff.")) + value = blocking_issue.get("value") + if value is not None: + reason = f"{reason} actual={value}" + issues.append( + self._analysis_issue( + step_name=step_name, + required=required, + label=metric_id, + reason=reason, + kind="analysis", + ) + ) + + hard_gates = summary.get("hard_gates", []) + for gate in hard_gates if isinstance(hard_gates, list) else []: + if not isinstance(gate, dict) or gate.get("passed") is not False: + continue + gate_id = str(gate.get("id", "QoR hard gate")) + reason = ( + f"{gate.get('metric', gate_id)} actual={gate.get('actual')} " + f"does not satisfy {gate.get('threshold')}" + ) + issues.append( + self._analysis_issue( + step_name=step_name, + required=required, + label=gate_id, + reason=reason, + kind="analysis", + ) + ) + + missing_metrics = summary.get("missing_metrics", []) + for missing_metric in missing_metrics if isinstance(missing_metrics, list) else []: + if not isinstance(missing_metric, dict): + continue + issues.append( + self._analysis_issue( + step_name=step_name, + required=False, + label=str(missing_metric.get("metric_id", "QoR metric")), + reason=str( + missing_metric.get( + "reason", "The required current QoR metric is unavailable." + ) + ), + kind="analysis", + ) + ) + return issues + + def _analysis_issue( + self, + step_name: str, + *, + required: bool, + reason: str, + kind: str, + label: str | None = None, + ) -> SignoffPackageIssue: + step_dir = self._step_dirs().get(step_name, step_name) + return SignoffPackageIssue( + kind=kind, + label=label or f"{step_name} QoR analysis", + location=f"{step_dir}/analysis/qor_summary.json", + reason=reason, + required=required, + destination=f"analysis/{step_name}/qor_summary.json", + ) + + def _checklist_counts(self, checklist_data: dict) -> dict: + counts = {"passed": 0, "warning": 0, "failed": 0} + for item in checklist_data.get("checklist", []): + if not isinstance(item, dict): + continue + state = str(item.get("state", "")).lower() + if state == "passed": + counts["passed"] += 1 + elif state == "warning": + counts["warning"] += 1 + elif state == "failed": + counts["failed"] += 1 + return counts + + def _checklist_issues(self, checklist_data: dict) -> list[SignoffPackageIssue]: + issues = [] + for item in checklist_data.get("checklist", []): + if not isinstance(item, dict): + continue + state = str(item.get("state", "")).strip() + normalized_state = state.lower() + if normalized_state not in {"warning", "failed"}: + continue + scope = " / ".join( + str(item.get(key, "")).strip() + for key in ("step", "type", "item") + if str(item.get(key, "")).strip() + ) + info = str(item.get("info", "")).strip() + issues.append( + SignoffPackageIssue( + kind="checklist", + label=str(item.get("item", "Checklist item")).strip() or "Checklist item", + location=scope or "home/checklist.json", + reason=f"{state or normalized_state.title()}{f': {info}' if info else ''}", + required=False, + destination="final/reports/checklist.json", + ) + ) + return issues + + def _sta_matrix(self, sta_config: dict) -> list[dict]: + liberty_by_corner = { + item.get("corner"): item + for item in sta_config.get("liberty", []) + if isinstance(item, dict) + } + matrix = [] + for signoff_group in sta_config.get("signoff", []): + if not isinstance(signoff_group, dict): + continue + for lib_corner, rcx_corners in signoff_group.items(): + liberty = liberty_by_corner.get(lib_corner, {}) + if isinstance(rcx_corners, str): + rcx_corners = [rcx_corners] + for rcx_corner in rcx_corners: + matrix.append( + { + "lib_corner": lib_corner, + "temperature": liberty.get("temperature", ""), + "rcx_corner": rcx_corner, + } + ) + return matrix + + def _temperature_token(self, temperature) -> str: + try: + numeric = float(temperature) + if numeric.is_integer(): + temperature = int(numeric) + except (TypeError, ValueError): + pass + return str(temperature).replace("-", "m").replace(".", "p") diff --git a/chipcompiler/engine/signoff/collector.py b/chipcompiler/engine/signoff/collector.py new file mode 100644 index 000000000..ba4fac133 --- /dev/null +++ b/chipcompiler/engine/signoff/collector.py @@ -0,0 +1,874 @@ +"""Signoff package collection: discovery, materialization, and archiving. + +The collector core orchestrates one signoff package build: it verifies +step states, copies artifacts into ``final/reports`` layout, writes the +manifest and summary, and archives the tree. Validation lives in +analysis.py, artifact lookup in discovery.py, and the package data +model in models.py. +""" + +import glob +import json +import os +import shutil +import tarfile +import time +from pathlib import Path + +from chipcompiler.data import StateEnum, StepEnum, Workspace +from chipcompiler.engine.signoff.analysis import CollectorAnalysisMixin +from chipcompiler.engine.signoff.discovery import CollectorDiscoveryMixin +from chipcompiler.engine.signoff.models import ( + SignoffPackageIssue, + SignoffPackageOptions, + SignoffPackageResult, +) +from chipcompiler.tools.ecc.sta_qor import ( + STA_POWER_REPORT_FILENAME, + STA_QOR_SUMMARY_FILENAME, + STA_REPORT_FILENAMES, + STA_TIMING_PATHS_FILENAME, + sta_artifact_directory, +) +from chipcompiler.utility import file_digest +from chipcompiler.utility.filelist import ( + FILELIST_SUFFIXES, + parse_filelist, + resolve_initial_rtl, + rewrite_absolute_entries, +) + + +class SignoffPackageCollector(CollectorAnalysisMixin, CollectorDiscoveryMixin): + def __init__(self, workspace: Workspace): + self.workspace = workspace + + def text_report(self) -> str: + """GUI-parity text design summary for this workspace. + + The implementation lives in engine.signoff.report to keep this module + from growing further; re-exported below as the public entry point. + """ + return generate_text_report(self.workspace) + + def collect( + self, + options: SignoffPackageOptions | None = None, + ) -> SignoffPackageResult: + options = options or SignoffPackageOptions() + if self.workspace is None or not self.workspace.directory: + raise FileNotFoundError("workspace is not configured") + + workspace_dir = Path(self.workspace.directory) + if not workspace_dir.exists(): + raise FileNotFoundError(f"workspace does not exist: {workspace_dir}") + + refresh_issues = ( + self._refresh_workspace_analysis(workspace_dir) if options.refresh_analysis else [] + ) + + from chipcompiler.data.workspace_config import workspace_config_path + + parameters = self._read_parameters(workspace_config_path(workspace_dir)) + design = ( + self.workspace.design.name + or parameters.get("design", "") + or self._design_from_outputs(workspace_dir) + ) + top_module = self.workspace.design.top_module or parameters.get("top_module", "") or design + pdk_name = getattr(self.workspace.pdk, "name", "") or parameters.get("pdk", "") + if not design: + raise ValueError("cannot determine design name for signoff package") + + package_root = Path(options.output_dir) if options.output_dir else workspace_dir / "signoff" + package_dir = package_root / f"{design}_signoff_package" + if options.materialize: + if package_dir.exists(): + shutil.rmtree(package_dir) + package_dir.mkdir(parents=True, exist_ok=True) + + copied: list[dict] = [] + missing_required: list[str] = [] + missing_optional: list[str] = [] + warnings: list[str] = [] + issues: list[SignoffPackageIssue] = [] + + def add_file( + role: str, + source: Path | None, + destination: str, + *, + required: bool = False, + content: str | None = None, + ) -> None: + self._add_file( + workspace_dir=workspace_dir, + package_dir=package_dir, + role=role, + source=source, + destination=destination, + required=required, + copied=copied, + missing_required=missing_required, + missing_optional=missing_optional, + issues=issues, + materialize=options.materialize, + content=content, + ) + + flow_path = workspace_dir / "home" / "flow.json" + checklist_path = workspace_dir / "home" / "checklist.json" + if self.workspace.flow.path is None: + self.workspace.flow.path = flow_path + checklist_data = self._read_json(checklist_path) + + has_synthesis = self.workspace.flow.has_step(StepEnum.SYNTHESIS) + synthesis_verilog = self._synthesis_output_verilog() if has_synthesis else None + # Golden precedence mirrors the execution wiring: synthesis output, + # then the declared golden netlist, then the origin RTL. + lec_golden = ( + synthesis_verilog + or getattr(self.workspace.design, "golden_verilog", None) + or getattr(self.workspace.design, "origin_verilog", None) + ) + filler_verilog = workspace_dir / "filler_ecc" / "output" / f"{design}_filler.v.gz" + # The canonical chain wires postRouteLec's gate input to the LVS output. + lec_gate = workspace_dir / "lvs_ecc" / "output" / f"{design}_lvs.v.gz" + require_lec = self._requires_post_route_lec(lec_golden, lec_gate) + required_steps = self._required_step_states(require_lec=require_lec) + for step_name, state in required_steps.items(): + if state != StateEnum.Success.value: + missing_required.append(f"flow step {step_name} is {state or 'missing'}") + issues.append( + SignoffPackageIssue( + kind="flow", + label=f"{step_name} flow step", + location=step_name, + reason=f"State is {state or 'missing'}", + required=True, + destination=f"flow step {step_name}", + ) + ) + + config_dir = workspace_dir / "config" + required_configs = { + "db_ecc.json", + "rcx_ecc.json", + "sta_ecc.json", + } + if not config_dir.is_dir(): + missing_required.append("config directory") + issues.append( + SignoffPackageIssue( + kind="resource", + label="Config directory", + location="config", + reason="Required directory does not exist", + required=True, + destination="config directory", + ) + ) + else: + for config_file in sorted(path for path in config_dir.rglob("*") if path.is_file()): + rel = config_file.relative_to(config_dir).as_posix() + add_file( + role=f"config.{config_file.stem}", + source=config_file, + destination=f"config/{rel}", + required=config_file.name in required_configs, + ) + for config_name in sorted(required_configs): + if not (config_dir / config_name).is_file(): + missing_required.append(f"config/{config_name}") + issues.append( + SignoffPackageIssue( + kind="resource", + label=f"Config {config_name}", + location=f"config/{config_name}", + reason="Required file is missing or empty", + required=True, + destination=f"config/{config_name}", + ) + ) + + db_config = self._read_json(config_dir / "db_ecc.json") + configured_filelist = ( + None if not has_synthesis else getattr(self.workspace.design, "input_filelist", None) + ) + origin_rtl = resolve_initial_rtl( + configured_filelist, + getattr(self.workspace.design, "origin_verilog", None), + workspace_dir / "origin", + ) + if origin_rtl is not None: + rtl_suffix = ".v.gz" if origin_rtl.name.endswith(".v.gz") else origin_rtl.suffix.lower() + rtl_destination = f"initial/{design}{rtl_suffix}" + else: + rtl_destination = f"initial/{design}.v" + if origin_rtl is None or not origin_rtl.is_file(): + missing_required.append("origin RTL") + issues.append( + SignoffPackageIssue( + kind="resource", + label="Origin RTL", + location=self._review_source_path(workspace_dir, origin_rtl, "origin"), + reason="Required file is missing or empty", + required=True, + destination=rtl_destination, + ) + ) + origin_sdc = self._path_from_config( + workspace_dir, + db_config.get("INPUT", {}).get("sdc_path", ""), + ) + if origin_sdc is None: + origin_sdc, origin_sdc_reason = self._find_one( + workspace_dir / "origin", + preferred_name=f"{design}.sdc", + pattern="*.sdc", + ) + if origin_sdc is None: + missing_required.append("origin SDC") + issues.append( + SignoffPackageIssue( + kind="resource", + label="Origin SDC", + location=f"origin/{design}.sdc", + reason=origin_sdc_reason, + required=True, + destination=f"initial/{design}.sdc", + ) + ) + + add_file( + role="harden.gds", + source=workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.gds", + destination=f"harden/{design}.gds", + required=True, + ) + add_file( + role="harden.lef", + source=workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.lef", + destination=f"harden/{design}.lef", + required=True, + ) + add_file( + role="harden.lib", + source=workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.lib", + destination=f"harden/{design}.lib", + required=True, + ) + add_file( + role="harden.image", + source=workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.png", + destination=f"harden/{design}.png", + ) + + if origin_rtl is not None and origin_rtl.is_file(): + # Like synthesis, a configured input_filelist is always a filelist; + # runtime-created ones are suffixless (origin/filelist), so the + # suffix check alone would miss them. + is_filelist = ( + origin_rtl.suffix.lower() in FILELIST_SUFFIXES + or origin_rtl.name == "filelist" + or (configured_filelist is not None and origin_rtl == Path(configured_filelist)) + ) + if is_filelist: + # Workspace creation copies filelist sources into origin/ keeping + # each entry's filelist-relative path (absolute entries land at + # their basename); bundle them the same way and rewrite absolute + # entries to those basenames so the packaged filelist stays + # resolvable. +incdir header trees are not bundled. + try: + rtl_entries = parse_filelist(str(origin_rtl)) + filelist_text = rewrite_absolute_entries(origin_rtl.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + # Without the entries the packaged filelist would dangle, so + # block the export instead of shipping an incomplete package. + issues.append( + SignoffPackageIssue( + kind="resource", + label="Origin RTL sources", + location=self._review_source_path(workspace_dir, origin_rtl, "origin"), + reason=f"Could not parse origin filelist for packaging: {error}", + required=True, + destination=rtl_destination, + ) + ) + rtl_entries = [] + else: + add_file( + "initial.filelist", + origin_rtl, + rtl_destination, + required=True, + content=filelist_text, + ) + packaged_entries = set() + escaped_entries = [] + for rtl_entry in rtl_entries: + relative = ( + os.path.basename(rtl_entry) if os.path.isabs(rtl_entry) else rtl_entry + ) + # Parent-relative entries would escape initial/ (and already + # escaped origin/ at creation); bundling them corrupts the + # package layout, so they block the export instead. + normalized = os.path.normpath(relative) + if normalized == ".." or normalized.startswith(f"..{os.sep}"): + escaped_entries.append(rtl_entry) + continue + if normalized in packaged_entries: + continue + packaged_entries.add(normalized) + add_file( + "initial.verilog", + workspace_dir / "origin" / normalized, + f"initial/{normalized}", + required=True, + ) + if escaped_entries: + issues.append( + SignoffPackageIssue( + kind="resource", + label="Origin RTL sources", + location=self._review_source_path(workspace_dir, origin_rtl, "origin"), + reason=( + "Filelist entries escape the package layout: " + + ", ".join(escaped_entries) + ), + required=True, + destination=rtl_destination, + ) + ) + else: + add_file("initial.verilog", origin_rtl, rtl_destination, required=True) + if origin_sdc is not None: + add_file("initial.sdc", origin_sdc, f"initial/{design}.sdc", required=True) + from chipcompiler.data.workspace_config import workspace_config_path + + parameters_config = workspace_config_path(workspace_dir) + if not parameters_config.exists(): + # A read-only legacy workspace (TOML migration deferred) runs on + # its parameters.json — package the file it actually runs on. + parameters_config = workspace_dir / "home" / "parameters.json" + add_file( + "initial.parameters", + parameters_config, + f"initial/{parameters_config.name}", + required=True, + ) + + if has_synthesis: + add_file( + role="synthesis.verilog", + source=synthesis_verilog, + destination=f"synthesis/{design}.v.gz", + required=True, + ) + + lec_dir = workspace_dir / self._step_dirs()[StepEnum.POST_ROUTE_LEC.value] + lec_result = lec_dir / "output" / f"{design}_{StepEnum.POST_ROUTE_LEC.value}_result.json" + if require_lec: + add_file( + role="lec.result", + source=lec_result, + destination="final/reports/postRouteLec/result.json", + required=True, + ) + add_file( + role="lec.equiv_status", + source=lec_dir / "report" / "equiv_status.rpt", + destination="final/reports/postRouteLec/report/equiv_status.rpt", + required=True, + ) + add_file( + role="lec.status_report", + source=lec_dir / "report" / "run_lec_status.rpt", + destination="final/reports/postRouteLec/report/run_lec_status.rpt", + required=True, + ) + add_file( + role="lec.failed_rtlil", + source=lec_dir / "report" / "equiv_failed.il", + destination="final/reports/postRouteLec/report/equiv_failed.il", + ) + add_file( + role="lec.failed_verilog", + source=lec_dir / "report" / "equiv_failed.v", + destination="final/reports/postRouteLec/report/equiv_failed.v", + ) + from chipcompiler.tools.yosys_lec.utility import lec_result_status + + lec_status = lec_result_status( + lec_result, + golden_verilog=lec_golden, + gate_verilog=lec_gate, + ) + if lec_result.is_file() and lec_status != "proven": + missing_required.append("final/reports/postRouteLec/result.json") + issues.append( + SignoffPackageIssue( + kind="resource", + label="lec.result", + location=self._review_source_path( + workspace_dir, + lec_result, + "final/reports/postRouteLec/result.json", + ), + reason=( + "Yosys LEC proof is stale; golden or gate netlist changed" + if lec_status == "stale" + else "Yosys LEC did not prove equivalence" + ), + required=True, + destination="final/reports/postRouteLec/result.json", + ) + ) + + add_file( + role="final.design.verilog", + source=filler_verilog, + destination=f"final/design/{design}.v.gz", + required=True, + ) + add_file( + role="final.design.def", + source=workspace_dir / "filler_ecc" / "output" / f"{design}_filler.def.gz", + destination=f"final/design/{design}.def.gz", + required=True, + ) + add_file( + role="final.design.gds", + source=workspace_dir / "filler_ecc" / "output" / f"{design}_filler.gds", + destination=f"final/design/{design}.gds", + required=True, + ) + add_file( + role="final.design.image", + source=workspace_dir / "filler_ecc" / "output" / f"{design}_filler.png", + destination=f"final/design/{design}.png", + ) + + sta_config = self._read_json(config_dir / "sta_ecc.json") + sta_matrix = self._sta_matrix(sta_config) + expected_spefs = set() + for item in sta_matrix: + expected_spefs.add( + f"{top_module}_{item['rcx_corner']}_{self._temperature_token(item['temperature'])}C.spef" + ) + report_dir = sta_artifact_directory( + workspace_dir / "sta_ecc" / "report", + item["lib_corner"], + item["temperature"], + item["rcx_corner"], + ) + feature_dir = sta_artifact_directory( + workspace_dir / "sta_ecc" / "feature", + item["lib_corner"], + item["temperature"], + item["rcx_corner"], + ) + report_dest = ( + f"final/timing/sta/{item['lib_corner']}_" + f"{self._temperature_token(item['temperature'])}/" + f"{item['rcx_corner']}/report" + ) + for report_name in STA_REPORT_FILENAMES: + add_file( + role="final.sta_report", + source=report_dir / report_name, + destination=f"{report_dest}/{report_name}", + required=True, + ) + # Optional: workspaces whose STA ran before power collection have + # no per-corner power report; package it when present. + add_file( + role="final.sta_report", + source=report_dir / STA_POWER_REPORT_FILENAME, + destination=f"{report_dest}/{STA_POWER_REPORT_FILENAME}", + ) + item["report"] = f"{report_dest}/qor_summary.rpt" + feature_dest = report_dest.removesuffix("/report") + "/feature" + add_file( + role="final.sta_qor_summary", + source=feature_dir / STA_QOR_SUMMARY_FILENAME, + destination=f"{feature_dest}/{STA_QOR_SUMMARY_FILENAME}", + required=True, + ) + add_file( + role="final.sta_timing_paths", + source=feature_dir / STA_TIMING_PATHS_FILENAME, + destination=f"{feature_dest}/{STA_TIMING_PATHS_FILENAME}", + required=True, + ) + item["qor_summary"] = f"{feature_dest}/{STA_QOR_SUMMARY_FILENAME}" + item["timing_paths"] = f"{feature_dest}/{STA_TIMING_PATHS_FILENAME}" + + rcx_output_dir = workspace_dir / "RCX_ecc" / "output" + spef_paths = sorted(rcx_output_dir.glob("*.spef")) if rcx_output_dir.is_dir() else [] + if expected_spefs: + for spef_name in sorted(expected_spefs): + add_file( + role="final.spef", + source=rcx_output_dir / spef_name, + destination=f"final/timing/spef/{spef_name}", + required=True, + ) + for spef_path in spef_paths: + if spef_path.name not in expected_spefs: + add_file( + role="final.spef", + source=spef_path, + destination=f"final/timing/spef/{spef_path.name}", + ) + elif spef_paths: + for spef_path in spef_paths: + add_file( + role="final.spef", + source=spef_path, + destination=f"final/timing/spef/{spef_path.name}", + required=True, + ) + else: + missing_required.append("RCX SPEF files") + issues.append( + SignoffPackageIssue( + kind="resource", + label="RCX SPEF files", + location="RCX_ecc/output", + reason="No SPEF files were found", + required=True, + destination="RCX SPEF files", + ) + ) + + add_file("status.flow", flow_path, "final/reports/flow.json", required=True) + + for step_name, step_dir in self._step_dirs().items(): + if step_name == StepEnum.POST_ROUTE_LEC.value: + continue + for kind in ("analysis", "report"): + self._copy_tree_files( + workspace_dir=workspace_dir, + package_dir=package_dir, + source_dir=workspace_dir / step_dir / kind, + destination_dir=f"final/reports/{step_name}/{kind}", + role=f"report.{kind}", + copied=copied, + missing_optional=missing_optional, + issues=issues, + materialize=options.materialize, + ) + + if options.include_debug: + self._collect_debug_files( + workspace_dir=workspace_dir, + package_dir=package_dir, + copied=copied, + missing_optional=missing_optional, + issues=issues, + materialize=options.materialize, + ) + + # Resource collection finds package evidence, but the refreshed home + # checklist is the single authority for signoff readiness and export. + from chipcompiler.tools.ecc.signoff_checklist import rebuild_home_checklist + + analysis_issues = refresh_issues + checklist_data = rebuild_home_checklist( + self.workspace, + resource_issues=[*issues, *analysis_issues], + ) + add_file( + "status.checklist", + checklist_path, + "final/reports/checklist.json", + required=True, + ) + checklist_counts = checklist_data.get("summary", {}) + checklist_items = checklist_data.get("checklist", []) + blocked_items = [ + item + for item in checklist_items + if isinstance(item, dict) and item.get("blocked") is True + ] + attention_items = [ + item + for item in checklist_items + if isinstance(item, dict) and item.get("state") == "warning" + ] + missing_required = [str(item.get("id")) for item in blocked_items] + missing_optional = [str(item.get("id")) for item in attention_items] + if blocked_items or attention_items: + warnings.append("home checklist requires attention; see final/reports/checklist.json") + + qor_metrics = self._read_json(workspace_dir / "drc_ecc" / "analysis" / "qor_metrics.json") + ok = len(blocked_items) == 0 + flow_success = all(state == StateEnum.Success.value for state in required_steps.values()) + summary = { + "schema_version": 1, + "status": "ok" if ok else "incomplete", + "design": design, + "top_module": top_module, + "pdk": pdk_name, + "required_steps": required_steps, + "checks": { + "flow": "passed" if flow_success else "failed", + "home_checklist": checklist_counts, + "qor_analysis_issue_count": len(analysis_issues), + }, + "initial": { + "verilog": rtl_destination, + "sdc": f"initial/{design}.sdc", + "parameters": f"initial/{parameters_config.name}", + }, + "config": "config/", + "harden": { + "gds": f"harden/{design}.gds", + "lef": f"harden/{design}.lef", + "lib": f"harden/{design}.lib", + }, + "final": { + "verilog": f"final/design/{design}.v.gz", + "def": f"final/design/{design}.def.gz", + "gds": f"final/design/{design}.gds", + "image": f"final/design/{design}.png", + }, + "qor_metrics": qor_metrics, + "sta_matrix": sta_matrix, + "missing_required": missing_required, + "missing_optional": missing_optional, + "warnings": warnings, + } + if require_lec: + lec_payload = self._read_json(lec_result) + summary["lec"] = { + "status": lec_payload.get("status", ""), + "result": "final/reports/postRouteLec/result.json", + "equiv_status": "final/reports/postRouteLec/report/equiv_status.rpt", + "status_report": "final/reports/postRouteLec/report/run_lec_status.rpt", + "golden_verilog": lec_payload.get("golden_verilog", ""), + "gate_verilog": lec_payload.get("gate_verilog", ""), + } + if has_synthesis: + summary["synthesis"] = {"verilog": f"synthesis/{design}.v.gz"} + summary_path = package_dir / "summary.json" + + manifest = { + "schema_version": 1, + "created_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "workspace": str(workspace_dir.resolve()), + "design": design, + "top_module": top_module, + "pdk": pdk_name, + "flow": { + "source": "home/flow.json", + "all_required_steps_success": flow_success, + }, + "files": copied, + "missing_required": missing_required, + "missing_optional": missing_optional, + "warnings": warnings, + } + manifest_path = package_dir / "manifest.json" + + archive_path = None + if options.materialize: + summary_path.write_text(json.dumps(summary, indent=2)) + manifest_path.write_text(json.dumps(manifest, indent=2)) + + readme_path = package_dir / "README.md" + input_verilog_description = "- Mapped synthesis netlist is under `synthesis/`.\n" + if not has_synthesis: + input_verilog_description = ( + "- Original imported netlist is under `initial/` because this flow " + "has no Synthesis step.\n" + ) + readme_path.write_text( + f"# {design} Signoff Package\n\n" + + f"- Workspace: {workspace_dir.resolve()}\n" + + f"- Status: {summary['status']}\n" + + input_verilog_description + + "- Harden outputs are under `harden/`.\n" + + "- Final physical resources are under `final/`.\n" + + "- Post-route LEC evidence is under `final/reports/postRouteLec/`.\n" + ) + + if options.archive and (ok or options.allow_incomplete): + archive_path = str(package_dir.with_suffix(".tar.gz")) + archive_file = Path(archive_path) + if archive_file.exists(): + archive_file.unlink() + with tarfile.open(archive_file, "w:gz") as archive: + archive.add(package_dir, arcname=package_dir.name) + + return SignoffPackageResult( + ok=ok, + package_dir=str(package_dir), + archive_path=archive_path, + manifest_path=str(manifest_path), + summary_path=str(summary_path), + copied=copied, + missing_required=missing_required, + missing_optional=missing_optional, + warnings=warnings, + issues=issues, + ) + + def _add_file( + self, + workspace_dir: Path, + package_dir: Path, + role: str, + source: Path | None, + destination: str, + *, + required: bool, + copied: list[dict], + missing_required: list[str], + missing_optional: list[str], + issues: list[SignoffPackageIssue], + materialize: bool, + content: str | None = None, + ) -> None: + if content is not None: + missing = not content + else: + missing = source is None or not source.is_file() or source.stat().st_size <= 0 + if missing: + if required: + missing_required.append(destination) + else: + missing_optional.append(destination) + issues.append( + SignoffPackageIssue( + kind="resource", + label=role, + location=self._review_source_path(workspace_dir, source, destination), + reason=( + "Required file is missing or empty" + if required + else "Optional file is missing or empty" + ), + required=required, + destination=destination, + ) + ) + return + + if materialize: + target = package_dir / destination + target.parent.mkdir(parents=True, exist_ok=True) + if content is None: + shutil.copy2(source, target) + else: + target.write_text(content, encoding="utf-8") + size_bytes = target.stat().st_size + digest = file_digest(target) + sha256 = digest[0] if digest else None + else: + size_bytes = len(content.encode()) if content is not None else source.stat().st_size + sha256 = None + copied.append( + { + "role": role, + "required": required, + "source": self._source_path(workspace_dir, source), + "destination": destination, + "size_bytes": size_bytes, + "sha256": sha256, + } + ) + + def _copy_tree_files( + self, + workspace_dir: Path, + package_dir: Path, + source_dir: Path, + destination_dir: str, + role: str, + copied: list[dict], + missing_optional: list[str], + issues: list[SignoffPackageIssue], + *, + materialize: bool, + ) -> None: + if not source_dir.is_dir(): + return + for source in sorted(path for path in source_dir.rglob("*") if path.is_file()): + relative = source.relative_to(source_dir).as_posix() + self._add_file( + workspace_dir=workspace_dir, + package_dir=package_dir, + role=role, + source=source, + destination=f"{destination_dir}/{relative}", + required=False, + copied=copied, + missing_required=[], + missing_optional=missing_optional, + issues=issues, + materialize=materialize, + ) + + def _collect_debug_files( + self, + workspace_dir: Path, + package_dir: Path, + copied: list[dict], + missing_optional: list[str], + issues: list[SignoffPackageIssue], + *, + materialize: bool, + ) -> None: + patterns = [ + "*_ecc/feature/**/*", + "*_ecc/subflow.json", + ] + for pattern in patterns: + for path_text in sorted(glob.glob(str(workspace_dir / pattern), recursive=True)): + source = Path(path_text) + if not source.is_file(): + continue + destination = f"debug/{source.relative_to(workspace_dir).as_posix()}" + self._add_file( + workspace_dir=workspace_dir, + package_dir=package_dir, + role="debug", + source=source, + destination=destination, + required=False, + copied=copied, + missing_required=[], + missing_optional=missing_optional, + issues=issues, + materialize=materialize, + ) + output_db_dirs = sorted(workspace_dir.glob("*_ecc/output/*_db")) + output_view_dirs = sorted(workspace_dir.glob("*_ecc/output/*_view")) + for source in output_db_dirs + output_view_dirs: + if not source.is_dir(): + continue + self._copy_tree_files( + workspace_dir=workspace_dir, + package_dir=package_dir, + source_dir=source, + destination_dir=f"debug/{source.relative_to(workspace_dir).as_posix()}", + role="debug", + copied=copied, + missing_optional=missing_optional, + issues=issues, + materialize=materialize, + ) + + def _step_dirs(self) -> dict[str, str]: + from chipcompiler.data.step_dirs import STEP_DIRECTORIES + + return STEP_DIRECTORIES + + +# Public entry point for the text design summary; the implementation lives in + + +# the engine.signoff.report* sibling modules, re-exported here as the API. +from chipcompiler.engine.signoff.report import generate_text_report # noqa: E402,F401 diff --git a/chipcompiler/engine/signoff/discovery.py b/chipcompiler/engine/signoff/discovery.py new file mode 100644 index 000000000..67343304f --- /dev/null +++ b/chipcompiler/engine/signoff/discovery.py @@ -0,0 +1,126 @@ +"""Artifact discovery and workspace-read helpers for the signoff collector. + +Split from the collector core: these mixin methods locate per-step +artifacts, read workspace configuration, and resolve step state +contracts. Shared through composition, so ``self`` is the composed +``SignoffPackageCollector``. +""" + +import json +from pathlib import Path + +from chipcompiler.data import StepEnum + + +class CollectorDiscoveryMixin: + def _read_json(self, path: Path) -> dict: + try: + with open(path, encoding="utf-8") as file: + data = json.load(file) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return {} + return data if isinstance(data, dict) else {} + + def _read_parameters(self, path: Path) -> dict: + """Read the workspace configuration's [params] section; {} when unreadable.""" + import tomllib + + try: + with open(path, "rb") as file: + data = tomllib.load(file) + except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError): + return {} + params = data.get("params", {}) + return params if isinstance(params, dict) else {} + + def _path_from_config(self, workspace_dir: Path, path_text: str) -> Path | None: + if not path_text: + return None + path = Path(path_text) + if not path.is_absolute(): + path = workspace_dir / path + return path if path.is_file() else None + + def _source_path(self, workspace_dir: Path, source: Path) -> str: + try: + return source.relative_to(workspace_dir).as_posix() + except ValueError: + return str(source) + + def _review_source_path( + self, + workspace_dir: Path, + source: Path | None, + fallback: str, + ) -> str: + if source is None: + return fallback + try: + return source.relative_to(workspace_dir).as_posix() + except ValueError: + return source.name + + def _find_one( + self, + directory: Path, + preferred_name: str, + pattern: str, + ) -> tuple[Path | None, str]: + preferred = directory / preferred_name + if preferred.is_file(): + return preferred, "" + matches = sorted(directory.glob(pattern)) if directory.is_dir() else [] + if len(matches) == 1: + return matches[0], "" + if len(matches) > 1: + return None, "Multiple matching files found" + return None, "Required file is missing or empty" + + def _design_from_outputs(self, workspace_dir: Path) -> str: + for pattern, suffix in ( + ("Harden_ecc/output/*_Harden.gds", "_Harden.gds"), + ("filler_ecc/output/*_filler.v.gz", "_filler.v.gz"), + ): + matches = sorted(workspace_dir.glob(pattern)) + if matches: + name = matches[0].name + if name.endswith(suffix): + return name[: -len(suffix)] + return "" + + def _synthesis_output_verilog(self) -> Path | None: + """Resolve the netlist from the Yosys step's declared output contract.""" + synthesis_step = self._build_workspace_step( + {"name": StepEnum.SYNTHESIS.value, "tool": "yosys"}, + previous_step=None, + ) + output = getattr(synthesis_step, "output", None) + verilog = getattr(output, "verilog", None) + return Path(verilog) if verilog else None + + def _required_step_states(self, *, require_lec: bool) -> dict: + required = [ + StepEnum.HARDEN.value, + StepEnum.RCX.value, + StepEnum.STA.value, + StepEnum.DRC.value, + StepEnum.LVS.value, + StepEnum.FILLER.value, + StepEnum.ROUTING.value, + ] + if require_lec: + required.append(StepEnum.POST_ROUTE_LEC.value) + states = {} + for step in required: + entry = self.workspace.flow.get_step(step) + states[step] = entry.get("state", "") if entry else "" + return states + + def _requires_post_route_lec( + self, + golden_verilog: Path | None, + gate_verilog: Path | None, + ) -> bool: + if golden_verilog is None or not Path(golden_verilog).is_file(): + return False + return bool(gate_verilog and Path(gate_verilog).is_file()) diff --git a/chipcompiler/engine/signoff/models.py b/chipcompiler/engine/signoff/models.py new file mode 100644 index 000000000..020fb02a4 --- /dev/null +++ b/chipcompiler/engine/signoff/models.py @@ -0,0 +1,49 @@ +"""Public dataclasses and constants for the signoff package collector.""" + +from dataclasses import dataclass, field + +from chipcompiler.data import StepEnum + +SIGNOFF_REQUIRED_QOR_STEPS = { + StepEnum.HARDEN.value, + StepEnum.RCX.value, + StepEnum.STA.value, + StepEnum.DRC.value, + StepEnum.LVS.value, + StepEnum.FILLER.value, + StepEnum.ROUTING.value, +} + + +@dataclass(frozen=True) +class SignoffPackageOptions: + output_dir: str | None = None + archive: bool = True + include_debug: bool = False + allow_incomplete: bool = False + materialize: bool = True + refresh_analysis: bool = False + + +@dataclass +class SignoffPackageResult: + ok: bool + package_dir: str + archive_path: str | None = None + manifest_path: str | None = None + summary_path: str | None = None + copied: list[dict] = field(default_factory=list) + missing_required: list[str] = field(default_factory=list) + missing_optional: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + issues: list["SignoffPackageIssue"] = field(default_factory=list) + + +@dataclass(frozen=True) +class SignoffPackageIssue: + kind: str + label: str + location: str + reason: str + required: bool + destination: str diff --git a/chipcompiler/engine/signoff/report_checklist.py b/chipcompiler/engine/signoff/report_checklist.py index ba767d0c6..4292ba07f 100644 --- a/chipcompiler/engine/signoff/report_checklist.py +++ b/chipcompiler/engine/signoff/report_checklist.py @@ -22,6 +22,15 @@ } +def _evidence_path(entry) -> str | None: + """Schema-v3 evidence entries are {kind, path} objects; tolerate plain strings.""" + if isinstance(entry, str): + return entry + if isinstance(entry, dict) and entry.get("path"): + return str(entry["path"]) + return None + + @dataclasses.dataclass(frozen=True) class ChecklistItemView: id: str @@ -85,7 +94,9 @@ def build_checklist_report(workspace) -> ChecklistReport: raw.get("blocked", policy == "block" and state in ("failed", "unavailable")) ), summary=str(raw.get("summary") or raw.get("info") or ""), - evidence=tuple(e for e in evidence if isinstance(e, str)) + evidence=tuple( + entry for entry in (_evidence_path(e) for e in evidence) if entry is not None + ) if isinstance(evidence, list) else (), ) @@ -114,9 +125,13 @@ def _pad(text: str, width: int) -> str: return text if len(text) >= width else text + " " * (width - len(text)) -def generate_checklist_report(workspace) -> str: - """Render the signoff checklist as a text report.""" - report = build_checklist_report(workspace) +def generate_checklist_report(workspace, report=None) -> str: + """Render the signoff checklist as a text report. + + Pass a prebuilt *report* to render the exact snapshot the caller + already collected instead of re-traversing the workspace. + """ + report = report if report is not None else build_checklist_report(workspace) lines: list[str] = [] title = " ECC SIGNOFF CHECKLIST REPORT " side = max(0, (WIDTH - len(title)) // 2) diff --git a/chipcompiler/engine/signoff/report_extract.py b/chipcompiler/engine/signoff/report_extract.py index fe126bf9e..636367670 100644 --- a/chipcompiler/engine/signoff/report_extract.py +++ b/chipcompiler/engine/signoff/report_extract.py @@ -5,6 +5,7 @@ from pathlib import Path from chipcompiler.data import StepEnum +from chipcompiler.data.step_dirs import STEP_DIRECTORIES as STEP_DIRS from chipcompiler.engine.signoff.report_data import ( DesignReportData, EvidenceProvenanceRecord, @@ -332,25 +333,6 @@ def _parse_corner_attributes(name: str): return process, temperature, voltage, rc_corner -# Mirrors SignoffPackageCollector._step_dirs() in engine/signoff.py; kept -# local to avoid an import cycle between the two signoff modules. -STEP_DIRS = { - StepEnum.SYNTHESIS.value: "Synthesis_yosys", - StepEnum.LEC.value: "lec_yosys_lec", - StepEnum.FLOORPLAN.value: "Floorplan_ecc", - StepEnum.PLACEMENT.value: "place_dreamplace", - StepEnum.CTS.value: "CTS_ecc", - StepEnum.LEGALIZATION.value: "legalization_dreamplace", - StepEnum.ROUTING.value: "route_ecc", - StepEnum.DRC.value: "drc_ecc", - StepEnum.LVS.value: "lvs_ecc", - StepEnum.FILLER.value: "filler_ecc", - StepEnum.POST_ROUTE_LEC.value: "postRouteLec_yosys_lec", - StepEnum.RCX.value: "RCX_ecc", - StepEnum.STA.value: "sta_ecc", - StepEnum.HARDEN.value: "Harden_ecc", -} - # --------------------------------------------------------------------------- # Workspace collection # --------------------------------------------------------------------------- diff --git a/chipcompiler/engine/signoff/report_text.py b/chipcompiler/engine/signoff/report_text.py index b8074d645..6d95d57ee 100644 --- a/chipcompiler/engine/signoff/report_text.py +++ b/chipcompiler/engine/signoff/report_text.py @@ -136,6 +136,8 @@ def format_text_report( f"{_fmt(timing.setup_wns_ns, 'ns')} / {_fmt(timing.setup_tns_ns, 'ns')}", "TIMING MET" if timing.setup_wns_ns is not None and timing.setup_wns_ns >= 0 + else "UNKNOWN" + if timing.setup_wns_ns is None else "VIOLATION", ) ) @@ -145,6 +147,8 @@ def format_text_report( f"{_fmt(timing.hold_wns_ns, 'ns')} / {_fmt(timing.hold_tns_ns, 'ns')}", "TIMING MET" if timing.hold_wns_ns is not None and timing.hold_wns_ns >= 0 + else "UNKNOWN" + if timing.hold_wns_ns is None else "VIOLATION", ) ) diff --git a/chipcompiler/engine/signoff/report_timing.py b/chipcompiler/engine/signoff/report_timing.py index 4bf6bee85..152579e79 100644 --- a/chipcompiler/engine/signoff/report_timing.py +++ b/chipcompiler/engine/signoff/report_timing.py @@ -59,9 +59,16 @@ def pick(*candidates): corner_hold_wns = pick( corner_data.get("hold_wns"), hold_obj.get("wns"), summary_hold.get("wns") ) - passed = (corner_setup_wns is None or corner_setup_wns >= 0) and ( - corner_hold_wns is None or corner_hold_wns >= 0 - ) + # Missing evidence is never a pass: a corner only passes when a + # measured slack exists and is non-negative; with no slack at all + # the corner stays unknown. + measured = [s for s in (corner_setup_wns, corner_hold_wns) if s is not None] + if any(s < 0 for s in measured): + corner_status = "fail" + elif measured: + corner_status = "pass" + else: + corner_status = "unknown" records.append( CornerTimingRecord( corner=corner_name, @@ -98,7 +105,7 @@ def pick(*candidates): hold_obj.get("nvp"), summary_hold.get("nvp"), ), - status="pass" if passed else "fail", + status=corner_status, ) ) return records @@ -120,13 +127,21 @@ def _rollup_from_corners(corners, values: dict) -> None: ] values[key] = min(valid) if valid else None if values.get("violating_endpoints_setup") is None: - values["violating_endpoints_setup"] = float( - sum(corner.violating_endpoints_setup or 0 for corner in corners) - ) + # Aggregate only measured counts: summing absent values as zero + # would present missing timing evidence as zero violations. + measured_setup = [ + corner.violating_endpoints_setup + for corner in corners + if corner.violating_endpoints_setup is not None + ] + values["violating_endpoints_setup"] = float(sum(measured_setup)) if measured_setup else None if values.get("violating_endpoints_hold") is None: - values["violating_endpoints_hold"] = float( - sum(corner.violating_endpoints_hold or 0 for corner in corners) - ) + measured_hold = [ + corner.violating_endpoints_hold + for corner in corners + if corner.violating_endpoints_hold is not None + ] + values["violating_endpoints_hold"] = float(sum(measured_hold)) if measured_hold else None def _timing_targets(q, inputs): @@ -305,7 +320,6 @@ def _extract_timing(q, inputs, corners) -> TimingMetrics: "trans_violations", "transition_violations", "max_transition_violations", - "max_slew", "slew_violation_count", "summary.slew.violations", "slew.violations", @@ -320,7 +334,6 @@ def _extract_timing(q, inputs, corners) -> TimingMetrics: "cap_violations", "max_cap_violations", "cap_viols", - "max_cap", "capacitance_violations", "max_capacitance_violations", "cap_violation_count", @@ -337,18 +350,14 @@ def _extract_timing(q, inputs, corners) -> TimingMetrics: "fanout_violations", "max_fanout_violations", "fanout_viols", - "fanout_max_violations", - "max_fanout", "fanout_violation_count", "summary.fanout.violations", "fanout.violations", "check_fanout", ], )[0] - if setup_wns_ns is not None and setup_wns_ns >= 0: - slew_violations = 0 if slew_violations is None else slew_violations - cap_violations = 0 if cap_violations is None else cap_violations - fanout_violations = 0 if fanout_violations is None else fanout_violations + # A met setup slack does not prove the DRC limits were checked: leave + # unmeasured violation counts unknown instead of inventing zeros. critical_path_delay_ns = q( "Timing", @@ -386,7 +395,9 @@ def _extract_timing(q, inputs, corners) -> TimingMetrics: def _extract_clock(q) -> ClockMetrics: - skew_value, skew_key, _ = q( + # q() returns (value, stage, source_key): the source key decides the + # unit heuristic, so unpack it into skew_key explicitly. + skew_value, _stage, skew_key = q( "Clock", "Clock Skew", ["CTS", "STA", "Route"], diff --git a/chipcompiler/runtime/signoff_export.py b/chipcompiler/runtime/signoff_export.py index e4c660c57..8206a964c 100644 --- a/chipcompiler/runtime/signoff_export.py +++ b/chipcompiler/runtime/signoff_export.py @@ -187,7 +187,7 @@ def _review_group_for_item(item: dict) -> str: return "sta" if step == "RCX" or path.startswith(("RCX_ecc/", "final/timing/spef/")): return "spef" - if step in {"Route", "drc", "lvs", "filler"} or path.startswith( + if step in {"route", "drc", "lvs", "filler"} or path.startswith( ("route_ecc/", "drc_ecc/", "lvs_ecc/", "filler_ecc/", "final/design/") ): return "final_design" diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 08e61be5b..8d4dd9c03 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -135,6 +135,12 @@ def collect_sta_signoff_items(workspace: Workspace) -> list[dict]: return [] sta_data = json_read(sta_config) rcx_output_dir = workspace_dir / f"{StepEnum.RCX.value}_ecc" / "output" + # STA-entry workspaces declare their parasitics (design.spef) instead of + # producing them with RCX: without an RCX step in the flow, every corner + # reads the declared SPEF. + flow = getattr(workspace, "flow", None) + has_rcx = bool(flow is not None and flow.has_step(StepEnum.RCX)) + declared_spef = getattr(getattr(workspace, "pdk", None), "spef", None) liberty_by_corner = {liberty.get("corner"): liberty for liberty in sta_data.get("liberty", [])} spef_design_name = workspace.design.top_module or workspace.design.name @@ -156,13 +162,18 @@ def collect_sta_signoff_items(workspace: Workspace) -> list[dict]: spef_name = ( f"{spef_design_name}_{rcx_corner_name}_{temperature_token(temperature)}C.spef" ) + spef_file = ( + str(declared_spef) + if not has_rcx and declared_spef + else str(rcx_output_dir / spef_name) + ) items.append( { "corner": corner_name, "temperature": temperature, "rcx_corner": rcx_corner_name, "liberty_files": liberty_files, - "spef_file": str(rcx_output_dir / spef_name), + "spef_file": spef_file, } ) diff --git a/chipcompiler/tools/ecc/signoff_checklist.py b/chipcompiler/tools/ecc/signoff_checklist.py index 7662fdf63..25cd09847 100644 --- a/chipcompiler/tools/ecc/signoff_checklist.py +++ b/chipcompiler/tools/ecc/signoff_checklist.py @@ -9,6 +9,7 @@ from pathlib import Path from chipcompiler.data import Checklist, StateEnum, StepEnum, Workspace, WorkspaceStep +from chipcompiler.data.step_dirs import STEP_DIRECTORIES from chipcompiler.tools.ecc.sta_qor import ( STA_QOR_SUMMARY_FILENAME, STA_REPORT_FILENAMES, @@ -20,23 +21,6 @@ from chipcompiler.utility import json_read from chipcompiler.utility.filelist import resolve_initial_rtl -_STEP_DIRECTORIES = { - StepEnum.SYNTHESIS.value: "Synthesis_yosys", - StepEnum.LEC.value: "lec_yosys_lec", - StepEnum.FLOORPLAN.value: "Floorplan_ecc", - StepEnum.PLACEMENT.value: "place_dreamplace", - StepEnum.CTS.value: "CTS_ecc", - StepEnum.LEGALIZATION.value: "legalization_dreamplace", - StepEnum.ROUTING.value: "route_ecc", - StepEnum.DRC.value: "drc_ecc", - StepEnum.LVS.value: "lvs_ecc", - StepEnum.FILLER.value: "filler_ecc", - StepEnum.POST_ROUTE_LEC.value: "postRouteLec_yosys_lec", - StepEnum.RCX.value: "RCX_ecc", - StepEnum.STA.value: "sta_ecc", - StepEnum.HARDEN.value: "Harden_ecc", -} - _QUALITY_GATES_BY_STEP = { StepEnum.DRC.value: ("qor.drc.clean",), StepEnum.LVS.value: ("qor.lvs.clean",), @@ -143,7 +127,7 @@ def _prefixed_evidence(step_directory: str, evidence: list) -> list[dict]: path = item.get("path") is_workspace_step_path = isinstance(path, str) and any( path == directory or path.startswith(directory + "/") - for directory in _STEP_DIRECTORIES.values() + for directory in STEP_DIRECTORIES.values() ) if ( isinstance(path, str) @@ -471,19 +455,16 @@ def _lec_artifact_items( golden_verilog=golden_verilog, gate_verilog=gate_verilog, ) - is_warning = step_name == StepEnum.LEC.value if status == "proven": state, summary = "pass", "Yosys LEC proved equivalence." elif status == "stale": - state = "warning" if is_warning else "failed" + state = "failed" summary = "Yosys LEC proof is stale; golden or gate netlist changed." elif result_json and Path(result_json).is_file(): - state = "warning" if is_warning else "failed" + state = "failed" summary = "Yosys LEC did not prove equivalence." else: state, summary = _file_state(result_json) - if is_warning and state == "failed": - state = "warning" result_path = _path_text(workspace, result_json) return [ _item( @@ -491,7 +472,7 @@ def _lec_artifact_items( step=step_name, category="artifact", owner="checklist", - policy="warn" if is_warning else "block", + policy="block", state=state, title="Yosys LEC result", summary=summary, @@ -515,23 +496,30 @@ def refresh_step_checklist(workspace: Workspace, step: WorkspaceStep) -> bool: def _post_route_lec_netlists(workspace: Workspace) -> tuple[Path | None, Path | None]: design = getattr(getattr(workspace, "design", None), "name", "") or "" + # Golden precedence mirrors the execution wiring (engine/flow.py): the + # synthesis output when the flow contains Synthesis, else the declared + # golden netlist, else the origin RTL. golden = getattr(getattr(workspace, "design", None), "origin_verilog", None) - filler = None + gate = None workspace_dir = Path(workspace.directory) if getattr(workspace, "directory", None) else None flow = getattr(workspace, "flow", None) if workspace_dir is not None: - filler = workspace_dir / "filler_ecc" / "output" / f"{design}_filler.v.gz" + # The canonical chain wires postRouteLec's gate input to the LVS + # output netlist (the step immediately before it), not the filler one. + gate = workspace_dir / "lvs_ecc" / "output" / f"{design}_lvs.v.gz" if flow is not None and flow.has_step(StepEnum.SYNTHESIS): golden = workspace_dir / "Synthesis_yosys" / "output" / f"{design}_Synthesis.v.gz" - return golden, filler + else: + golden = getattr(workspace.design, "golden_verilog", None) or golden + return golden, gate def _requires_post_route_lec(workspace: Workspace) -> bool: flow = getattr(workspace, "flow", None) - if flow is None or not flow.has_step(StepEnum.FILLER): + if flow is None or not flow.has_step(StepEnum.LVS): return False - golden, filler = _post_route_lec_netlists(workspace) - return bool(golden and Path(golden).is_file() and filler and Path(filler).is_file()) + golden, gate = _post_route_lec_netlists(workspace) + return bool(golden and Path(golden).is_file() and gate and Path(gate).is_file()) def _flow_items(workspace: Workspace) -> list[dict]: @@ -658,8 +646,8 @@ def rebuild_home_checklist(workspace: Workspace, resource_issues=None) -> dict: return {} workspace_dir = Path(workspace_directory) items = [] - post_route_lec_dir = _STEP_DIRECTORIES[StepEnum.POST_ROUTE_LEC.value] - for directory in _STEP_DIRECTORIES.values(): + post_route_lec_dir = STEP_DIRECTORIES[StepEnum.POST_ROUTE_LEC.value] + for directory in STEP_DIRECTORIES.values(): if directory == post_route_lec_dir: continue data = json_read(workspace_dir / directory / "checklist.json") @@ -678,7 +666,7 @@ def rebuild_home_checklist(workspace: Workspace, resource_issues=None) -> dict: _lec_artifact_items(workspace, StepEnum.POST_ROUTE_LEC.value, result_json, golden, gate) ) for step_name in _QUALITY_GATES_BY_STEP: - step_directory = workspace_dir / _STEP_DIRECTORIES[step_name] + step_directory = workspace_dir / STEP_DIRECTORIES[step_name] items.extend( _quality_gate_items_from_summary( workspace, diff --git a/chipcompiler/tools/ecc_sizer/builder.py b/chipcompiler/tools/ecc_sizer/builder.py index a4ac7cc2d..6009ebebd 100644 --- a/chipcompiler/tools/ecc_sizer/builder.py +++ b/chipcompiler/tools/ecc_sizer/builder.py @@ -2,8 +2,6 @@ import shutil from pathlib import Path -from rosettakit import cmdfile - from chipcompiler.data import EccStep, Workspace from chipcompiler.tools.ecc import builder as ecc_builder @@ -13,16 +11,17 @@ SIZER_STAGING_VERILOG_NAME = "sizer.v.gz" -def build_step( +def step_shape( workspace: Workspace, step_name: str, - input_def: Path | None, - input_verilog: Path | None, - input_db: Path | str | None = None, output_def: Path | None = None, output_verilog: Path | None = None, - output_gds: Path | None = None, -) -> EccStep: +) -> tuple[Path, Path, Path]: + """Canonical sizer step directory and default outputs. + + Dependency-free (no rosettakit import at module scope of this module's + callers) so deferred creation and selected creation share one shape. + """ safe_step_name = "_".join(step_name.split()).lower() step_directory = Path(workspace.directory) / f"{safe_step_name}_sizer" if output_def is None: @@ -31,6 +30,48 @@ def build_step( output_verilog = ( step_directory / "output" / f"{workspace.design.name}_{safe_step_name}.v.gz" ) + return step_directory, output_def, output_verilog + + +def deferred_step( + workspace: Workspace, + step_name: str, + input_def: Path | None, + input_verilog: Path | None, + input_db: Path | str | None, + output_def: Path | None = None, + output_verilog: Path | None = None, +) -> EccStep: + """Path-only sizer step for deferred creation (no config files).""" + step_directory, output_def, output_verilog = step_shape( + workspace, step_name, output_def, output_verilog + ) + return ecc_builder.build_step( + workspace=workspace, + step_name=step_name, + input_def=input_def, + input_verilog=input_verilog, + input_db=input_db, + output_def=output_def, + output_verilog=output_verilog, + tool="sizer", + step_directory=step_directory, + ) + + +def build_step( + workspace: Workspace, + step_name: str, + input_def: Path | None, + input_verilog: Path | None, + input_db: Path | str | None = None, + output_def: Path | None = None, + output_verilog: Path | None = None, + output_gds: Path | None = None, +) -> EccStep: + step_directory, output_def, output_verilog = step_shape( + workspace, step_name, output_def, output_verilog + ) step = ecc_builder.build_step( workspace=workspace, @@ -94,6 +135,8 @@ def _sizer_env_template() -> Path | None: def _tech_text(workspace: Workspace) -> str: sizer_root = find_sizer_root() + from rosettakit import cmdfile + env = cmdfile.CommandFile(prefix="-", dialect=cmdfile.PLAIN_DIALECT) env.option("lef", workspace.pdk.tech, value_type=cmdfile.ValueType.PATH, omit_empty=True) env.options("lef", workspace.pdk.lefs, value_type=cmdfile.ValueType.PATH) @@ -105,7 +148,7 @@ def _tech_text(workspace: Workspace) -> str: return env.build() -def _append_route_layer_options(command: cmdfile.CommandFile, workspace: Workspace) -> None: +def _append_route_layer_options(command, workspace: Workspace) -> None: bottom = workspace.parameters.data.get("bottom_layer", "") top = workspace.parameters.data.get("top_layer", "") @@ -130,6 +173,8 @@ def sizer_staging_verilog(step: EccStep) -> Path: def _cmd_text(workspace: Workspace, step: EccStep) -> str: + from rosettakit import cmdfile + output_dir = step.data.workdir_for(step.name) or "" command = cmdfile.CommandFile(prefix="-", dialect=cmdfile.PLAIN_DIALECT) diff --git a/chipcompiler/tools/ecc_sizer/utility.py b/chipcompiler/tools/ecc_sizer/utility.py index 9b0dff365..60cc83ef6 100644 --- a/chipcompiler/tools/ecc_sizer/utility.py +++ b/chipcompiler/tools/ecc_sizer/utility.py @@ -55,7 +55,8 @@ def get_sizer_command() -> list[str]: override = os.environ.get("CHIPCOMPILER_ECC_SIZER_ROOT", "").strip() if override: binary = Path(override).expanduser() / "bin" / "Sizer" - if binary.is_file(): + # A non-executable override must not shadow a working PATH install. + if binary.is_file() and os.access(binary, os.X_OK): return [str(binary.resolve())] sizer = shutil.which("Sizer") return [str(Path(sizer).resolve())] if sizer else [] diff --git a/chipcompiler/tools/eda.py b/chipcompiler/tools/eda.py index df55e92d9..fc813915b 100644 --- a/chipcompiler/tools/eda.py +++ b/chipcompiler/tools/eda.py @@ -67,7 +67,7 @@ def create_step( # Unselected optional tools only need a path-only step to preserve the # positional input chain; importing them can trigger expensive native # builds before the selected-step dependency check even runs. - if not check_dependency and eda in {"dreamplace", "sizer"}: + if not check_dependency and eda in _DEFERRED_EDA_TOOLS: return _build_deferred_step( workspace, step, @@ -106,6 +106,12 @@ def create_step( return workspace_step +# Optional tools whose unselected steps are built deferred (no EDA package +# import). Each tool owns a dependency-free deferred builder in its builder +# module; this set only names them. +_DEFERRED_EDA_TOOLS = frozenset({"dreamplace", "sizer"}) + + def _build_deferred_step( workspace: Workspace, step_name: str, @@ -127,26 +133,18 @@ def _build_deferred_step( from chipcompiler.tools.ecc import builder as ecc_builder if eda == "sizer": - safe_name = "_".join(step_name.split()).lower() - step_directory = Path(workspace.directory or ".") / f"{safe_name}_sizer" - output_def = ( - output_def or step_directory / "output" / f"{workspace.design.name}_{safe_name}.def.gz" - ) - output_verilog = ( - output_verilog - or step_directory / "output" / f"{workspace.design.name}_{safe_name}.v.gz" - ) - return ecc_builder.build_step( - workspace=workspace, - step_name=step_name, - input_def=input_def, - input_verilog=input_verilog, - input_db=input_db, - output_def=output_def, - output_verilog=output_verilog, - output_gds=output_gds, - tool=eda, - step_directory=step_directory, + # The sizer builder module owns its step shape; importing it is safe + # (rosettakit is imported lazily inside its functions). + from chipcompiler.tools.ecc_sizer.builder import deferred_step + + return deferred_step( + workspace, + step_name, + input_def, + input_verilog, + input_db, + output_def, + output_verilog, ) return ecc_builder.build_step( diff --git a/chipcompiler/utility/file.py b/chipcompiler/utility/file.py index 987f386e3..3dc6a1879 100644 --- a/chipcompiler/utility/file.py +++ b/chipcompiler/utility/file.py @@ -2,10 +2,49 @@ import hashlib import os +import tempfile from contextlib import suppress from pathlib import Path +def write_text_atomic(path: str, text: str) -> None: + """Replace the file at `path` with `text` via a sibling temp file + os.replace. + + A plain `open(path, "w")` truncates first, so an interruption or write + failure after truncation can destroy the existing file; the sibling temp + file keeps the old content intact until the fully written replacement + can be renamed in. An existing file's permissions are preserved + (mkstemp's 0600 would otherwise silently narrow a shared ecc.toml); a + new file gets the umask-respecting default the plain write would have + produced. + """ + directory = os.path.dirname(os.path.abspath(path)) + try: + mode = os.stat(path).st_mode & 0o7777 + except OSError: + mode = 0o666 & ~_current_umask() + fd, tmp_path = tempfile.mkstemp( + dir=directory, prefix=f".{os.path.basename(path)}.", suffix=".tmp" + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as file: + file.write(text) + file.flush() + os.fsync(file.fileno()) + os.chmod(tmp_path, mode) + os.replace(tmp_path, path) + except BaseException: + os.unlink(tmp_path) + raise + + +def _current_umask() -> int: + """Read the process umask (querying requires setting it back).""" + mask = os.umask(0o022) + os.umask(mask) + return mask + + def chmod_folder(folder: str, mode: int = 0o777): def _try_chmod(path): with suppress(Exception): diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 656673287..000000000 --- a/docs/architecture.md +++ /dev/null @@ -1,178 +0,0 @@ -# Architecture - -ECOS Chip Compiler orchestrates EDA tools through a layered, plugin-based architecture. - -## Layered Architecture - -``` -┌────────────────────────────────────────────────────┐ -│ Service Layer (chipcompiler/services/) [removed] │ -├────────────────────────────────────────────────────┤ -│ RTL2GDS Layer (chipcompiler/rtl2gds/) │ -│ Pre-configured flow templates │ -├────────────────────────────────────────────────────┤ -│ Engine Layer (chipcompiler/engine/) │ -│ EngineFlow (orchestration) + EngineDB (analysis) │ -├────────────────────────────────────────────────────┤ -│ Tool Layer (chipcompiler/tools/) │ -│ yosys, ecc, klayout, openroad, magic │ -├────────────────────────────────────────────────────┤ -│ Data Layer (chipcompiler/data/) │ -│ Workspace, WorkspaceStep, Parameters, PDK │ -├────────────────────────────────────────────────────┤ -│ Utility Layer (chipcompiler/utility/) │ -│ Logging, JSON I/O, file operations │ -└────────────────────────────────────────────────────┘ -``` - -## Core Design Patterns - -### 1. Plugin Architecture -Tools loaded dynamically via `load_eda_module()`. Standard interface: -```python -def is_eda_exist() -> bool # Check availability -def build_step() -> WorkspaceStep # Create workspace -def build_step_space() -> None # Initialize directories -def build_step_config() -> None # Generate config -def run_step() -> StateEnum # Execute tool -``` - -### 2. Workspace Isolation -Each step has isolated directory structure: -``` -workspace_step/ -├── input/ # Input files -├── output/ # Output files -├── config/ # Tool configuration -├── logs/ # Run logs -├── scripts/ # Execution scripts -├── reports/ # Analysis reports -├── data/ # Intermediate data -├── features/ # Feature data -└── analysis/ # Analysis results -``` - -### 3. State Machine -``` -Unstart → Ongoing → Success - ↘ Incomplete / Warning -``` -States: Unstart (not started), Ongoing (running), Success (completed), Warning (non-blocking check failed), Incomplete (blocking failure), Invalid, Ignored, Pending. - -### 4. Configuration as Data -- `workspace.flow.json` - Flow state persistence -- `config/*.json` - Tool configurations - -### 5. Process Isolation -Steps execute in subprocesses (`multiprocessing.Process`) for resource isolation, timeout control, and fault isolation. - -### 6. Flow Persistence -Saved flow state enables resume after interruption, state checks, and incremental execution. - -## Data Flow - -### Inter-step Transfer -``` -Synthesis → output/design.v - ↓ -Placement → input/design.v → output/design.def - ↓ -Routing → input/design.def → ... -``` - -**Rules:** -- First step uses `workspace.design.origin_verilog/origin_def` -- Subsequent steps chain: previous `output/` → next `input/` - -### Typical Execution Flow -``` -1. Create Workspace (PDK, parameters, RTL) -2. Initialize EngineFlow (load/create workspace.flow.json) -3. Run flow.run_steps() - - Iterate workspace_steps - - Skip Success states - - Run remaining: Ongoing → subprocess → Success/Incomplete -4. Optional: Initialize EngineDB for post-flow analysis -``` - -## Layer Details - -### Data Layer (chipcompiler/data/) - -| Entity | Purpose | -|--------|---------| -| `Workspace` | Top-level container: design files, PDK, parameters, flow state | -| `WorkspaceStep` | Per-step workspace: inputs, outputs, configs, logs, reports | -| `Parameters` | Design specs: die size, clock frequency, buffer/filler/tie cells | -| `PDK` | Tech library paths: LEF, liberty, timing, SPEF | -| `StepEnum` | Flow steps: SYNTHESIS, LEC, PLACEMENT, CTS, LEGALIZATION, TIMING_OPT (sizer + inner legalize), ROUTING, FILLER | -| `StateEnum` | Step states: Unstart, Ongoing, Success, Incomplete, Invalid, Ignored, Pending | - -### Engine Layer (chipcompiler/engine/) - -**EngineFlow (flow.py):** -- Load/save flow config from `workspace.flow.json` -- Build workflow: `build_default_steps()` or `add_step()` -- Chain workspaces: `create_step_workspaces()` links input/output -- Execute: `run_steps()` runs steps in subprocess, tracks state/runtime -- State management: `check_state()`, `set_state()`, `clear_states()` - -**EngineDB (db.py):** -Wraps ECC-Tools C++ engine for post-flow circuit analysis. Initialized with a WorkspaceStep (typically last successful). - -### Tool Layer (chipcompiler/tools/) - -**Directory Structure:** -``` -tool_name/ -├── __init__.py # Interface exports -├── builder.py # Workspace + config creation -├── runner.py # Tool execution -├── utility.py # Helpers -├── configs/ # Config templates -├── scripts/ # Tool scripts (TCL/Python/Shell) -└── bin/ # Binaries (ecc only) - └── lib/ # Runtime dependencies (ecc only) -``` - -**Integrated Tools:** - -**Yosys** - RTL synthesis (Verilog → gate-level netlist) - -**ECC-Tools** - Physical design backend -- Tool name: `"ecc"` (e.g., `add_step(StepEnum.PLACEMENT, tool="ecc")`) -- Source: `chipcompiler/thirdparty/ecc-tools` (C++ engine) -- Wrapper: `chipcompiler/tools/ecc/` (Python integration) -- Operations: placement, CTS, timing optimization, legalization, routing, filler -- I/O: DEF/Verilog, PDK LEF/liberty, SDC -- Runtime deps bundled in `bin/lib/` with RPATH `$ORIGIN:$ORIGIN/lib` for portability - -**KLayout** - Layout visualization, GDS/OASIS handling, DRC - -**Runtime Dependency Bundling (ECC-Tools):** -Script `scripts/autopatch-ecc-py.sh` collects `.so` dependencies, copies to `bin/lib/`, patches RPATH with `auto-patchelf`, verifies with `ldd`. Enables deployment without build directory. - -**Yosys Runtime Resolution:** -1. `utility.get_yosys_command()` - Resolve executable (bundled `CHIPCOMPILER_OSS_CAD_DIR` → system PATH) -2. `utility.get_yosys_runtime()` - Prepare subprocess env (no global `os.environ` mutation) -3. `utility.check_slang_plugin()` - Preflight check -4. `runner.run_step()` - Execute with resolved `(command, env)` - -### RTL2GDS Layer (chipcompiler/rtl2gds/) - -`build_rtl2gds_flow()` returns the complete flow: SYNTHESIS → synthesis-level LEC → FLOORPLAN → PLACEMENT → CTS → LEGALIZATION → TIMING_OPT → ROUTING → FILLER → RCX → STA → LVS → post-route LEC → DRC → HARDEN. - -Timing Opt (`TIMING_OPT`, tool `sizer`) sits **after legalization and before routing**. CTS dirties legality, so the post-CTS `legalization` sibling still runs first. Sizer then sizes cells and Timing Opt legalizes internally before publishing DEF/Verilog. Missing Sizer does not prevent building the rest of the flow; the Timing Opt step is marked Invalid and later steps still chain from its declared outputs. - -### Benchmark Module (benchmark/) - -Batch testing infrastructure: -- `benchmark.py` - `run_benchmark()`, `benchmark_statis()`, `benchmark_metrics()` -- `parameters.py` - `get_parameters(pdk_name, design)` factory -- JSON configs: `ics55_benchmark.json`, `ics55_tapeout.json` - -Usage: `parameters = get_parameters("ics55", "gcd")` - -## Related Documentation - -- [Development Guide](development.md) - Setup, workflows, adding tools diff --git a/docs/development.cn.md b/docs/development.cn.md new file mode 100644 index 000000000..0b14b99a0 --- /dev/null +++ b/docs/development.cn.md @@ -0,0 +1,675 @@ +# 开发指南 + +ECOS Chip Compiler 的开发环境搭建与工作流,含 `ecc` CLI 的扩展开发方式。代码路径均相对 `ecc/` 子模块根目录。 + +## 安装 + +ECC 使用 `uv` 管理。ECC 主工作区以 editable 方式从本地源码树安装 `ecc`。 + +如果本机有 Nix,先进入开发 shell 再执行 `uv sync`: + +```bash +nix develop +``` + +如果没有 Nix,在普通 shell 中执行同样的 `uv sync` 命令即可(需先装好原生构建所需的系统包)。 + +### ECC 工作区 + +在 `ecc` 仓库根目录执行: + +```bash +uv sync --no-build-isolation-package ecc-dreamplace --no-build-isolation-package ecc-tools-bin --verbose +source .venv/bin/activate +``` + +这会创建 Python 虚拟环境并安装: + +- 来自本地源码树的 `ecc`; +- 来自 `chipcompiler/thirdparty/ecc-dreamplace` 的 `ecc-dreamplace`; +- 来自 `chipcompiler/thirdparty/ecc-tools` 的 `ecc-tools-bin`。 + +`ecc` 是 editable 安装,Python 源码改动在下次导入时即生效。 + +### 用 direnv 自动加载 + +```bash +direnv allow +``` + +之后 `cd` 进入仓库时 `direnv` 会自动进入 Nix 开发 shell。 + +## 构建包 + +用 uv 构建 Python 包: + +```bash +uv build +``` + +wheel 和 sdist 产物写入 `dist/`。 + +自己编译可安装的 PyInstaller CLI 包见 [README - 源码构建](../README.cn.md#源码构建)。 + +## 调试 + +常规调试: + +1. 按上面的命令同步 ECC 工作区; +2. 激活 `.venv`; +3. 用 `.venv/bin/python` 运行 CLI、测试或调试器。 + +常规 ECC 开发不需要额外的 `PYTHONPATH` 覆盖;进程再次导入 `ecc` 时读的是源码树。 + +可选的 IDE 索引配置: + +```json +{ + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python" +} +``` + +这只改善导航体验;运行时行为跟随激活的 uv 环境。 + +## 代码质量 + +```bash +# 格式化与 lint +uv run ruff format chipcompiler/ test/ +uv run ruff check chipcompiler/ test/ + +# 类型检查 +uv run ty check +uv run pyright chipcompiler/ +uv run mypy chipcompiler/ + +# 旧格式化工具 +uv run black chipcompiler/ test/ +uv run isort chipcompiler/ test/ +``` + +## Git 钩子 + +每个 clone 安装一次 pre-commit 钩子,即可在每次提交前自动运行 ruff lint/format(以及 commit message 格式检查): + +```bash +uv run prek install +``` + +这会注册 `.pre-commit-config.yaml` 中的 `pre-commit` 阶段(ruff lint + ruff format)和 `commit-msg` 阶段(约定式提交检查)(由其 `default_install_hook_types` 声明)。如果已装过钩子——或旧安装缺 commit-msg 钩子——用显式命令重装: + +```bash +uv run prek install --config .pre-commit-config.yaml --hook-type pre-commit --hook-type commit-msg --overwrite +``` + +## 测试 + +```bash +uv run pytest test/ +uv run pytest test/tools/yosys/test_utility.py -v +uv run pytest test/ --cov=chipcompiler --cov-report=term-missing +uv run pytest test/formal/ -v +``` + +### 形式化验证 + +基于 z3 的形式化验证。方法、测试清单与已知发现的 bug 见 [test/formal/README.md](../test/formal/README.md)。 + +## 新增 EDA 工具 + +### 1. 创建目录结构 + +```bash +mkdir -p chipcompiler/tools//{configs,scripts} +touch chipcompiler/tools//{__init__.py,builder.py,runner.py,utility.py} +``` + +### 2. 实现接口 + +`builder.py`: + +```python +from pathlib import Path + +from chipcompiler.data import Workspace, WorkspaceStep + +def build_step( + workspace: Workspace, + step_name: str, + input_def: Path | None, + input_verilog: Path | None, + input_db: Path | str | None = None, + output_def: Path | None = None, + output_verilog: Path | None = None, + output_gds: Path | None = None, +) -> WorkspaceStep: + directory = Path(workspace.directory) / f"{step_name}_" + return WorkspaceStep(name=step_name, tool="", directory=directory) + +def build_step_space(workspace_step: WorkspaceStep) -> None: + Path(workspace_step.directory).mkdir(parents=True, exist_ok=True) + +def build_step_config(workspace: Workspace, workspace_step: WorkspaceStep) -> None: + ... # 根据 workspace 参数写出该步骤的配置文件 +``` + +`runner.py`: + +```python +import subprocess + +from chipcompiler.data import Workspace, WorkspaceStep + +def is_eda_exist() -> bool: + try: + subprocess.run(["", "--version"], capture_output=True, check=True) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + +def run_step(workspace: Workspace, step: WorkspaceStep, ecc_module=None) -> bool: + result = subprocess.run( + ["", str(step.script.main)], + cwd=step.directory, + capture_output=True, + ) + return result.returncode == 0 +``` + +`__init__.py`: + +```python +from .builder import build_step, build_step_space, build_step_config +from .runner import is_eda_exist, run_step + +__all__ = ["build_step", "build_step_space", "build_step_config", "is_eda_exist", "run_step"] +``` + +### 3. 添加配置与脚本 + +- JSON 模板放 `configs/`; +- TCL、Python 或 shell 脚本放 `scripts/`。 + +### 4. 接入流程 + +修改 `EngineFlow.build_default_steps()` 或使用 `add_step()`。 + +### 5. 编写测试 + +```python +import pytest +from chipcompiler.tools. import is_eda_exist, run_step + +@pytest.mark.skipif(not is_eda_exist(), reason=" not installed") +def test_run_step(): + pass +``` + +## 接入第三方工具 + +ECC 用 uv 做 Python 依赖解析。第三方仓库按独立项目对待,其包专属的搭建说明留在各自仓库中。 + +### 1. Python 依赖 + +把包加入根 `pyproject.toml`,然后执行: + +```bash +uv lock +``` + +再按 [安装](#安装) 一节的命令同步 ECC 工作区。 + +### 2. 运行时接入 + +创建 `chipcompiler/tools//`,包含 `__init__.py`、`builder.py`、`runner.py`。每个工具必须实现 `is_eda_exist`、`build_step`、`run_step`。通过 `EngineFlow.build_default_steps()` 或 `add_step()` 接入流程。 + +### Sizer 开发约定 + +Sizer 目前被视为外部原生工具,而不是 ECC 的 Python 工作区包。不要把 `ecc-sizer` 加入 `[tool.uv.workspace]`:`uv` 解决的是 Python 包与 lockfile,而 Sizer 是独立的 CMake/Nix C++ 项目,自带 OpenROAD 子模块树。 + +不要把 Sizer vendor 到 `chipcompiler/thirdparty` 下,除非 ECC 有意接管该原生运行时的构建与分发。本地开发时把 Sizer 放在同级检出目录,通过 PATH 暴露其可执行文件。只有当 CI、release bundle 或最终用户安装必须在没有单独准备 Sizer 检出的情况下可复现时,才把它提升为 ECC 的 thirdparty 输入;届时优先选 Nix input 或 release 产物,仅当该仓库本就应由 ECC 自身构建时才用 `chipcompiler/thirdparty/ecc-sizer` 检出的方式。 + +## 扩展 CLI + +本节面向需要在 `ecc` CLI 中新增/修改命令的开发者。 + +### CLI 整体结构 + +``` +pyproject.toml # scripts.ecc = "chipcompiler.cli.main:main" +chipcompiler/cli/main.py # run(argv) / main(),仅做薄封装 +chipcompiler/cli/app.py # 根 typer app;invoke_typer_app() 统一执行与退出码;version / layout-image 两命令直接注册于此 +chipcompiler/cli/commands/ # typer 命令定义层(薄) + ├── project.py # init/check/run/status/log/config/migrate 的注册与参数声明 + ├── doctor.py # doctor 顶层命令(环境体检) + ├── param.py # param 子应用(list/show/set/unset/diff) + ├── pdk.py # pdk 子应用(set-root/show/unset) + ├── project_config.py # project 子应用(set/unset/add/remove/show) + ├── workspace.py # workspace 子应用(refresh) + ├── signoff.py # signoff 子应用(inspect/export) + ├── report.py # report 子应用(summary/qor/checklist/step) + └── rpc.py # rpc 子应用(serve) +chipcompiler/cli/command_handlers/ # 业务处理层(唯一的处理器包,有状态/重逻辑) + ├── project.py # init / check / run / migrate / workspace refresh(含 preset 解析与环境预检) + ├── inspect.py # status / log / config + ├── doctor.py # doctor(组装 env_probe 结果为 records) + ├── param.py # param 五子命令(校验 + 经 cli/project/toml_edit.py 做 TOML 定点改写) + ├── pdk.py # pdk 三子命令(TOML 定点改写 + root 来源解析) + ├── project_config.py # project 五子命令(声明 schema + 经 cli/project/config_fields.py 做 TOML 定点改写) + ├── workspace_params.py # workspace 局部 param set/unset/list/diff(改 home/params.toml + 失效后缀步骤) + ├── signoff.py # signoff inspect/export + └── report.py # report 四子命令(文件写出 + 记录汇总) +chipcompiler/cli/core/ # 框架层 + ├── inputs.py # 各命令的 frozen dataclass 输入模型 + ├── invocation.py # execute_command():上下文构建→handler→渲染→退出码 + ├── options.py # 共享 Annotated 选项别名 + ├── output.py # disclosure_cmd() / step 名与状态归一化 + ├── records.py # error_record() + ├── types.py # CommandContext / CommandResult / OutputMode + └── version_info.py # version 命令的包元数据版本(环境工具版本见 inspection/tool_versions.py) +chipcompiler/cli/inspection/ # 只读探查逻辑 + ├── discovery.py / config_view.py / log_view.py + ├── env_probe.py # doctor/run 预检的环境探查(ProbeResult 体系) + └── tool_versions.py # ecc version 的环境工具版本(yosys/sizer/klayout) +chipcompiler/cli/project/ # config.py(ecc.toml 解析校验)/ config_fields.py(`ecc project` 的项目声明 schema)/ params.py(参数注册表)/ workspace_params.py(workspace 局部覆盖记录)/ manifest.py(项目形态分类)/ effective_config.py / config_params/(直配参数 schema)/ migrate*.py(旧布局迁移)/ run_*.py(run 目标解析与分发) +chipcompiler/cli/rendering/ # 输出渲染(render / renderers / pretty / progress) +chipcompiler/engine/signoff/ # 签核收集器 + 设计/checklist 报告(包,见下文) +chipcompiler/engine/qor_report.py # QoR 总分计分(GUI 规则移植) +``` + +模块归属由 `test/cli/test_cli_module_layout.py` 强制:核心框架必须在 `cli/core/`、命令注册在 `cli/commands/`、全部处理器在唯一的 `cli/command_handlers/` 包、只读探查在 `cli/inspection/`、渲染在 `cli/rendering/`;旧的 `chipcompiler/cli/*.py` 平铺模块必须不可导入。新增文件时放进对应子包,不要在 `cli/` 根下新建模块。 + +公开命令的归属必须严格:`ecc signoff` 只负责签核包就绪度与归档导出(`inspect`、`export`);`ecc report` 统一承载报告输出(`summary`、`qor`、`checklist`、`step`)。`ecc config [STEP]` 始终返回解析后的数据,因此不提供 `--resolved` 开关。不要在错误的命令组中增加别名,也不要添加没有行为分支的选项。 + +### 一次命令调用的完整链路 + +以 `ecc check --project gcd --plain` 为例: + +1. `main.py::run()` 把 `sys.argv[1:]` 交给 `app.py::invoke_typer_app(raw)`(`cli/app.py`)。 +2. typer 解析参数,命中 `commands/project.py::check_cmd`(`cli/commands/project.py`)。命令函数只做一件事:把 typer 参数装进 frozen dataclass `CheckInput`(定义在 `cli/core/inputs.py`),然后调用: + ```python + execute_command("check", command_input, project_handlers.check) + ``` +3. `core/invocation.py::execute_command()`(`cli/core/invocation.py`)依次: + - `build_context()`:解析项目目录(`--project`,缺省为 cwd)→ 读项目唯一的 `ecc.toml`(不可读时记入 `config_error`)→ `cli/project/manifest.py::classify_project()` 判定项目形态(manifest / legacy / virgin)。manifest 项目只从 `project.json` workspace 表解析 `--workspace NAME`:唯一活跃 workspace 自动选中,多个时必须选择;新的 `ecc run --workspace NAME` 会在创建文件前登记。`--workspace` 是项目内单路径段名称,不是直接路径。legacy 项目必须先迁移才能 `ecc run`;清单损坏为 `manifest_invalid`。随后由 `--plain` 推导 `OutputMode`,组装成带 `project_state` / `manifest_error` 字段的 `CommandContext`(`cli/core/types.py`)。 + - 调 handler:`handler(command_input, ctx) -> CommandResult`。 + - handler 返回后按需追加记录(`_with_legacy_hint` / `_with_config_shadow_hint`):legacy 项目的 `run/check/status` 附加迁移提示(指向 `ecc migrate`);workspace 的 `home/` 同时存在 `params.toml` 与旧 `parameters.json` 时打 `workspace_config_shadowed` 警告(旧 JSON 已失效)。 + - 渲染:`rendering/renderers.py::render_command_result()` 先查 `RENDERERS[(render_key, output_mode)]` 定制渲染器,没有则落到通用 `rendering/render.py::render_result()`。 + - `raise typer.Exit(code=result.exit_code)` 把退出码透传给 `invoke_typer_app`。 +4. `invoke_typer_app` 以 `standalone_mode=False` 运行 click 命令,捕获 `click.exceptions.Exit` / `ClickException` 并转换成进程退出码,保证测试里 `cli_main.run([...])` 能拿到返回值。 + +### 输出约定(records 模型) + +经 `execute_command()` 分发的命令统一使用「记录列表」: + +- handler 返回 `CommandResult.ok(records)` / `CommandResult.err(records, exit_code=1)`(`cli/core/types.py`);`records` 是 `tuple[dict, ...]`,每个 dict 是一行结构化记录。 +- 两种输出模式(见 `cli/core/invocation.py`): + - `--plain`:`key=value` 逐行(含空格的值会加引号),面向脚本 grep; + - 默认 TEXT:走 pretty 渲染;无定制渲染器时打印 `key=value`,键名去掉 `_cmd` 后缀。 +- 错误记录用 `core/records.py::error_record(...)`,产出 `{"kind": "error", "error": "<机器可读错误码>", ...}`;TEXT 模式下由 `render_error` 打成 `[error]` 块。错误码是稳定契约(如 `missing_config`、`run_exists`、`unknown_parameter`、`invalid_value`),测试会对它们断言。 +- 给用户的「下一步」提示统一用 `core/output.py::disclosure_cmd("ecc status", project, run_id)` 生成可复制的完整命令,记录里放在 `inspect` / `log_cmd` / `run` 等字段。 + +`ecc version` 直接格式化版本元数据;另有一个隐藏的 `--json` 选项(单对象、版本专用 schema)预留给桌面应用,不出现在 `--help` 中。`ecc rpc serve` 与 `ecc layout-image` 有意不使用 records 渲染器输出模式。 + +### 新增一个命令 + +以新增 `ecc check` 这样的命令为例,共 5 步(前 3 步必须,后 2 步按需): + +1. **定义输入模型**。在 `cli/core/inputs.py` 增加 frozen dataclass,必须满足 `CommandInput` 协议(`cli/core/invocation.py`)——即带 `output: OutputOptions` 与 `project: ProjectOptions` 两个字段: + + ```python + @dataclass(frozen=True) + class CheckInput: + output: OutputOptions + project: ProjectOptions + # 命令私有字段放这里 + ``` + +2. **编写 handler**,放在 `cli/command_handlers/`,签名固定: + + ```python + def check(command_input: CheckInput, ctx: CommandContext) -> CommandResult: + if ctx.config is None: + return CommandResult.err([error_record("missing_config", path=...)]) + ... + return CommandResult.ok([{...}, ...]) + ``` + + 约定:handler 不直接 print、不解析命令行字符串;重逻辑延迟导入(现有代码普遍在函数体内 `from chipcompiler... import ...`,保持该风格以缩短 CLI 启动时间);处理只读探查的逻辑放 `cli/inspection/`,handler 做记录拼装。 + +3. **注册 typer 命令**。在 `cli/commands/project.py`(或新模块)声明命令函数并注册,共享选项直接用 `cli/core/options.py` 的别名: + + ```python + from chipcompiler.cli.core.options import PlainOption, ProjectOption + + def register_project_commands(app: typer.Typer) -> None: + app.command("check", help="Validate the current project setup")(check_cmd) + + def check_cmd( + *, + project: ProjectOption = None, + plain: PlainOption = False, + ) -> None: + command_input = CheckInput( + output=output_options(plain=plain), + project=project_options(project), + ) + execute_command("check", command_input, project_handlers.check) + ``` + + 顶层单命令直接 `app.command(...)`(现成范例:`cli/commands/doctor.py`,全链路最短);命令组则新建 `xxx_app = typer.Typer(...)` 再在 `app.py` 里 `app.add_typer(xxx_app, name="xxx")`(现成范例:`cli/commands/signoff.py`,含子命令经 `execute_command(..., render_key=f"signoff:{sub}")` 复用同一 handler 模块)。注意 `app.py` 构建的根 app 设置了 `add_completion=False, no_args_is_help=True`。 + +4. **(可选)定制 TEXT 渲染**。默认 TEXT 是 `key=value`。若要更友好的输出: + + - 单命令:在 `cli/rendering/pretty.py` 的 `get_pretty_renderer()` 注册表加一个渲染函数(现有 `init/check/run/status/config` 即此路径); + - 子命令组:在 `cli/rendering/renderers.py` 的 `RENDERERS` 字典加 `(render_key, OutputMode)` 条目,`render_key` 通过 `execute_command(..., render_key="param:show")` 传入(param 即此路径)。 + + PLAIN 无需任何定制。 + +5. **补测试**。测试放置按所有权边界(见 [../CLAUDE.md](../CLAUDE.md) 第 5 节);CLI 特有约定: + + - 命令行为 → `test/cli/commands/test_.py`;param → `test/cli/params/`;只读探查 → `test/cli/inspect/`;渲染 → `test/cli/rendering/`。 + - 测试直接调 Python 入口而非子进程: + ```python + from chipcompiler.cli import main as cli_main + + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) + assert rc == 0 + records = plain_records(capsys.readouterr().out) # fixture 来自 test/cli/conftest.py + ``` + - 复用 `test/cli/conftest.py` 的 fixture:`create_cli_project`(生成带 `ecc.toml` 的临时项目)、`create_flow_json`(伪造 `runs//home/flow.json`)、`create_step_dir`、`create_workspace_config`、`mock_pdk_validation` 等。**注意 autouse 的 `_stub_run_preflight`**:它把 `env_probe.probe_environment` 打桩为空,保证 CLI 测试不依赖宿主工具(doctor/预检相关测试自行覆盖该补丁即可覆盖生效)。 + - 引擎层报告/签核的测试放顶层 `test/`(如 `test/test_signoff_report.py`、`test/test_qor_report.py`、`test/test_signoff_package.py`),伪造 workspace 复用其 fixture。 + - 新命令别忘了在 `test/cli/test_typer_cli.py::test_root_help_returns_zero_and_lists_commands` 与 `test/cli/test_cli_module_layout.py`(commands 元组)里登记。 + +### 常见扩展场景 + +#### 新增可调参数(param 体系) + +旧的语义参数仍在 `cli/project/params.py::_LEGACY_PARAM_REGISTRY`。工具 JSON 的直配字段按 owner 分别放在 `data/config_params/`(`cts.py`、`floorplan.py`、`dreamplace.py` 等),每项都必须人工审核。`ParamSchema` 只能拥有一种目标:旧的 `maps_to`、JSON `config_target` 或白名单 PDK `pdk_target`。 + +已审核的静态模板字段使用 `config_param()` 声明(`description` 为必填关键字参数,逐参数人工撰写,`test/data/test_descriptions.py` 会校验): + +```python +# chipcompiler/data/config_params/cts.py +config_param( + "cts.skew_bound", + "cts", + ("skew_bound",), + "0.08", + applies="cts", + description="Allowed clock skew upper bound in ns.", +) +``` + +该声明会同时启用 `ecc param list/show/set/unset/diff`、重复的 `ecc run --set key=value`,以及嵌套 `[params.*]` TOML 的读写与校验。默认 `ecc param list` 保持简明;用 `--step ` 或 `--all` 查看直配 schema。命令行列表和对象值使用 JSON 字面量。 + +项目 run 创建时,非默认 `config_target` 会以结构化 `config_overrides` 存入 `home/params.toml`;每次刷新 workspace 配置后由 `data.workspace.config_overrides` 重放。PDK 路径 schema 在 `config_params/pdk.py`,写入 `[pdk.overrides]`;`pdk.root` 始终使用 `ecc pdk set-root`。不得将 workspace 的输入、输出、临时、生成产物或 STA 多 corner liberty 路径暴露为 CLI 参数。 + +`config_params/coverage.py` 会把每个 JSON 模板字段与唯一一个直配 schema、旧映射或受保护路径清单比对。模板变化时必须同步更新该清单和 `test/cli/params/test_config_coverage.py`。解析和定点 TOML 编辑仍在 `params.py`,命令测试仍放在 `test/cli/params/`。 + +#### 扩展 `ecc run` + +`run` 有两条互斥路径(`cli/command_handlers/project.py` 的 `run()` / `_run_workspace()`): + +- **新建 workspace**:解析 `[design]` 输入声明、PDK、参数与请求入口步骤;只校验入口步骤所需文件;先原子登记受管名称到 `project.json`(`not_started`);预检工具;在 `/` 调用 `create_workspace`。`create_workspace` 将输入复制到 `origin/` 并产出全部步骤配置,CLI 后续不改写配置。正常新建 flow 用 preset;`--from A --to B` 改用 `rtl2gds.build_flow_range(A, B)` 动态构建包含式规范范围。新范围不能与 `--preset`、`--overwrite`、`--resume`、`--only`、`--force` 组合。 +- **已有 workspace**:先由 `chipcompiler/engine/reconcile.py` 把持久化 flow 与目标对齐(前缀 → 追加扩展;超集且全成 → `no_op`;分叉 → `flow_mismatch`),再 `load_workspace` 后由 `chipcompiler.engine.rerun` 的 `run_resume`、`run_from` 或 `run_only` 原地复跑。`--from A --to B` 是已有 flow 的包含式范围,会将其后的步骤状态失效但保留其输出文件。已有 workspace 不会重新预检输入,也不会改写已复制输入或配置。 + +项目 preset 的步骤序列定义在 `chipcompiler/rtl2gds/builder.py`(`build_*_flow()` / `get_flow_builders()`),不在 CLI 层。`build_flow_range()` 对规范的 `build_rtl2gds_flow()` 结果切片,步骤别名和顺序只有一份来源。修改序列时须同步引擎默认 flow、`StepEnum` 与 manifest 范围映射;CLI 只负责参数解析、输入契约、进度渲染选择与结果映射。 + +#### 扩展环境探查(doctor / 预检) + +`cli/inspection/env_probe.py` 是唯一的探查层:`ProbeResult(component, status, required, detail, remediation)` + 每组件一个 probe 函数(yosys / yosys-slang / ecc-tools / dreamplace / klayout / sizer / pdk)。新增组件 = 加一个 probe 函数并登记进 `_PROBES`/`ALL_COMPONENTS`;`probe_environment()` 对异常兜底(探查失败计为 fail 而非崩溃)。`probe_components_for_preset()` 决定当前 run 预检范围(始终 ecc-tools,yosys↔含 Synthesis,dreamplace↔含 place/legalization,sizer↔含 Timing optimization)。PDK 由配置校验覆盖,slang 留给综合步骤;Sizer 也是 doctor 的必需组件。 + +#### 扩展签核(`ecc signoff inspect/export`) + +- **CLI 层**:`cli/commands/signoff.py` + `cli/command_handlers/signoff.py`。`inspection/discovery.py::resolve_loaded_workspace()` 在选定项目中解析受管 `--workspace NAME`(或唯一活跃 workspace)。inspect 复用 `runtime/signoff_export.py::inspect_signoff_package`(blocked 也 rc=0);export 复用 `export_signoff_package_archive`(`RuntimeApiError` → `signoff_incomplete`)。 +- **引擎层**:`chipcompiler/engine/signoff/` 包负责签核收集器 `SignoffPackageCollector`,以及就绪度检查和归档导出所使用的包级 API。 + +#### 扩展报告(`ecc report summary/qor/checklist/step`) + +- **设计总结**:`ecc report summary` 调用 `chipcompiler.engine.signoff.generate_text_report`。其实现按职责分模块(`report.py` 编排 / `report_data.py` 数据契约 / `report_extract.py` 解析器+workspace 收集 / `report_sections.py` 分区抽取 / `report_timing.py` timing 链 / `report_text.py` 格式化),全部经包 `__init__` 对外暴露。新增报告分区时,在 `report_sections.py`(或 timing 链)增加 `_extract_(q)`,并在 `report.py` 编排处注册。 +- `engine/qor_report.py`:GUI `projectQorTrend.ts` 的单 workspace 移植——常量表(`METRIC_FAIL_VALUES`/`DIMENSION_WEIGHTS`/`QOR_SCORE_THRESHOLD`)+ 归一化 + 项目级记录选择(role 优先级 final>gate>trend、area_cost 只取最后成功的 area 步)+ `score_record` 计分公式 + 维度加权(不重归一化)。新增可计分指标 = 在 GUI 与 `METRIC_FAIL_VALUES` 同步加阈值。 +- `engine/signoff/report_checklist.py`:只读渲染 `home/checklist.json`(不合法时报 unavailable,绝不回写文件)。 +- CLI:`cli/commands/report.py` + `cli/command_handlers/report.py`;workspace 解析复用 `inspection/discovery.py`(`resolve_workspace_path` 是无副作用核心,`resolve_command_workspace` 是核心加 `load_workspace`;signoff、report 与只读的 status/log/config 共用)。 + +#### 扩展 RPC(`ecc rpc serve`) + +`rpc serve --stdio` 启动 JSON-RPC 2.0 sidecar(`chipcompiler/runtime/stdio_server.py`)。方法在 `chipcompiler/runtime/methods.py::RUNTIME_METHODS` 声明(`method_name` + pydantic `request_model` + `handler_name`),handler 实现在 `chipcompiler/runtime/workspace_api.py`,由 `runtime/server.py` 统一挂载;协议细节见 [rpc-guide.md](rpc-guide.md)。新增方法 = 加一个 `RuntimeMethodSpec` + 对应 API 方法 + 请求模型,无需改 CLI 层。 + +#### 扩展项目声明(`ecc project *` / `ecc workspace refresh`) + +- `ecc project set/unset/add/remove/show` 的可编辑键在 `cli/project/config_fields.py::PROJECT_FIELDS` 声明(`key` / TOML 表 / 字段名 / 类型 / `list_value`)。加一个字段五个子命令自动生效;`add`/`remove` 硬性只支持 `design.rtl`(其余键报 `unsupported_project_collection`)。 +- `ecc workspace refresh` 的实现等价于 run 路径的 `overwrite=True, execute_flow=False`(`cli/command_handlers/project.py::refresh_workspace`),因此它和新建 run 一样做环境预检(`ecc.toml` 的 `preset: rtl2gds` 要求全套工具就绪,即使并不真正执行步骤);非 manifest 项目报 `workspace_refresh_requires_managed_workspace`。 +- workspace 局部 `param set/unset/list/diff --workspace NAME` 经 `cli/command_handlers/workspace_params.py` 修改 `home/params.toml`(记录到 `workspace_param_overrides`,经 `chipcompiler.engine.rerun` 失效后缀步骤);项目级 `param` 走 `cli/command_handlers/param.py`。 + +## CLI 用法 + +面向命令行自动化与脚本,经 Nix 运行 CLI: + +```bash +nix run . -- init gcd +nix run . -- check --project gcd +nix run . -- run --project gcd +nix run . -- status --project gcd +nix run . -- log --project gcd +``` + +或经激活的 uv 环境运行: + +```bash +uv run ecc init gcd +uv run ecc check --project gcd +uv run ecc run --project gcd +``` + +### 环境体检 + +`ecc doctor` 探查宿主环境(PDK、yosys 含 slang 前端、捆绑的 ecc-tools/dreamplace、必需的 sizer、可选的 klayout),逐项报告 pass/fail/skip 与修复建议;只有必需项失败才返回非零。`ecc run` 对所选 preset 需要的工具做同样的探查,并在创建 workspace 之前以 `env_not_ready` 快速失败: + +```bash +uv run ecc doctor # 在项目目录内执行(PDK 探针需要) +uv run ecc doctor --project gcd --plain +``` + +### PDK 路径 + +`ecc pdk set-root ` 把已就绪的 ics55 PDK 接入项目(写入 `ecc.toml` 的 `[pdk] root`,自动展开为绝对路径;内容不完整只提示不阻断)。`ecc pdk show` 报告生效的 root、命中的解析来源(ecc.toml / `CHIPCOMPILER_ICS55_PDK_ROOT` / `ICS55_PDK_ROOT` / 仓库默认)以及内容校验;`ecc pdk unset` 清除该覆盖: + +```bash +uv run ecc pdk set-root ~/pdk/icsprout55-pdk +uv run ecc pdk show +``` + +### Flow Preset 覆盖 + +`ecc run --preset ` 单次覆盖 `[flow] preset`,不改 `ecc.toml`。合法名从 `chipcompiler/rtl2gds/builder.py` 自动发现(`rtl2gds | syn_sta | synthesis_lec`);`rtl2gds` preset 是完整的综合到 Harden 链(15 步,Synthesis 后紧跟一次综合级 LEC;Harden 产出 GDS + 抽象 LEF + 时序 LIB): + +```bash +uv run ecc run --project gcd --preset rtl2gds +``` + +### 报告 + +`ecc report qor` 按 GUI 项目看板相同的方式给 workspace 打分(每指标对固定 fail 阈值计分、维度求均值、加权总分——缺失维度不做权重重归一化);`ecc report checklist` 渲染签核清单状态;`ecc report summary` 写出与 GUI 一致的文本设计总结。三者默认写入 `/signoff/`,接受 `-o` 以及常规的 `--project` 和可选的受管 `--workspace NAME` 选择器: + +```bash +uv run ecc report qor --project gcd +uv run ecc report checklist --project gcd --workspace default +uv run ecc report summary --project gcd +``` + +### 签核 + +流程完成后,审阅并导出签核包: + +```bash +uv run ecc signoff inspect --project gcd # 就绪度审阅(blocked 也返回 0) +uv run ecc signoff export -o gcd.tar.gz --project gcd [--include-debug] +``` + +`inspect`/`export` 会先刷新步骤分析(与 GUI 一致)。它们使用选定项目及其受管 `--workspace NAME`;只有一个活跃 workspace 时自动选中。 + +项目配置是 CLI 的输入面: + +```toml +[design] +name = "gcd" +top = "gcd" +rtl = ["rtl/gcd.v"] +# 非 RTL 范围可按需声明入口输入: +# netlist = "inputs/gcd.v" +# golden_netlist = "inputs/gcd-golden.v" +# def = "inputs/gcd.def" +# sdc = "constraints/gcd.sdc" +# spef = "inputs/gcd.spef" +clock_port = "clk" +frequency_mhz = 100.0 + +[pdk] +name = "ics55" +root = "/path/to/ics55" + +[flow] +preset = "rtl2gds" # rtl2gds | syn_sta | synthesis_lec +``` + +filelist 模式下把 `design.rtl` 设为单个 filelist 路径,如 `rtl = ["rtl/filelist.f"]`。多 RTL 源应列在 filelist 里,而不是写多个 `design.rtl` 条目。 + +## 运行时解析 + +### Yosys + +`chipcompiler/tools/yosys/utility.py` 的解析优先级: + +1. 经 `CHIPCOMPILER_OSS_CAD_DIR` 的捆绑运行时; +2. 系统 PATH 中的 `yosys`。 + +运行时处理: + +- `get_yosys_command()` 做无副作用探测; +- `get_yosys_runtime()` 返回供子进程使用的 `(command, env)`; +- `check_slang_plugin()` 执行预检 `yosys -p "plugin -i slang"`。 + +找不到 Yosys 时,用 ECC 安装脚本的 `--with-toolchain` 安装受管工具链(见 [README](../README.cn.md#安装))。安装脚本的 wrapper 会导出 `CHIPCOMPILER_OSS_CAD_DIR` 与 `CHIPCOMPILER_ICS55_PDK_ROOT`。指向已有的 OSS CAD Suite: + +```bash +export CHIPCOMPILER_OSS_CAD_DIR=/path/to/oss-cad-suite +``` + +### Sizer + +Sizer 集成依赖外部 [`ecc-sizer`](https://github.com/openecos-projects/ecc-sizer) 仓库单独构建。在 ECC 仓库之外克隆: + +```bash +git clone --recursive https://github.com/openecos-projects/ecc-sizer /path/to/ecc-sizer +cd /path/to/ecc-sizer +git submodule update --init --recursive +``` + +用 Sizer 自己的开发环境构建: + +```bash +nix develop +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --target Sizer -j "$(nproc)" +``` + +可执行文件预期位于: + +```text +/path/to/ecc-sizer/build/src/Sizer +``` + +ECC 开发时,在跑流程或测试前把可执行文件目录加入 PATH: + +```bash +export PATH=/path/to/ecc-sizer/build/src:$PATH +which Sizer +``` + +命令名在 Linux 上大小写敏感。当前 ECC 的探测逻辑先找 `Sizer`,再从该二进制向上查找含 `src/sizer_os.tcl` 的 Sizer runtime root。如果可执行文件来自不在 Sizer 检出树内的 wrapper,还需设置: + +```bash +export CHIPCOMPILER_ECC_SIZER_ROOT=/path/to/ecc-sizer +``` + +ICS55 GCD 工具集成测试: + +```bash +nix develop +export PATH=/path/to/ecc-sizer/build/src:$PATH +export CHIPCOMPILER_ICS55_PDK_ROOT=/path/to/ics55-pdk +.venv/bin/python -m pytest test/integration/test_rtl2gds_flow.py::test_ics55_gcd -q -s +``` + +### PDK + +`chipcompiler/data/pdk.py` 中 `get_pdk("ics55")` 的解析优先级: + +1. 显式的 `pdk_root` 参数; +2. `CHIPCOMPILER_ICS55_PDK_ROOT` 环境变量; +3. 旧的 `ICS55_PDK_ROOT` 环境变量; +4. 默认:ecc 检出目录旁的 `../pdk/icsprout55-pdk`(ecos-studio 工作区布局)。 + +后端支持 `POST /api/workspace/set_pdk_root` 设置运行时路径。workspace 创建时会把解析出的 root 持久化到 `home/params.toml` 的 `pdk_root`。 + +示例: + +```bash +CHIPCOMPILER_ICS55_PDK_ROOT=/path/to/pdk uv run ecc +``` + +## 常见工作流 + +### 调试流程步骤 + +1. 查看 `workspace_step.logs/` 的工具输出; +2. 检查 `workspace_step.config/` 的配置; +3. 核对 `workspace_step.input/` 的文件; +4. 用项目和受管 workspace 名原地复现或续跑失败: + +```bash +project=/path/to/project +workspace=default + +# 从首个未成功步骤续跑(不给选择器时的默认行为)。 +.venv/bin/ecc run --project "$project" --workspace "$workspace" +.venv/bin/ecc run --project "$project" --workspace "$workspace" --resume + +# 重跑持久化 flow 中包含式的 CTS 到 route 范围。 +.venv/bin/ecc run --project "$project" --workspace "$workspace" --from CTS --to route + +# 只跑一个步骤;已成功过则加 --force。 +.venv/bin/ecc run --project "$project" --workspace "$workspace" --only place +.venv/bin/ecc run --project "$project" --workspace "$workspace" --only place --force +``` + +`--resume`、`--only` 与范围三者互斥,`--force` 只能与 `--only` 组合。`--workspace` 可与 `--project` 组合;新范围不能与 `--overwrite` 组合。步骤名使用规范的 flow 别名。 + +workspace 模式原地修改 workspace:每个被执行步骤的 `output/` 会被替换,其下游步骤标记为 `Unstart`,后续 resume 会重跑它们。重跑步骤会从 `home/params.toml` 重新生成 `workspace/config/*.json`,因此请调整参数而不是手改生成的配置;需要保持不变的已报告 workspace 请自行留档。 + +Python 层调试可直接调同一 CLI 模块: + +```bash +.venv/bin/python -m chipcompiler.cli.main run \ + --workspace "$workspace" \ + --only place \ + --force +``` + +### 修改流程步骤序列 + +1. 编辑 `EngineFlow.build_default_steps()` 或使用 `add_step()`; +2. 用 `flow.save()` 持久化到 `workspace.flow.json`; +3. 用 `flow.run_steps()` 运行;已成功步骤会跳过; +4. 用 `clear_states()` 重跑。 + +## 仓库约定 + +贡献者约定不在此重复:见 [../CLAUDE.md](../CLAUDE.md)(行为准则、测试放置、模块体积、避坑清单)与 [review-guidelines.md](review-guidelines.md)(评审标准)。 + +## 相关文档 + +- [rpc-guide.md](rpc-guide.md) - RPC sidecar 协议 +- [examples/](examples/) - 示例项目与 CLI 用法 +- [English version](development.md) diff --git a/docs/development.md b/docs/development.md index fb59c19d3..bb23cba1d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,8 @@ # Development Guide -Development environment setup and workflows for ECOS Chip Compiler. +Development environment setup and workflows for ECOS Chip Compiler, including +how to extend the `ecc` CLI. All code paths are relative to the `ecc` +repository root. ## Installation @@ -52,6 +54,9 @@ uv build Wheel and source distributions are written to `dist/`. +To build the installable PyInstaller CLI bundle yourself, see +[README - Build from source](../README.md#build-from-source). + ## Debugging For normal debugging: @@ -102,7 +107,13 @@ uv run prek install This registers both the `pre-commit` stage (ruff lint + ruff format) and the `commit-msg` stage (conventional commit message check) from -`.pre-commit-config.yaml`. +`.pre-commit-config.yaml` (via its `default_install_hook_types`). If hooks +were already installed — or an older install lacks the commit-msg hook — +reinstall explicitly with: + +```bash +uv run prek install --config .pre-commit-config.yaml --hook-type pre-commit --hook-type commit-msg --overwrite +``` ## Testing @@ -132,24 +143,36 @@ touch chipcompiler/tools//{__init__.py,builder.py,runner.py,utility.p `builder.py`: ```python -from chipcompiler.data import Workspace, WorkspaceStep, StepEnum - -def build_step(workspace: Workspace, step: StepEnum) -> WorkspaceStep: - return WorkspaceStep(workspace=workspace, step=step, tool="") +from pathlib import Path + +from chipcompiler.data import Workspace, WorkspaceStep + +def build_step( + workspace: Workspace, + step_name: str, + input_def: Path | None, + input_verilog: Path | None, + input_db: Path | str | None = None, + output_def: Path | None = None, + output_verilog: Path | None = None, + output_gds: Path | None = None, +) -> WorkspaceStep: + directory = Path(workspace.directory) / f"{step_name}_" + return WorkspaceStep(name=step_name, tool="", directory=directory) def build_step_space(workspace_step: WorkspaceStep) -> None: - workspace_step.create_directories() + Path(workspace_step.directory).mkdir(parents=True, exist_ok=True) -def build_step_config(workspace_step: WorkspaceStep) -> None: - config = {"input": workspace_step.input_path, "output": workspace_step.output_path} - workspace_step.write_config(config) +def build_step_config(workspace: Workspace, workspace_step: WorkspaceStep) -> None: + ... # write the step's config files from the workspace parameters ``` `runner.py`: ```python import subprocess -from chipcompiler.data import WorkspaceStep, StateEnum + +from chipcompiler.data import Workspace, WorkspaceStep def is_eda_exist() -> bool: try: @@ -158,18 +181,13 @@ def is_eda_exist() -> bool: except (subprocess.CalledProcessError, FileNotFoundError): return False -def run_step(workspace_step: WorkspaceStep) -> StateEnum: - try: - result = subprocess.run( - ["", "-c", workspace_step.config_file], - cwd=workspace_step.path, - capture_output=True, - timeout=workspace_step.timeout, - ) - return StateEnum.Success if result.returncode == 0 else StateEnum.Incomplete - except Exception as e: - workspace_step.log_error(str(e)) - return StateEnum.Incomplete +def run_step(workspace: Workspace, step: WorkspaceStep, ecc_module=None) -> bool: + result = subprocess.run( + ["", str(step.script.main)], + cwd=step.directory, + capture_output=True, + ) + return result.returncode == 0 ``` `__init__.py`: @@ -240,6 +258,364 @@ checkout. If that happens, prefer a Nix input or release artifact first; use a `chipcompiler/thirdparty/ecc-sizer` checkout only if the repository is meant to be built as part of ECC itself. +## Extending The CLI + +This section is for developers who add or modify commands in the `ecc` CLI. + +### CLI Architecture + +``` +pyproject.toml # scripts.ecc = "chipcompiler.cli.main:main" +chipcompiler/cli/main.py # run(argv) / main(), a thin wrapper +chipcompiler/cli/app.py # root typer app; invoke_typer_app() owns execution and exit codes; the version / layout-image commands are registered here directly +chipcompiler/cli/commands/ # typer command definition layer (thin) + ├── project.py # registration and option declarations for init/check/run/status/log/config/migrate + ├── doctor.py # doctor top-level command (environment check) + ├── param.py # param sub-app (list/show/set/unset/diff) + ├── pdk.py # pdk sub-app (set-root/show/unset) + ├── project_config.py # project sub-app (set/unset/add/remove/show) + ├── workspace.py # workspace sub-app (refresh) + ├── signoff.py # signoff sub-app (inspect/export) + ├── report.py # report sub-app (summary/qor/checklist/step) + └── rpc.py # rpc sub-app (serve) +chipcompiler/cli/command_handlers/ # business logic layer (stateful / heavy) + ├── project.py # init / check / run / migrate / workspace refresh (preset resolution and environment preflight) + ├── inspect.py # status / log / config + ├── doctor.py # doctor (assembles env_probe results into records) + ├── param.py # the five param subcommands (validation + TOML edits via cli/project/toml_edit.py) + ├── pdk.py # the three pdk subcommands (surgical TOML edit + root source resolution) + ├── project_config.py # the five project subcommands (declaration schema + TOML edits via cli/project/config_fields.py) + ├── workspace_params.py # workspace-scoped param set/unset/list/diff (home/params.toml mutation + step invalidation) + ├── signoff.py # signoff inspect/export + └── report.py # the four report subcommands (file writing + record summary) +chipcompiler/cli/core/ # framework layer + ├── inputs.py # frozen dataclass input models per command + ├── invocation.py # execute_command(): context build → handler → rendering → exit code + ├── options.py # shared Annotated option aliases + ├── output.py # disclosure_cmd() / step-name and state normalization + ├── records.py # error_record() + ├── types.py # CommandContext / CommandResult / OutputMode + └── version_info.py # package-metadata versions for the version command (environment tool versions live in inspection/tool_versions.py) +chipcompiler/cli/inspection/ # read-only probing logic + ├── discovery.py / config_view.py / log_view.py + ├── env_probe.py # environment probes for doctor/run preflight (the ProbeResult model) + └── tool_versions.py # environment tool versions for ecc version (yosys/sizer/klayout) +chipcompiler/cli/project/ # config.py (ecc.toml parsing and validation) / config_fields.py (project declaration schema for `ecc project`) / params.py (parameter registry) / workspace_params.py (workspace-local override records) / manifest.py (project-state classification) / effective_config.py / config_params/ (direct-config schemas) / migrate*.py (legacy-layout migration) / run_*.py (workspace target resolution and dispatch) +chipcompiler/cli/rendering/ # output rendering (render / renderers / pretty / progress) +chipcompiler/engine/signoff/ # signoff collector + design/checklist reports (package, see below) +chipcompiler/engine/qor_report.py # overall QoR scoring (port of the GUI rules) +``` + +Module placement is enforced by `test/cli/test_cli_module_layout.py`: the core +framework must live under `cli/core/`, command registration under +`cli/commands/`, all handlers under the single `cli/command_handlers/` package, +read-only probing under `cli/inspection/`, and rendering under +`cli/rendering/`; the old flat `chipcompiler/cli/*.py` modules must not be +importable. Put new files in the matching subpackage — do not create modules at +the `cli/` root. + +Public command ownership is strict: `ecc signoff` owns package readiness and +archive export (`inspect`, `export`); `ecc report` owns all report output +(`summary`, `qor`, `checklist`, `step`). `ecc config [STEP]` always returns +resolved data, so it has no `--resolved` switch. Do not introduce an alias in +the wrong group or an option that does not change behavior. + +### The Path Of One Command Invocation + +Using `ecc check --project gcd --plain` as the example: + +1. `main.py::run()` hands `sys.argv[1:]` to `app.py::invoke_typer_app(raw)` (`cli/app.py`). +2. typer parses the arguments and dispatches to `commands/project.py::check_cmd` (`cli/commands/project.py`). The command function does exactly one thing: it packs the typer parameters into the frozen dataclass `CheckInput` (defined in `cli/core/inputs.py`) and calls: + ```python + execute_command("check", command_input, project_handlers.check) + ``` +3. `core/invocation.py::execute_command()` (`cli/core/invocation.py`) then: + - `build_context()`: resolves the project directory (`--project`, defaulting to cwd) → reads its sole `ecc.toml` (an unreadable file is recorded in `config_error`) → classifies the project state via `cli/project/manifest.py::classify_project()` (manifest / legacy / virgin). Manifest projects resolve `--workspace NAME` only through the `project.json` workspaces table: one active workspace auto-selects, multiple ones require the selector, and a new `ecc run --workspace NAME` target is registered before files are created. `--workspace` is a single project-local name, never a direct path. A legacy project must migrate before `ecc run`; a corrupt manifest yields `manifest_invalid`. The context derives `OutputMode` from `--plain` and carries `project_state` / `manifest_error` (`cli/core/types.py`). + - Calls the handler: `handler(command_input, ctx) -> CommandResult`. + - After the handler, records are appended as needed (`_with_legacy_hint` / `_with_config_shadow_hint`): `run/check/status` on a legacy project carry a migration hint (pointing at `ecc migrate`); when a workspace's `home/` holds both `params.toml` and the legacy `parameters.json`, a `workspace_config_shadowed` warning is emitted (the JSON is inert). + - Renders: `rendering/renderers.py::render_command_result()` first looks up a custom renderer in `RENDERERS[(render_key, output_mode)]`, falling back to the generic `rendering/render.py::render_result()`. + - `raise typer.Exit(code=result.exit_code)` passes the exit code through to `invoke_typer_app`. +4. `invoke_typer_app` runs the click command with `standalone_mode=False`, catching `click.exceptions.Exit` / `ClickException` and converting them into a process exit code, so tests can read the return value of `cli_main.run([...])`. + +### CLI Output Conventions + +Commands dispatched through `execute_command()` use a "list of records": + +- The handler returns `CommandResult.ok(records)` / `CommandResult.err(records, exit_code=1)` (`cli/core/types.py`); `records` is a `tuple[dict, ...]` where each dict is one structured record. +- Two output modes (see `cli/core/invocation.py`): + - `--plain`: `key=value` per line (values containing whitespace are quoted), for scripting/grep; + - TEXT by default: pretty rendering; without a custom renderer it prints `key=value` with the `_cmd` suffix stripped from key names. +- Error records use `core/records.py::error_record(...)`, producing `{"kind": "error", "error": "", ...}`; in TEXT mode `render_error` prints them as an `[error]` block. Error codes are a stable contract (e.g. `missing_config`, `run_exists`, `unknown_parameter`, `invalid_value`) and tests assert against them. +- "Next step" hints for users are uniformly generated by `core/output.py::disclosure_cmd("ecc status", project, run_id)` as a copy-pasteable full command, stored in record fields such as `inspect` / `log_cmd` / `run`. + +`ecc version` formats version metadata directly; it also has a hidden `--json` +flag (a single object with a version-specific schema) reserved for the desktop +app and kept out of `--help`. `ecc rpc serve` and `ecc layout-image` +intentionally do not use record-renderer output modes. + +### Adding A New Command + +Using a command like `ecc check` as the example, there are 5 steps (the first 3 +are mandatory, the last 2 as needed): + +1. **Define the input model.** Add a frozen dataclass in `cli/core/inputs.py`. + It must satisfy the `CommandInput` protocol (`cli/core/invocation.py`) — i.e. + carry the two fields `output: OutputOptions` and `project: ProjectOptions`: + + ```python + @dataclass(frozen=True) + class CheckInput: + output: OutputOptions + project: ProjectOptions + # command-private fields go here + ``` + +2. **Write the handler** in `cli/command_handlers/`, with a fixed signature: + + ```python + def check(command_input: CheckInput, ctx: CommandContext) -> CommandResult: + if ctx.config is None: + return CommandResult.err([error_record("missing_config", path=...)]) + ... + return CommandResult.ok([{...}, ...]) + ``` + + Conventions: handlers do not print directly and do not parse command-line + strings; heavy logic is imported lazily (existing code routinely does + `from chipcompiler... import ...` inside function bodies — keep that style + to shorten CLI startup); read-only probing logic goes in `cli/inspection/`, + while the handler assembles records. + +3. **Register the typer command.** Declare the command function in + `cli/commands/project.py` (or a new module) and register it; shared options + use the aliases from `cli/core/options.py` directly: + + ```python + from chipcompiler.cli.core.options import PlainOption, ProjectOption + + def register_project_commands(app: typer.Typer) -> None: + app.command("check", help="Validate the current project setup")(check_cmd) + + def check_cmd( + *, + project: ProjectOption = None, + plain: PlainOption = False, + ) -> None: + command_input = CheckInput( + output=output_options(plain=plain), + project=project_options(project), + ) + execute_command("check", command_input, project_handlers.check) + ``` + + For a top-level single command use `app.command(...)` directly (working + example: `cli/commands/doctor.py`, the shortest full chain); for a command + group create `xxx_app = typer.Typer(...)` and add it in `app.py` with + `app.add_typer(xxx_app, name="xxx")` (working example: + `cli/commands/signoff.py`, whose subcommands reuse one handler module via + `execute_command(..., render_key=f"signoff:{sub}")`). Note that the root app + built in `app.py` sets `add_completion=False, no_args_is_help=True`. + +4. **(Optional) Customize TEXT rendering.** The default TEXT output is + `key=value`. For friendlier output: + + - Single commands: add a renderer function to the `get_pretty_renderer()` + registry in `cli/rendering/pretty.py` (the existing + `init/check/run/status/config` commands take this path); + - Subcommand groups: add a `(render_key, OutputMode)` entry to the + `RENDERERS` dict in `cli/rendering/renderers.py`, passing `render_key` via + `execute_command(..., render_key="param:show")` (the param group takes + this path). + + PLAIN needs no customization at all. + +5. **Add tests.** Test placement follows ownership boundaries (see + [../CLAUDE.md](../CLAUDE.md) section 5); CLI specifics: + + - Command behavior → `test/cli/commands/test_.py`; param → + `test/cli/params/`; read-only probing → `test/cli/inspect/`; rendering → + `test/cli/rendering/`. + - Tests call the Python entry point directly, not a subprocess: + ```python + from chipcompiler.cli import main as cli_main + + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) + assert rc == 0 + records = plain_records(capsys.readouterr().out) # fixture from test/cli/conftest.py + ``` + - Reuse the fixtures in `test/cli/conftest.py`: `create_cli_project` + (creates a temporary project with `ecc.toml`), `create_flow_json` + (fabricates `home/flow.json` beneath the given workspace directory), + `create_step_dir`, + `create_workspace_config`, `mock_pdk_validation`, and others. **Note the + autouse `_stub_run_preflight`**: it stubs `env_probe.probe_environment` to + return nothing, so CLI tests never depend on host tools (doctor/preflight + tests override that stub themselves, which takes precedence). + - Tests for engine-layer reports/signoff go in the top-level `test/` (e.g. + `test/test_signoff_report.py`, `test/test_qor_report.py`, + `test/test_signoff_package.py`), reusing their fixtures to fabricate + workspaces. + - Don't forget to register new commands in both + `test/cli/test_typer_cli.py::test_root_help_returns_zero_and_lists_commands` + and `test/cli/test_cli_module_layout.py` (the commands tuple). + +### Extension Scenarios + +#### Adding a tunable parameter (the param system) + +Legacy semantic parameters remain in `cli/project/params.py::_LEGACY_PARAM_REGISTRY`. +Direct tool configuration belongs in one reviewed module per owner under +`data/config_params/` (`cts.py`, `floorplan.py`, `dreamplace.py`, and so on). +`ParamSchema` has one target: legacy `maps_to`, a JSON `config_target`, or a +whitelisted PDK `pdk_target`. + +Use `config_param()` for a reviewed static template field (`description` is a +required keyword argument, written per parameter by a human reviewer and +enforced by `test/data/test_descriptions.py`): + +```python +# chipcompiler/data/config_params/cts.py +config_param( + "cts.skew_bound", + "cts", + ("skew_bound",), + "0.08", + applies="cts", + description="Allowed clock skew upper bound in ns.", +) +``` + +This enables `ecc param list/show/set/unset/diff`, repeated `ecc run --set +key=value`, and recursive `[params.*]` TOML parsing/writing. `ecc param list` +stays concise; use `--step ` or `--all` to enumerate direct schemas. +List and object values use JSON literals on the command line. + +At project-run creation, non-default `config_target` values are saved as +structured `config_overrides` in `home/params.toml`; +`data.workspace.config_overrides` replays them after every workspace +configuration refresh. PDK path schemas live in `config_params/pdk.py` and +write `[pdk.overrides]`; keep `pdk.root` on `ecc pdk set-root`. Never add +workspace input, output, temporary, generated-artifact, or STA multi-corner +liberty paths as CLI parameters. + +`config_params/coverage.py` compares each JSON template field with exactly one +direct schema, legacy mapping, or protected-path entry. Update that manifest +and `test/cli/params/test_config_coverage.py` whenever a template changes. +Parsing and surgical TOML editing remain in `params.py`; command tests remain +in `test/cli/params/`. + +#### Extending `ecc run` + +`run` has two mutually exclusive paths (`run()` / `_run_workspace()` in +`cli/command_handlers/project.py`): + +- **Fresh workspace**: resolve `[design]` input declarations, PDK, parameters, + and the requested entry step; validate only that entry step's required files; + atomically pre-register the managed name in `project.json` as `not_started`; + preflight tools; call `create_workspace` at `/`. + `create_workspace` copies inputs to `origin/` and produces all step configs; + the CLI never rewrites those configs afterwards. A normal fresh flow uses a + preset. `--from A --to B` instead calls `rtl2gds.build_flow_range(A, B)` to + construct the inclusive canonical range. New ranges cannot combine with + `--preset`, `--overwrite`, `--resume`, `--only`, or `--force`. +- **Existing workspace**: first reconcile the persisted flow against the target + via `chipcompiler/engine/reconcile.py` (proper prefix → append/extend; + superset with all steps successful → `no_op`; divergent → `flow_mismatch`), + then after `load_workspace`, re-run in place via `run_resume`, `run_from`, or + `run_only` from `chipcompiler.engine.rerun`. `--from A --to B` is an + inclusive persisted range and invalidates its downstream suffix while + retaining downstream output files. Existing workspaces neither preflight + fresh inputs nor rewrite copied inputs/configuration. + +Project preset sequences are defined in `chipcompiler/rtl2gds/builder.py` +(`build_*_flow()` / `get_flow_builders()`), not in the CLI layer. +`build_flow_range()` slices the canonical `build_rtl2gds_flow()` result, so +step aliases and ordering have one source of truth. Keep a sequence change +coordinated with the engine's default flow, `StepEnum`, and manifest range +mappings; the CLI only handles argument parsing, input contracts, +progress-renderer selection, and result mapping. + +#### Extending environment probing (doctor / preflight) + +`cli/inspection/env_probe.py` is the single probing layer: +`ProbeResult(component, status, required, detail, remediation)` plus one probe +function per component (yosys / yosys-slang / ecc-tools / dreamplace / klayout +/ sizer / pdk). Adding a component = adding a probe function and registering it +in `_PROBES`/`ALL_COMPONENTS`; `probe_environment()` guards against exceptions +(a crashing probe counts as a fail rather than aborting the sweep). +`probe_components_for_preset()` decides the current run-preflight scope +(ecc-tools always, yosys ↔ contains Synthesis, dreamplace ↔ contains +place/legalization, sizer ↔ contains Timing optimization). The PDK is covered +by configuration validation, slang is left to synthesis, and Sizer is also +required by doctor. + +#### Extending signoff (`ecc signoff inspect/export`) + +- **CLI layer**: `cli/commands/signoff.py` + `cli/command_handlers/signoff.py`. + `inspection/discovery.py::resolve_loaded_workspace()` resolves a managed + `--workspace NAME` in the selected project (or its sole active workspace). + inspect reuses `runtime/signoff_export.py::inspect_signoff_package` (blocked + still exits 0); export reuses `export_signoff_package_archive` + (`RuntimeApiError` → `signoff_incomplete`). +- **Engine layer**: the `chipcompiler/engine/signoff/` package owns the signoff + collector `SignoffPackageCollector` and the package-export APIs used by + readiness inspection and archive generation. + +#### Extending reports (`ecc report summary/qor/checklist/step`) + +- **Design summary**: `ecc report summary` calls + `chipcompiler.engine.signoff.generate_text_report`. Its implementation is + split by responsibility (`report.py` orchestration / `report_data.py` data + contract / `report_extract.py` parsers + workspace collection / + `report_sections.py` section extraction / `report_timing.py` the timing chain + / `report_text.py` formatting), all exposed through the package `__init__`. + Add a report section through an `_extract_(q)` in + `report_sections.py` (or the timing chain) and register it from `report.py`. +- `engine/qor_report.py`: the single-workspace port of the GUI's + `projectQorTrend.ts` — constant tables + (`METRIC_FAIL_VALUES`/`DIMENSION_WEIGHTS`/`QOR_SCORE_THRESHOLD`) + + normalization + project-level record selection (role priority + final>gate>trend; area_cost only from the last successful area step) + the + `score_record` formulas + dimension weighting (no renormalization). Adding a + scoreable metric = adding its threshold here and in the GUI. +- `engine/signoff/report_checklist.py`: read-only rendering of + `home/checklist.json` (reports unavailable on an invalid file; never writes + back). +- CLI: `cli/commands/report.py` + `cli/command_handlers/report.py`; workspace + resolution reuses `inspection/discovery.py` (`resolve_workspace_path` = + side-effect-free core, `resolve_command_workspace` = core + `load_workspace`; + shared by signoff, report, and the read-only status/log/config commands). + +#### Extending the RPC (`ecc rpc serve`) + +`rpc serve --stdio` starts the JSON-RPC 2.0 sidecar +(`chipcompiler/runtime/stdio_server.py`). Methods are declared in +`chipcompiler/runtime/methods.py::RUNTIME_METHODS` (`method_name` + a pydantic +`request_model` + `handler_name`), handler implementations live in +`chipcompiler/runtime/workspace_api.py`, and `runtime/server.py` mounts them +uniformly; protocol details in [rpc-guide.md](rpc-guide.md). Adding a +method = one `RuntimeMethodSpec` + the matching API method + a request model; +no CLI-layer changes needed. + +#### Extending project declarations (`ecc project *` / `ecc workspace refresh`) + +- The editable keys of `ecc project set/unset/add/remove/show` are declared in + `cli/project/config_fields.py::PROJECT_FIELDS` (`key` / TOML table / name / + type / `list_value`). Add a field there and the subcommands pick it up; + `add`/`remove` are hard-restricted to `design.rtl` + (`unsupported_project_collection` otherwise). +- `ecc workspace refresh` is implemented as the run path with `overwrite=True, + execute_flow=False` (`cli/command_handlers/project.py::refresh_workspace`), + which is why it runs the same environment preflight as a fresh run (`preset: + rtl2gds` from `ecc.toml` means the full tool set must be ready, even though + no step executes). On a non-manifest project it reports + `workspace_refresh_requires_managed_workspace`. +- Workspace-scoped `param set/unset/list/diff --workspace NAME` mutate + `home/params.toml` through `cli/command_handlers/workspace_params.py` + (records in `workspace_param_overrides`, suffix invalidation via + `chipcompiler.engine.rerun`), and project-scoped `param` goes through + `cli/command_handlers/param.py`. + ## CLI Usage For command-line automation and scripting, run CLI via Nix: @@ -270,14 +646,12 @@ preset needs and fails fast with `env_not_ready` before creating a workspace: ```bash uv run ecc doctor # inside a project for the PDK probe -uv run ecc doctor --project gcd --json +uv run ecc doctor --project gcd --plain ``` ### PDK Path -`ecc pdk setup [path]` does everything: clone icsprout55-pdk when missing, -`make unzip` when liberty files are missing (honors `GH_PROXY`, retries 3x), then -writes the root. `ecc pdk set-root ` wires an already-ready ics55 PDK into the +`ecc pdk set-root ` wires an already-ready ics55 PDK into the project (writes `[pdk] root` in `ecc.toml` as an absolute path; incomplete contents are advisory). `ecc pdk show` reports the effective root, which resolver won (ecc.toml / `CHIPCOMPILER_ICS55_PDK_ROOT` / `ICS55_PDK_ROOT` / @@ -507,7 +881,15 @@ For Python-level debugging, invoke the same CLI module directly: 3. Run with `flow.run_steps()`; successful steps are skipped. 4. Use `clear_states()` to re-run. +## Conventions + +Contributor conventions are not repeated here; they live in +[../CLAUDE.md](../CLAUDE.md) (behavioral guidelines, test placement, module +size, gotchas) and [review-guidelines.md](review-guidelines.md) (review +standards). + ## Related Documentation -- [Architecture](architecture.md) - System design and patterns +- [RPC Guide](rpc-guide.md) - RPC sidecar protocol - [Examples](examples/) - Example projects and CLI usage +- [中文开发指南](development.cn.md) diff --git a/docs/index.md b/docs/index.md index a53387d2e..72997594c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,30 +6,24 @@ Welcome to the ChipCompiler documentation center. The `ecc` command-line tool ships bilingual guides (`.en.md` / `.cn.md`): -- **[CLI Tutorial](../chipcompiler/docs/ecc-cli-tutorial.en.md)** / **[中文教程](../chipcompiler/docs/ecc-cli-tutorial.cn.md)** - From zero to RTL → Harden with a signoff package +- **[CLI Tutorial](../chipcompiler/docs/ecc-tutorial.en.md)** / **[中文教程](../chipcompiler/docs/ecc-tutorial.cn.md)** - From zero to RTL → Harden with a signoff package - Installing the ecc CLI, PDK, and Yosys - First project, the 15-step `rtl2gds` flow, signoff package, and reports - Tuning parameters, workspaces, and rerun scenarios -- **[CLI User Guide](../chipcompiler/docs/ecc-cli-ug.en.md)** / **[中文用户指南](../chipcompiler/docs/ecc-cli-ug.cn.md)** - All currently supported commands +- **[CLI User Guide](../chipcompiler/docs/ecc-user-guide.en.md)** / **[中文用户指南](../chipcompiler/docs/ecc-user-guide.cn.md)** - All currently supported commands - Every command and option: `init`/`check`/`run`/`status`/`log`/`config`/`doctor`/`param`/`pdk`/`project`/`workspace`/`signoff`/`report`/`rpc`/`layout-image` - Run selectors (`--resume`/`--from`/`--to`/`--only`), error-code reference, end-to-end workflows -- **[CLI Config Reference](../chipcompiler/docs/ecc-cli-config.en.md)** / **[中文配置参考](../chipcompiler/docs/ecc-cli-config.cn.md)** - `ecc.toml`, workspace files, and the parameter system -- **[CLI Dev Guide](../chipcompiler/docs/ecc-cli-dev.en.md)** / **[中文开发指南](../chipcompiler/docs/ecc-cli-dev.cn.md)** - Adding or modifying CLI commands -- **[Workspace CLI Guide](workspace-cli.md)** - Private JSON-RPC runtime sidecar protocol (`ecc rpc serve`) +- **[CLI Config Reference](../chipcompiler/docs/ecc-config-ref.en.md)** / **[中文配置参考](../chipcompiler/docs/ecc-config-ref.cn.md)** - `ecc.toml`, workspace files, and the parameter system +- **[RPC Guide](rpc-guide.md)** - Private JSON-RPC runtime sidecar protocol (`ecc rpc serve`) ## Core Documentation -- **[Architecture](architecture.md)** - Detailed system architecture and design patterns - - Layered architecture explanation - - Core design patterns - - Data flow and execution paths - - Module details - -- **[Development Guide](development.md)** - Development environment setup and workflows +- **[Development Guide](development.md)** / **[中文开发指南](development.cn.md)** - Development environment setup and workflows - Environment configuration - Code quality tools - Adding new EDA tools - Debugging and testing + - Extending the `ecc` CLI - **[Release Guide](release.md)** - Release branch and hotfix release workflow - Preparing `release/v*` branches @@ -58,12 +52,11 @@ ChipCompiler supports various EDA file formats. Technical specifications for par ### I want to... - **Get started with ChipCompiler** → See main [README](../README.md) -- **Run my first RTL-to-GDS flow** → [CLI Tutorial](../chipcompiler/docs/ecc-cli-tutorial.en.md) / [中文教程](../chipcompiler/docs/ecc-cli-tutorial.cn.md) -- **Look up an `ecc` command or option** → [CLI User Guide](../chipcompiler/docs/ecc-cli-ug.en.md) / [中文用户指南](../chipcompiler/docs/ecc-cli-ug.cn.md) -- **Understand `ecc.toml` / workspace files / parameters** → [CLI Config Reference](../chipcompiler/docs/ecc-cli-config.en.md) / [中文配置参考](../chipcompiler/docs/ecc-cli-config.cn.md) -- **Extend the CLI with new commands** → [CLI Dev Guide](../chipcompiler/docs/ecc-cli-dev.en.md) -- **Use legacy workspace commands** → [Workspace CLI Guide](workspace-cli.md) -- **Understand the architecture** → [Architecture](architecture.md) +- **Run my first RTL-to-GDS flow** → [CLI Tutorial](../chipcompiler/docs/ecc-tutorial.en.md) / [中文教程](../chipcompiler/docs/ecc-tutorial.cn.md) +- **Look up an `ecc` command or option** → [CLI User Guide](../chipcompiler/docs/ecc-user-guide.en.md) / [中文用户指南](../chipcompiler/docs/ecc-user-guide.cn.md) +- **Understand `ecc.toml` / workspace files / parameters** → [CLI Config Reference](../chipcompiler/docs/ecc-config-ref.en.md) / [中文配置参考](../chipcompiler/docs/ecc-config-ref.cn.md) +- **Extend the CLI with new commands** → [CLI Dev Guide](development.md#extending-the-cli) +- **Use legacy workspace commands** → [RPC Guide](rpc-guide.md) - **Set up development environment** → [Development Guide](development.md) - **Create a release** → [Release Guide](release.md) - **Add new tools** → [Development Guide - Adding EDA Tools](development.md#add-a-new-eda-tool) diff --git a/docs/workspace-cli.md b/docs/rpc-guide.md similarity index 100% rename from docs/workspace-cli.md rename to docs/rpc-guide.md diff --git a/docs/specification/cli-design.md b/docs/specification/cli-design.md index 0c97323e3..d678d39c1 100644 --- a/docs/specification/cli-design.md +++ b/docs/specification/cli-design.md @@ -14,7 +14,7 @@ also provide explicit commands for deeper inspection. - Keep default output concise and stable. - Make output easy to parse with simple tools such as `rg`, `awk`, and shell scripts. -- Provide structured output for agents through `--json` and `--jsonl`. +- Provide structured output for agents through `--plain`. - Preserve the existing Python API for advanced integration. - Build CLI behavior as a wrapper around the current Python APIs. @@ -92,7 +92,7 @@ Recommended style: workspace_id=default status=failed workspace=gcd/default inspect_cmd="ecc status" log_cmd="ecc log" step=synthesis tool=yosys status=success runtime=0:00:18 log_cmd="ecc log synthesis" step=floorplan tool=ecc status=success runtime=0:00:04 log_cmd="ecc log floorplan" -config=place_default_config.json scope=step step=placement role=config path=gcd/default/config/place_default_config.json inspect="ecc config placement --json" +config=place_default_config.json scope=step step=placement role=config path=gcd/default/config/place_default_config.json inspect="ecc config placement" ``` Current implementation note: `--plain` provides this stable key-value output. @@ -108,43 +108,39 @@ interface. ### Structured Output -Every inspection command should support: - -```bash ---json ---jsonl -``` - -Use `--json` for object-level output and `--jsonl` for stream or list output. +Every record-producing command supports `--plain`, which prints one stable +key-value record per line for scripting. Example: -```jsonl -{"step":"synthesis","tool":"yosys","status":"success","runtime":"0:00:18","log_cmd":"ecc log synthesis"} -{"config":"place_default_config.json","scope":"step","step":"placement","role":"config","path":"gcd/default/config/place_default_config.json","inspect":"ecc config placement --json"} +```text +step=routing tool=ecc status=failed runtime=0:03:42 log_cmd="ecc log routing" +config=route_ecc.json scope=step step=routing role=config path=gcd/default/config/route_ecc.json inspect="ecc config routing" ``` -Text output and JSON output should describe the same objects. The text output is -the human and shell interface; JSON is the strict machine interface. +Text output and plain output describe the same objects: pretty text is the +human interface, `--plain` is the strict machine interface. Current implementation status: | Command family | Structured options | | --- | --- | -| `ecc init` | `--json`, `--jsonl`, `--plain` | -| `ecc check`, `ecc doctor` | `--json`, `--jsonl`, `--plain` | -| `ecc run`, `ecc status`, `ecc log`, `ecc config`, `ecc migrate` | `--json`, `--jsonl`, `--plain` | -| `ecc param list/show/set/unset/diff` | `--json`, `--jsonl`, `--plain` | -| `ecc pdk setup/set-root/show/unset` | `--json`, `--jsonl`, `--plain` | -| `ecc signoff inspect/export` | `--json`, `--jsonl`, `--plain` | -| `ecc report summary/qor/checklist/step` | `--json`, `--jsonl`, `--plain` | -| `ecc version` | `--json`, `--jsonl`, `--plain` | +| `ecc init` | `--plain` | +| `ecc check`, `ecc doctor` | `--plain` | +| `ecc run`, `ecc status`, `ecc log`, `ecc config`, `ecc migrate` | `--plain` | +| `ecc param list/show/set/unset/diff` | `--plain` | +| `ecc pdk set-root/show/unset` | `--plain` | +| `ecc project set/unset/add/remove/show` | `--plain` | +| `ecc workspace refresh` | `--plain` | +| `ecc signoff inspect/export` | `--plain` | +| `ecc report summary/qor/checklist/step` | `--plain` | +| `ecc doc` | `--plain` | +| `ecc version` | hidden `--json` only (desktop app contract) | | `ecc rpc serve` | none (machine protocol) | | `ecc layout-image` | none (tool invocation; produces a file) | -When multiple project output options are provided, the implementation selects -`--jsonl` first, then `--json`, then `--plain`, and otherwise renders pretty -text. +When `--plain` is given, the implementation renders plain records; otherwise it +renders pretty text. ### Object-Oriented CLI Model @@ -231,7 +227,7 @@ Responsibilities: | `ecc config` | Show the resolved project or step configuration | | `ecc migrate` | Migrate a legacy `runs/` project to the manifest layout | | `ecc param` | List, inspect, set, unset, and diff parameter overrides | -| `ecc pdk` | `setup` clones + `make unzip`s + wires in a PDK checkout; also `set-root`/`show`/`unset` for the `[pdk] root` path | +| `ecc pdk` | `set-root`/`show`/`unset` manage the `[pdk] root` path | | `ecc project` | Edit declared design, PDK, and flow resource fields in `ecc.toml` | | `ecc workspace` | Refresh a declared workspace from current `ecc.toml` without running it | | `ecc signoff` | Inspect package readiness and export the tar.gz package | @@ -258,7 +254,7 @@ implementation detail: | --- | --- | --- | | `ecc signoff` | `inspect`, `export` | Signoff package readiness and archive generation | | `ecc report` | `summary`, `qor`, `checklist`, `step` | File reports and per-step evidence viewing | -| `ecc pdk` | `setup`, `set-root`, `show`, `unset` | Project PDK configuration | +| `ecc pdk` | `set-root`, `show`, `unset` | Project PDK configuration | | `ecc param` | `list`, `show`, `set`, `unset`, `diff` | Project parameter overrides | | `ecc project` | `set`, `unset`, `add`, `remove`, `show` | Project design, PDK, and flow declarations in `ecc.toml` | | `ecc workspace` | `refresh` | Recreate one declared workspace from `ecc.toml`, without execution | @@ -269,9 +265,8 @@ only through the project's `project.json`; it is not a direct filesystem path. Run-scoped inspection and reporting commands (`run`, `status`, `log`, `config`, `report *`, `signoff *`) may combine the two options, and the read-only commands (`status`, `log`, `config`, `report step`) never load or mutate the -workspace. Record-producing commands use default human text, -`--plain` for stable key-value records, `--json` for a record envelope, and -`--jsonl` for one record per line. Commands that only create, configure, or +workspace. Record-producing commands use default human text and `--plain` +for stable key-value records. Commands that only create, configure, or serve a process expose only the options meaningful for that operation. ### Unified CLI Standard @@ -284,7 +279,7 @@ The command graph follows these rules; new commands must follow them too: reporting live in noun groups (`param`, `pdk`, `project`, `workspace`, `signoff`, `report`, `rpc`). - **Subcommand verbs.** Mutable resources use the CRUD set - (`list`, `show`, `set`, `unset`, `diff`, plus `setup` for pdk). The `report` + (`list`, `show`, `set`, `unset`, `diff`). The `report` group names its artifacts instead (`summary`, `qor`, `checklist`, `step`) because `report ` reads as one action. - **Naming.** Lowercase single words; multi-word names use kebab-case @@ -480,7 +475,7 @@ and stop session-scoped DB reuse explicitly; `workspace.open`, reuse for a session that has not called `db.ensure`. The former custom workspace JSON object is not part of the supported output -contract. See `docs/workspace-cli.md` for framing examples and method payloads. +contract. See `docs/rpc-guide.md` for framing examples and method payloads. ## Output Contracts @@ -497,7 +492,7 @@ Examples: ```text workspace_id=default status=success workspace=gcd/default inspect_cmd="ecc status" log_cmd="ecc log" step=routing tool=ecc status=failed runtime=0:03:42 log_cmd="ecc log routing" -config=route_ecc.json scope=step step=routing role=config path=gcd/default/config/route_ecc.json inspect="ecc config routing --json" +config=route_ecc.json scope=step step=routing role=config path=gcd/default/config/route_ecc.json inspect="ecc config routing" ``` Rules: @@ -519,8 +514,6 @@ Current output modes: | --- | --- | --- | | Pretty text | default | Human-oriented grouped output with disclosure commands | | Plain text | `--plain` | Stable one-record-per-line key-value output | -| JSON | `--json` | Project and `param` JSON envelope with `records`; `version` and `workspace` use their own root-level schemas | -| JSONL | `--jsonl` | One JSON object per record | Plain output preserves record keys exactly. Pretty text may normalize labels for display, for example rendering `inspect_cmd` as `inspect`. @@ -721,7 +714,6 @@ commands. - [x] `ecc status` - [x] `ecc log` - [x] Stable grep-friendly summary output through `--plain` -- [x] `--json` and `--jsonl` for status, log, run, config, and param commands Success criteria: @@ -742,8 +734,8 @@ Success criteria: - [x] A failed step can be investigated through status, log, and resolved config output. -- [x] Agent frameworks can follow disclosure commands from `--plain`, `--json`, - or `--jsonl` output without parsing prose. +- [x] Agent frameworks can follow disclosure commands from `--plain` output + without parsing prose. ### Phase 3: Exploration And Assistance diff --git a/ecc.spec b/ecc.spec index 528444ce0..0f1dba9b5 100644 --- a/ecc.spec +++ b/ecc.spec @@ -53,14 +53,12 @@ DREAMPLACE_THIRDPARTY_FILES = ( ) DOC_GUIDES = ( - "chipcompiler/docs/ecc-cli-config.en.md", - "chipcompiler/docs/ecc-cli-config.cn.md", - "chipcompiler/docs/ecc-cli-ug.en.md", - "chipcompiler/docs/ecc-cli-ug.cn.md", - "chipcompiler/docs/ecc-cli-tutorial.en.md", - "chipcompiler/docs/ecc-cli-tutorial.cn.md", - "chipcompiler/docs/ecc-cli-dev.en.md", - "chipcompiler/docs/ecc-cli-dev.cn.md", + "chipcompiler/docs/ecc-config-ref.en.md", + "chipcompiler/docs/ecc-config-ref.cn.md", + "chipcompiler/docs/ecc-user-guide.en.md", + "chipcompiler/docs/ecc-user-guide.cn.md", + "chipcompiler/docs/ecc-tutorial.en.md", + "chipcompiler/docs/ecc-tutorial.cn.md", ) LINUX_RUNTIME_LIBS = ( @@ -239,6 +237,13 @@ def filter_host_fontconfig(binaries): # host libfontconfig always matches the host fontconfig data. Applied # to the Analysis output, because input-list filtering cannot stop the # dependency walk from re-collecting it. + # + # Consequence (verified host prerequisite): the bundle keeps libcairo + # (PyInstaller dependency) and DreamPlace's draw_place_cpp loads + # fontconfig through it, so a supported host must provide + # libfontconfig.so.1 itself (any glibc-based distro with fontconfig + # installed qualifies; a bare container without fontconfig will fail + # placement with a loader error). return [ entry for entry in binaries diff --git a/test/cli/commands/conftest.py b/test/cli/commands/conftest.py index 40c7abfd6..10cd97022 100644 --- a/test/cli/commands/conftest.py +++ b/test/cli/commands/conftest.py @@ -49,7 +49,7 @@ def flow_mocks(monkeypatch): `flow` (the DummyFlow class, for instance/state assertions). """ capture = {"create_kwargs": None} - workspace_obj = SimpleNamespace(name="workspace") + workspace_obj = SimpleNamespace(name="workspace", flow=SimpleNamespace(data={"steps": []})) DummyFlow.instances = [] DummyFlow.has_init_value = False @@ -74,9 +74,9 @@ def fake_create_workspace(**kwargs): @pytest.fixture -def manifest_stubs(capsys): +def manifest_stubs(capsys, plain_records): """Shared manifest-project scaffolding: project.json writer, workspace - entry builder, and JSON record reader bound to capsys.""" + entry builder, and record reader bound to capsys.""" def _write(project_dir, workspaces, **overrides): rtl = project_dir / "rtl" / "gcd.v" @@ -108,7 +108,7 @@ def _entry(project_dir, workspace_id, status="success"): } def _records(): - return json.loads(capsys.readouterr().out)["records"] + return plain_records(capsys.readouterr().out) return SimpleNamespace(write=_write, entry=_entry, records=_records) diff --git a/test/cli/commands/test_check.py b/test/cli/commands/test_check.py index 3a854494f..0b247bedf 100644 --- a/test/cli/commands/test_check.py +++ b/test/cli/commands/test_check.py @@ -1,4 +1,3 @@ -import json import os import pytest @@ -47,7 +46,7 @@ def counting_load(config_path): monkeypatch.setattr("chipcompiler.cli.project.config.load_project_config", counting_load) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir]) assert rc == 0 assert len(calls) == 1 @@ -63,7 +62,14 @@ def test_check_fails_malformed_toml(self, tmp_path, capsys): rc = cli_main.run(["check", "--project", str(project_dir)]) assert rc == 1 - def test_check_fails_missing_rtl(self, tmp_path, capsys, create_cli_project, monkeypatch): + def test_check_fails_missing_rtl( + self, + tmp_path, + capsys, + create_cli_project, + monkeypatch, + plain_records, + ): monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", lambda name, root, overrides=None: None, @@ -75,13 +81,18 @@ def test_check_fails_missing_rtl(self, tmp_path, capsys, create_cli_project, mon content = content.replace('rtl = ["rtl/gcd.v"]', 'rtl = ["rtl/missing.v"]') with open(toml_path, "w") as f: f.write(content) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 1 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert any(r.get("check") == "rtl" and r.get("status") == "fail" for r in records) def test_check_fails_second_missing_rtl( - self, tmp_path, capsys, create_cli_project, monkeypatch + self, + tmp_path, + capsys, + create_cli_project, + monkeypatch, + plain_records, ): # Only the second of two declared sources is missing; the first # must not mask it. @@ -96,9 +107,9 @@ def test_check_fails_second_missing_rtl( content = content.replace('rtl = ["rtl/gcd.v"]', 'rtl = ["rtl/gcd.v", "rtl/missing.v"]') with open(toml_path, "w") as f: f.write(content) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 1 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert any(r.get("check") == "rtl" and r.get("status") == "fail" for r in records) def test_check_fails_empty_pdk_root(self, tmp_path, create_cli_project, monkeypatch): @@ -137,6 +148,22 @@ def test_check_fails_unsupported_preset(self, tmp_path, create_cli_project): rc = cli_main.run(["check", "--project", project_dir]) assert rc == 1 + def test_check_accepts_legacy_preset_aliases( + self, tmp_path, create_cli_project, monkeypatch, minimal_ics55_pdk_factory + ): + # Presets folded into the canonical chain stay valid for existing + # configs: they resolve as legacy ranges. + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + project_dir = create_cli_project(pdk_root=pdk_root) + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content = content.replace('preset = "rtl2gds"', 'preset = "harden"') + with open(toml_path, "w") as f: + f.write(content) + rc = cli_main.run(["check", "--project", project_dir]) + assert rc == 0 + def test_check_fails_non_positive_frequency(self, tmp_path, create_cli_project): project_dir = create_cli_project() toml_path = os.path.join(project_dir, "ecc.toml") @@ -173,19 +200,19 @@ def test_check_fails_non_numeric_frequency(self, tmp_path, create_cli_project): rc = cli_main.run(["check", "--project", project_dir]) assert rc == 1 - def test_check_json_output(self, tmp_path, monkeypatch, capsys, create_cli_project): + def test_check_json_output( + self, tmp_path, monkeypatch, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", lambda name, root, overrides=None: None, ) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 0 - out = capsys.readouterr().out - data = json.loads(out) - assert "records" in data - assert data["records"][0]["status"] == "checked" - assert data["records"][0]["project"] == "gcd" + records = plain_records(capsys.readouterr().out) + assert records[0]["status"] == "checked" + assert records[0]["project"] == "gcd" class TestCheckFilelistValidation: @@ -265,14 +292,6 @@ def test_check_fails_invalid_filelist_directive(self, tmp_path, monkeypatch): class TestMissingConfigErrorRecord: - def test_check_missing_config_has_kind_error_json(self, tmp_path, capsys): - rc = cli_main.run(["check", "--project", str(tmp_path), "--json"]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] - assert record["kind"] == "error" - assert record["error"] == "missing_config" - def test_check_missing_config_has_kind_error_text(self, tmp_path, capsys): rc = cli_main.run(["check", "--project", str(tmp_path)]) assert rc == 1 @@ -280,11 +299,11 @@ def test_check_missing_config_has_kind_error_text(self, tmp_path, capsys): assert "[error]" in out assert "missing_config" in out - def test_check_missing_config_has_disclosure_command(self, tmp_path, capsys): - rc = cli_main.run(["check", "--project", str(tmp_path), "--json"]) + def test_check_missing_config_has_disclosure_command(self, tmp_path, capsys, plain_records): + rc = cli_main.run(["check", "--project", str(tmp_path), "--plain"]) assert rc == 1 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] + records = plain_records(capsys.readouterr().out) + record = records[0] assert "inspect" in record or "inspect_cmd" in record def test_check_pdk_overrides_valid( @@ -495,7 +514,15 @@ class TestCheckFlowRunShape: ], ) def test_check_rejects_well_formed_run_shapes( - self, tmp_path, monkeypatch, capsys, create_cli_project, set_flow_run, run_line, value + self, + tmp_path, + monkeypatch, + capsys, + create_cli_project, + set_flow_run, + run_line, + value, + plain_records, ): project_dir = create_cli_project() set_flow_run(project_dir, run_line) @@ -504,10 +531,10 @@ def test_check_rejects_well_formed_run_shapes( lambda name, root, overrides=None: None, ) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 1 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) reason = f"unsupported_flow_run: [flow].run is not supported ({value!r})" assert any(record.get("reason") == reason for record in records) @@ -522,21 +549,33 @@ def test_check_rejects_well_formed_run_shapes( ], ) def test_check_rejects_degenerate_run_shapes( - self, tmp_path, capsys, create_cli_project, set_flow_run, run_line, reason + self, + tmp_path, + capsys, + create_cli_project, + set_flow_run, + run_line, + reason, + plain_records, ): project_dir = create_cli_project() set_flow_run(project_dir, run_line) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 1 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert any(record.get("reason") == reason for record in records) class TestCheckWorkspaceDisplay: def test_check_reports_default_workspace( - self, tmp_path, monkeypatch, capsys, create_cli_project + self, + tmp_path, + monkeypatch, + capsys, + create_cli_project, + plain_records, ): project_dir = create_cli_project() monkeypatch.setattr( @@ -544,10 +583,10 @@ def test_check_reports_default_workspace( lambda name, root, overrides=None: None, ) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert records[0] == { "project": "gcd", "status": "checked", @@ -558,17 +597,22 @@ def test_check_reports_default_workspace( } def test_check_reports_declared_workspace( - self, tmp_path, capsys, minimal_ics55_pdk_factory, manifest_stubs + self, + tmp_path, + capsys, + minimal_ics55_pdk_factory, + manifest_stubs, + plain_records, ): project_dir = tmp_path / "proj" project_dir.mkdir() manifest_stubs.write(project_dir, [manifest_stubs.entry(project_dir, "ws_0001")]) minimal_ics55_pdk_factory(project_dir / "pdk") - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert records[0] == { "project": "gcd", "status": "checked", @@ -579,7 +623,12 @@ def test_check_reports_declared_workspace( } def test_check_reports_declared_workspace_via_symlinked_project( - self, tmp_path, capsys, minimal_ics55_pdk_factory, manifest_stubs + self, + tmp_path, + capsys, + minimal_ics55_pdk_factory, + manifest_stubs, + plain_records, ): project_dir = tmp_path / "proj" project_dir.mkdir() @@ -588,16 +637,21 @@ def test_check_reports_declared_workspace_via_symlinked_project( project_link = str(tmp_path / "project_link") os.symlink(str(project_dir), project_link) - rc = cli_main.run(["check", "--project", project_link, "--json"]) + rc = cli_main.run(["check", "--project", project_link, "--plain"]) assert rc == 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert records[0]["workspace"] == "ws_0001" assert records[0]["run"] == f"ecc run --project {project_link}" assert records[0]["inspect_cmd"] == f"ecc status --project {project_link}" def test_check_manifest_multi_workspace_requires_selector( - self, tmp_path, capsys, minimal_ics55_pdk_factory, manifest_stubs + self, + tmp_path, + capsys, + minimal_ics55_pdk_factory, + manifest_stubs, + plain_records, ): project_dir = tmp_path / "proj" project_dir.mkdir() @@ -607,15 +661,20 @@ def test_check_manifest_multi_workspace_requires_selector( ) minimal_ics55_pdk_factory(project_dir / "pdk") - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 1 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert records[0]["error"] == "workspace_required" assert "ws_a" in records[0]["reason"] and "ws_b" in records[0]["reason"] def test_check_manifest_accepts_workspace_selector( - self, tmp_path, capsys, minimal_ics55_pdk_factory, manifest_stubs + self, + tmp_path, + capsys, + minimal_ics55_pdk_factory, + manifest_stubs, + plain_records, ): project_dir = tmp_path / "proj" project_dir.mkdir() @@ -625,9 +684,11 @@ def test_check_manifest_accepts_workspace_selector( ) minimal_ics55_pdk_factory(project_dir / "pdk") - rc = cli_main.run(["check", "--project", str(project_dir), "--workspace", "ws_b", "--json"]) + rc = cli_main.run( + ["check", "--project", str(project_dir), "--workspace", "ws_b", "--plain"] + ) assert rc == 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert records[0]["status"] == "checked" assert records[0]["workspace"] == "ws_b" diff --git a/test/cli/commands/test_config_layers.py b/test/cli/commands/test_config_layers.py index e2cb1f508..e914a2c08 100644 --- a/test/cli/commands/test_config_layers.py +++ b/test/cli/commands/test_config_layers.py @@ -56,7 +56,7 @@ def test_check_warns_on_name_rtl_frequency_and_patched_parameter( ) (project_dir / "rtl" / "other.v").write_text("module other; endmodule\n") - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 warnings = _divergences(manifest_stubs.records()) @@ -80,7 +80,7 @@ def test_run_warns_on_same_projection( '\n[flow]\npreset = "rtl2gds"\n', ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--plain"]) assert rc == 0 warnings = _divergences(manifest_stubs.records()) @@ -147,7 +147,7 @@ def test_check_explicit_empty_fails( ): project_dir = self._hybrid(manifest_stubs, tmp_path, monkeypatch, toml_text) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc != 0 reasons = "\n".join(r.get("reason", "") for r in manifest_stubs.records()) @@ -166,12 +166,12 @@ def test_check_explicit_empty_rtl_allowed_until_run( _HYBRID_TOML.replace('rtl = ["rtl/gcd.v"]', "rtl = []"), ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 capsys.readouterr() - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--plain"]) assert rc != 0 reasons = "\n".join(r.get("reason", "") for r in manifest_stubs.records()) @@ -184,7 +184,7 @@ def test_run_explicit_empty_fails_before_mutation( ): project_dir = self._hybrid(manifest_stubs, tmp_path, monkeypatch, toml_text) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--plain"]) assert rc != 0 reasons = "\n".join(r.get("reason", "") for r in manifest_stubs.records()) @@ -205,7 +205,7 @@ def test_check_explicit_empty_pdk_root_matches_env_base_quietly( ) monkeypatch.setenv("CHIPCOMPILER_ICS55_PDK_ROOT", str(project_dir / "pdk")) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 warnings = _divergences(manifest_stubs.records()) @@ -226,7 +226,7 @@ def test_check_explicit_empty_pdk_root_diverges_with_env_root( other_root.mkdir() monkeypatch.setenv("CHIPCOMPILER_ICS55_PDK_ROOT", str(other_root)) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 warnings = _divergences(manifest_stubs.records()) @@ -276,7 +276,7 @@ def test_check_warns_on_zero_lower_frequency( entry_range=("Synth", "Harden"), ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 warnings = _divergences(manifest_stubs.records()) @@ -294,7 +294,7 @@ def test_run_warns_on_zero_lower_frequency( entry_range=("Synth", "Harden"), ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--plain"]) assert rc == 0 warnings = _divergences(manifest_stubs.records()) @@ -312,7 +312,7 @@ def test_check_warns_on_different_flow_range( entry_range=("Place", "Route"), ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 warnings = _divergences(manifest_stubs.records()) @@ -330,7 +330,7 @@ def test_run_warns_on_different_flow_range( entry_range=("Place", "Route"), ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--plain"]) assert rc == 0 warnings = _divergences(manifest_stubs.records()) @@ -349,7 +349,7 @@ def test_check_equivalent_flow_range_stays_silent( entry_range=("Synth", "Harden"), ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 assert _divergences(manifest_stubs.records()) == [] @@ -365,7 +365,7 @@ def test_run_equivalent_flow_range_stays_silent( entry_range=("Synth", "Harden"), ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--plain"]) assert rc == 0 assert _divergences(manifest_stubs.records()) == [] @@ -407,7 +407,7 @@ def _hybrid(self, manifest_stubs, tmp_path, monkeypatch, toml_rtl): def test_check_warns_on_reordered_rtl(self, manifest_stubs, tmp_path, capsys, monkeypatch): project_dir = self._hybrid(manifest_stubs, tmp_path, monkeypatch, '"rtl/b.v", "rtl/gcd.v"') - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 warnings = _divergences(manifest_stubs.records()) @@ -419,7 +419,7 @@ def test_run_warns_on_reordered_rtl( ): project_dir = self._hybrid(manifest_stubs, tmp_path, monkeypatch, '"rtl/b.v", "rtl/gcd.v"') - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--plain"]) assert rc == 0 warnings = _divergences(manifest_stubs.records()) @@ -429,7 +429,7 @@ def test_run_warns_on_reordered_rtl( def test_same_order_stays_silent(self, manifest_stubs, tmp_path, capsys, monkeypatch): project_dir = self._hybrid(manifest_stubs, tmp_path, monkeypatch, '"rtl/gcd.v", "rtl/b.v"') - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 assert _divergences(manifest_stubs.records()) == [] @@ -454,7 +454,7 @@ def test_manifest_only_project_shows_layered_config( lambda name, root, overrides=None: None, ) - rc = cli_main.run(["config", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["config", "--project", str(project_dir), "--plain"]) assert rc == 0 by_key = self._records_by_key(manifest_stubs.records()) @@ -487,7 +487,7 @@ def test_hybrid_project_labels_explicit_and_filled_sources( lambda name, root, overrides=None: None, ) - rc = cli_main.run(["config", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["config", "--project", str(project_dir), "--plain"]) assert rc == 0 by_key = self._records_by_key(manifest_stubs.records()) @@ -517,7 +517,7 @@ def test_check_tolerates_huge_manifest_frequency(tmp_path, capsys, manifest_stub }, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc != 0 records = manifest_stubs.records() @@ -548,7 +548,7 @@ def test_run_warns_when_set_overrides_a_different_lower_value( ) rc = cli_main.run( - ["run", "--project", str(project_dir), "--set", "cts.max_fanout=32", "--json"] + ["run", "--project", str(project_dir), "--set", "cts.max_fanout=32", "--plain"] ) assert rc == 0 @@ -580,7 +580,7 @@ def test_run_quiet_when_set_restates_the_lower_value( ) rc = cli_main.run( - ["run", "--project", str(project_dir), "--set", "cts.max_fanout=20", "--json"] + ["run", "--project", str(project_dir), "--set", "cts.max_fanout=20", "--plain"] ) assert rc == 0 diff --git a/test/cli/commands/test_doctor.py b/test/cli/commands/test_doctor.py index 300927da3..2c67d7b96 100644 --- a/test/cli/commands/test_doctor.py +++ b/test/cli/commands/test_doctor.py @@ -1,4 +1,3 @@ -import json from pathlib import Path import pytest @@ -21,7 +20,9 @@ def fake(components, *, cfg=None, include_slang=True): class TestDoctorCommand: - def test_doctor_all_pass(self, tmp_path, capsys, monkeypatch, create_cli_project): + def test_doctor_all_pass( + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records + ): project_dir = create_cli_project() monkeypatch.setattr( "chipcompiler.cli.inspection.env_probe.ALL_COMPONENTS", @@ -35,20 +36,20 @@ def test_doctor_all_pass(self, tmp_path, capsys, monkeypatch, create_cli_project }, ) - rc = cli_main.run(["doctor", "--project", project_dir, "--json"]) + rc = cli_main.run(["doctor", "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + records = plain_records(capsys.readouterr().out) assert rc == 0 - summary = data["records"][0] + summary = records[0] assert summary["doctor"] == "environment" assert summary["status"] == "ok" - assert summary["checked"] == 2 - assert summary["failed"] == 0 - assert summary["attention"] == 0 - assert {r["component"] for r in data["records"][1:]} == {"yosys", "ecc-tools"} + assert summary["checked"] == "2" + assert summary["failed"] == "0" + assert summary["attention"] == "0" + assert {r["component"] for r in records[1:]} == {"yosys", "ecc-tools"} def test_doctor_required_failure_exits_nonzero( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records ): project_dir = create_cli_project() monkeypatch.setattr( @@ -62,17 +63,17 @@ def test_doctor_required_failure_exits_nonzero( }, ) - rc = cli_main.run(["doctor", "--project", project_dir, "--json"]) + rc = cli_main.run(["doctor", "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + records = plain_records(capsys.readouterr().out) assert rc == 1 - assert data["records"][0]["status"] == "failed" - assert data["records"][0]["failed"] == 1 - assert data["records"][0]["attention"] == 0 - assert data["records"][1]["remediation"] == "install yosys" + assert records[0]["status"] == "failed" + assert records[0]["failed"] == "1" + assert records[0]["attention"] == "0" + assert records[1]["remediation"] == "install yosys" def test_doctor_optional_failure_stays_zero( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records ): project_dir = create_cli_project() monkeypatch.setattr( @@ -88,17 +89,17 @@ def test_doctor_optional_failure_stays_zero( }, ) - rc = cli_main.run(["doctor", "--project", project_dir, "--json"]) + rc = cli_main.run(["doctor", "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + records = plain_records(capsys.readouterr().out) assert rc == 0 - summary = data["records"][0] + summary = records[0] assert summary["status"] == "attention" - assert summary["failed"] == 0 # optional failure never inflates `failed` - assert summary["attention"] == 1 + assert summary["failed"] == "0" # optional failure never inflates `failed` + assert summary["attention"] == "1" def test_doctor_sizer_failure_exits_nonzero( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records ): project_dir = create_cli_project() monkeypatch.setattr( @@ -110,13 +111,13 @@ def test_doctor_sizer_failure_exits_nonzero( {"sizer": ProbeResult("sizer", FAIL, remediation="install sizer")}, ) - rc = cli_main.run(["doctor", "--project", project_dir, "--json"]) + rc = cli_main.run(["doctor", "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + records = plain_records(capsys.readouterr().out) assert rc == 1 - assert data["records"][0]["status"] == "failed" - assert data["records"][0]["failed"] == 1 - assert data["records"][1]["required"] is True + assert records[0]["status"] == "failed" + assert records[0]["failed"] == "1" + assert records[1]["required"] == "True" def test_sizer_probe_is_required(self, monkeypatch): monkeypatch.setattr( @@ -141,7 +142,7 @@ def test_sizer_probe_requires_binary_and_runtime(self, monkeypatch): assert result.status == FAIL assert result.required is True - def test_doctor_without_project_skips_pdk(self, tmp_path, capsys, monkeypatch): + def test_doctor_without_project_skips_pdk(self, tmp_path, capsys, monkeypatch, plain_records): monkeypatch.chdir(tmp_path) monkeypatch.setattr( "chipcompiler.cli.inspection.env_probe.ALL_COMPONENTS", @@ -152,14 +153,14 @@ def test_doctor_without_project_skips_pdk(self, tmp_path, capsys, monkeypatch): {"pdk": ProbeResult("pdk", "skip", detail="no ecc.toml")}, ) - rc = cli_main.run(["doctor", "--json"]) + rc = cli_main.run(["doctor", "--plain"]) - data = json.loads(capsys.readouterr().out) + records = plain_records(capsys.readouterr().out) assert rc == 0 - assert data["records"][1]["status"] == "skip" + assert records[1]["status"] == "skip" def test_doctor_real_pdk_failure_names_problem( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records ): project_dir = create_cli_project() monkeypatch.setattr( @@ -177,11 +178,11 @@ def only_pdk(components, *, cfg=None, include_slang=True): lambda name, root, overrides=None: "PDK has no liberty files", ) - rc = cli_main.run(["doctor", "--project", project_dir, "--json"]) + rc = cli_main.run(["doctor", "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + records = plain_records(capsys.readouterr().out) assert rc == 1 - pdk_record = data["records"][1] + pdk_record = records[1] assert pdk_record["component"] == "pdk" assert pdk_record["status"] == "fail" assert pdk_record["remediation"] == "PDK has no liberty files" @@ -200,7 +201,7 @@ def test_doctor_skips_slang_when_yosys_missing(self, tmp_path, monkeypatch): class TestRunPreflight: def test_run_blocks_when_required_tool_missing( - self, tmp_path, capsys, monkeypatch, create_cli_project, flow_mocks + self, tmp_path, capsys, monkeypatch, create_cli_project, flow_mocks, plain_records ): project_dir = create_cli_project() monkeypatch.setattr( @@ -210,9 +211,9 @@ def test_run_blocks_when_required_tool_missing( ], ) - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "env_not_ready" assert record["preset"] == "rtl2gds" @@ -243,12 +244,15 @@ def test_preflight_components_mapping(self, monkeypatch): "chipcompiler.rtl2gds.builder.build_rtl2gds_flow", lambda: [ ("Synthesis", "yosys", "Unstart"), + ("Floorplan", "ecc", "Unstart"), ("place", "dreamplace", "Unstart"), ("Timing optimization", "sizer", "Unstart"), ], ) - assert env_probe.probe_components_for_preset("syn_sta") == ("ecc-tools", "yosys") + # The mapping derives components from the chain's own tools: a + # Yosys-only stub chain needs Yosys, not an unconditional ecc-tools. + assert env_probe.probe_components_for_preset("syn_sta") == ("yosys",) assert env_probe.probe_components_for_preset("rtl2gds") == ( "ecc-tools", "yosys", @@ -316,9 +320,7 @@ def create_step_workspaces(self, *, executable_steps=None): workspace = Path(project_dir) / "ws" (workspace / "home").mkdir(parents=True) (workspace / "home" / "flow.json").write_text('{"steps": []}') - rc = cli_main.run( - ["run", "--project", project_dir, "--workspace", "ws", "--resume", "--json"] - ) + rc = cli_main.run(["run", "--project", project_dir, "--workspace", "ws", "--resume"]) assert rc == 0 diff --git a/test/cli/commands/test_effective_config.py b/test/cli/commands/test_effective_config.py index 6ce728e56..d035086c3 100644 --- a/test/cli/commands/test_effective_config.py +++ b/test/cli/commands/test_effective_config.py @@ -46,12 +46,12 @@ def test_flowless_ecc_toml_existing_run_uses_workspace_flow( {"start": "Synthesis", "end": "Synthesis"}, ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--plain"]) assert rc == 0 records = manifest_stubs.records() assert records[0]["status"] == "success" - assert records[0]["no_op"] is True + assert records[0]["no_op"] == "True" def test_multi_rtl_manifest_materializes_filelist( self, tmp_path, capsys, flow_mocks, monkeypatch, manifest_stubs @@ -88,7 +88,7 @@ def capture_materialize(cfg): monkeypatch.setattr(run_prepare, "_materialize_rtl_filelist", capture_materialize) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 lines = generated["content"].splitlines() @@ -117,7 +117,7 @@ def test_check_reports_layer_divergence( lambda name, root, overrides=None: None, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 records = manifest_stubs.records() @@ -140,7 +140,7 @@ def test_check_flowless_partial_hybrid_passes( lambda name, root, overrides=None: None, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 records = manifest_stubs.records() @@ -169,7 +169,7 @@ def test_check_multi_rtl_manifest_passes(self, tmp_path, capsys, monkeypatch, ma lambda name, root, overrides=None: None, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 records = manifest_stubs.records() @@ -203,7 +203,7 @@ def test_manifest_only_check_fails_semantic_validation(self, tmp_path, capsys, m base_design=self._manifest_base(project_dir, top_module="", clock=""), ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc != 0 reasons = "\n".join(r.get("reason", "") for r in manifest_stubs.records()) @@ -232,7 +232,7 @@ def test_manifest_only_check_validates_every_rtl_source( # declared source is the missing one. (project_dir / "rtl" / "gcd.v").unlink() - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc != 0 reasons = "\n".join(r.get("reason", "") for r in manifest_stubs.records()) @@ -257,7 +257,7 @@ def test_hybrid_check_falls_back_to_origin_verilog( lambda name, root, overrides=None: None, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 records = manifest_stubs.records() @@ -279,7 +279,9 @@ def test_flowless_undeclared_run_fails_before_any_mutation( '\n[pdk]\nname = "ics55"\nroot = "' + str(project_dir / "pdk") + '"\n' ) - rc = cli_main.run(["run", "--project", str(project_dir), "--workspace", "sweep1", "--json"]) + rc = cli_main.run( + ["run", "--project", str(project_dir), "--workspace", "sweep1", "--plain"] + ) assert rc != 0 reasons = "\n".join(r.get("reason", "") for r in manifest_stubs.records()) @@ -314,7 +316,7 @@ def test_check_warns_on_gui_geometry_divergence( lambda name, root, overrides=None: None, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 warnings = [ @@ -347,7 +349,7 @@ def test_equivalent_path_spellings_produce_no_divergence( lambda name, root, overrides=None: None, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 records = manifest_stubs.records() @@ -394,7 +396,7 @@ def test_check_fills_absent_frequency_from_manifest( manifest_stubs, tmp_path, monkeypatch, self._FULL_DESIGN_NO_FREQUENCY ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 assert manifest_stubs.records()[0]["status"] == "checked" @@ -409,7 +411,7 @@ def test_check_explicit_zero_frequency_still_fails( self._FULL_DESIGN_NO_FREQUENCY + "\nfrequency_mhz = 0", ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc != 0 reasons = "\n".join(r.get("reason", "") for r in manifest_stubs.records()) @@ -422,7 +424,7 @@ def test_run_fills_absent_frequency_from_manifest( manifest_stubs, tmp_path, monkeypatch, self._FULL_DESIGN_NO_FREQUENCY ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 parameters = flow_mocks.capture["create_kwargs"]["parameters"] @@ -438,7 +440,7 @@ def test_run_explicit_zero_frequency_fails_before_mutation( self._FULL_DESIGN_NO_FREQUENCY + "\nfrequency_mhz = 0", ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--plain"]) assert rc != 0 reasons = "\n".join(r.get("reason", "") for r in manifest_stubs.records()) @@ -446,7 +448,7 @@ def test_run_explicit_zero_frequency_fails_before_mutation( assert flow_mocks.capture["create_kwargs"] is None def test_check_rejects_unreadable_ecc_toml_instead_of_manifest_fallback( - self, tmp_path, capsys, monkeypatch, manifest_stubs + self, tmp_path, capsys, monkeypatch, manifest_stubs, plain_records ): """An existing but unreadable ecc.toml is the highest-precedence config: check must fail loud, not silently run on the manifest.""" @@ -460,14 +462,14 @@ def deny(config_path): monkeypatch.setattr("chipcompiler.cli.project.config.load_project_config", deny) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc != 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert any(r.get("error") == "config_error" for r in records) def test_check_rejects_symlink_loop_ecc_toml_instead_of_manifest_fallback( - self, tmp_path, capsys, manifest_stubs + self, tmp_path, capsys, manifest_stubs, plain_records ): """Discovery itself must not swallow the config: a symlink loop at ecc.toml is PRESENT but unreadable — config_error, not a silent @@ -477,10 +479,10 @@ def test_check_rejects_symlink_loop_ecc_toml_instead_of_manifest_fallback( manifest_stubs.write(project_dir, [manifest_stubs.entry(project_dir, "ws_0001")]) (project_dir / "ecc.toml").symlink_to("ecc.toml") - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc != 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert any(r.get("error") == "config_error" for r in records) def test_multi_rtl_manifest_expands_nested_filelists( @@ -520,7 +522,7 @@ def capture_materialize(cfg): monkeypatch.setattr(run_prepare, "_materialize_rtl_filelist", capture_materialize) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 lines = generated["content"].splitlines() @@ -589,7 +591,7 @@ def test_check_rejects_out_of_range_manifest_frequency( ): project_dir = _write_manifest_project(manifest_stubs, tmp_path, monkeypatch, 99999) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc != 0 reasons = "\n".join(r.get("reason", "") for r in manifest_stubs.records()) @@ -603,7 +605,7 @@ def test_check_explicit_frequency_overrides_invalid_manifest_value( toml_text = self._hybrid_toml(project_dir, frequency=True) _write_manifest_project(manifest_stubs, tmp_path, monkeypatch, 99999, ecc_toml=toml_text) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 records = manifest_stubs.records() @@ -625,7 +627,6 @@ def test_run_set_overrides_invalid_manifest_value( str(project_dir), "--set", "design.frequency_mhz=100", - "--json", ] ) @@ -674,7 +675,7 @@ def test_check_rejects_bad_manifest_bool(self, tmp_path, capsys, monkeypatch, ma extra_parameters={"run_analysis": "maybe"}, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc != 0 reasons = "\n".join(r.get("reason", "") for r in manifest_stubs.records()) @@ -691,7 +692,7 @@ def test_check_accepts_bool_like_manifest_string( extra_parameters={"run_analysis": "false"}, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 assert manifest_stubs.records()[0]["status"] == "checked" @@ -710,7 +711,7 @@ def test_check_params_override_supersedes_invalid_manifest_bool( extra_parameters={"run_analysis": "maybe"}, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 assert manifest_stubs.records()[0]["status"] == "checked" @@ -729,7 +730,7 @@ def test_check_misplaced_flow_section_key_is_not_an_override( extra_parameters={"run_analysis": "maybe"}, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc != 0 reasons = "\n".join(r.get("reason", "") for r in manifest_stubs.records()) @@ -749,7 +750,7 @@ def test_check_no_divergence_when_coerced_values_match( extra_parameters={"run_analysis": "false"}, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 diverged = [ @@ -771,7 +772,7 @@ def test_check_divergence_for_type_invalid_manifest_bool( extra_parameters={"run_analysis": 0}, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 diverged = [ @@ -793,7 +794,7 @@ def test_check_divergence_for_dict_manifest_bool( extra_parameters={"run_analysis": {"bad": True}}, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 diverged = [ @@ -823,7 +824,7 @@ def test_run_reports_only_toml_divergence_for_three_layer_bool( str(project_dir), "--set", "flow.run_analysis=false", - "--json", + "--plain", ] ) @@ -856,7 +857,7 @@ def test_run_matching_design_frequency_has_no_cli_divergence( str(project_dir), "--set", "design.frequency_mhz=200", - "--json", + "--plain", ] ) @@ -878,7 +879,7 @@ def test_check_no_divergence_when_params_supersedes_design_frequency( ) _write_manifest_project(manifest_stubs, tmp_path, monkeypatch, 100, ecc_toml=toml_text) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 diverged = [ @@ -903,7 +904,7 @@ def test_check_rejects_out_of_range_gui_flat_geometry( extra_parameters={"utilitization": 5.0}, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc != 0 reasons = "\n".join(r.get("reason", "") for r in manifest_stubs.records()) @@ -920,7 +921,7 @@ def test_check_rejects_non_numeric_manifest_parameter( extra_parameters={"max_fanout": "abc"}, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc != 0 reasons = "\n".join(r.get("reason", "") for r in manifest_stubs.records()) @@ -937,7 +938,7 @@ def test_check_accepts_valid_gui_flat_geometry( extra_parameters={"utilitization": 0.6, "aspect_ratio": 1.0, "margin": 2}, ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 assert manifest_stubs.records()[0]["status"] == "checked" @@ -948,7 +949,7 @@ class TestManifestResolvedConfigView: same canonical projection the run uses (GUI-flat aliases included).""" def test_config_resolved_shows_gui_flat_manifest_geometry( - self, tmp_path, capsys, monkeypatch, manifest_stubs + self, tmp_path, capsys, monkeypatch, manifest_stubs, plain_records ): project_dir = tmp_path / "proj" project_dir.mkdir() @@ -965,16 +966,16 @@ def test_config_resolved_shows_gui_flat_manifest_geometry( }, ) - rc = cli_main.run(["config", "--json", "--project", str(project_dir)]) + rc = cli_main.run(["config", "--plain", "--project", str(project_dir)]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - params = {r["key"]: r for r in data["records"] if r.get("kind") == "param"} - assert params["floorplan.core_util"]["value"] == 0.6 + data = plain_records(capsys.readouterr().out) + params = {r["key"]: r for r in data if r.get("kind") == "param"} + assert params["floorplan.core_util"]["value"] == "0.6" assert params["floorplan.core_util"]["source"] == "project.json" def test_config_resolved_surfaces_null_manifest_bool( - self, tmp_path, capsys, monkeypatch, manifest_stubs + self, tmp_path, capsys, monkeypatch, manifest_stubs, plain_records ): project_dir = tmp_path / "proj" project_dir.mkdir() @@ -991,15 +992,15 @@ def test_config_resolved_surfaces_null_manifest_bool( }, ) - rc = cli_main.run(["config", "--json", "--project", str(project_dir)]) + rc = cli_main.run(["config", "--plain", "--project", str(project_dir)]) assert rc == 1 - data = json.loads(capsys.readouterr().out) + data = plain_records(capsys.readouterr().out) # The view's records never leak raw; errors use the standard record # contract, not the view's internal status field. The NoneType reason # proves the explicit null was seen — not silently read as the # default True while the runtime value is falsy. - records = data["records"] + records = data assert all(r.get("kind") == "error" for r in records) assert all(r.get("error") == "invalid_config" for r in records) assert any("expected bool for flow.run_analysis" in r["reason"] for r in records) @@ -1028,7 +1029,7 @@ def test_config_resolved_text_output_shows_error_reason( assert "expected bool for flow.run_analysis" in capsys.readouterr().out def test_config_resolved_reports_manifest_frequency_type_error( - self, tmp_path, capsys, monkeypatch, manifest_stubs + self, tmp_path, capsys, monkeypatch, manifest_stubs, plain_records ): project_dir = tmp_path / "proj" project_dir.mkdir() @@ -1045,36 +1046,36 @@ def test_config_resolved_reports_manifest_frequency_type_error( }, ) - rc = cli_main.run(["config", "--json", "--project", str(project_dir)]) + rc = cli_main.run(["config", "--plain", "--project", str(project_dir)]) assert rc == 1 - data = json.loads(capsys.readouterr().out) + data = plain_records(capsys.readouterr().out) # The manifest-supplied string must not be masked by a phantom # ecc.toml-layer frequency injection. - errors = [r for r in data["records"] if r.get("kind") == "error"] + errors = [r for r in data if r.get("kind") == "error"] assert all(r.get("error") == "invalid_config" for r in errors) assert any( "expected float for design.frequency_mhz, got str" in r["reason"] for r in errors ) def test_config_resolved_surfaces_param_errors_for_hybrid_project( - self, tmp_path, capsys, monkeypatch, manifest_stubs + self, tmp_path, capsys, monkeypatch, manifest_stubs, plain_records ): project_dir = tmp_path / "proj" project_dir.mkdir() manifest_stubs.write(project_dir, [manifest_stubs.entry(project_dir, "ws_0001")]) (project_dir / "ecc.toml").write_text('\n[params.flow]\nrun_analysis = "maybe"\n') - rc = cli_main.run(["config", "--json", "--project", str(project_dir)]) + rc = cli_main.run(["config", "--plain", "--project", str(project_dir)]) assert rc == 1 - data = json.loads(capsys.readouterr().out) - errors = [r for r in data["records"] if r.get("kind") == "error"] + data = plain_records(capsys.readouterr().out) + errors = [r for r in data if r.get("kind") == "error"] assert all(r.get("error") == "invalid_config" for r in errors) assert any("expected bool for flow.run_analysis" in r["reason"] for r in errors) def test_config_resolved_error_records_full_contract( - self, tmp_path, capsys, monkeypatch, manifest_stubs + self, tmp_path, capsys, monkeypatch, manifest_stubs, plain_records ): project_dir = tmp_path / "proj" project_dir.mkdir() @@ -1083,11 +1084,11 @@ def test_config_resolved_error_records_full_contract( '\n[params.flow]\nrun_analysis = "maybe"\n\n[params.cts]\nmax_fanout = "nope"\n' ) - rc = cli_main.run(["config", "--json", "--project", str(project_dir)]) + rc = cli_main.run(["config", "--plain", "--project", str(project_dir)]) assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"] == [ + data = plain_records(capsys.readouterr().out) + assert data == [ { "kind": "error", "error": "invalid_config", diff --git a/test/cli/commands/test_flow_continuation.py b/test/cli/commands/test_flow_continuation.py index 4749e572a..ea451480d 100644 --- a/test/cli/commands/test_flow_continuation.py +++ b/test/cli/commands/test_flow_continuation.py @@ -56,8 +56,8 @@ def _write_existing_workspace(run_dir, step_names, states=None, preset="rtl2gds" ] -def _records(capsys): - return json.loads(capsys.readouterr().out)["records"] +def _records(capsys, plain_records): + return plain_records(capsys.readouterr().out) class TestFlowContinuation: @@ -68,6 +68,7 @@ def test_noop_when_flow_already_complete( create_cli_project, minimal_ics55_pdk_factory, monkeypatch, + plain_records, ): pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") project_dir = create_cli_project(pdk_root=pdk_root) @@ -87,12 +88,12 @@ def create_step_workspaces(self, *, executable_steps=None): monkeypatch.setattr("chipcompiler.engine.EngineFlow", Flow) - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc == 0 - records = _records(capsys) + records = _records(capsys, plain_records) assert records[0]["status"] == "success" - assert records[0]["no_op"] is True + assert records[0]["no_op"] == "True" def test_set_rejected_on_existing_run( self, @@ -101,6 +102,7 @@ def test_set_rejected_on_existing_run( create_cli_project, minimal_ics55_pdk_factory, monkeypatch, + plain_records, ): pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") project_dir = create_cli_project(pdk_root=pdk_root) @@ -112,10 +114,12 @@ def test_set_rejected_on_existing_run( _write_existing_workspace(run_dir, RTL2GDS_NAMES, pdk_root=pdk_root) flow_before = Path(run_dir, "home", "flow.json").read_bytes() - rc = cli_main.run(["run", "--project", project_dir, "--set", "cts.max_fanout=16", "--json"]) + rc = cli_main.run( + ["run", "--project", project_dir, "--set", "cts.max_fanout=16", "--plain"] + ) assert rc != 0 - (record,) = _records(capsys) + (record,) = _records(capsys, plain_records) assert record["error"] == "set_requires_fresh_run" assert Path(run_dir, "home", "flow.json").read_bytes() == flow_before @@ -126,6 +130,7 @@ def test_params_warning_on_existing_run( create_cli_project, minimal_ics55_pdk_factory, monkeypatch, + plain_records, ): pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") project_dir = create_cli_project(pdk_root=pdk_root) @@ -147,10 +152,10 @@ def create_step_workspaces(self, *, executable_steps=None): monkeypatch.setattr("chipcompiler.engine.EngineFlow", Flow) - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc == 0 - records = _records(capsys) + records = _records(capsys, plain_records) warning = [r for r in records if r.get("warning") == "params_ignored_on_existing_run"] assert len(warning) == 1 @@ -161,6 +166,7 @@ def test_malformed_workspace_config_is_config_invalid_not_invalid_workspace( create_cli_project, minimal_ics55_pdk_factory, monkeypatch, + plain_records, ): pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") project_dir = create_cli_project(pdk_root=pdk_root) @@ -172,10 +178,10 @@ def test_malformed_workspace_config_is_config_invalid_not_invalid_workspace( _write_existing_workspace(run_dir, RTL2GDS_NAMES, pdk_root=pdk_root) Path(run_dir, "home", "params.toml").write_text("[params\nbroken =") - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc != 0 - (record,) = _records(capsys) + (record,) = _records(capsys, plain_records) assert record["error"] == "workspace_config_invalid" @@ -231,7 +237,7 @@ def _write_manifest_with_workspace(project_dir, run_dir, pdk_root): class TestFlowMismatchZeroMutation: def test_manifest_backed_mismatch_leaves_every_surface_untouched( - self, tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory + self, tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory, plain_records ): """AC-14: a divergent persisted flow fails with flow_mismatch and zero mutation — the whole workspace tree (paths and bytes, lock files and @@ -252,16 +258,16 @@ def test_manifest_backed_mismatch_leaves_every_surface_untouched( tree_before = _tree_snapshot(run_dir) manifest_before = Path(manifest_path).read_bytes() - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc != 0 - errors = [r for r in _records(capsys) if r.get("error") == "flow_mismatch"] + errors = [r for r in _records(capsys, plain_records) if r.get("error") == "flow_mismatch"] assert len(errors) == 1 assert _tree_snapshot(run_dir) == tree_before assert Path(manifest_path).read_bytes() == manifest_before def test_legacy_parameters_mismatch_never_migrates( - self, tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory + self, tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory, plain_records ): """AC-14 with a legacy-parameters workspace: the mismatch refusal must not migrate parameters.json, create params.toml/lock/home.json, or touch @@ -305,16 +311,22 @@ def test_legacy_parameters_mismatch_never_migrates( tree_before = _tree_snapshot(run_dir) manifest_before = Path(manifest_path).read_bytes() - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc != 0 - errors = [r for r in _records(capsys) if r.get("error") == "flow_mismatch"] + errors = [r for r in _records(capsys, plain_records) if r.get("error") == "flow_mismatch"] assert len(errors) == 1 assert _tree_snapshot(run_dir) == tree_before assert Path(manifest_path).read_bytes() == manifest_before def test_existing_run_rejects_symlinked_legacy_target( - self, tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory, monkeypatch + self, + tmp_path, + capsys, + create_cli_project, + minimal_ics55_pdk_factory, + monkeypatch, + plain_records, ): """A symlinked run target must never be executed or mutated: the run fails loud with run_target_unsafe and the external workspace behind @@ -330,15 +342,17 @@ def test_existing_run_rejects_symlinked_legacy_target( os.symlink(str(external), os.path.join(project_dir, "default")) flow_before = (external / "home" / "flow.json").read_bytes() - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc != 0 - errors = [r for r in _records(capsys) if r.get("error") == "run_target_unsafe"] + errors = [ + r for r in _records(capsys, plain_records) if r.get("error") == "run_target_unsafe" + ] assert len(errors) == 1 assert (external / "home" / "flow.json").read_bytes() == flow_before def test_existing_run_rejects_symlinked_manifest_target( - self, tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory + self, tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory, plain_records ): """A declared workspace whose directory is a symlink into an external tree never reaches the engine: the manifest layer rejects it @@ -353,19 +367,25 @@ def test_existing_run_rejects_symlinked_manifest_target( _write_manifest_with_workspace(project_dir, run_dir, pdk_root) flow_before = (external / "home" / "flow.json").read_bytes() - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc != 0 errors = [ r - for r in _records(capsys) + for r in _records(capsys, plain_records) if r.get("error") in {"manifest_invalid", "run_target_unsafe"} ] assert len(errors) == 1 assert (external / "home" / "flow.json").read_bytes() == flow_before def test_flow_exception_marks_manifest_status_failed( - self, tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory, monkeypatch + self, + tmp_path, + capsys, + create_cli_project, + minimal_ics55_pdk_factory, + monkeypatch, + plain_records, ): """A handled engine exception must not leave the manifest status at running: the write-back records failed.""" @@ -396,12 +416,12 @@ def run_steps(self, **_kwargs): monkeypatch.setattr("chipcompiler.engine.EngineFlow", Flow) - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc != 0 manifest = json.loads(Path(manifest_path).read_text()) assert manifest["workspaces"][0]["status"] == "failed" - errors = [r for r in _records(capsys) if r.get("error") == "flow_failed"] + errors = [r for r in _records(capsys, plain_records) if r.get("error") == "flow_failed"] assert len(errors) == 1 @@ -431,7 +451,7 @@ def release(): class TestWorkspaceRunLock: def test_workspace_run_waits_for_an_active_workspace_lock( - self, tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory + self, tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory, plain_records ): """Two runs of the same workspace serialize on the sibling lock.""" pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") @@ -440,7 +460,7 @@ def test_workspace_run_waits_for_an_active_workspace_lock( _write_existing_workspace(run_dir, RTL2GDS_NAMES, pdk_root=pdk_root) thread, released = _hold_workspace_lock_briefly(run_dir) - rc = cli_main.run(["run", "--project", project_dir, "--workspace", "ws", "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--workspace", "ws"]) thread.join() assert rc == 0 diff --git a/test/cli/commands/test_legacy_readonly.py b/test/cli/commands/test_legacy_readonly.py index 4e180bd5b..d12cc6833 100644 --- a/test/cli/commands/test_legacy_readonly.py +++ b/test/cli/commands/test_legacy_readonly.py @@ -67,6 +67,7 @@ def test_readonly_commands_never_migrate_legacy_workspace( create_cli_project, minimal_ics55_pdk_factory, create_flow_json, + plain_records, ): """status/log/check/config on a legacy workspace rewrite nothing: they resolve the managed / path and never touch runs/, so no @@ -83,18 +84,18 @@ def test_readonly_commands_never_migrate_legacy_workspace( params_toml = os.path.join(home, "params.toml") assert not os.path.exists(params_toml) - rc = cli_main.run([*command, "--project", project_dir, "--json"]) + rc = cli_main.run([*command, "--project", project_dir, "--plain"]) assert rc == expected_rc assert {path: Path(path).read_bytes() for path in watched} == snapshots assert not os.path.exists(params_toml) - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) has_hint = any(r.get("warning") == "legacy_layout_detected" for r in records) assert has_hint == expects_hint def test_shadowed_workspace_config_warns_without_touching_files( - tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory + tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory, plain_records ): """params.toml + parameters.json 并存:run/check/status 输出 workspace_config_shadowed 提示,且两个文件都原样不动。""" @@ -139,17 +140,17 @@ def test_shadowed_workspace_config_warns_without_touching_files( params_before = Path(params_path).read_bytes() legacy_before = Path(legacy_path).read_bytes() - rc = cli_main.run(["status", "--project", project_dir, "--workspace", "ws_shadow", "--json"]) + rc = cli_main.run(["status", "--project", project_dir, "--workspace", "ws_shadow", "--plain"]) assert rc == 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) (warning,) = [r for r in records if r.get("warning") == "workspace_config_shadowed"] assert "delete" in warning["reason"] assert Path(params_path).read_bytes() == params_before assert Path(legacy_path).read_bytes() == legacy_before -def test_shadow_warning_probes_the_explicit_workspace_target(tmp_path, capsys): +def test_shadow_warning_probes_the_explicit_workspace_target(tmp_path, capsys, plain_records): """run --workspace 探测的是显式目标,不是项目推导的 run_dir。""" project_dir = tmp_path / "proj" (project_dir / "rtl").mkdir(parents=True) @@ -182,14 +183,14 @@ def test_shadow_warning_probes_the_explicit_workspace_target(tmp_path, capsys): # The workspace lacks PDK assets so the run fails validation — the # boundary warning is appended on EVERY outcome, including this one. - rc = cli_main.run(["run", "--project", str(project_dir), "--workspace", "ws", "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--workspace", "ws", "--plain"]) assert rc != 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert any(r.get("warning") == "workspace_config_shadowed" for r in records) -def test_shadow_warning_fires_on_dangling_legacy_symlink(tmp_path, capsys): +def test_shadow_warning_fires_on_dangling_legacy_symlink(tmp_path, capsys, plain_records): """lexists 语义:悬挂的 parameters.json 链接同样构成并存。""" project_dir = tmp_path / "proj" (project_dir / "rtl").mkdir(parents=True) @@ -205,7 +206,7 @@ def test_shadow_warning_fires_on_dangling_legacy_symlink(tmp_path, capsys): (home / "params.toml").write_text('[params]\ndesign = "gcd"\n') os.symlink(tmp_path / "gone.json", home / "parameters.json") - cli_main.run(["run", "--project", str(project_dir), "--workspace", "ws", "--json"]) + cli_main.run(["run", "--project", str(project_dir), "--workspace", "ws", "--plain"]) - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert any(r.get("warning") == "workspace_config_shadowed" for r in records) diff --git a/test/cli/commands/test_log.py b/test/cli/commands/test_log.py index e4ca82f7c..1d4b320f1 100644 --- a/test/cli/commands/test_log.py +++ b/test/cli/commands/test_log.py @@ -34,7 +34,7 @@ def test_log_step_errors(self, tmp_path, capsys, create_cli_project): assert "Warning: meh" in out assert "Info: running" in out - def test_log_step_errors_jsonl(self, tmp_path, capsys, create_cli_project): + def test_log_step_errors_plain(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") @@ -43,9 +43,9 @@ def test_log_step_errors_jsonl(self, tmp_path, capsys, create_cli_project): with open(os.path.join(step_dir, "synthesis.log"), "w") as f: f.write("Info: running\nError: bad thing\n") - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) + rc = cli_main.run(["log", "synthesis", "--plain", "--project", project_dir]) assert rc == 0 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + objects = plain_records(capsys.readouterr().out) assert any("Error" in obj["line"] for obj in objects) def test_log_no_step_shows_locations(self, tmp_path, capsys, create_cli_project): @@ -183,7 +183,7 @@ def test_traceback_complete_in_default_output(self, tmp_path, capsys, create_cli assert "^^^^^^^^^" in out assert "ValueError: invalid value" in out - def test_traceback_complete_in_jsonl(self, tmp_path, capsys, create_cli_project): + def test_traceback_complete_in_plain(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") @@ -191,15 +191,19 @@ def test_traceback_complete_in_jsonl(self, tmp_path, capsys, create_cli_project) with open(os.path.join(step_dir, "synthesis.log"), "w") as f: f.write('Traceback (most recent call last):\n File "a.py", line 1\nValueError: fail\n') - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) + rc = cli_main.run(["log", "synthesis", "--plain", "--project", project_dir]) assert rc == 0 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + objects = plain_records(capsys.readouterr().out) assert objects[0]["kind"] == "traceback" assert objects[1]["kind"] == "traceback" assert objects[2]["kind"] == "error" - def test_keyboard_interrupt_jsonl_classified_as_error( - self, tmp_path, capsys, create_cli_project + def test_keyboard_interrupt_plain_classified_as_error( + self, + tmp_path, + capsys, + create_cli_project, + plain_records, ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") @@ -210,9 +214,9 @@ def test_keyboard_interrupt_jsonl_classified_as_error( 'Traceback (most recent call last):\n File "a.py", line 1\nKeyboardInterrupt\n' ) - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) + rc = cli_main.run(["log", "synthesis", "--plain", "--project", project_dir]) assert rc == 0 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + objects = plain_records(capsys.readouterr().out) assert objects[0]["kind"] == "traceback" assert objects[1]["kind"] == "traceback" assert objects[2]["kind"] == "error" @@ -272,10 +276,16 @@ def test_plain_stable_quoting_for_special_chars(self, tmp_path, capsys, create_c assert "inspect_cmd=" in lines[0] -class TestLogJsonlMode: - """AC-6: --jsonl emits full-content structured log objects.""" +class TestLogPlainRecords: + """--plain emits full-content structured log records.""" - def test_jsonl_per_line_objects(self, tmp_path, capsys, create_cli_project): + def test_plain_per_line_objects( + self, + tmp_path, + capsys, + create_cli_project, + plain_records, + ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") @@ -283,9 +293,9 @@ def test_jsonl_per_line_objects(self, tmp_path, capsys, create_cli_project): with open(os.path.join(step_dir, "synthesis.log"), "w") as f: f.write("Error: bad\nINFO: ok\nplain\n") - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) + rc = cli_main.run(["log", "synthesis", "--plain", "--project", project_dir]) assert rc == 0 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + objects = plain_records(capsys.readouterr().out) assert len(objects) == 3 for obj in objects: assert "step" in obj @@ -295,50 +305,6 @@ def test_jsonl_per_line_objects(self, tmp_path, capsys, create_cli_project): assert "line" in obj assert "inspect_cmd" in obj - def test_jsonl_no_ansi(self, tmp_path, capsys, create_cli_project): - project_dir = create_cli_project() - run_dir = os.path.join(project_dir, "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("Error: bad\n") - - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "\x1b[" not in out - - -class TestLogJsonMode: - """ecc log --json must produce JSON envelope output.""" - - def test_json_step_output(self, tmp_path, capsys, create_cli_project): - project_dir = create_cli_project() - run_dir = os.path.join(project_dir, "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("Error: bad\nINFO: ok\n") - - rc = cli_main.run(["log", "synthesis", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - assert len(data["records"]) == 2 - - def test_json_listing_output(self, tmp_path, capsys, create_cli_project): - project_dir = create_cli_project() - run_dir = os.path.join(project_dir, "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("content\n") - - rc = cli_main.run(["log", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - class TestLogListingMode: """AC-7: ecc log without step lists available logs.""" @@ -357,7 +323,9 @@ def test_listing_shows_logs(self, tmp_path, capsys, create_cli_project): assert "synthesis" in out assert "ecc log synthesis" in out - def test_listing_and_reading_yosys_lec_logs(self, tmp_path, capsys, create_cli_project): + def test_listing_and_reading_yosys_lec_logs( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") home = os.path.join(run_dir, "home") @@ -385,16 +353,16 @@ def test_listing_and_reading_yosys_lec_logs(self, tmp_path, capsys, create_cli_p with open(os.path.join(log_dir, f"{name}.log"), "w") as f: f.write(contents) - rc = cli_main.run(["log", "--json", "--project", project_dir]) + rc = cli_main.run(["log", "--plain", "--project", project_dir]) assert rc == 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert [record["step"] for record in records] == ["lec", "postroutelec"] - rc = cli_main.run(["log", "postroutelec", "--json", "--project", project_dir]) + rc = cli_main.run(["log", "postroutelec", "--plain", "--project", project_dir]) assert rc == 0 - assert json.loads(capsys.readouterr().out)["records"][0]["line"] == "post-route equivalence" + assert plain_records(capsys.readouterr().out)[0]["line"] == "post-route equivalence" def test_listing_no_logs_returns_no_log_status(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() @@ -406,7 +374,7 @@ def test_listing_no_logs_returns_no_log_status(self, tmp_path, capsys, create_cl out = capsys.readouterr().out assert "no_logs" in out - def test_listing_jsonl_records(self, tmp_path, capsys, create_cli_project): + def test_listing_plain_records(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") @@ -414,11 +382,9 @@ def test_listing_jsonl_records(self, tmp_path, capsys, create_cli_project): with open(os.path.join(step_dir, "synthesis.log"), "w") as f: f.write("content\n") - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + rc = cli_main.run(["log", "--plain", "--project", project_dir]) assert rc == 0 - objects = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] + objects = plain_records(capsys.readouterr().out) assert any("step" in o for o in objects) def test_listing_plain_step_logs(self, tmp_path, capsys, create_cli_project): @@ -467,17 +433,6 @@ def test_unknown_step_returns_nonzero(self, tmp_path, capsys, create_cli_project out = capsys.readouterr().out assert "unknown_step" in out - def test_unknown_step_json(self, tmp_path, capsys, create_cli_project): - project_dir = create_cli_project() - run_dir = os.path.join(project_dir, "default") - os.makedirs(run_dir, exist_ok=True) - - rc = cli_main.run(["log", "nonexistent", "--jsonl", "--project", project_dir]) - assert rc == 1 - record = json.loads(capsys.readouterr().out.strip()) - assert record["kind"] == "error" - assert record["error"] == "unknown_step" - def test_known_step_no_logs_returns_nonzero(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") @@ -488,16 +443,6 @@ def test_known_step_no_logs_returns_nonzero(self, tmp_path, capsys, create_cli_p out = capsys.readouterr().out assert "missing" in out - def test_known_step_no_logs_json(self, tmp_path, capsys, create_cli_project): - project_dir = create_cli_project() - run_dir = os.path.join(project_dir, "default") - os.makedirs(os.path.join(run_dir, "Synthesis_yosys"), exist_ok=True) - - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) - assert rc == 1 - record = json.loads(capsys.readouterr().out.strip()) - assert record["log_status"] == "missing" - def test_empty_log_returns_zero(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") @@ -523,7 +468,7 @@ def test_listing_disclosure_no_errors(self, tmp_path, capsys, create_cli_project with open(os.path.join(step_dir, "synthesis.log"), "w") as f: f.write("ok\n") - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + rc = cli_main.run(["log", "--plain", "--project", project_dir]) assert rc == 0 out = capsys.readouterr().out assert "--errors" not in out @@ -536,7 +481,7 @@ def test_step_log_inspect_no_errors(self, tmp_path, capsys, create_cli_project): with open(os.path.join(step_dir, "synthesis.log"), "w") as f: f.write("ok\n") - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) + rc = cli_main.run(["log", "synthesis", "--plain", "--project", project_dir]) assert rc == 0 out = capsys.readouterr().out assert "--errors" not in out @@ -574,23 +519,6 @@ def test_unreadable_log_returns_nonzero( out = capsys.readouterr().out assert "unreadable" in out - def test_unreadable_log_jsonl(self, tmp_path, monkeypatch, capsys, create_cli_project): - project_dir = create_cli_project() - run_dir = os.path.join(project_dir, "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - log_path = os.path.join(step_dir, "synthesis.log") - with open(log_path, "w") as f: - f.write("content\n") - _make_path_unreadable(monkeypatch, log_path) - - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) - assert rc == 1 - record = json.loads(capsys.readouterr().out.strip()) - assert record["log_status"] == "unreadable" - assert "source" in record - assert "error" in record - class TestLogMultiSource: """AC-1: Multiple log files per step shown with separate source headers.""" @@ -645,7 +573,12 @@ def _setup_steps_with_flow( return project_dir def test_steps_follow_flow_json_order( - self, tmp_path, capsys, create_cli_project, create_flow_json + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + plain_records, ): project_dir = self._setup_steps_with_flow( tmp_path, @@ -653,16 +586,19 @@ def test_steps_follow_flow_json_order( create_flow_json, ["Synthesis", "Floorplan", "CTS"], ) - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + rc = cli_main.run(["log", "--plain", "--project", project_dir]) assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] + records = plain_records(capsys.readouterr().out) steps = [r.get("step") for r in records if "step" in r] assert steps == ["synthesis", "floorplan", "cts"] def test_run_level_logs_before_step_logs( - self, tmp_path, capsys, create_cli_project, create_flow_json + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + plain_records, ): project_dir = self._setup_steps_with_flow( tmp_path, @@ -675,11 +611,9 @@ def test_run_level_logs_before_step_logs( os.makedirs(log_dir, exist_ok=True) with open(os.path.join(log_dir, "flow.log"), "w") as f: f.write("run-level log\n") - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + rc = cli_main.run(["log", "--plain", "--project", project_dir]) assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] + records = plain_records(capsys.readouterr().out) run_indices = [i for i, r in enumerate(records) if "log" in r and "step" not in r] step_indices = [i for i, r in enumerate(records) if "step" in r] assert run_indices, "expected at least one run-level record" @@ -687,7 +621,12 @@ def test_run_level_logs_before_step_logs( assert max(run_indices) < min(step_indices) def test_extra_steps_after_flow_steps( - self, tmp_path, capsys, create_cli_project, create_flow_json + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + plain_records, ): project_dir = self._setup_steps_with_flow( tmp_path, @@ -696,11 +635,9 @@ def test_extra_steps_after_flow_steps( ["Synthesis", "CTS"], extra_dirs=["Floorplan"], ) - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + rc = cli_main.run(["log", "--plain", "--project", project_dir]) assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] + records = plain_records(capsys.readouterr().out) steps = [r.get("step") for r in records if "step" in r] synth_idx = steps.index("synthesis") cts_idx = steps.index("cts") @@ -709,7 +646,12 @@ def test_extra_steps_after_flow_steps( assert cts_idx < fp_idx def test_extra_steps_sorted_alphabetically( - self, tmp_path, capsys, create_cli_project, create_flow_json + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + plain_records, ): project_dir = self._setup_steps_with_flow( tmp_path, @@ -718,17 +660,19 @@ def test_extra_steps_sorted_alphabetically( ["Synthesis"], extra_dirs=["Floorplan", "CTS"], ) - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + rc = cli_main.run(["log", "--plain", "--project", project_dir]) assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] + records = plain_records(capsys.readouterr().out) steps = [r.get("step") for r in records if "step" in r] extras = [s for s in steps if s != "synthesis"] assert extras == sorted(extras) def test_missing_flow_json_falls_back_to_alphabetical( - self, tmp_path, capsys, create_cli_project + self, + tmp_path, + capsys, + create_cli_project, + plain_records, ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") @@ -738,16 +682,18 @@ def test_missing_flow_json_falls_back_to_alphabetical( os.makedirs(step_dir, exist_ok=True) with open(os.path.join(step_dir, "test.log"), "w") as f: f.write("content\n") - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + rc = cli_main.run(["log", "--plain", "--project", project_dir]) assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] + records = plain_records(capsys.readouterr().out) steps = [r.get("step") for r in records if "step" in r] assert steps == sorted(steps) def test_corrupt_flow_json_falls_back_to_alphabetical( - self, tmp_path, capsys, create_cli_project + self, + tmp_path, + capsys, + create_cli_project, + plain_records, ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") @@ -760,11 +706,9 @@ def test_corrupt_flow_json_falls_back_to_alphabetical( os.makedirs(step_dir, exist_ok=True) with open(os.path.join(step_dir, "test.log"), "w") as f: f.write("content\n") - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + rc = cli_main.run(["log", "--plain", "--project", project_dir]) assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] + records = plain_records(capsys.readouterr().out) steps = [r.get("step") for r in records if "step" in r] assert steps == sorted(steps) @@ -855,34 +799,6 @@ def test_plain_no_tail(self, tmp_path, capsys, create_cli_project): out = capsys.readouterr().out assert "tail=" not in out - def test_json_no_tail(self, tmp_path, capsys, create_cli_project): - project_dir = create_cli_project() - run_dir = os.path.join(project_dir, "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("line 1\nline 2\n") - rc = cli_main.run(["log", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - for rec in data["records"]: - assert "tail" not in rec - - def test_jsonl_no_tail(self, tmp_path, capsys, create_cli_project): - project_dir = create_cli_project() - run_dir = os.path.join(project_dir, "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("line 1\nline 2\n") - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) - assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] - for rec in records: - assert "tail" not in rec - class TestLogStepUnchanged: """ecc log full output must remain unchanged.""" @@ -916,22 +832,6 @@ def test_step_plain_unchanged(self, tmp_path, capsys, create_cli_project): assert "line_no=3" in out assert "tail" not in out - def test_step_jsonl_unchanged(self, tmp_path, capsys, create_cli_project): - project_dir = create_cli_project() - run_dir = os.path.join(project_dir, "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("a\nb\n") - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) - assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] - assert len(records) == 2 - for rec in records: - assert "tail" not in rec - class TestLogListingUnreadable: """Unreadable logs in listing mode must omit tail, keep path+inspect, no traceback.""" diff --git a/test/cli/commands/test_manifest_discovery.py b/test/cli/commands/test_manifest_discovery.py index a7c2baffa..c5ef74dcc 100644 --- a/test/cli/commands/test_manifest_discovery.py +++ b/test/cli/commands/test_manifest_discovery.py @@ -15,7 +15,7 @@ def test_single_active_workspace_auto_selected(self, tmp_path, capsys, manifest_ (run_dir / "home").mkdir(parents=True) (run_dir / "home" / "flow.json").write_text('{"steps": []}') - rc = cli_main.run(["status", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["status", "--project", str(project_dir), "--plain"]) assert rc == 0 assert manifest_stubs.records()[0]["workspace"] == str(run_dir) @@ -35,7 +35,7 @@ def test_workspace_selects_by_declared_id(self, tmp_path, capsys, manifest_stubs (run_dir / "home" / "flow.json").write_text('{"steps": []}') rc = cli_main.run( - ["status", "--project", str(project_dir), "--workspace", "ws_0002", "--json"] + ["status", "--project", str(project_dir), "--workspace", "ws_0002", "--plain"] ) assert rc == 0 @@ -52,7 +52,7 @@ def test_multiple_workspaces_without_selector_errors(self, tmp_path, capsys, man ], ) - rc = cli_main.run(["status", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["status", "--project", str(project_dir), "--plain"]) assert rc != 0 (record,) = manifest_stubs.records() @@ -69,7 +69,7 @@ def test_nested_workspace_name_is_invalid_not_undeclared( manifest_stubs.write(project_dir, [manifest_stubs.entry(project_dir, "ws_0001")]) rc = cli_main.run( - ["status", "--project", str(project_dir), "--workspace", "sweeps/s1", "--json"] + ["status", "--project", str(project_dir), "--workspace", "sweeps/s1", "--plain"] ) assert rc != 0 @@ -86,7 +86,7 @@ def test_absolute_workspace_name_is_invalid_not_undeclared( manifest_stubs.write(project_dir, [manifest_stubs.entry(project_dir, "ws_0001")]) rc = cli_main.run( - ["status", "--project", str(project_dir), "--workspace", "/tmp/x", "--json"] + ["status", "--project", str(project_dir), "--workspace", "/tmp/x", "--plain"] ) assert rc != 0 @@ -101,7 +101,7 @@ def test_unknown_workspace_errors_with_declared_ids(self, tmp_path, capsys, mani manifest_stubs.write(project_dir, [manifest_stubs.entry(project_dir, "ws_0001")]) rc = cli_main.run( - ["status", "--project", str(project_dir), "--workspace", "nope", "--json"] + ["status", "--project", str(project_dir), "--workspace", "nope", "--plain"] ) assert rc != 0 @@ -123,7 +123,7 @@ def test_archived_workspace_not_auto_selected(self, tmp_path, capsys, manifest_s (run_dir / "home").mkdir(parents=True) (run_dir / "home" / "flow.json").write_text('{"steps": []}') - rc = cli_main.run(["status", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["status", "--project", str(project_dir), "--plain"]) assert rc == 0 assert manifest_stubs.records()[0]["workspace"] == str(run_dir) @@ -133,7 +133,7 @@ def test_invalid_manifest_errors(self, tmp_path, capsys, manifest_stubs): project_dir.mkdir() (project_dir / "project.json").write_text("{broken") - rc = cli_main.run(["status", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["status", "--project", str(project_dir), "--plain"]) assert rc != 0 (record,) = manifest_stubs.records() @@ -149,7 +149,7 @@ def test_check_reports_manifest_project( manifest_stubs.write(project_dir, [manifest_stubs.entry(project_dir, "ws_0001")]) minimal_ics55_pdk_factory(project_dir / "pdk") - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 records = manifest_stubs.records() @@ -165,7 +165,7 @@ def test_check_emits_legacy_hint_in_runs_project( project_dir = create_cli_project(pdk_root=pdk_root) os.makedirs(os.path.join(project_dir, "runs", "default")) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 0 records = manifest_stubs.records() @@ -179,7 +179,7 @@ def test_check_no_hint_in_virgin_project( pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") project_dir = create_cli_project(pdk_root=pdk_root) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 0 records = manifest_stubs.records() @@ -193,7 +193,7 @@ def test_check_no_hint_in_manifest_project( manifest_stubs.write(project_dir, [manifest_stubs.entry(project_dir, "ws_0001")]) minimal_ics55_pdk_factory(project_dir / "pdk") - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc == 0 records = manifest_stubs.records() @@ -206,7 +206,7 @@ def test_status_emits_legacy_hint_in_runs_project( run_dir = os.path.join(project_dir, "runs", "default") create_flow_json(run_dir, profile="main") - rc = cli_main.run(["status", "--project", project_dir, "--json"]) + rc = cli_main.run(["status", "--project", project_dir, "--plain"]) # Read-only commands resolve the managed path, not runs/: the # legacy workspace is reported missing, with the migration hint. @@ -241,7 +241,7 @@ def test_outcome_carries_hint( project_dir = create_cli_project(pdk_root=pdk_root) create_flow_json(os.path.join(project_dir, "runs", "default"), profile="main") - rc = cli_main.run([command, "--project", project_dir, "--json"]) + rc = cli_main.run([command, "--project", project_dir, "--plain"]) records = manifest_stubs.records() assert len(self._hints(records)) == 1 @@ -266,7 +266,7 @@ def test_run_refusal_carries_hint( project_dir = create_cli_project(pdk_root=pdk_root) os.makedirs(os.path.join(project_dir, "runs", ".keep"), exist_ok=True) - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) # Legacy projects must migrate first: run refuses before any work. assert rc != 0 @@ -280,7 +280,7 @@ def test_status_missing_flow_carries_hint( project_dir = create_cli_project() os.makedirs(os.path.join(project_dir, "runs", "default")) - rc = cli_main.run(["status", "--project", project_dir, "--json"]) + rc = cli_main.run(["status", "--project", project_dir, "--plain"]) assert rc != 0 records = manifest_stubs.records() @@ -296,7 +296,7 @@ def test_status_corrupt_flow_carries_hint( with open(os.path.join(home, "flow.json"), "w") as f: f.write("{broken") - rc = cli_main.run(["status", "--project", project_dir, "--json"]) + rc = cli_main.run(["status", "--project", project_dir, "--plain"]) # The corrupt legacy ledger is never read: status resolves the # managed path and reports the workspace missing. @@ -319,7 +319,7 @@ def test_run_failure_flow_never_runs_on_legacy( os.makedirs(os.path.join(project_dir, "runs", ".keep"), exist_ok=True) flow_mocks.flow.run_steps_value = False - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) # The engine never starts on a legacy project — not even to fail. assert rc != 0 @@ -338,7 +338,7 @@ def test_config_error_carries_hint( with open(os.path.join(project_dir, "ecc.toml"), "a") as f: f.write("\n[params.cts]\nmax_fanout = 0\n") - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc != 0 assert len(self._hints(manifest_stubs.records())) == 1 @@ -360,7 +360,7 @@ def test_manifest_project_never_hints( home.mkdir(parents=True) (home / "flow.json").write_text('{"steps": []}') - rc = cli_main.run([command, "--project", str(project_dir), "--json"]) + rc = cli_main.run([command, "--project", str(project_dir), "--plain"]) assert rc == 0 assert self._hints(manifest_stubs.records()) == [] @@ -378,7 +378,7 @@ def test_manifest_run_never_hints( manifest_stubs.write(project_dir, [manifest_stubs.entry(project_dir, "ws_0001")]) minimal_ics55_pdk_factory(project_dir / "pdk") - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--plain"]) assert rc == 0 assert self._hints(manifest_stubs.records()) == [] @@ -394,7 +394,7 @@ def _manifest_project(self, manifest_stubs, tmp_path): def test_param_list_requires_ecc_toml(self, tmp_path, capsys, manifest_stubs): project_dir = self._manifest_project(manifest_stubs, tmp_path) - rc = cli_main.run(["param", "list", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["param", "list", "--project", str(project_dir), "--plain"]) assert rc != 0 (record,) = manifest_stubs.records() @@ -404,7 +404,7 @@ def test_param_set_requires_ecc_toml(self, tmp_path, capsys, manifest_stubs): project_dir = self._manifest_project(manifest_stubs, tmp_path) rc = cli_main.run( - ["param", "set", "cts.max_fanout", "16", "--project", str(project_dir), "--json"] + ["param", "set", "cts.max_fanout", "16", "--project", str(project_dir), "--plain"] ) assert rc != 0 @@ -427,7 +427,7 @@ def test_check_errors_when_workspace_selection_ambiguous( ], ) - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir), "--plain"]) assert rc != 0 (record,) = manifest_stubs.records() @@ -441,7 +441,7 @@ def test_check_ok_with_single_workspace( manifest_stubs.write(project_dir, [manifest_stubs.entry(project_dir, "ws_0001")]) minimal_ics55_pdk_factory(project_dir / "pdk") - rc = cli_main.run(["check", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["check", "--project", str(project_dir)]) assert rc == 0 @@ -466,7 +466,7 @@ def test_hybrid_check_errors_on_ambiguous_selection( ) ) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc != 0 (record,) = manifest_stubs.records() diff --git a/test/cli/commands/test_manifest_run.py b/test/cli/commands/test_manifest_run.py index eeed0acb7..e17e62fc5 100644 --- a/test/cli/commands/test_manifest_run.py +++ b/test/cli/commands/test_manifest_run.py @@ -11,7 +11,7 @@ def test_virgin_run_generates_manifest_at_root_layout( ): project_dir = create_cli_project() - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc == 0 run_dir = os.path.join(project_dir, "default") @@ -60,7 +60,7 @@ def test_virgin_run_set_values_stay_out_of_manifest( ): project_dir = create_cli_project() - rc = cli_main.run(["run", "--project", project_dir, "--set", "cts.max_fanout=16", "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--set", "cts.max_fanout=16"]) assert rc == 0 manifest = json.loads((tmp_path / "gcd" / "project.json").read_text()) @@ -73,7 +73,7 @@ def test_virgin_run_failed_writes_back_failed( flow_mocks.flow.run_steps_value = False project_dir = create_cli_project() - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir]) assert rc != 0 manifest = json.loads((tmp_path / "gcd" / "project.json").read_text()) @@ -84,7 +84,7 @@ def test_virgin_run_rejects_nested_workspace_name( ): project_dir = create_cli_project() - rc = cli_main.run(["run", "--project", project_dir, "--workspace", "sweeps/s1", "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--workspace", "sweeps/s1", "--plain"]) assert rc != 0 (record,) = manifest_stubs.records() @@ -100,7 +100,7 @@ def test_virgin_run_fails_manifest_invalid_when_manifest_path_is_a_directory( # a silent virgin demotion. os.mkdir(os.path.join(project_dir, "project.json")) - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc != 0 records = manifest_stubs.records() @@ -128,7 +128,7 @@ def test_run_rejects_canonical_alias_of_a_declared_symlinked_workspace( ], ) - rc = cli_main.run(["run", "--project", project_dir, "--workspace", "actual", "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--workspace", "actual", "--plain"]) assert rc != 0 records = manifest_stubs.records() @@ -144,11 +144,11 @@ def test_virgin_run_fails_loud_when_manifest_registration_fails( # project.json): a loud error before any workspace creation — never # a quiet success. monkeypatch.setattr( - "chipcompiler.cli.project.manifest.write_manifest_if_absent", + "chipcompiler.cli.project.manifest_write.write_manifest_if_absent", lambda *args, **kwargs: False, ) - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc != 0 (record,) = manifest_stubs.records() @@ -165,7 +165,7 @@ def test_undeclared_workspace_registers_and_creates_at_root( project_dir.mkdir() manifest_stubs.write(project_dir, [manifest_stubs.entry(project_dir, "ws_0001")]) - rc = cli_main.run(["run", "--project", str(project_dir), "--workspace", "exp2", "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--workspace", "exp2", "--plain"]) assert rc == 0 assert flow_mocks.capture["create_kwargs"]["directory"] == str(project_dir / "exp2") @@ -184,7 +184,7 @@ def test_declared_workspace_run_writes_back_status( project_dir.mkdir() manifest_stubs.write(project_dir, [manifest_stubs.entry(project_dir, "ws_0001")]) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 assert flow_mocks.capture["create_kwargs"]["directory"] == str(project_dir / "ws_0001") @@ -204,11 +204,11 @@ def test_write_back_failure_degrades_to_warning( project_dir, [manifest_stubs.entry(project_dir, "ws_0001", status="running")] ) monkeypatch.setattr( - "chipcompiler.cli.project.manifest.write_back_workspace_status", + "chipcompiler.cli.project.manifest_write.write_back_workspace_status", lambda project_dir, workspace_id, status: False, ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--plain"]) assert rc == 0 records = manifest_stubs.records() @@ -253,7 +253,7 @@ def test_manifest_only_relative_def_resolved_against_project( ): project_dir = self._project(manifest_stubs, tmp_path, "inputs/gcd.def", hybrid=False) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 assert flow_mocks.capture["create_kwargs"]["origin_def"] == str( @@ -266,7 +266,7 @@ def test_hybrid_relative_def_resolved_against_project( project_dir = self._project(manifest_stubs, tmp_path, "inputs/gcd.def", hybrid=True) flow_mocks.flow.has_init_value = True - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 assert flow_mocks.capture["create_kwargs"]["origin_def"] == str( @@ -278,7 +278,7 @@ def test_absolute_def_preserved(self, tmp_path, capsys, flow_mocks, manifest_stu project_dir = self._project(manifest_stubs, tmp_path, absolute, hybrid=True) flow_mocks.flow.has_init_value = True - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 assert flow_mocks.capture["create_kwargs"]["origin_def"] == absolute @@ -309,7 +309,7 @@ def test_ecc_toml_overlays_manifest_base(self, tmp_path, capsys, flow_mocks, man + '"\n\n[flow]\npreset = "rtl2gds"\n' ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 parameters = flow_mocks.capture["create_kwargs"]["parameters"] @@ -345,7 +345,7 @@ def test_manifest_origin_verilog_fallback(self, tmp_path, capsys, flow_mocks): } (project_dir / "project.json").write_text(json.dumps(document)) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 assert flow_mocks.capture["create_kwargs"]["origin_verilog"].endswith("src/gcd.v") @@ -376,7 +376,7 @@ def test_empty_flow_ledger_is_an_error( {"preset": "rtl2gds"}, ) - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc != 0 (record,) = manifest_stubs.records() @@ -398,7 +398,7 @@ def test_partial_ecc_toml_filled_from_manifest_base( '[design]\nfrequency_mhz = 200.0\n\n[flow]\npreset = "rtl2gds"\n' ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 kwargs = flow_mocks.capture["create_kwargs"] @@ -432,7 +432,7 @@ def test_project_flow_preset_outranks_manifest_entry_range( '\n[flow]\npreset = "rtl2gds"\n' ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 kwargs = flow_mocks.capture["create_kwargs"] @@ -451,7 +451,7 @@ def test_diverging_layers_emit_warning( '\n[flow]\npreset = "rtl2gds"\n' ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir), "--plain"]) assert rc == 0 records = manifest_stubs.records() @@ -481,14 +481,16 @@ def losing_write(project_dir_arg, document): return False monkeypatch.setattr( - "chipcompiler.cli.project.manifest.write_manifest_if_absent", losing_write + "chipcompiler.cli.project.manifest_write.write_manifest_if_absent", losing_write ) - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc != 0 (record,) = manifest_stubs.records() - assert record["error"] == "workspace_registration_failed" + # The lost create race falls through to the locked registration, + # which classifies the same-id winner at another path as a conflict. + assert record["error"] == "workspace_conflict" assert flow_mocks.capture["create_kwargs"] is None winner = json.loads((Path(project_dir) / "project.json").read_text()) # Our run never wrote its status into the other path's entry. @@ -517,7 +519,7 @@ def test_run_coerced_bool_reaches_create_workspace( }, ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 parameters = flow_mocks.capture["create_kwargs"]["parameters"] @@ -541,7 +543,7 @@ def test_run_set_overrides_invalid_manifest_bool( ) rc = cli_main.run( - ["run", "--project", str(project_dir), "--set", "flow.run_analysis=false", "--json"] + ["run", "--project", str(project_dir), "--set", "flow.run_analysis=false"] ) assert rc == 0 @@ -572,7 +574,7 @@ def test_run_explicit_design_frequency_overrides_invalid_manifest_frequency( + '"\n\n[flow]\npreset = "rtl2gds"\n' ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 parameters = flow_mocks.capture["create_kwargs"]["parameters"] @@ -601,7 +603,7 @@ def test_run_params_override_supersedes_invalid_manifest_fanout( + '"\n\n[flow]\npreset = "rtl2gds"\n\n[params.cts]\nmax_fanout = 20\n' ) - rc = cli_main.run(["run", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["run", "--project", str(project_dir)]) assert rc == 0 parameters = flow_mocks.capture["create_kwargs"]["parameters"] diff --git a/test/cli/commands/test_migrate.py b/test/cli/commands/test_migrate.py index c9b65cbce..2bd767a07 100644 --- a/test/cli/commands/test_migrate.py +++ b/test/cli/commands/test_migrate.py @@ -1,3 +1,4 @@ +import ast import json import os from pathlib import Path @@ -6,7 +7,21 @@ def _records(capsys): - return json.loads(capsys.readouterr().out)["records"] + import shlex + + records = [] + buffer = "" + for line in capsys.readouterr().out.splitlines(): + buffer = f"{buffer}\n{line}" if buffer else line + try: + fields = shlex.split(buffer) + except ValueError: + continue + records.append(dict(field.split("=", 1) for field in fields)) + buffer = "" + if buffer: + raise ValueError(f"unparseable --plain output: {buffer!r}") + return records def _manifest(project_dir): @@ -27,7 +42,7 @@ def test_full_migration_moves_rebases_and_registers( project_dir = create_cli_project(pdk_root=pdk_root) run_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc == 0 target = os.path.join(project_dir, "exp1") @@ -85,7 +100,7 @@ def test_non_tty_requires_yes( run_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) monkeypatch.setattr("sys.stdin.isatty", lambda: False) - rc = cli_main.run(["migrate", "--project", project_dir, "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--plain"]) assert rc != 0 records = _records(capsys) @@ -127,7 +142,7 @@ def test_resume_appends_missing_entries( with open(os.path.join(project_dir, "project.json"), "w") as f: json.dump(document, f) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) assert rc == 0 manifest = _manifest(project_dir) @@ -148,7 +163,7 @@ def test_already_migrated_noop(self, tmp_path, capsys, create_cli_project): with open(os.path.join(project_dir, "project.json"), "w") as f: json.dump(document, f) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc == 0 (record,) = _records(capsys) @@ -161,7 +176,7 @@ def test_malformed_manifest_fails_instead_of_reporting_already_migrated( with open(os.path.join(project_dir, "project.json"), "w") as f: f.write('{"schema_version": 1, "workspaces": "not-a-list"}') - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc == 1 (record,) = _records(capsys) @@ -180,7 +195,7 @@ def test_collision_skips_that_workspace( run_dir = create_legacy_workspace(project_dir, pdk_root, "rtl", ["Success", "Success"]) create_legacy_workspace(project_dir, pdk_root, "exp2", ["Success", "Success"]) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 records = _records(capsys) @@ -213,7 +228,7 @@ def failing_refresh(workspace): failing_refresh, ) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 records = _records(capsys) @@ -242,7 +257,7 @@ def test_migrate_rejects_broken_ecc_toml( with open(f"{project_dir}/ecc.toml", "a") as f: f.write('\n[params.cts]\nmax_fanout = "loud"\n') - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 records = _records(capsys) @@ -265,7 +280,7 @@ def test_registration_failure_moves_batch_back( run2 = create_legacy_workspace(project_dir, pdk_root, "exp2", ["Success", "Success"]) monkeypatch.setattr( - "chipcompiler.cli.project.manifest.write_manifest_if_absent", + "chipcompiler.cli.project.manifest_write.write_manifest_if_absent", lambda *a, **k: False, ) monkeypatch.setattr( @@ -273,7 +288,7 @@ def test_registration_failure_moves_batch_back( lambda _dir: None, ) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 records = _records(capsys) @@ -301,7 +316,7 @@ def test_invalid_existing_manifest_fails_before_any_move( with open(os.path.join(project_dir, "project.json"), "w") as f: f.write("{broken") - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 (record,) = _records(capsys) @@ -320,7 +335,7 @@ def test_incomplete_outranks_ongoing_in_status_mapping( project_dir = create_cli_project(pdk_root=pdk_root) create_legacy_workspace(project_dir, pdk_root, "exp1", ["Ongoing", "Incomplete"]) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) assert rc == 0 manifest = _manifest(project_dir) @@ -357,7 +372,7 @@ def test_legacy_pdk_config_path_rebased_after_move( long_keys["PDK Config"] = legacy["PDK Config"] Path(run_dir, "home", "parameters.json").write_text(_json.dumps(long_keys)) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc == 0 from chipcompiler.data.parameter import load_parameter as lp @@ -365,6 +380,39 @@ def test_legacy_pdk_config_path_rebased_after_move( moved = lp(Path(project_dir, "exp1", "home", "params.toml")) assert moved.data["pdk_config"] == os.path.join(project_dir, "exp1", "home", "pdk.json") + def test_flow_step_info_paths_rebased_after_move( + self, + tmp_path, + capsys, + create_cli_project, + minimal_ics55_pdk_factory, + create_legacy_workspace, + ): + # STA-entry workspaces persist the declared SPEF (and LEC workspaces + # the golden netlist) as absolute origin/ paths in flow.json step + # info; the move must rebase them or the reloaded workspace reads + # files that no longer exist. + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + project_dir = create_cli_project(pdk_root=pdk_root) + run_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) + flow_path = Path(run_dir, "home", "flow.json") + flow_data = json.loads(flow_path.read_text()) + flow_data["steps"][0].setdefault("info", {})["spef"] = os.path.join( + run_dir, "origin", "gcd.spef" + ) + flow_data["steps"][0]["info"]["golden_verilog"] = os.path.join( + run_dir, "origin", "golden_gcd.v" + ) + flow_path.write_text(json.dumps(flow_data)) + + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) + + assert rc == 0 + moved = json.loads(Path(project_dir, "exp1", "home", "flow.json").read_text()) + info = moved["steps"][0]["info"] + assert info["spef"] == os.path.join(project_dir, "exp1", "origin", "gcd.spef") + assert info["golden_verilog"] == os.path.join(project_dir, "exp1", "origin", "golden_gcd.v") + class TestMigrationPlanningRobustness: """Malformed workspace state never crashes planning: a MISSING ledger @@ -395,7 +443,7 @@ def test_missing_flow_json_migrates_with_not_started_defaults( run_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) Path(run_dir, "home", "flow.json").unlink() - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) assert rc == 0 assert not os.path.exists(run_dir) @@ -416,7 +464,7 @@ def test_non_object_flow_json_is_blocked( # JSON-valid but not an object: unreadable as a flow ledger. Path(run_dir, "home", "flow.json").write_text("[]") - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) self._assert_blocked(rc, capsys, project_dir, run_dir) @@ -433,7 +481,7 @@ def test_undecodable_flow_json_is_blocked( run_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) Path(run_dir, "home", "flow.json").write_bytes(b"\xff") - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) self._assert_blocked(rc, capsys, project_dir, run_dir) @@ -452,7 +500,7 @@ def test_nameless_step_record_is_blocked( json.dumps({"steps": [{"name": "", "state": "Success"}]}) ) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) self._assert_blocked(rc, capsys, project_dir, run_dir) @@ -471,7 +519,7 @@ def test_steps_less_flow_object_is_blocked( # one, so this is hand-made or corrupt state. Path(run_dir, "home", "flow.json").write_text("{}") - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) self._assert_blocked(rc, capsys, project_dir, run_dir) @@ -490,7 +538,7 @@ def test_resume_with_only_blocked_workspaces_is_not_already_migrated( Path(run_dir, "home", "flow.json").write_bytes(b"\xff") manifest_stubs.write(Path(project_dir), []) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) # Blocked workspaces ARE left in runs/: reporting already_migrated # would claim otherwise. @@ -517,7 +565,7 @@ def test_undecodable_manifest_is_a_recorded_error_not_a_crash( # fail BEFORE the first rename, not escape as UnicodeDecodeError. Path(project_dir, "project.json").write_bytes(b"\xff") - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 (failure,) = [r for r in _records(capsys) if r.get("error") == "manifest_invalid"] @@ -542,7 +590,7 @@ def test_yes_discloses_and_executes_the_exact_create_document( project_dir = create_cli_project(pdk_root=pdk_root) create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc == 0 records = _records(capsys) @@ -556,7 +604,7 @@ def test_yes_discloses_and_executes_the_exact_create_document( ] (create,) = [r for r in records if r.get("manifest") == "create"] # Execution consumed the previewed document byte-for-byte. - assert create["document"] == _manifest(project_dir) + assert ast.literal_eval(create["document"]) == _manifest(project_dir) def test_non_tty_refusal_discloses_preview_without_mutation( self, @@ -572,12 +620,13 @@ def test_non_tty_refusal_discloses_preview_without_mutation( run_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) monkeypatch.setattr("sys.stdin.isatty", lambda: False) - rc = cli_main.run(["migrate", "--project", project_dir, "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--plain"]) assert rc != 0 records = _records(capsys) (create,) = [r for r in records if r.get("manifest") == "create"] - assert [w["workspace_id"] for w in create["document"]["workspaces"]] == ["exp1"] + document = ast.literal_eval(create["document"]) + assert [w["workspace_id"] for w in document["workspaces"]] == ["exp1"] assert records[-1]["error"] == "confirmation_required" # Disclosure only: nothing moved, nothing created. assert os.path.exists(run_dir) @@ -598,7 +647,7 @@ def test_tty_accept_renders_preview_and_executes( monkeypatch.setattr("sys.stdin.isatty", lambda: True) monkeypatch.setattr("builtins.input", lambda prompt="": "y") - rc = cli_main.run(["migrate", "--project", project_dir, "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir]) assert rc == 0 # The TTY render shows the full manifest document, not an id summary. @@ -637,7 +686,7 @@ def test_resume_append_disclosed_in_preview( with open(os.path.join(project_dir, "project.json"), "w") as f: json.dump(document, f) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc == 0 records = _records(capsys) @@ -646,7 +695,7 @@ def test_resume_append_disclosed_in_preview( w for w in _manifest(project_dir)["workspaces"] if w["workspace_id"] == "exp2" ] # The applied entry IS the previewed entry, complete and verbatim. - (previewed,) = append["workspaces"] + (previewed,) = ast.literal_eval(append["workspaces"]) assert appended == previewed def test_mixed_result_baseline_follows_first_success( @@ -676,7 +725,7 @@ def selective_refresh(workspace): monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", selective_refresh) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) assert rc != 0 manifest = _manifest(project_dir) @@ -714,7 +763,7 @@ def test_resume_with_huge_integer_mpc_index_does_not_crash( }, ) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) assert rc == 0 assert not os.path.exists(os.path.join(project_dir, "runs", "exp1")) @@ -773,7 +822,7 @@ def test_non_contiguous_legacy_flow_is_refused_not_registered( ) ) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc == 1 errors = [r for r in _records(capsys) if r.get("error") == "migration_unsupported"] diff --git a/test/cli/commands/test_migrate_guards.py b/test/cli/commands/test_migrate_guards.py index b8f22130c..27f24f83d 100644 --- a/test/cli/commands/test_migrate_guards.py +++ b/test/cli/commands/test_migrate_guards.py @@ -7,8 +7,8 @@ from chipcompiler.cli import main as cli_main -def _records(capsys): - return json.loads(capsys.readouterr().out)["records"] +def _records(capsys, plain_records): + return plain_records(capsys.readouterr().out) def _tree_snapshot(root): @@ -52,6 +52,7 @@ def test_replaced_before_content_phase_is_refused( minimal_ics55_pdk_factory, monkeypatch, create_legacy_workspace, + plain_records, ): import shutil @@ -76,10 +77,12 @@ def swapping_screen(source): monkeypatch.setattr(migrate_fs, "_unsafe_workspace_source", swapping_screen) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - (failure,) = [r for r in _records(capsys) if r.get("error") == "migration_failed"] + (failure,) = [ + r for r in _records(capsys, plain_records) if r.get("error") == "migration_failed" + ] assert "replaced during migration" in failure["reason"] # The pre-content gate fired: the replacement was never loaded or # registered, and sits untouched for manual inspection. @@ -94,6 +97,7 @@ def test_replaced_during_content_phase_is_never_registered( minimal_ics55_pdk_factory, monkeypatch, create_legacy_workspace, + plain_records, ): import shutil @@ -119,10 +123,10 @@ def swapping_refresh(workspace): monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", swapping_refresh) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - records = _records(capsys) + records = _records(capsys, plain_records) (failure,) = [r for r in records if r.get("error") == "migration_failed"] assert "NOT registered" in failure["reason"] # The registration gate caught the identity change: project.json was @@ -139,6 +143,7 @@ def test_missing_target_with_unproven_source_is_incomplete_rollback( minimal_ics55_pdk_factory, monkeypatch, create_legacy_workspace, + plain_records, ): import shutil @@ -158,10 +163,12 @@ def failing_refresh(workspace): monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", failing_refresh) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - (failure,) = [r for r in _records(capsys) if r.get("error") == "migration_failed"] + (failure,) = [ + r for r in _records(capsys, plain_records) if r.get("error") == "migration_failed" + ] assert "rollback incomplete" in failure["reason"] # The unconfirmed replacement was never reverse-rebased. current = json.loads((Path(run_dir) / "home" / "home.json").read_text()) @@ -208,7 +215,7 @@ def test_second_migrate_waits_for_the_first_lock( create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) thread, released = _hold_lock_briefly(project_dir) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) thread.join() assert rc == 0 @@ -223,7 +230,7 @@ def test_run_creation_waits_for_migration_lock( project_dir = create_cli_project() thread, released = _hold_lock_briefly(project_dir) - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) thread.join() assert rc == 0 @@ -247,6 +254,7 @@ def test_stale_legacy_run_does_not_recreate_migrated_workspace( minimal_ics55_pdk_factory, create_legacy_workspace, monkeypatch, + plain_records, ): pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") project_dir = create_cli_project(pdk_root=pdk_root) @@ -263,10 +271,10 @@ def fake_create_workspace(**kwargs): # A legacy project is refused BEFORE any locked decision, so no # stale-classification window exists: the run never mutates the # tree, and the legacy workspace stays exactly where it was. - rc = cli_main.run(["run", "--project", project_dir, "--workspace", "exp1", "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--workspace", "exp1", "--plain"]) assert rc != 0 - records = _records(capsys) + records = _records(capsys, plain_records) assert any(r.get("error") == "legacy_workspace_migration_required" for r in records) assert any( r.get("warning") == "legacy_layout_detected" and "ecc migrate" in r.get("migrate", "") @@ -286,6 +294,7 @@ def test_retargeted_project_symlink_is_refused_before_any_move( minimal_ics55_pdk_factory, create_legacy_workspace, monkeypatch, + plain_records, ): import chipcompiler.cli.project.migrate_plan as migrate_module @@ -310,10 +319,12 @@ def retargeting_plan(project_dir_arg): monkeypatch.setattr(migrate_module, "plan_migration", retargeting_plan) - rc = cli_main.run(["migrate", "--project", link, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", link, "--yes", "--plain"]) assert rc != 0 - (failure,) = [r for r in _records(capsys) if r.get("error") == "migration_failed"] + (failure,) = [ + r for r in _records(capsys, plain_records) if r.get("error") == "migration_failed" + ] assert failure["reason"] == "project directory changed after preview" # Nothing was moved into the retargeted destination and nothing was # written anywhere: the real workspace stays under runs/. @@ -338,6 +349,7 @@ def test_corrupt_home_json_does_not_crash_rollback( create_legacy_workspace, monkeypatch, payload, + plain_records, ): pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") project_dir = create_cli_project(pdk_root=pdk_root) @@ -352,10 +364,10 @@ def corrupting_refresh(workspace): monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", corrupting_refresh) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - records = _records(capsys) + records = _records(capsys, plain_records) assert any(r.get("error") == "migration_failed" for r in records) # The move was rolled back; nothing was registered. assert os.path.isfile(os.path.join(project_dir, "runs", "exp1", "home", "flow.json")) @@ -369,6 +381,7 @@ def test_manifest_lock_failure_rolls_back_the_moves( minimal_ics55_pdk_factory, create_legacy_workspace, manifest_stubs, + plain_records, ): pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") project_dir = create_cli_project(pdk_root=pdk_root) @@ -378,10 +391,10 @@ def test_manifest_lock_failure_rolls_back_the_moves( # to False and the moved workspace rolls back instead of stranding. os.mkdir(os.path.join(project_dir, ".manifest.lock")) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - records = _records(capsys) + records = _records(capsys, plain_records) assert any(r.get("error") == "manifest_update_failed" for r in records) assert any(r.get("error") == "migration_rolled_back" for r in records) assert os.path.isfile(os.path.join(project_dir, "runs", "exp1", "home", "flow.json")) @@ -429,7 +442,7 @@ def test_migrate_waits_for_an_active_workspace_execution( create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) thread, released = _hold_workspace_lock_briefly(os.path.join(project_dir, "runs", "exp1")) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) thread.join() assert rc == 0 @@ -445,6 +458,7 @@ def test_rollback_with_unrestored_content_reports_incomplete( create_legacy_workspace, manifest_stubs, monkeypatch, + plain_records, ): pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") project_dir = create_cli_project(pdk_root=pdk_root) @@ -461,10 +475,10 @@ def corrupting_update(project_dir_arg, mutator): monkeypatch.setattr("chipcompiler.cli.project.migrate.update_manifest", corrupting_update) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - records = _records(capsys) + records = _records(capsys, plain_records) assert any(r.get("error") == "migration_rollback_incomplete" for r in records) assert not any(r.get("error") == "migration_rolled_back" for r in records) assert os.path.isfile(os.path.join(project_dir, "runs", "exp1", "home", "flow.json")) diff --git a/test/cli/commands/test_migrate_safety.py b/test/cli/commands/test_migrate_safety.py index 0985cc3a1..e73e67ab8 100644 --- a/test/cli/commands/test_migrate_safety.py +++ b/test/cli/commands/test_migrate_safety.py @@ -7,8 +7,8 @@ from chipcompiler.cli import main as cli_main -def _records(capsys): - return json.loads(capsys.readouterr().out)["records"] +def _records(capsys, plain_records): + return plain_records(capsys.readouterr().out) def _manifest(project_dir): @@ -56,17 +56,21 @@ class TestMigrationSymlinkSafety: project-external tree — at discovery, at move time, and after rename.""" def test_symlinked_run_source_never_mutates_external_tree( - self, tmp_path, capsys, create_cli_project + self, + tmp_path, + capsys, + create_cli_project, + plain_records, ): project_dir = create_cli_project() external = _external_workspace(tmp_path) os.symlink(external, os.path.join(project_dir, "runs", "linked")) before = _tree_snapshot(external) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - records = _records(capsys) + records = _records(capsys, plain_records) (failure,) = [r for r in records if r.get("error") == "migration_failed"] assert failure["run"] == "linked" # The external tree is untouched (bytes, dirs, and symlinks), the @@ -83,6 +87,7 @@ def test_real_sibling_migrates_while_unsafe_entry_stays( create_cli_project, minimal_ics55_pdk_factory, create_legacy_workspace, + plain_records, ): pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") project_dir = create_cli_project(pdk_root=pdk_root) @@ -91,10 +96,10 @@ def test_real_sibling_migrates_while_unsafe_entry_stays( os.symlink(external, os.path.join(project_dir, "runs", "linked")) before = _tree_snapshot(external) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - records = _records(capsys) + records = _records(capsys, plain_records) assert any( r.get("error") == "migration_failed" and r.get("run") == "linked" for r in records ) @@ -116,6 +121,7 @@ def test_source_substitution_after_preview_is_rejected( minimal_ics55_pdk_factory, monkeypatch, create_legacy_workspace, + plain_records, ): import shutil @@ -138,10 +144,10 @@ def swapping_plan(project_dir_arg): monkeypatch.setattr(migrate_module, "plan_migration", swapping_plan) before = _tree_snapshot(external) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - records = _records(capsys) + records = _records(capsys, plain_records) (failure,) = [r for r in records if r.get("error") == "migration_failed"] assert "unsafe run source" in failure["reason"] # The execution-time revalidation rejected the swapped source @@ -178,7 +184,11 @@ class TestMigrationIdentityBinding: sources, and absent targets — across the plan→confirm→execute window.""" def test_symlinked_runs_container_refused_before_enumeration( - self, tmp_path, capsys, create_cli_project + self, + tmp_path, + capsys, + create_cli_project, + plain_records, ): project_dir = create_cli_project() external_runs = _external_runs_with_workspace(tmp_path) @@ -186,10 +196,12 @@ def test_symlinked_runs_container_refused_before_enumeration( os.symlink(external_runs, os.path.join(project_dir, "runs")) before = _tree_snapshot(external_runs) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - (failure,) = [r for r in _records(capsys) if r.get("error") == "migration_failed"] + (failure,) = [ + r for r in _records(capsys, plain_records) if r.get("error") == "migration_failed" + ] assert "unsafe runs container" in failure["reason"] # Nothing enumerated or moved: the external tree is identical, # nothing landed at the project root, no manifest was written. @@ -206,6 +218,7 @@ def test_real_source_substitution_after_preview_fails( minimal_ics55_pdk_factory, monkeypatch, create_legacy_workspace, + plain_records, ): import shutil @@ -230,10 +243,12 @@ def swapping_plan(project_dir_arg): monkeypatch.setattr(migrate_module, "plan_migration", swapping_plan) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - (failure,) = [r for r in _records(capsys) if r.get("error") == "migration_failed"] + (failure,) = [ + r for r in _records(capsys, plain_records) if r.get("error") == "migration_failed" + ] assert failure["reason"] == "run source changed after preview" # The substitute was never migrated or rebased: it sits unchanged # at the runs/ path, and no manifest was written. @@ -250,6 +265,7 @@ def test_target_appearance_after_preview_is_collision( minimal_ics55_pdk_factory, monkeypatch, create_legacy_workspace, + plain_records, ): import chipcompiler.cli.project.migrate_plan as migrate_module @@ -275,10 +291,10 @@ def appearing_plan(project_dir_arg): monkeypatch.setattr(migrate_module, "plan_migration", appearing_plan) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - records = _records(capsys) + records = _records(capsys, plain_records) (collision,) = [r for r in records if r.get("error") == "migration_collision"] assert collision["run"] == "exp1" # The appearing object is UNTOUCHED and the source stays under runs/. @@ -306,6 +322,7 @@ def test_substitution_reaching_the_move_is_rejected_after_it( minimal_ics55_pdk_factory, monkeypatch, create_legacy_workspace, + plain_records, ): import shutil @@ -329,10 +346,12 @@ def swapping_move(src_fd, src_name, dst_fd, dst_name): monkeypatch.setattr(migrate_fs, "move_noreplace", swapping_move) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - (failure,) = [r for r in _records(capsys) if r.get("error") == "migration_failed"] + (failure,) = [ + r for r in _records(capsys, plain_records) if r.get("error") == "migration_failed" + ] assert "identity changed after rename" in failure["reason"] # The post-move rejection moved the substitute back untouched: # no rebase/refresh reached it, and nothing stayed at the root. @@ -348,6 +367,7 @@ def test_container_replacement_after_validation_refuses_batch( minimal_ics55_pdk_factory, monkeypatch, create_legacy_workspace, + plain_records, ): import shutil @@ -372,10 +392,12 @@ def swapping_plan(project_dir_arg): monkeypatch.setattr(migrate_module, "plan_migration", swapping_plan) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - (failure,) = [r for r in _records(capsys) if r.get("error") == "migration_failed"] + (failure,) = [ + r for r in _records(capsys, plain_records) if r.get("error") == "migration_failed" + ] assert failure["reason"] == "runs/ container changed after preview" # The batch was refused before any move: nothing at the root, no # manifest, and the replacement container is untouched. @@ -391,6 +413,7 @@ def test_reappeared_source_is_never_touched_by_rollback( minimal_ics55_pdk_factory, monkeypatch, create_legacy_workspace, + plain_records, ): pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") project_dir = create_cli_project(pdk_root=pdk_root) @@ -406,10 +429,12 @@ def failing_refresh(workspace): monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", failing_refresh) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - (failure,) = [r for r in _records(capsys) if r.get("error") == "migration_failed"] + (failure,) = [ + r for r in _records(capsys, plain_records) if r.get("error") == "migration_failed" + ] assert "rollback incomplete" in failure["reason"] # The replacement was NEVER reverse-rebased or refreshed, and the # honestly-reported moved workspace stays at the root untouched. @@ -427,6 +452,7 @@ def test_target_replacement_before_move_back_is_skipped( minimal_ics55_pdk_factory, monkeypatch, create_legacy_workspace, + plain_records, ): import shutil @@ -462,10 +488,12 @@ def swapping_screen(source): monkeypatch.setattr(migrate_fs, "move_noreplace", swapping_move) monkeypatch.setattr(migrate_fs, "_unsafe_workspace_source", swapping_screen) - rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--json"]) + rc = cli_main.run(["migrate", "--project", project_dir, "--yes", "--plain"]) assert rc != 0 - (failure,) = [r for r in _records(capsys) if r.get("error") == "migration_failed"] + (failure,) = [ + r for r in _records(capsys, plain_records) if r.get("error") == "migration_failed" + ] assert "rollback incomplete" in failure["reason"] # The move-back refused to move the wrong object: the third-party # directory stays at the root untouched, and no manifest was written. diff --git a/test/cli/commands/test_overwrite_guard.py b/test/cli/commands/test_overwrite_guard.py index 7b7987c56..2b7d387e8 100644 --- a/test/cli/commands/test_overwrite_guard.py +++ b/test/cli/commands/test_overwrite_guard.py @@ -1,4 +1,3 @@ -import json import os from chipcompiler.cli import main as cli_main @@ -13,6 +12,7 @@ def test_refuses_foreign_non_empty_dir( create_cli_project, mock_pdk_validation, spy_mutations, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -25,11 +25,11 @@ def test_refuses_foreign_non_empty_dir( mutations = spy_mutations() rc = cli_main.run( - ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--json"] + ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--plain"] ) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "overwrite_refused", @@ -52,6 +52,7 @@ def test_refuses_unreadable_target_dir( mock_pdk_validation, monkeypatch, spy_mutations, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -70,11 +71,11 @@ def denying_listdir(path): mutations = spy_mutations() rc = cli_main.run( - ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--json"] + ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--plain"] ) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "overwrite_refused", @@ -93,6 +94,7 @@ def test_refuses_symlink_target( create_cli_project, create_flow_json, mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -102,11 +104,11 @@ def test_refuses_symlink_target( os.symlink(real_run, link) rc = cli_main.run( - ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--json"] + ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--plain"] ) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "overwrite_refused", @@ -119,7 +121,12 @@ def test_refuses_symlink_target( assert os.path.isfile(os.path.join(real_run, "home", "flow.json")) def test_refuses_non_directory_target( - self, tmp_path, capsys, create_cli_project, mock_pdk_validation + self, + tmp_path, + capsys, + create_cli_project, + mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -128,11 +135,11 @@ def test_refuses_non_directory_target( f.write("not a directory\n") rc = cli_main.run( - ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--json"] + ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--plain"] ) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "overwrite_refused", @@ -150,7 +157,7 @@ def test_allows_empty_dir(self, tmp_path, capsys, create_cli_project, flow_mocks os.makedirs(run_dir) rc = cli_main.run( - ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--json"] + ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--plain"] ) assert rc == 0 @@ -165,7 +172,7 @@ def test_allows_default_run_under_symlinked_project_dir( run_dir = os.path.join(link, "default") create_flow_json(run_dir, profile="main") - rc = cli_main.run(["run", "--project", link, "--overwrite", "--json"]) + rc = cli_main.run(["run", "--project", link, "--overwrite", "--plain"]) assert rc == 0 assert flow_mocks.capture["create_kwargs"]["directory"] == run_dir @@ -177,6 +184,7 @@ def test_refuses_home_symlink( create_cli_project, create_flow_json, mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -190,11 +198,11 @@ def test_refuses_home_symlink( os.symlink(os.path.join(real_run, "home"), os.path.join(run_dir, "home")) rc = cli_main.run( - ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--json"] + ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--plain"] ) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "overwrite_refused", @@ -214,6 +222,7 @@ def test_refuses_flow_json_symlink( create_cli_project, create_flow_json, mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -230,11 +239,11 @@ def test_refuses_flow_json_symlink( ) rc = cli_main.run( - ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--json"] + ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--plain"] ) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "overwrite_refused", @@ -254,6 +263,7 @@ def test_refuses_ancestor_symlink_to_empty_dir( create_cli_project, mock_pdk_validation, spy_mutations, + plain_records, ): """A multi-segment target would leave the project through a symlinked ancestor; the workspace name is rejected before anything is touched.""" @@ -272,12 +282,12 @@ def test_refuses_ancestor_symlink_to_empty_dir( "--workspace", "sweeps/victim", "--overwrite", - "--json", + "--plain", ] ) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "invalid_workspace", @@ -295,6 +305,7 @@ def test_refuses_ancestor_symlink_to_sentinel_dir( create_flow_json, mock_pdk_validation, spy_mutations, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -314,12 +325,12 @@ def test_refuses_ancestor_symlink_to_sentinel_dir( "--workspace", "sweeps/victim", "--overwrite", - "--json", + "--plain", ] ) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "invalid_workspace", @@ -340,6 +351,7 @@ def test_refuses_dotdot_after_symlink_component( create_flow_json, mock_pdk_validation, spy_mutations, + plain_records, ): """A ".." after a symlink component would reach a victim outside the project; the multi-segment spelling is rejected as a workspace name.""" @@ -358,11 +370,11 @@ def test_refuses_dotdot_after_symlink_component( run_id = os.path.join("sweeps", "jump", "..", "victim") mutations = spy_mutations() rc = cli_main.run( - ["run", "--project", project_dir, "--workspace", run_id, "--overwrite", "--json"] + ["run", "--project", project_dir, "--workspace", run_id, "--overwrite", "--plain"] ) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "invalid_workspace", @@ -383,6 +395,7 @@ def test_refuses_dotdot_escape_through_symlinked_project_dir( create_flow_json, mock_pdk_validation, spy_mutations, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -399,11 +412,11 @@ def test_refuses_dotdot_escape_through_symlinked_project_dir( run_id = os.path.join("..", "victim") mutations = spy_mutations() rc = cli_main.run( - ["run", "--project", link, "--workspace", run_id, "--overwrite", "--json"] + ["run", "--project", link, "--workspace", run_id, "--overwrite", "--plain"] ) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "invalid_workspace", diff --git a/test/cli/commands/test_partial_workspace_recovery.py b/test/cli/commands/test_partial_workspace_recovery.py index 3ae18334b..87c881c95 100644 --- a/test/cli/commands/test_partial_workspace_recovery.py +++ b/test/cli/commands/test_partial_workspace_recovery.py @@ -17,16 +17,17 @@ def test_failed_creation_removes_fresh_target( create_cli_project, mock_pdk_validation, monkeypatch, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() run_dir = os.path.join(project_dir, "exp1") monkeypatch.setattr("chipcompiler.data.create_workspace", _failing_create_workspace) - rc = cli_main.run(["run", "--project", project_dir, "--workspace", "exp1", "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--workspace", "exp1", "--plain"]) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "workspace_failed", @@ -49,6 +50,7 @@ def test_existing_dir_without_overwrite_preserves_content( create_cli_project, mock_pdk_validation, spy_mutations, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -59,10 +61,10 @@ def test_existing_dir_without_overwrite_preserves_content( f.write("precious\n") mutations = spy_mutations() - rc = cli_main.run(["run", "--project", project_dir, "--workspace", "exp1", "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--workspace", "exp1", "--plain"]) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "run_exists", @@ -75,7 +77,7 @@ def test_existing_dir_without_overwrite_preserves_content( with open(keep) as f: assert f.read() == "precious\n" - def test_failed_creation_after_overwrite_removes_partial( + def test_failed_creation_after_overwrite_restores_previous( self, tmp_path, capsys, @@ -83,19 +85,22 @@ def test_failed_creation_after_overwrite_removes_partial( create_flow_json, mock_pdk_validation, monkeypatch, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() run_dir = os.path.join(project_dir, "exp1") create_flow_json(run_dir) + with open(os.path.join(run_dir, "home", "flow.json")) as f: + previous_ledger = f.read() monkeypatch.setattr("chipcompiler.data.create_workspace", _failing_create_workspace) rc = cli_main.run( - ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--json"] + ["run", "--project", project_dir, "--workspace", "exp1", "--overwrite", "--plain"] ) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "workspace_failed", @@ -104,7 +109,12 @@ def test_failed_creation_after_overwrite_removes_partial( "reason": "rtl copy failed", }, ] - assert not os.path.lexists(run_dir) + # The partial replacement is gone, but the previous workspace was + # only renamed aside, not deleted: a failed creation puts it back. + assert not any("overwritten" in name for name in os.listdir(project_dir)) + assert not os.path.lexists(os.path.join(run_dir, "home", "params.toml")) + with open(os.path.join(run_dir, "home", "flow.json")) as f: + assert f.read() == previous_ledger def test_lost_ownership_race_preserves_active_workspace( self, @@ -113,6 +123,7 @@ def test_lost_ownership_race_preserves_active_workspace( create_cli_project, mock_pdk_validation, spy_mutations, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -122,10 +133,10 @@ def test_lost_ownership_race_preserves_active_workspace( os.makedirs(os.path.join(run_dir, "home")) mutations = spy_mutations() - rc = cli_main.run(["run", "--project", project_dir, "--workspace", "exp1", "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--workspace", "exp1", "--plain"]) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "run_exists", @@ -138,17 +149,17 @@ def test_lost_ownership_race_preserves_active_workspace( assert os.path.isdir(os.path.join(run_dir, "home")) def test_empty_dir_without_overwrite_reports_run_exists( - self, tmp_path, capsys, create_cli_project, mock_pdk_validation + self, tmp_path, capsys, create_cli_project, mock_pdk_validation, plain_records ): mock_pdk_validation() project_dir = create_cli_project() run_dir = os.path.join(project_dir, "exp1") os.makedirs(run_dir) - rc = cli_main.run(["run", "--project", project_dir, "--workspace", "exp1", "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--workspace", "exp1", "--plain"]) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "run_exists", @@ -159,14 +170,14 @@ def test_empty_dir_without_overwrite_reports_run_exists( ] assert os.listdir(run_dir) == [] - def test_nonexistent_workspace_run_leaves_no_artifacts(self, tmp_path, capsys): + def test_nonexistent_workspace_run_leaves_no_artifacts(self, tmp_path, capsys, plain_records): """A failed --workspace run must not mutate the tree: no parent directories, no sibling lock file.""" workspace_path = os.path.join(str(tmp_path), "new", "sub", "ws") - rc = cli_main.run(["run", "--workspace", workspace_path, "--json"]) + rc = cli_main.run(["run", "--workspace", workspace_path, "--plain"]) assert rc == 1 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert any(r.get("error") == "invalid_workspace" for r in records) assert not os.path.exists(os.path.join(str(tmp_path), "new")) diff --git a/test/cli/commands/test_pdk_config.py b/test/cli/commands/test_pdk_config.py index cacc58f1e..d5f207159 100644 --- a/test/cli/commands/test_pdk_config.py +++ b/test/cli/commands/test_pdk_config.py @@ -1,4 +1,3 @@ -import json import os from chipcompiler.cli import main as cli_main @@ -10,47 +9,61 @@ def _read_toml(project_dir): class TestPdkSetRoot: - def test_set_root_writes_absolute_path(self, tmp_path, capsys, create_cli_project): + def test_set_root_writes_absolute_path( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project(pdk_root="") target = tmp_path / "my-pdk" target.mkdir() - rc = cli_main.run(["pdk", "set-root", str(target), "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "set-root", str(target), "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + data = plain_records(capsys.readouterr().out) assert rc == 0 - assert data["records"][0]["status"] == "set" - assert data["records"][0]["path"] == str(target) + assert data[0]["status"] == "set" + assert data[0]["path"] == str(target) assert f'root = "{target}"' in _read_toml(project_dir) def test_set_root_expands_relative_path( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + plain_records, ): project_dir = create_cli_project(pdk_root="") target = tmp_path / "rel-pdk" target.mkdir() monkeypatch.chdir(tmp_path) - rc = cli_main.run(["pdk", "set-root", "rel-pdk", "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "set-root", "rel-pdk", "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + data = plain_records(capsys.readouterr().out) assert rc == 0 - assert data["records"][0]["path"] == str(target) + assert data[0]["path"] == str(target) assert f'root = "{target}"' in _read_toml(project_dir) - def test_set_root_rejects_missing_directory(self, tmp_path, capsys, create_cli_project): + def test_set_root_rejects_missing_directory( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project(pdk_root="") rc = cli_main.run( - ["pdk", "set-root", str(tmp_path / "nope"), "--project", project_dir, "--json"] + ["pdk", "set-root", str(tmp_path / "nope"), "--project", project_dir, "--plain"] ) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "invalid_pdk_path" def test_set_root_warns_on_incomplete_contents( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + plain_records, ): project_dir = create_cli_project(pdk_root="") target = tmp_path / "bare-pdk" # exists but has no LEF/liberty @@ -60,18 +73,18 @@ def test_set_root_warns_on_incomplete_contents( lambda name, root, overrides=None: "PDK has no liberty files", ) - rc = cli_main.run(["pdk", "set-root", str(target), "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "set-root", str(target), "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + data = plain_records(capsys.readouterr().out) assert rc == 0 # advisory: set-root still succeeds - assert data["records"][0]["status"] == "set" - assert data["records"][1]["status"] == "incomplete" - assert "make unzip" in data["records"][1]["hint"] + assert data[0]["status"] == "set" + assert data[1]["status"] == "incomplete" + assert "make unzip" in data[1]["hint"] def test_set_root_preserves_other_keys(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project(pdk_root="/old/location") - rc = cli_main.run(["pdk", "set-root", str(tmp_path), "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "set-root", str(tmp_path), "--project", project_dir, "--plain"]) toml = _read_toml(project_dir) assert rc == 0 @@ -81,7 +94,9 @@ def test_set_root_preserves_other_keys(self, tmp_path, capsys, create_cli_projec class TestPdkShow: - def test_show_reports_ecc_toml_source(self, tmp_path, capsys, monkeypatch, create_cli_project): + def test_show_reports_ecc_toml_source( + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records + ): project_dir = create_cli_project(pdk_root=str(tmp_path / "pdk-a")) (tmp_path / "pdk-a").mkdir() monkeypatch.setattr( @@ -89,17 +104,19 @@ def test_show_reports_ecc_toml_source(self, tmp_path, capsys, monkeypatch, creat lambda name, root, overrides=None: None, ) - rc = cli_main.run(["pdk", "show", "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "show", "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + data = plain_records(capsys.readouterr().out) assert rc == 0 - record = data["records"][0] + record = data[0] assert record["name"] == "ics55" assert record["source"] == "ecc.toml" assert record["root"] == str(tmp_path / "pdk-a") - assert data["records"][1]["status"] == "pass" + assert data[1]["status"] == "pass" - def test_show_reports_env_source(self, tmp_path, capsys, monkeypatch, create_cli_project): + def test_show_reports_env_source( + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records + ): project_dir = create_cli_project(pdk_root="") env_dir = tmp_path / "env-pdk" env_dir.mkdir() @@ -109,27 +126,34 @@ def test_show_reports_env_source(self, tmp_path, capsys, monkeypatch, create_cli lambda name, root, overrides=None: None, ) - rc = cli_main.run(["pdk", "show", "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "show", "--project", project_dir, "--plain"]) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 0 assert record["source"] == "CHIPCOMPILER_ICS55_PDK_ROOT" assert record["root"] == str(env_dir) - def test_show_flags_missing_root(self, tmp_path, capsys, monkeypatch, create_cli_project): + def test_show_flags_missing_root( + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records + ): project_dir = create_cli_project(pdk_root=str(tmp_path / "ghost")) # never created monkeypatch.delenv("CHIPCOMPILER_ICS55_PDK_ROOT", raising=False) monkeypatch.delenv("ICS55_PDK_ROOT", raising=False) - rc = cli_main.run(["pdk", "show", "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "show", "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + data = plain_records(capsys.readouterr().out) assert rc == 0 # show is advisory - assert data["records"][1]["status"] == "missing" - assert "set-root" in data["records"][1]["set_root"] + assert data[1]["status"] == "missing" + assert "set-root" in data[1]["set_root"] def test_show_reports_unreadable_config( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + plain_records, ): project_dir = create_cli_project() @@ -138,10 +162,10 @@ def deny(_config_path): monkeypatch.setattr("chipcompiler.cli.project.config.load_project_config", deny) - rc = cli_main.run(["pdk", "show", "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "show", "--project", project_dir, "--plain"]) assert rc == 1 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert records[0]["kind"] == "error" assert records[0]["error"] == "config_error" assert records[0]["reason"].startswith("unreadable project config:") @@ -149,18 +173,23 @@ def deny(_config_path): class TestPdkUnset: - def test_unset_restores_empty_root(self, tmp_path, capsys, create_cli_project): + def test_unset_restores_empty_root(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project(pdk_root=str(tmp_path)) - rc = cli_main.run(["pdk", "unset", "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "unset", "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + data = plain_records(capsys.readouterr().out) assert rc == 0 - assert data["records"][0]["status"] == "unset" + assert data[0]["status"] == "unset" assert 'root = ""' in _read_toml(project_dir) def test_unset_then_show_falls_back_to_env( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + plain_records, ): project_dir = create_cli_project(pdk_root=str(tmp_path)) env_dir = tmp_path / "env-pdk" @@ -171,207 +200,10 @@ def test_unset_then_show_falls_back_to_env( lambda name, root, overrides=None: None, ) - cli_main.run(["pdk", "unset", "--project", project_dir, "--json"]) + cli_main.run(["pdk", "unset", "--project", project_dir, "--plain"]) capsys.readouterr() - rc = cli_main.run(["pdk", "show", "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "show", "--project", project_dir, "--plain"]) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 0 assert record["source"] == "CHIPCOMPILER_ICS55_PDK_ROOT" - - -class _FakeResult: - def __init__(self, returncode=0, stderr="", stdout=""): - self.returncode = returncode - self.stderr = stderr - self.stdout = stdout - - -class TestPdkSetup: - def test_setup_complete_checkout_only_sets_root( - self, tmp_path, capsys, monkeypatch, create_cli_project - ): - project_dir = create_cli_project(pdk_root="") - pdk_dir = tmp_path / "ready-pdk" - pdk_dir.mkdir() - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root, overrides=None: None, - ) - - rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--json"]) - - data = json.loads(capsys.readouterr().out) - assert rc == 0 - assert data["records"][0]["status"] == "ready" - assert data["records"][0]["actions"] == [] # nothing fetched - assert f'root = "{pdk_dir}"' in _read_toml(project_dir) - - def test_setup_clones_and_unzips_missing_checkout( - self, tmp_path, capsys, monkeypatch, create_cli_project - ): - import subprocess as real_subprocess - - project_dir = create_cli_project(pdk_root="") - pdk_dir = tmp_path / "fresh-pdk" - calls = {"clone": [], "make": []} - - def fake_run(cmd, cwd=None, *, capture_output=True, text=True, **kwargs): - if cmd[:2] == ["git", "clone"]: - calls["clone"].append(cmd) - pdk_dir.mkdir() # pretend the clone created the checkout - return _FakeResult() - if cmd[0] == "make": - calls["make"].append((cmd, cwd)) - return _FakeResult() - return real_subprocess.run( - cmd, cwd=cwd, capture_output=capture_output, text=text, **kwargs - ) - - monkeypatch.setattr("subprocess.run", fake_run) - problems = {"first": "PDK has no liberty files"} - - def fake_validate(name, root, overrides=None): - return problems.get("first") if not calls["make"] else None - - monkeypatch.setattr("chipcompiler.cli.project.config._validate_pdk_contents", fake_validate) - - rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--json"]) - - data = json.loads(capsys.readouterr().out) - assert rc == 0 - assert data["records"][0]["actions"] == ["clone", "unzip"] - assert calls["clone"][0][-1] == str(pdk_dir) - assert calls["make"][0][1] == str(pdk_dir) - assert f'root = "{pdk_dir}"' in _read_toml(project_dir) - - def test_setup_clone_failure(self, tmp_path, capsys, monkeypatch, create_cli_project): - import subprocess as real_subprocess - - project_dir = create_cli_project(pdk_root="") - pdk_dir = tmp_path / "never-created" - - def fake_run(cmd, **kwargs): - if cmd[:2] == ["git", "clone"]: - return _FakeResult(returncode=128, stderr="fatal: repository not found") - return real_subprocess.run(cmd, **kwargs) - - monkeypatch.setattr("subprocess.run", fake_run) - - rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--json"]) - - record = json.loads(capsys.readouterr().out)["records"][0] - assert rc == 1 - assert record["error"] == "clone_failed" - assert "repository not found" in record["reason"] - - def test_setup_unzip_retries_then_fails( - self, tmp_path, capsys, monkeypatch, create_cli_project - ): - import subprocess as real_subprocess - - project_dir = create_cli_project(pdk_root="") - pdk_dir = tmp_path / "stubborn-pdk" - pdk_dir.mkdir() - make_calls = [] - - def fake_run(cmd, cwd=None, **kwargs): - if cmd[0] == "make": - make_calls.append(cmd) - return _FakeResult(returncode=2, stderr="curl: (28) timeout") - return real_subprocess.run(cmd, cwd=cwd, **kwargs) - - monkeypatch.setattr("subprocess.run", fake_run) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root, overrides=None: "PDK has no liberty files", - ) - - rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--json"]) - - data = json.loads(capsys.readouterr().out) - assert rc == 1 - assert len(make_calls) == 3 # retried three times - assert data["records"][0]["error"] == "unzip_failed" - - def test_setup_unzip_recovers_on_retry(self, tmp_path, capsys, monkeypatch, create_cli_project): - import subprocess as real_subprocess - - project_dir = create_cli_project(pdk_root="") - pdk_dir = tmp_path / "flaky-pdk" - pdk_dir.mkdir() - attempts = {"n": 0} - - def fake_run(cmd, cwd=None, **kwargs): - if cmd[0] == "make": - attempts["n"] += 1 - ok = attempts["n"] >= 2 - return _FakeResult(returncode=0 if ok else 1) - return real_subprocess.run(cmd, cwd=cwd, **kwargs) - - monkeypatch.setattr("subprocess.run", fake_run) - - def fake_validate(name, root, overrides=None): - return None if attempts["n"] >= 2 else "PDK has no liberty files" - - monkeypatch.setattr("chipcompiler.cli.project.config._validate_pdk_contents", fake_validate) - - rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--json"]) - - data = json.loads(capsys.readouterr().out) - assert rc == 0 - assert attempts["n"] == 2 - assert data["records"][0]["actions"] == ["unzip"] - - def test_setup_forwards_gh_proxy_to_make( - self, tmp_path, capsys, monkeypatch, create_cli_project - ): - import subprocess as real_subprocess - - project_dir = create_cli_project(pdk_root="") - pdk_dir = tmp_path / "proxy-pdk" - pdk_dir.mkdir() - seen = {} - - def fake_run(cmd, cwd=None, **kwargs): - if cmd[0] == "make": - seen["cmd"] = cmd - return _FakeResult() - return real_subprocess.run(cmd, cwd=cwd, **kwargs) - - monkeypatch.setattr("subprocess.run", fake_run) - - def fake_validate(name, root, overrides=None): - return "PDK has no liberty files" if "cmd" not in seen else None - - monkeypatch.setattr("chipcompiler.cli.project.config._validate_pdk_contents", fake_validate) - monkeypatch.setenv("GH_PROXY", "https://gh-proxy.org/") - - rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--json"]) - - assert rc == 0 - assert seen["cmd"] == [ - "make", - "unzip", - "USE_PROXY=true", - "GH_PROXY=https://gh-proxy.org/", - ] - - def test_setup_default_path_when_argument_omitted( - self, tmp_path, capsys, monkeypatch, create_cli_project - ): - project_dir = create_cli_project(pdk_root="") - monkeypatch.setattr( - "chipcompiler.cli.command_handlers.pdk.DEFAULT_PDK_DIR", str(tmp_path / "default-pdk") - ) - (tmp_path / "default-pdk").mkdir() - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root, overrides=None: None, - ) - - rc = cli_main.run(["pdk", "setup", "--project", project_dir, "--json"]) - - data = json.loads(capsys.readouterr().out) - assert rc == 0 - assert data["records"][0]["path"] == str(tmp_path / "default-pdk") diff --git a/test/cli/commands/test_project_config.py b/test/cli/commands/test_project_config.py index 5bc767caa..a41ebf545 100644 --- a/test/cli/commands/test_project_config.py +++ b/test/cli/commands/test_project_config.py @@ -1,21 +1,19 @@ -import json - from chipcompiler.cli import main as cli_main -def _records(capsys): - return json.loads(capsys.readouterr().out)["records"] +def _records(capsys, plain_records): + return plain_records(capsys.readouterr().out) -def test_project_set_and_show_design_resource(capsys, create_cli_project): +def test_project_set_and_show_design_resource(capsys, create_cli_project, plain_records): project_dir = create_cli_project() rc = cli_main.run( - ["project", "set", "design.def", "inputs/gcd.def", "--project", project_dir, "--json"] + ["project", "set", "design.def", "inputs/gcd.def", "--project", project_dir, "--plain"] ) assert rc == 0 - assert _records(capsys) == [ + assert _records(capsys, plain_records) == [ { "project_field": "design.def", "value": "inputs/gcd.def", @@ -24,13 +22,15 @@ def test_project_set_and_show_design_resource(capsys, create_cli_project): } ] - rc = cli_main.run(["project", "show", "design.def", "--project", project_dir, "--json"]) + rc = cli_main.run(["project", "show", "design.def", "--project", project_dir, "--plain"]) assert rc == 0 - assert _records(capsys)[0]["value"] == "inputs/gcd.def" + assert _records(capsys, plain_records)[0]["value"] == "inputs/gcd.def" -def test_project_rtl_list_replacement_and_incremental_changes(capsys, create_cli_project): +def test_project_rtl_list_replacement_and_incremental_changes( + capsys, create_cli_project, plain_records +): project_dir = create_cli_project() rc = cli_main.run( @@ -42,33 +42,35 @@ def test_project_rtl_list_replacement_and_incremental_changes(capsys, create_cli "rtl/alu.sv", "--project", project_dir, - "--json", + "--plain", ] ) assert rc == 0 - assert _records(capsys)[0]["value"] == ["rtl/gcd.sv", "rtl/alu.sv"] + assert _records(capsys, plain_records)[0]["value"] == "['rtl/gcd.sv', 'rtl/alu.sv']" rc = cli_main.run( - ["project", "add", "design.rtl", "rtl/fifo.sv", "--project", project_dir, "--json"] + ["project", "add", "design.rtl", "rtl/fifo.sv", "--project", project_dir, "--plain"] ) assert rc == 0 - assert _records(capsys)[0]["value"] == ["rtl/gcd.sv", "rtl/alu.sv", "rtl/fifo.sv"] + assert ( + _records(capsys, plain_records)[0]["value"] == "['rtl/gcd.sv', 'rtl/alu.sv', 'rtl/fifo.sv']" + ) rc = cli_main.run( - ["project", "remove", "design.rtl", "rtl/alu.sv", "--project", project_dir, "--json"] + ["project", "remove", "design.rtl", "rtl/alu.sv", "--project", project_dir, "--plain"] ) assert rc == 0 - assert _records(capsys)[0]["value"] == ["rtl/gcd.sv", "rtl/fifo.sv"] + assert _records(capsys, plain_records)[0]["value"] == "['rtl/gcd.sv', 'rtl/fifo.sv']" -def test_project_set_rejects_unknown_field(capsys, create_cli_project): +def test_project_set_rejects_unknown_field(capsys, create_cli_project, plain_records): project_dir = create_cli_project() rc = cli_main.run( - ["project", "set", "design.unknown", "value", "--project", project_dir, "--json"] + ["project", "set", "design.unknown", "value", "--project", project_dir, "--plain"] ) assert rc == 1 - assert _records(capsys) == [ + assert _records(capsys, plain_records) == [ {"kind": "error", "error": "unknown_project_field", "key": "design.unknown"} ] diff --git a/test/cli/commands/test_readonly_workspace.py b/test/cli/commands/test_readonly_workspace.py index 366f66864..baa482df7 100644 --- a/test/cli/commands/test_readonly_workspace.py +++ b/test/cli/commands/test_readonly_workspace.py @@ -1,6 +1,5 @@ """--workspace resolution shared by the read-only status/log/config commands.""" -import json import os import pytest @@ -19,71 +18,75 @@ class TestWorkspaceSelection: options combine (no conflict) and read-only commands never load the workspace.""" - def test_status_resolves_workspace_inside_project(self, tmp_path, capsys, monkeypatch): + def test_status_resolves_workspace_inside_project( + self, tmp_path, capsys, monkeypatch, plain_records + ): monkeypatch.setattr( "chipcompiler.data.load_workspace", lambda _path: pytest.fail("read-only commands must not load a workspace"), ) - rc = cli_main.run(["status", "--project", str(tmp_path), "--workspace", "w", "--json"]) + rc = cli_main.run(["status", "--project", str(tmp_path), "--workspace", "w", "--plain"]) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["workspace_id"] == "w" assert record["status"] == "missing" assert record["workspace"] == str(tmp_path / "w") def test_log_rejects_legacy_run_id_option(self, tmp_path, capsys): - rc = cli_main.run(["log", "--run-id", "r", "--workspace", "w", "--json"]) + rc = cli_main.run(["log", "--run-id", "r", "--workspace", "w", "--plain"]) captured = capsys.readouterr() assert rc == 2 assert "No such option: --run-id" in captured.err assert captured.out == "" - def test_config_resolves_workspace_inside_project(self, tmp_path, capsys, monkeypatch): + def test_config_resolves_workspace_inside_project( + self, tmp_path, capsys, monkeypatch, plain_records + ): monkeypatch.setattr( "chipcompiler.data.load_workspace", lambda _path: pytest.fail("read-only commands must not load a workspace"), ) - rc = cli_main.run(["config", "--project", str(tmp_path), "--workspace", "w", "--json"]) + rc = cli_main.run(["config", "--project", str(tmp_path), "--workspace", "w", "--plain"]) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "missing_config" class TestInvalidWorkspace: @pytest.mark.parametrize("command", (["status"], ["log"], ["config"])) - def test_workspace_path_is_not_a_name(self, tmp_path, capsys, command): + def test_workspace_path_is_not_a_name(self, tmp_path, capsys, command, plain_records): absent = str(tmp_path / "absent") - rc = cli_main.run([*command, "--workspace", absent, "--json"]) + rc = cli_main.run([*command, "--workspace", absent, "--plain"]) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "invalid_workspace" assert record["reason"] == f"invalid_workspace: {absent!r} is not a single workspace name" class TestWorkspaceViews: - def test_status_reads_flow_json(self, tmp_path, capsys, create_flow_json): + def test_status_reads_flow_json(self, tmp_path, capsys, create_flow_json, plain_records): from test.cli.conftest import create_step_dir ws = _make_workspace(tmp_path) create_flow_json(ws, profile="inspect") create_step_dir(ws, "CTS", "ecc") - rc = cli_main.run(["status", "--project", str(tmp_path), "--workspace", "ws", "--json"]) + rc = cli_main.run(["status", "--project", str(tmp_path), "--workspace", "ws", "--plain"]) - data = json.loads(capsys.readouterr().out) + records = plain_records(capsys.readouterr().out) assert rc == 0 - assert data["records"][0]["workspace"] == ws - assert data["records"][0]["workspace_id"] == "ws" - assert any(r.get("step") == "cts" for r in data["records"][1:]) + assert records[0]["workspace"] == ws + assert records[0]["workspace_id"] == "ws" + assert any(r.get("step") == "cts" for r in records[1:]) - def test_log_reads_step_log(self, tmp_path, capsys, create_flow_json): + def test_log_reads_step_log(self, tmp_path, capsys, create_flow_json, plain_records): from test.cli.conftest import create_step_dir ws = _make_workspace(tmp_path) @@ -97,14 +100,14 @@ def test_log_reads_step_log(self, tmp_path, capsys, create_flow_json): ) rc = cli_main.run( - ["log", "synthesis", "--project", str(tmp_path), "--workspace", "ws", "--json"] + ["log", "synthesis", "--project", str(tmp_path), "--workspace", "ws", "--plain"] ) - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert rc == 0 assert any("bad thing" in r.get("line", "") for r in records) - def test_config_step_view(self, tmp_path, capsys, create_flow_json): + def test_config_step_view(self, tmp_path, capsys, create_flow_json, plain_records): from test.cli.conftest import create_cts_workspace_config ws = _make_workspace(tmp_path) @@ -112,10 +115,10 @@ def test_config_step_view(self, tmp_path, capsys, create_flow_json): create_cts_workspace_config(ws) rc = cli_main.run( - ["config", "cts", "--project", str(tmp_path), "--workspace", "ws", "--json"] + ["config", "cts", "--project", str(tmp_path), "--workspace", "ws", "--plain"] ) - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert rc == 0 assert any(r.get("step") == "cts" for r in records) @@ -141,10 +144,8 @@ def snapshot(): before = snapshot() cli_main.run(["status", "--project", str(tmp_path), "--workspace", "ws"]) - cli_main.run( - ["log", "synthesis", "--project", str(tmp_path), "--workspace", "ws", "--json"] - ) - cli_main.run(["config", "--project", str(tmp_path), "--workspace", "ws", "--json"]) + cli_main.run(["log", "synthesis", "--project", str(tmp_path), "--workspace", "ws"]) + cli_main.run(["config", "--project", str(tmp_path), "--workspace", "ws"]) assert snapshot() == before @@ -165,29 +166,29 @@ def test_config_unknown_step_text(self, tmp_path, capsys, create_flow_json): assert "unknown_step" in out assert "nope" in out - def test_config_unknown_step_json(self, tmp_path, capsys, create_flow_json): + def test_config_unknown_step_plain(self, tmp_path, capsys, create_flow_json, plain_records): ws = _make_workspace(tmp_path) create_flow_json(ws, profile="inspect") rc = cli_main.run( - ["config", "nope", "--project", str(tmp_path), "--workspace", "ws", "--json"] + ["config", "nope", "--project", str(tmp_path), "--workspace", "ws", "--plain"] ) assert rc == 1 - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert record["kind"] == "error" assert record["error"] == "unknown_step" assert record["step"] == "nope" - def test_log_unknown_step_json(self, tmp_path, capsys, create_flow_json): + def test_log_unknown_step_plain(self, tmp_path, capsys, create_flow_json, plain_records): ws = _make_workspace(tmp_path) create_flow_json(ws, profile="inspect") rc = cli_main.run( - ["log", "nope", "--project", str(tmp_path), "--workspace", "ws", "--jsonl"] + ["log", "nope", "--project", str(tmp_path), "--workspace", "ws", "--plain"] ) assert rc == 1 - record = json.loads(capsys.readouterr().out.strip()) + record = plain_records(capsys.readouterr().out)[0] assert record["kind"] == "error" assert record["error"] == "unknown_step" diff --git a/test/cli/commands/test_report.py b/test/cli/commands/test_report.py index 005b4dd43..b29ef133c 100644 --- a/test/cli/commands/test_report.py +++ b/test/cli/commands/test_report.py @@ -1,4 +1,4 @@ -import json +import ast import os from types import SimpleNamespace @@ -74,47 +74,80 @@ def report_mocks(monkeypatch): from chipcompiler.engine.signoff import report_checklist as checklist_module monkeypatch.setattr(qor_module, "build_qor_report", lambda ws: qor_report) - monkeypatch.setattr(qor_module, "generate_qor_report", lambda ws: "QOR BODY") + monkeypatch.setattr(qor_module, "generate_qor_report", lambda ws, report=None: "QOR BODY") monkeypatch.setattr(checklist_module, "build_checklist_report", lambda ws: checklist_report) - monkeypatch.setattr(checklist_module, "generate_checklist_report", lambda ws: "CHECKLIST BODY") + monkeypatch.setattr( + checklist_module, "generate_checklist_report", lambda ws, report=None: "CHECKLIST BODY" + ) return SimpleNamespace(workspace=workspace, qor=qor_report, checklist=checklist_report) class TestReportQor: def test_qor_writes_default_destination( - self, tmp_path, capsys, monkeypatch, create_cli_project, report_mocks + self, tmp_path, capsys, monkeypatch, create_cli_project, report_mocks, plain_records ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") os.makedirs(run_dir) report_mocks.workspace.directory = run_dir - rc = cli_main.run(["report", "qor", "--project", project_dir, "--json"]) + rc = cli_main.run(["report", "qor", "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + record = plain_records(capsys.readouterr().out)[0] assert rc == 0 - record = data["records"][0] assert record["report"] == "qor" assert record["status"] == "written" - assert record["overall_score"] == 72.5 - assert record["dimensions"][0]["dimension"] == "Timing" + assert record["overall_score"] == "72.5" + assert ast.literal_eval(record["dimensions"])[0]["dimension"] == "Timing" expected = os.path.join(run_dir, "signoff", "gcd_qor_report.txt") assert record["path"] == expected with open(expected) as f: assert f.read() == "QOR BODY" + def test_qor_write_failure_is_a_structured_error_not_a_traceback( + self, tmp_path, capsys, monkeypatch, create_cli_project, report_mocks, plain_records + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "default") + os.makedirs(run_dir) + report_mocks.workspace.directory = run_dir + # An existing report must survive a failed write, and the failure + # must surface as a structured record, never a traceback. + existing = os.path.join(run_dir, "signoff", "gcd_qor_report.txt") + os.makedirs(os.path.dirname(existing)) + with open(existing, "w") as f: + f.write("PREVIOUS REPORT") + + real_replace = os.replace + + def failing_replace(src, dst): + if str(dst).endswith("gcd_qor_report.txt"): + raise OSError(28, "No space left on device") + real_replace(src, dst) + + monkeypatch.setattr(os, "replace", failing_replace) + + rc = cli_main.run(["report", "qor", "--project", project_dir, "--plain"]) + + record = plain_records(capsys.readouterr().out)[0] + assert rc == 1 + assert record["error"] == "report_write_failed" + assert "No space left" in record["reason"] + with open(existing) as f: + assert f.read() == "PREVIOUS REPORT" + def test_qor_output_override( - self, tmp_path, capsys, monkeypatch, create_cli_project, report_mocks + self, tmp_path, capsys, monkeypatch, create_cli_project, report_mocks, plain_records ): project_dir = create_cli_project() os.makedirs(os.path.join(project_dir, "default")) override = str(tmp_path / "qor.txt") - rc = cli_main.run(["report", "qor", "-o", override, "--project", project_dir, "--json"]) + rc = cli_main.run(["report", "qor", "-o", override, "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + record = plain_records(capsys.readouterr().out)[0] assert rc == 0 - assert data["records"][0]["path"] == override + assert record["path"] == override assert os.path.isfile(override) def test_qor_with_workspace_flag(self, tmp_path, capsys, monkeypatch, report_mocks): @@ -130,31 +163,30 @@ def test_qor_with_workspace_flag(self, tmp_path, capsys, monkeypatch, report_moc class TestReportChecklist: def test_checklist_records_summary( - self, tmp_path, capsys, monkeypatch, create_cli_project, report_mocks + self, tmp_path, capsys, monkeypatch, create_cli_project, report_mocks, plain_records ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") os.makedirs(run_dir) report_mocks.workspace.directory = run_dir - rc = cli_main.run(["report", "checklist", "--project", project_dir, "--json"]) + rc = cli_main.run(["report", "checklist", "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + record = plain_records(capsys.readouterr().out)[0] assert rc == 0 - record = data["records"][0] assert record["report"] == "checklist" assert record["status"] == "written" assert record["checklist_status"] == "attention" - assert record["blocked"] == 1 - assert record["attention"] == 1 - assert record["items"] == 2 + assert record["blocked"] == "1" + assert record["attention"] == "1" + assert record["items"] == "2" expected = os.path.join(run_dir, "signoff", "checklist_report.txt") assert record["path"] == expected with open(expected) as f: assert f.read() == "CHECKLIST BODY" def test_checklist_unavailable_maps_to_error( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records ): project_dir = create_cli_project() os.makedirs(os.path.join(project_dir, "default")) @@ -173,18 +205,20 @@ def test_checklist_unavailable_maps_to_error( "build_checklist_report", lambda ws: checklist_module.ChecklistReport(available=False, workspace="/tmp/x"), ) - monkeypatch.setattr(checklist_module, "generate_checklist_report", lambda ws: "UNAVAILABLE") + monkeypatch.setattr( + checklist_module, "generate_checklist_report", lambda ws, report=None: "UNAVAILABLE" + ) - rc = cli_main.run(["report", "checklist", "--project", project_dir, "--json"]) + rc = cli_main.run(["report", "checklist", "--project", project_dir, "--plain"]) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "checklist_unavailable" class TestReportSummary: def test_summary_writes_default_destination( - self, capsys, monkeypatch, create_cli_project, report_mocks + self, capsys, monkeypatch, create_cli_project, report_mocks, plain_records ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") @@ -198,15 +232,14 @@ def generate_summary(workspace): monkeypatch.setattr("chipcompiler.engine.signoff.generate_text_report", generate_summary) - rc = cli_main.run(["report", "summary", "--project", project_dir, "--json"]) + rc = cli_main.run(["report", "summary", "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + record = plain_records(capsys.readouterr().out)[0] assert rc == 0 - record = data["records"][0] assert record["report"] == "summary" assert record["status"] == "written" assert record["design"] == "gcd" - assert record["bytes"] == len(b"LINE1\nLINE2") + assert record["bytes"] == str(len(b"LINE1\nLINE2")) expected_path = os.path.join(run_dir, "signoff", "gcd_design_summary.txt") assert record["path"] == expected_path with open(expected_path) as f: @@ -214,7 +247,7 @@ def generate_summary(workspace): assert calls == [report_mocks.workspace] def test_summary_output_override( - self, tmp_path, capsys, monkeypatch, create_cli_project, report_mocks + self, tmp_path, capsys, monkeypatch, create_cli_project, report_mocks, plain_records ): project_dir = create_cli_project() os.makedirs(os.path.join(project_dir, "default")) @@ -223,15 +256,17 @@ def test_summary_output_override( ) override = str(tmp_path / "custom.txt") - rc = cli_main.run(["report", "summary", "-o", override, "--project", project_dir, "--json"]) + rc = cli_main.run( + ["report", "summary", "-o", override, "--project", project_dir, "--plain"] + ) - data = json.loads(capsys.readouterr().out) + record = plain_records(capsys.readouterr().out)[0] assert rc == 0 - assert data["records"][0]["path"] == override + assert record["path"] == override assert os.path.isfile(override) def test_summary_failure_maps_to_error( - self, capsys, monkeypatch, create_cli_project, report_mocks + self, capsys, monkeypatch, create_cli_project, report_mocks, plain_records ): project_dir = create_cli_project() os.makedirs(os.path.join(project_dir, "default")) @@ -241,16 +276,16 @@ def fail(_workspace): monkeypatch.setattr("chipcompiler.engine.signoff.generate_text_report", fail) - rc = cli_main.run(["report", "summary", "--project", project_dir, "--json"]) + rc = cli_main.run(["report", "summary", "--project", project_dir, "--plain"]) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "report_failed" class TestReportWorkspaceResolution: def test_unresolved_workspace_rejected_before_load( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records ): monkeypatch.setattr( "chipcompiler.data.load_workspace", @@ -258,19 +293,21 @@ def test_unresolved_workspace_rejected_before_load( ) rc = cli_main.run( - ["report", "qor", "--project", str(tmp_path), "--workspace", "absent", "--json"] + ["report", "qor", "--project", str(tmp_path), "--workspace", "absent", "--plain"] ) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "missing_workspace" assert record["workspace"] == str(tmp_path / "absent") - def test_missing_run_workspace(self, tmp_path, capsys, monkeypatch, create_cli_project): + def test_missing_run_workspace( + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records + ): project_dir = create_cli_project() # no runs/default - rc = cli_main.run(["report", "qor", "--project", project_dir, "--json"]) + rc = cli_main.run(["report", "qor", "--project", project_dir, "--plain"]) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "missing_workspace" diff --git a/test/cli/commands/test_report_step.py b/test/cli/commands/test_report_step.py index d90e83910..983be0ccb 100644 --- a/test/cli/commands/test_report_step.py +++ b/test/cli/commands/test_report_step.py @@ -39,7 +39,7 @@ { "id": "num_instances", "display_name": "Instance Count", - "value": 454, + "value": "454", "unit": "count", "category": "area_cost", "direction": "lower_is_better", @@ -242,9 +242,9 @@ def create_step_workspace(tmp_path, *, with_drc=True, with_home_checklist=False) return ws -def run_step(args, capsys): +def run_step(args, capsys, plain_records): rc = cli_main.run(args) - return rc, json.loads(capsys.readouterr().out) + return rc, plain_records(capsys.readouterr().out) def step_args(tmp_path, *args, workspace="ws"): @@ -253,7 +253,7 @@ def step_args(tmp_path, *args, workspace="ws"): class TestStepOverview: - def test_overview_includes_every_rtl2gds_step(self, tmp_path, capsys): + def test_overview_includes_every_rtl2gds_step(self, tmp_path, capsys, plain_records): from chipcompiler.rtl2gds.builder import build_rtl2gds_flow ws = str(tmp_path / "ws") @@ -267,10 +267,10 @@ def test_overview_includes_every_rtl2gds_step(self, tmp_path, capsys): }, ) - rc, data = run_step(step_args(tmp_path, "--json"), capsys) + rc, records = run_step(step_args(tmp_path, "--plain"), capsys, plain_records) assert rc == 0 - assert [record["step"] for record in data["records"][1:]] == [ + assert [record["step"] for record in records[1:]] == [ "synthesis", "lec", "floorplan", @@ -288,18 +288,19 @@ def test_overview_includes_every_rtl2gds_step(self, tmp_path, capsys): "harden", ] - def test_overview_records(self, tmp_path, capsys): + def test_overview_records(self, tmp_path, capsys, plain_records): ws = create_step_workspace(tmp_path) - rc, data = run_step(step_args(tmp_path, "--json"), capsys) + rc, records = run_step(step_args(tmp_path, "--plain"), capsys, plain_records) assert rc == 0 - assert data["records"] == [ + # --plain renders values as strings and omits None-valued keys + assert records == [ { "report": "step", "view": "overview", "workspace": ws, - "steps": 3, + "steps": "3", "inspect": f"ecc report step --project {tmp_path} --workspace ws", }, { @@ -307,11 +308,11 @@ def test_overview_records(self, tmp_path, capsys): "tool": "ecc", "status": "success", "runtime": "0:0:1", - "peak_memory_mb": 143.227, - "metrics": 2, + "peak_memory_mb": "143.227", + "metrics": "2", "quality": "pass", "checklist": "ready", - "blocked": 0, + "blocked": "0", "inspect": f"ecc report step floorplan --project {tmp_path} --workspace ws", }, { @@ -319,11 +320,8 @@ def test_overview_records(self, tmp_path, capsys): "tool": "sizer", "status": "success", "runtime": "0:0:9", - "peak_memory_mb": 2.305, - "metrics": None, - "quality": None, - "checklist": None, - "blocked": 0, + "peak_memory_mb": "2.305", + "blocked": "0", "inspect": ( f"ecc report step timing_optimization --project {tmp_path} --workspace ws" ), @@ -332,36 +330,36 @@ def test_overview_records(self, tmp_path, capsys): "step": "drc", "tool": "ecc", "status": "unknown", - "runtime": None, - "peak_memory_mb": None, - "metrics": 1, + "metrics": "1", "quality": "blocked", "checklist": "blocked", - "blocked": 1, + "blocked": "1", "inspect": f"ecc report step drc --project {tmp_path} --workspace ws", }, ] - def test_overview_without_steps(self, tmp_path, capsys): + def test_overview_without_steps(self, tmp_path, capsys, plain_records): ws = str(tmp_path / "empty") os.makedirs(ws) - rc, data = run_step(step_args(tmp_path, "--json", workspace="empty"), capsys) + rc, records = run_step( + step_args(tmp_path, "--plain", workspace="empty"), capsys, plain_records + ) assert rc == 0 - assert data["records"] == [ + assert records == [ { "report": "step", "view": "overview", "workspace": ws, - "steps": 0, + "steps": "0", "inspect": f"ecc report step --project {tmp_path} --workspace empty", "step_status": "no_steps", "run": f"ecc run --project {tmp_path} --workspace empty", } ] - def test_overview_text(self, tmp_path, capsys): + def test_overview_text(self, tmp_path, capsys, plain_records): create_step_workspace(tmp_path) rc = cli_main.run(step_args(tmp_path)) @@ -376,26 +374,28 @@ def test_overview_text(self, tmp_path, capsys): class TestStepDetail: - def test_detail_feature_section(self, tmp_path, capsys): + def test_detail_feature_section(self, tmp_path, capsys, plain_records): create_step_workspace(tmp_path) - rc, data = run_step( - step_args(tmp_path, "floorplan", "--section", "feature", "--json"), capsys + rc, records = run_step( + step_args(tmp_path, "floorplan", "--section", "feature", "--plain"), + capsys, + plain_records, ) assert rc == 0 - head, *section = data["records"] + head, *section = records assert head["view"] == "detail" assert head["step"] == "floorplan" assert head["step_name"] == "Floorplan" - assert head["sections"] == ["feature"] + assert head["sections"] == "[feature]" assert section == [ { "step": "floorplan", "section": "feature", "kind": "run", "key": "run.peak_memory_mb", - "value": 143.227, + "value": "143.227", "source": "Floorplan_ecc/feature/Floorplan.step.json", }, { @@ -403,7 +403,7 @@ def test_detail_feature_section(self, tmp_path, capsys): "section": "feature", "kind": "run", "key": "run.runtime_seconds", - "value": 1.757, + "value": "1.757", "source": "Floorplan_ecc/feature/Floorplan.step.json", }, { @@ -435,7 +435,7 @@ def test_detail_feature_section(self, tmp_path, capsys): "section": "feature", "kind": "constraint", "key": "constraints.sdc.size_bytes", - "value": 253, + "value": "253", "source": "Floorplan_ecc/feature/Floorplan.step.json", }, { @@ -459,7 +459,7 @@ def test_detail_feature_section(self, tmp_path, capsys): "section": "feature", "kind": "stat", "key": "Design Statis.num_instances", - "value": 454, + "value": "454", "source": "Floorplan_ecc/feature/Floorplan.db.json", }, { @@ -467,28 +467,30 @@ def test_detail_feature_section(self, tmp_path, capsys): "section": "feature", "kind": "stat", "key": "Design Statis.num_nets", - "value": 368, + "value": "368", "source": "Floorplan_ecc/feature/Floorplan.db.json", }, ] - def test_detail_analysis_section_with_gate(self, tmp_path, capsys): + def test_detail_analysis_section_with_gate(self, tmp_path, capsys, plain_records): create_step_workspace(tmp_path) - rc, data = run_step(step_args(tmp_path, "drc", "--section", "analysis", "--json"), capsys) + rc, records = run_step( + step_args(tmp_path, "drc", "--section", "analysis", "--plain"), capsys, plain_records + ) assert rc == 0 - head, summary, metric, gate = data["records"] - assert head["sections"] == ["analysis"] + head, summary, metric, gate = records + assert head["sections"] == "[analysis]" assert summary == { "step": "drc", "section": "analysis", "kind": "summary", "quality_status": "blocked", "analysis_status": "valid", - "metric_count": 1, - "dimensions": {}, - "missing_metrics": [], + "metric_count": "1", + "dimensions": "{}", + "missing_metrics": "[]", "analysis_revision": "quality-gates-v4", } assert metric == { @@ -497,13 +499,13 @@ def test_detail_analysis_section_with_gate(self, tmp_path, capsys): "kind": "metric", "metric": "drc_count", "label": "DRC Count", - "value": 336, + "value": "336", "unit": "count", "category": "clock_robustness_dfm", "direction": "lower_is_better", "role": "gate", - "gate": True, - "score": True, + "gate": "True", + "score": "True", "source": "drc_ecc/feature/drc.step.json#/drc/number", } assert gate == { @@ -513,29 +515,31 @@ def test_detail_analysis_section_with_gate(self, tmp_path, capsys): "gate": "qor.drc.clean", "title": "Final DRC clean", "state": "failed", - "blocking": True, - "checks": [{"metric": "drc_count", "actual": 336, "operator": "==", "expected": 0}], + "blocking": "True", + "checks": "[{'metric': 'drc_count', 'actual': 336, 'operator': '==', 'expected': 0}]", } - def test_detail_checklist_from_step_file(self, tmp_path, capsys): + def test_detail_checklist_from_step_file(self, tmp_path, capsys, plain_records): create_step_workspace(tmp_path) - rc, data = run_step( - step_args(tmp_path, "floorplan", "--section", "checklist", "--json"), capsys + rc, records = run_step( + step_args(tmp_path, "floorplan", "--section", "checklist", "--plain"), + capsys, + plain_records, ) assert rc == 0 - head, summary, item = data["records"] + head, summary, item = records assert summary == { "step": "floorplan", "section": "checklist", "kind": "summary", "checklist_status": "ready", "source": "step", - "passed": 1, - "blocked": 0, - "attention": 0, - "unavailable": 0, + "passed": "1", + "blocked": "0", + "attention": "0", + "unavailable": "0", } assert item == { "step": "floorplan", @@ -546,53 +550,57 @@ def test_detail_checklist_from_step_file(self, tmp_path, capsys): "title": "Floorplan DEF", "state": "pass", "policy": "block", - "blocked": False, + "blocked": "False", "summary": "Current output is present and non-empty.", - "evidence": ["Floorplan_ecc/output/gcd_Floorplan.def.gz"], + "evidence": "[Floorplan_ecc/output/gcd_Floorplan.def.gz]", } - def test_detail_checklist_falls_back_to_home(self, tmp_path, capsys): + def test_detail_checklist_falls_back_to_home(self, tmp_path, capsys, plain_records): ws = create_step_workspace(tmp_path, with_home_checklist=True) os.remove(os.path.join(ws, "Floorplan_ecc", "checklist.json")) - rc, data = run_step( - step_args(tmp_path, "floorplan", "--section", "checklist", "--json"), capsys + rc, records = run_step( + step_args(tmp_path, "floorplan", "--section", "checklist", "--plain"), + capsys, + plain_records, ) assert rc == 0 - head, summary, item = data["records"] + head, summary, item = records assert summary["source"] == "home" - assert summary["passed"] == 1 - assert summary["blocked"] == 0 + assert summary["passed"] == "1" + assert summary["blocked"] == "0" assert item["id"] == "artifact.floorplan.def" - def test_detail_unavailable_sections(self, tmp_path, capsys): + def test_detail_unavailable_sections(self, tmp_path, capsys, plain_records): create_step_workspace(tmp_path, with_drc=False) - rc, data = run_step(step_args(tmp_path, "timing_optimization", "--json"), capsys) + rc, records = run_step( + step_args(tmp_path, "timing_optimization", "--plain"), capsys, plain_records + ) assert rc == 0 - statuses = { - r["section"]: r["section_status"] for r in data["records"] if "section_status" in r - } + statuses = {r["section"]: r["section_status"] for r in records if "section_status" in r} # The sizer step has run facts but no analysis or checklist outputs. assert statuses == {"analysis": "unavailable", "checklist": "unavailable"} - feature_keys = [r["key"] for r in data["records"] if r.get("section") == "feature"] + feature_keys = [r["key"] for r in records if r.get("section") == "feature"] assert feature_keys == [ "run.peak_memory_mb", "run.runtime_seconds", "run.state", ] - def test_detail_accepts_flow_token_with_spaces(self, tmp_path, capsys): + def test_detail_accepts_flow_token_with_spaces(self, tmp_path, capsys, plain_records): create_step_workspace(tmp_path, with_drc=False) - rc, data = run_step(step_args(tmp_path, "timing optimization", "--json"), capsys) + rc, records = run_step( + step_args(tmp_path, "timing optimization", "--plain"), capsys, plain_records + ) assert rc == 0 - assert data["records"][0]["step"] == "timing_optimization" + assert records[0]["step"] == "timing_optimization" - def test_detail_text(self, tmp_path, capsys): + def test_detail_text(self, tmp_path, capsys, plain_records): create_step_workspace(tmp_path) rc = cli_main.run(step_args(tmp_path, "drc")) @@ -604,51 +612,55 @@ def test_detail_text(self, tmp_path, capsys): class TestStepErrors: - def test_unknown_step(self, tmp_path, capsys): + def test_unknown_step(self, tmp_path, capsys, plain_records): create_step_workspace(tmp_path, with_drc=False) - rc, data = run_step(step_args(tmp_path, "nope", "--json"), capsys) + rc, records = run_step(step_args(tmp_path, "nope", "--plain"), capsys, plain_records) assert rc == 1 - assert data["records"] == [ + assert records == [ { "kind": "error", "error": "unknown_step", "step": "nope", - "available": ["floorplan", "timing_optimization"], + "available": "['floorplan', 'timing_optimization']", "inspect": f"ecc report step --project {tmp_path} --workspace ws", } ] - def test_invalid_section(self, tmp_path, capsys): + def test_invalid_section(self, tmp_path, capsys, plain_records): create_step_workspace(tmp_path, with_drc=False) - rc, data = run_step( - step_args(tmp_path, "floorplan", "--section", "bogus", "--json"), capsys + rc, records = run_step( + step_args(tmp_path, "floorplan", "--section", "bogus", "--plain"), capsys, plain_records ) assert rc == 1 - assert data["records"][0]["error"] == "invalid_section" - assert data["records"][0]["sections"] == ["feature", "analysis", "checklist"] + assert records[0]["error"] == "invalid_section" + assert records[0]["sections"] == "['feature', 'analysis', 'checklist']" - def test_section_requires_step(self, tmp_path, capsys): + def test_section_requires_step(self, tmp_path, capsys, plain_records): create_step_workspace(tmp_path, with_drc=False) - rc, data = run_step(step_args(tmp_path, "--section", "analysis", "--json"), capsys) + rc, records = run_step( + step_args(tmp_path, "--section", "analysis", "--plain"), capsys, plain_records + ) assert rc == 1 - assert data["records"][0]["error"] == "section_requires_step" + assert records[0]["error"] == "section_requires_step" - def test_missing_workspace_directory(self, tmp_path, capsys): - rc, data = run_step(step_args(tmp_path, "--json", workspace="absent"), capsys) + def test_missing_workspace_directory(self, tmp_path, capsys, plain_records): + rc, records = run_step( + step_args(tmp_path, "--plain", workspace="absent"), capsys, plain_records + ) assert rc == 1 - assert data["records"][0]["error"] == "missing_workspace" - assert data["records"][0]["workspace"] == str(tmp_path / "absent") + assert records[0]["error"] == "missing_workspace" + assert records[0]["workspace"] == str(tmp_path / "absent") class TestStepReadOnly: - def test_invocation_writes_nothing(self, tmp_path, capsys): + def test_invocation_writes_nothing(self, tmp_path, capsys, plain_records): ws = create_step_workspace(tmp_path) def snapshot(): @@ -658,6 +670,6 @@ def snapshot(): before = snapshot() cli_main.run(step_args(tmp_path)) - cli_main.run(step_args(tmp_path, "floorplan", "--json")) + cli_main.run(step_args(tmp_path, "floorplan", "--plain")) assert snapshot() == before diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py index 3c12e8a96..bda8d81db 100644 --- a/test/cli/commands/test_run.py +++ b/test/cli/commands/test_run.py @@ -62,17 +62,17 @@ def test_run_overwrite_removes_existing( assert rc == 0 def test_run_fails_if_target_dir_exists_without_overwrite( - self, tmp_path, create_cli_project, capsys, mock_pdk_validation + self, tmp_path, create_cli_project, capsys, mock_pdk_validation, plain_records ): mock_pdk_validation() project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") os.makedirs(run_dir) - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc == 1 - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert record["error"] == "run_exists" assert record["workspace_id"] == "default" @@ -102,47 +102,35 @@ def test_run_fails_when_run_steps_false(self, tmp_path, create_cli_project, flow rc = cli_main.run(["run", "--project", project_dir]) assert rc == 1 - def test_run_json_uses_non_progress_path( - self, tmp_path, capsys, create_cli_project, flow_mocks + def test_run_plain_uses_non_progress_path( + self, tmp_path, capsys, create_cli_project, flow_mocks, plain_records ): project_dir = create_cli_project() - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc == 0 - out = capsys.readouterr().out - data = json.loads(out) - assert "records" in data - assert data["records"][0]["status"] == "success" + records = plain_records(capsys.readouterr().out) + assert records[0]["status"] == "success" assert flow_mocks.flow.instances[0].run_called - def test_run_jsonl_uses_non_progress_path( + def test_run_plain_no_progress_on_stderr( self, tmp_path, capsys, create_cli_project, flow_mocks ): project_dir = create_cli_project() - rc = cli_main.run(["run", "--project", project_dir, "--jsonl"]) - assert rc == 0 - out = capsys.readouterr().out - objects = [json.loads(ln) for ln in out.strip().split("\n")] - assert any("status" in obj for obj in objects) - assert flow_mocks.flow.instances[0].run_called - - def test_run_json_no_progress_on_stderr(self, tmp_path, capsys, create_cli_project, flow_mocks): - project_dir = create_cli_project() - - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc == 0 err = capsys.readouterr().err assert "step=" not in err - def test_run_preserves_final_records(self, tmp_path, capsys, create_cli_project, flow_mocks): + def test_run_preserves_final_records( + self, tmp_path, capsys, create_cli_project, flow_mocks, plain_records + ): project_dir = create_cli_project() - rc = cli_main.run(["run", "--project", project_dir, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) assert rc == 0 - out = capsys.readouterr().out - data = json.loads(out) - record = data["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert record["workspace_id"] == "default" assert record["status"] == "success" assert "inspect_cmd" in record @@ -212,14 +200,14 @@ def test_run_preset_flag_does_not_edit_toml( assert f.read() == before def test_run_preset_flag_rejects_unknown_preset( - self, tmp_path, capsys, monkeypatch, create_cli_project, flow_mocks + self, tmp_path, capsys, monkeypatch, create_cli_project, flow_mocks, plain_records ): project_dir = create_cli_project() _patch_all_flow_builders(monkeypatch) - rc = cli_main.run(["run", "--project", project_dir, "--preset", "bogus", "--json"]) + rc = cli_main.run(["run", "--project", project_dir, "--preset", "bogus", "--plain"]) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "unsupported_preset" assert record["preset"] == "bogus" @@ -384,7 +372,7 @@ def fake_load_workspace(path): ) return seen - def test_only_force_wiring(self, workspace_mocks, capsys): + def test_only_force_wiring(self, workspace_mocks, capsys, plain_records): rc = cli_main.run( [ "run", @@ -395,11 +383,11 @@ def test_only_force_wiring(self, workspace_mocks, capsys): "--only", "place", "--force", - "--json", + "--plain", ] ) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 0 assert workspace_mocks.load_path == workspace_mocks.run_dir assert workspace_mocks.selected == {"from_step": None, "only": "place", "force": True} @@ -408,8 +396,8 @@ def test_only_force_wiring(self, workspace_mocks, capsys): assert record["workspace_id"] == "workspace" assert record["status"] == "success" assert record["workspace"] == workspace_mocks.run_dir - assert record["executed_steps"] == ["place"] - assert record["no_op"] is False + assert record["executed_steps"] == "[place]" + assert record["no_op"] == "False" def test_default_selector_is_resume(self, workspace_mocks): rc = cli_main.run( @@ -420,7 +408,6 @@ def test_default_selector_is_resume(self, workspace_mocks): "--workspace", "workspace", "--resume", - "--plain", ] ) @@ -448,7 +435,7 @@ def test_from_step_wiring(self, workspace_mocks): assert workspace_mocks.from_step == "CTS" assert workspace_mocks.executable == {"CTS"} - def test_noop_selection_skips_workspace_rebuild(self, workspace_mocks, capsys): + def test_noop_selection_skips_workspace_rebuild(self, workspace_mocks, capsys, plain_records): workspace_mocks.result = StepRunResult(ok=True, executed=()) rc = cli_main.run( @@ -460,11 +447,11 @@ def test_noop_selection_skips_workspace_rebuild(self, workspace_mocks, capsys): "workspace", "--only", "place", - "--json", + "--plain", ] ) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 0 assert workspace_mocks.create_calls == 0 assert workspace_mocks.only == ("place", False) @@ -472,11 +459,13 @@ def test_noop_selection_skips_workspace_rebuild(self, workspace_mocks, capsys): "workspace_id": "workspace", "status": "success", "workspace": workspace_mocks.run_dir, - "executed_steps": [], - "no_op": True, + "executed_steps": "[]", + "no_op": "True", } - def test_failed_run_reports_failed_step_and_resume(self, workspace_mocks, capsys): + def test_failed_run_reports_failed_step_and_resume( + self, workspace_mocks, capsys, plain_records + ): workspace_mocks.result = StepRunResult(ok=False, executed=(), failed="place") rc = cli_main.run( @@ -488,30 +477,30 @@ def test_failed_run_reports_failed_step_and_resume(self, workspace_mocks, capsys "workspace", "--only", "place", - "--json", + "--plain", ] ) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record == { "workspace_id": "workspace", "status": "failed", "workspace": workspace_mocks.run_dir, - "executed_steps": [], - "no_op": False, + "executed_steps": "[]", + "no_op": "False", "failed_step": "place", "resume_cmd": "ecc run --workspace workspace --resume", } - def test_invalid_workspace(self, tmp_path, capsys): - rc = cli_main.run(["run", "--workspace", str(tmp_path / "missing"), "--json"]) + def test_invalid_workspace(self, tmp_path, capsys, plain_records): + rc = cli_main.run(["run", "--workspace", str(tmp_path / "missing"), "--plain"]) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "invalid_workspace" - def test_missing_flow(self, workspace_mocks, capsys): + def test_missing_flow(self, workspace_mocks, capsys, plain_records): workspace_mocks.has_init = False rc = cli_main.run( @@ -522,15 +511,15 @@ def test_missing_flow(self, workspace_mocks, capsys): "--workspace", "workspace", "--resume", - "--json", + "--plain", ] ) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "missing_flow" - def test_unknown_step(self, workspace_mocks, capsys): + def test_unknown_step(self, workspace_mocks, capsys, plain_records): workspace_mocks.selected_error = ValueError("unknown step 'bogus'") rc = cli_main.run( @@ -542,11 +531,11 @@ def test_unknown_step(self, workspace_mocks, capsys): "workspace", "--only", "bogus", - "--json", + "--plain", ] ) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "unknown_step" assert "bogus" in record["reason"] @@ -580,23 +569,31 @@ def test_unknown_step(self, workspace_mocks, capsys): (["--from", "place"], "flow_range_requires_pair"), ], ) - def test_option_conflicts(self, argv, error, tmp_path, capsys, monkeypatch, create_cli_project): + def test_option_conflicts( + self, argv, error, tmp_path, capsys, monkeypatch, create_cli_project, plain_records + ): project_dir = create_cli_project() monkeypatch.setattr( "chipcompiler.data.load_workspace", lambda _path: pytest.fail("conflicts must be rejected before workspace load"), ) - rc = cli_main.run(["run", "--project", project_dir, *argv, "--json"]) + rc = cli_main.run(["run", "--project", project_dir, *argv, "--plain"]) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == error class TestWorkspaceNoOp: def test_complete_workspace_resume_is_noop( - self, tmp_path, capsys, monkeypatch, create_cli_project, minimal_ics55_pdk_factory + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + minimal_ics55_pdk_factory, + plain_records, ): import json as _json @@ -639,13 +636,13 @@ def test_complete_workspace_resume_is_noop( ) rc = cli_main.run( - ["run", "--project", project_dir, "--workspace", "workspace", "--resume", "--json"] + ["run", "--project", project_dir, "--workspace", "workspace", "--resume", "--plain"] ) assert rc == 0 - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert record["status"] == "success" - assert record["no_op"] is True + assert record["no_op"] == "True" # The adopted narrower target replaced the stale wider one. from chipcompiler.data.workspace_config import load_workspace_config diff --git a/test/cli/commands/test_signoff.py b/test/cli/commands/test_signoff.py index aa779e35e..9df845104 100644 --- a/test/cli/commands/test_signoff.py +++ b/test/cli/commands/test_signoff.py @@ -1,4 +1,3 @@ -import json import os from types import SimpleNamespace @@ -93,36 +92,36 @@ def fake_export(workspace, output_path, additional_files=None, *, include_debug= class TestSignoffInspect: def test_inspect_payload( - self, tmp_path, capsys, monkeypatch, create_cli_project, workspace_stub + self, tmp_path, capsys, monkeypatch, create_cli_project, workspace_stub, plain_records ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") os.makedirs(run_dir) _patch_inspect(monkeypatch) - rc = cli_main.run(["signoff", "inspect", "--project", project_dir, "--json"]) + rc = cli_main.run(["signoff", "inspect", "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + records = plain_records(capsys.readouterr().out) assert rc == 0 - summary = data["records"][0] + summary = records[0] assert summary["signoff"] == "inspect" assert summary["status"] == "attention" - groups = [r for r in data["records"] if "group" in r] + groups = [r for r in records if "group" in r] assert [g["group"] for g in groups] == ["harden", "sta"] - risks = [r for r in data["records"] if "risk" in r] + risks = [r for r in records if "risk" in r] assert risks[0]["title"] == "STA report missing" def test_inspect_blocked_still_exits_zero( - self, tmp_path, capsys, monkeypatch, create_cli_project, workspace_stub + self, tmp_path, capsys, monkeypatch, create_cli_project, workspace_stub, plain_records ): project_dir = create_cli_project() os.makedirs(os.path.join(project_dir, "default")) _patch_inspect(monkeypatch, {"status": "blocked", "groups": [], "risks": []}) - rc = cli_main.run(["signoff", "inspect", "--project", project_dir, "--json"]) + rc = cli_main.run(["signoff", "inspect", "--project", project_dir, "--plain"]) assert rc == 0 - assert json.loads(capsys.readouterr().out)["records"][0]["status"] == "blocked" + assert plain_records(capsys.readouterr().out)[0]["status"] == "blocked" def test_inspect_text_rendering( self, tmp_path, capsys, monkeypatch, create_cli_project, workspace_stub @@ -144,14 +143,14 @@ def test_inspect_with_workspace_flag(self, tmp_path, capsys, monkeypatch, worksp os.makedirs(tmp_path / "ws") rc = cli_main.run( - ["signoff", "inspect", "--project", str(tmp_path), "--workspace", "ws", "--json"] + ["signoff", "inspect", "--project", str(tmp_path), "--workspace", "ws", "--plain"] ) assert rc == 0 assert workspace_stub.seen.load_path == str(tmp_path / "ws") def test_unresolved_workspace_rejected_before_load( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records ): monkeypatch.setattr( "chipcompiler.data.load_workspace", @@ -159,39 +158,41 @@ def test_unresolved_workspace_rejected_before_load( ) rc = cli_main.run( - ["signoff", "inspect", "--project", str(tmp_path), "--workspace", "absent", "--json"] + ["signoff", "inspect", "--project", str(tmp_path), "--workspace", "absent", "--plain"] ) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "missing_workspace" assert record["workspace"] == str(tmp_path / "absent") - def test_missing_run_workspace(self, tmp_path, capsys, monkeypatch, create_cli_project): + def test_missing_run_workspace( + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records + ): project_dir = create_cli_project() # no runs/default directory - rc = cli_main.run(["signoff", "inspect", "--project", project_dir, "--json"]) + rc = cli_main.run(["signoff", "inspect", "--project", project_dir, "--plain"]) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "missing_workspace" class TestSignoffExport: def test_export_records_path( - self, tmp_path, capsys, monkeypatch, create_cli_project, workspace_stub + self, tmp_path, capsys, monkeypatch, create_cli_project, workspace_stub, plain_records ): project_dir = create_cli_project() os.makedirs(os.path.join(project_dir, "default")) calls = _patch_export(monkeypatch, destination="/tmp/out/pkg.tar.gz") rc = cli_main.run( - ["signoff", "export", "-o", "/tmp/out/pkg.tar.gz", "--project", project_dir, "--json"] + ["signoff", "export", "-o", "/tmp/out/pkg.tar.gz", "--project", project_dir, "--plain"] ) - data = json.loads(capsys.readouterr().out) + records = plain_records(capsys.readouterr().out) assert rc == 0 - assert data["records"][0] == { + assert records[0] == { "signoff": "export", "status": "exported", "path": "/tmp/out/pkg.tar.gz", @@ -216,7 +217,6 @@ def test_export_forwards_include_debug( "--include-debug", "--project", project_dir, - "--json", ] ) @@ -224,7 +224,7 @@ def test_export_forwards_include_debug( assert calls[0]["include_debug"] is True def test_export_incomplete_maps_to_error( - self, tmp_path, capsys, monkeypatch, create_cli_project, workspace_stub + self, tmp_path, capsys, monkeypatch, create_cli_project, workspace_stub, plain_records ): from chipcompiler.runtime.workspace_api import RuntimeApiError @@ -236,10 +236,10 @@ def test_export_incomplete_maps_to_error( ) rc = cli_main.run( - ["signoff", "export", "-o", "/tmp/pkg.tar.gz", "--project", project_dir, "--json"] + ["signoff", "export", "-o", "/tmp/pkg.tar.gz", "--project", project_dir, "--plain"] ) - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert rc == 1 assert record["error"] == "signoff_incomplete" assert "incomplete" in record["reason"] diff --git a/test/cli/commands/test_status.py b/test/cli/commands/test_status.py index 2b6d684b3..99f458619 100644 --- a/test/cli/commands/test_status.py +++ b/test/cli/commands/test_status.py @@ -6,7 +6,7 @@ class TestStatus: def test_status_normalizes_every_rtl2gds_step( - self, tmp_path, capsys, create_cli_project, create_flow_json + self, tmp_path, capsys, create_cli_project, create_flow_json, plain_records ): from chipcompiler.rtl2gds.builder import build_rtl2gds_flow @@ -21,10 +21,10 @@ def test_status_normalizes_every_rtl2gds_step( ], ) - rc = cli_main.run(["status", "--json", "--project", project_dir]) + rc = cli_main.run(["status", "--plain", "--project", project_dir]) assert rc == 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert [record["step"] for record in records if "step" in record] == [ "synthesis", "lec", @@ -55,16 +55,16 @@ def test_status_reads_flow_json(self, tmp_path, capsys, create_cli_project, crea assert "synthesis" in out assert "floorplan" in out - def test_status_json(self, tmp_path, capsys, create_cli_project, create_flow_json): + def test_status_plain( + self, tmp_path, capsys, create_cli_project, create_flow_json, plain_records + ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") create_flow_json(run_dir, profile="main") - rc = cli_main.run(["status", "--project", project_dir, "--json"]) + rc = cli_main.run(["status", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - records = data["records"] + records = plain_records(capsys.readouterr().out) assert records[0]["workspace_id"] == "default" assert records[0]["status"] == "success" assert "inspect_cmd" in records[0] @@ -75,18 +75,6 @@ def test_status_json(self, tmp_path, capsys, create_cli_project, create_flow_jso assert all("log_cmd" in r for r in step_records) assert all("metrics_cmd" not in r for r in step_records) - def test_status_jsonl(self, tmp_path, capsys, create_cli_project, create_flow_json): - project_dir = create_cli_project() - run_dir = os.path.join(project_dir, "default") - create_flow_json(run_dir, profile="main") - - rc = cli_main.run(["status", "--project", project_dir, "--jsonl"]) - assert rc == 0 - lines = capsys.readouterr().out.strip().split("\n") - objects = [json.loads(ln) for ln in lines] - assert "workspace_id" in objects[0] - assert "step" in objects[1] - def test_status_normalizes_step_names( self, tmp_path, capsys, create_cli_project, create_flow_json ): @@ -106,25 +94,6 @@ def test_status_normalizes_step_names( assert "synthesis" in out assert "placement" in out - def test_status_reports_warning_when_flow_has_non_blocking_warning( - self, tmp_path, capsys, create_cli_project, create_flow_json - ): - project_dir = create_cli_project() - run_dir = os.path.join(project_dir, "default") - create_flow_json( - run_dir, - [ - {"name": "Synthesis", "tool": "yosys", "state": "Success"}, - {"name": "lec", "tool": "yosys_lec", "state": "Warning"}, - {"name": "Floorplan", "tool": "ecc", "state": "Success"}, - ], - ) - - rc = cli_main.run(["status", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["status"] == "warning" - def test_status_missing_run(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() @@ -149,7 +118,7 @@ def test_status_invalid_flow_json(self, tmp_path, capsys, create_cli_project): class TestCorruptFlowJson: """Non-dict flow.json must be reported as corrupt, not missing.""" - def test_array_flow_json_is_corrupt(self, tmp_path, capsys, create_cli_project): + def test_array_flow_json_is_corrupt(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") home = os.path.join(run_dir, "home") @@ -157,12 +126,12 @@ def test_array_flow_json_is_corrupt(self, tmp_path, capsys, create_cli_project): with open(os.path.join(home, "flow.json"), "w") as f: json.dump([], f) - rc = cli_main.run(["status", "--json", "--project", project_dir]) + rc = cli_main.run(["status", "--plain", "--project", project_dir]) assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0].get("status") == "corrupt" + records = plain_records(capsys.readouterr().out) + assert records[0].get("status") == "corrupt" - def test_string_flow_json_is_corrupt(self, tmp_path, capsys, create_cli_project): + def test_string_flow_json_is_corrupt(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") home = os.path.join(run_dir, "home") @@ -170,10 +139,10 @@ def test_string_flow_json_is_corrupt(self, tmp_path, capsys, create_cli_project) with open(os.path.join(home, "flow.json"), "w") as f: json.dump("bad", f) - rc = cli_main.run(["status", "--json", "--project", project_dir]) + rc = cli_main.run(["status", "--plain", "--project", project_dir]) assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0].get("status") == "corrupt" + records = plain_records(capsys.readouterr().out) + assert records[0].get("status") == "corrupt" class TestRunStatusStates: @@ -184,14 +153,13 @@ def flow(*states): return {"steps": [{"state": state} for state in states]} assert get_run_status(flow("Success", "Unstart")) == "partial" - assert get_run_status(flow("Success", "Warning", "Unstart")) == "partial" assert get_run_status(flow("Success")) == "success" assert get_run_status(flow("Unstart")) == "unstart" assert get_run_status(flow("Success", "Incomplete")) == "failed" assert get_run_status(flow("Success", "Ongoing")) == "ongoing" def test_status_reports_partial_after_bounded_rerun( - self, tmp_path, capsys, create_cli_project, create_flow_json + self, tmp_path, capsys, create_cli_project, create_flow_json, plain_records ): project_dir = create_cli_project() create_flow_json( @@ -202,8 +170,8 @@ def test_status_reports_partial_after_bounded_rerun( ], ) - rc = cli_main.run(["status", "--json", "--project", project_dir]) + rc = cli_main.run(["status", "--plain", "--project", project_dir]) assert rc == 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert records[0]["status"] == "partial" diff --git a/test/cli/commands/test_workspace_range.py b/test/cli/commands/test_workspace_range.py index 06e551956..3cfce1a91 100644 --- a/test/cli/commands/test_workspace_range.py +++ b/test/cli/commands/test_workspace_range.py @@ -24,7 +24,7 @@ def _set_design_inputs(project_dir: str) -> tuple[Path, Path]: def test_new_workspace_range_uses_ecc_toml_inputs_and_registers_before_execution( - monkeypatch, tmp_path, capsys, create_cli_project, flow_mocks + monkeypatch, tmp_path, capsys, create_cli_project, flow_mocks, plain_records ): project_dir = create_cli_project() design_def, netlist = _set_design_inputs(project_dir) @@ -44,7 +44,7 @@ def test_new_workspace_range_uses_ecc_toml_inputs_and_registers_before_execution "CTS", "--to", "CTS", - "--json", + "--plain", ] ) @@ -59,26 +59,28 @@ def test_new_workspace_range_uses_ecc_toml_inputs_and_registers_before_execution assert entry["workspace_id"] == "cts-only" assert entry["status"] == "success" assert "input_snapshot" not in entry - result = json.loads(capsys.readouterr().out)["records"][0] + result = plain_records(capsys.readouterr().out)[0] assert result["workspace_id"] == "cts-only" assert result["status"] == "success" -def test_fresh_workspace_requires_a_complete_flow_range(tmp_path, capsys, create_cli_project): +def test_fresh_workspace_requires_a_complete_flow_range( + tmp_path, capsys, create_cli_project, plain_records +): project_dir = create_cli_project() rc = cli_main.run( - ["run", "--project", project_dir, "--workspace", "cts-only", "--from", "CTS", "--json"] + ["run", "--project", project_dir, "--workspace", "cts-only", "--from", "CTS", "--plain"] ) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ {"kind": "error", "error": "flow_range_requires_pair"} ] assert not (Path(project_dir) / "project.json").exists() -def test_flow_range_rejects_overwrite(tmp_path, capsys, create_cli_project): +def test_flow_range_rejects_overwrite(tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() rc = cli_main.run( @@ -93,11 +95,11 @@ def test_flow_range_rejects_overwrite(tmp_path, capsys, create_cli_project): "--to", "CTS", "--overwrite", - "--json", + "--plain", ] ) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ {"kind": "error", "error": "selector_conflict"} ] diff --git a/test/cli/commands/test_workspace_refresh.py b/test/cli/commands/test_workspace_refresh.py index fe210579e..bc35a9697 100644 --- a/test/cli/commands/test_workspace_refresh.py +++ b/test/cli/commands/test_workspace_refresh.py @@ -11,6 +11,7 @@ def test_workspace_refresh_recreates_without_running( create_flow_json, flow_mocks, manifest_stubs, + plain_records, ): project_dir = create_cli_project() workspace_dir = os.path.join(project_dir, "baseline") @@ -18,12 +19,50 @@ def test_workspace_refresh_recreates_without_running( manifest_stubs.write(project_path, [manifest_stubs.entry(project_path, "baseline")]) create_flow_json(workspace_dir) - rc = cli_main.run(["workspace", "refresh", "baseline", "--project", project_dir, "--json"]) + rc = cli_main.run(["workspace", "refresh", "baseline", "--project", project_dir, "--plain"]) assert rc == 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert records[-1]["status"] == "refreshed" assert flow_mocks.flow.instances[-1].create_called is True assert flow_mocks.flow.instances[-1].run_called is False document = json.loads((project_path / "project.json").read_text()) assert document["workspaces"][0]["status"] == "not_started" + + +def test_refresh_failure_restores_the_previous_workspace( + capsys, + create_cli_project, + create_flow_json, + flow_mocks, + manifest_stubs, + monkeypatch, + plain_records, +): + project_dir = create_cli_project() + workspace_dir = os.path.join(project_dir, "baseline") + project_path = Path(project_dir) + manifest_stubs.write( + project_path, + [manifest_stubs.entry(project_path, "baseline", status="success")], + ) + create_flow_json(workspace_dir) + sentinel = Path(workspace_dir, "Synthesis_yosys", "output", "gcd.v.gz") + sentinel.parent.mkdir(parents=True) + sentinel.write_text("previous artifacts") + + def failing_create(**_kwargs): + raise RuntimeError("config generation exploded") + + monkeypatch.setattr("chipcompiler.data.create_workspace", failing_create) + + rc = cli_main.run(["workspace", "refresh", "baseline", "--project", project_dir, "--plain"]) + + assert rc == 1 + # The previous workspace is back at its path with its artifacts, and no + # rename-aside backup directory lingers behind. + assert sentinel.read_text() == "previous artifacts" + assert [name for name in os.listdir(project_dir) if "overwritten" in name] == [] + # The restored workspace keeps its prior manifest status. + document = json.loads((project_path / "project.json").read_text()) + assert document["workspaces"][0]["status"] == "success" diff --git a/test/cli/conftest.py b/test/cli/conftest.py index fd84107b0..6ec4c57d8 100644 --- a/test/cli/conftest.py +++ b/test/cli/conftest.py @@ -1,10 +1,38 @@ import json import os import re +import shlex import pytest +def parse_plain_records(out: str) -> list[dict[str, str]]: + """Parse `--plain` output (one key=value record per line) into dicts. + + All values come back as strings; compare against "3" rather than 3. + Quoted values may span multiple physical lines (e.g. multi-line reasons), + so lines accumulate until shlex can close the quotation. + """ + records = [] + buffer = "" + for line in out.splitlines(): + buffer = f"{buffer}\n{line}" if buffer else line + try: + fields = shlex.split(buffer) + except ValueError: + continue + records.append(dict(field.split("=", 1) for field in fields)) + buffer = "" + if buffer: + raise ValueError(f"unparseable --plain output: {buffer!r}") + return records + + +@pytest.fixture +def plain_records(): + return parse_plain_records + + def create_cli_project(tmp_path, name="gcd", pdk_root=None, freq=100.0): project_dir = tmp_path / name project_dir.mkdir(exist_ok=True) diff --git a/test/cli/inspect/test_config.py b/test/cli/inspect/test_config.py index a8b83bc44..f7e9cffc6 100644 --- a/test/cli/inspect/test_config.py +++ b/test/cli/inspect/test_config.py @@ -19,47 +19,32 @@ def test_config_resolved_project( assert "pdk.name" in out assert "run_dir" in out - def test_config_resolved_json( - self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation - ): - mock_pdk_validation() - project_dir = create_cli_project() - - rc = cli_main.run(["config", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - keys = [item["config"] for item in data["records"]] - assert "design.name" in keys - assert "pdk.name" in keys - assert "run_dir" in keys - def test_config_resolved_default_run_dir_value( - self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() - rc = cli_main.run(["config", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - run_item = next(i for i in data["records"] if i["config"] == "run_dir") + records = plain_records(capsys.readouterr().out) + run_item = next(i for i in records if i["config"] == "run_dir") assert run_item["value"] == "default" - def test_config_resolved_jsonl( - self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation - ): - mock_pdk_validation() - project_dir = create_cli_project() - - rc = cli_main.run(["config", "--jsonl", "--project", project_dir]) - assert rc == 0 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] - keys = [o["config"] for o in objects] - assert "design.name" in keys - def test_config_resolved_pdk_root_from_env( - self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + mock_pdk_validation, + plain_records, ): mock_pdk_validation() pdk_root = tmp_path / "ics55_env" @@ -68,14 +53,20 @@ def test_config_resolved_pdk_root_from_env( project_dir = create_cli_project(pdk_root="") - rc = cli_main.run(["config", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - pdk_item = next(i for i in data["records"] if i["config"] == "pdk.root") + records = plain_records(capsys.readouterr().out) + pdk_item = next(i for i in records if i["config"] == "pdk.root") assert pdk_item["source"] == "env" def test_config_resolved_workspace( - self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -85,14 +76,14 @@ def test_config_resolved_workspace( "config", "--workspace", "sweep_004", - "--json", + "--plain", "--project", project_dir, ] ) assert rc == 0 - data = json.loads(capsys.readouterr().out) - run_item = next(i for i in data["records"] if i["config"] == "run_dir") + records = plain_records(capsys.readouterr().out) + run_item = next(i for i in records if i["config"] == "run_dir") assert run_item["value"] == "sweep_004" def test_config_missing_config(self, tmp_path, capsys): @@ -102,24 +93,14 @@ def test_config_missing_config(self, tmp_path, capsys): rc = cli_main.run(["config", "--project", str(project_dir)]) assert rc == 1 - def test_config_missing_config_json_has_kind_error(self, tmp_path, capsys): - project_dir = tmp_path / "empty_project" - project_dir.mkdir() - - rc = cli_main.run(["config", "--project", str(project_dir), "--json"]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] - assert record["kind"] == "error" - assert record["error"] == "missing_config" - - def test_config_missing_config_jsonl_has_kind_error(self, tmp_path, capsys): + def test_config_missing_config_plain_has_kind_error(self, tmp_path, capsys, plain_records): project_dir = tmp_path / "empty_project" project_dir.mkdir() - rc = cli_main.run(["config", "--project", str(project_dir), "--jsonl"]) + rc = cli_main.run(["config", "--project", str(project_dir), "--plain"]) assert rc == 1 - record = json.loads(capsys.readouterr().out.strip()) + records = plain_records(capsys.readouterr().out) + record = records[0] assert record["kind"] == "error" assert record["error"] == "missing_config" @@ -138,7 +119,12 @@ def test_config_missing_config_text_has_kind_error(self, tmp_path, capsys): class TestConfigStepResolved: def test_config_accepts_extended_rtl2gds_step_token( - self, tmp_path, capsys, create_cli_project, create_flow_json + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + plain_records, ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") @@ -153,10 +139,10 @@ def test_config_accepts_extended_rtl2gds_step_token( ], ) - rc = cli_main.run(["config", "timing_optimization", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "timing_optimization", "--plain", "--project", project_dir]) assert rc == 0 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ {"step": "timing_optimization", "config_status": "none"} ] @@ -192,7 +178,7 @@ def test_config_step_lists_files( assert "default/config/db_ecc.json" in out assert "cts_ecc.json" in out - def test_config_step_json( + def test_config_step_plain_records( self, tmp_path, capsys, @@ -202,6 +188,7 @@ def test_config_step_json( create_step_dir, create_workspace_config, mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -216,11 +203,9 @@ def test_config_step_json( }, ) - rc = cli_main.run(["config", "cts", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "cts", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - records = data["records"] + records = plain_records(capsys.readouterr().out) assert all(item["scope"] == "step" for item in records) assert all(item["step"] == "cts" for item in records) assert all(item["source"] == "workspace_config" for item in records) @@ -239,6 +224,7 @@ def test_config_step_workspace_records_inspect_with_config_command( create_step_dir, create_cts_workspace_config, mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -247,12 +233,12 @@ def test_config_step_workspace_records_inspect_with_config_command( create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) create_cts_workspace_config(run_dir) - rc = cli_main.run(["config", "cts", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "cts", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) + records = plain_records(capsys.readouterr().out) assert all( - item["inspect"] == f"ecc config cts --json --project {project_dir} --workspace default" - for item in data["records"] + item["inspect"] == f"ecc config cts --project {project_dir} --workspace default" + for item in records ) def test_config_step_unknown_step(self, tmp_path, capsys, create_cli_project): @@ -284,6 +270,7 @@ def test_config_dreamplace_legalization_uses_dreamplace_config( create_step_dir, create_dreamplace_workspace_config, mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -302,13 +289,13 @@ def test_config_dreamplace_legalization_uses_dreamplace_config( create_step_dir(run_dir, "legalization", "dreamplace", subdirs=["output"]) create_dreamplace_workspace_config(run_dir) - rc = cli_main.run(["config", "legalization", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "legalization", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert [item["path"] for item in data["records"]] == [ + records = plain_records(capsys.readouterr().out) + assert [item["path"] for item in records] == [ "default/config/dreamplace_ecc.json", ] - assert data["records"][0]["source"] == "workspace_config" + assert records[0]["source"] == "workspace_config" def test_config_sizer_timing_opt_uses_db_and_dreamplace_configs( self, @@ -318,6 +305,7 @@ def test_config_sizer_timing_opt_uses_db_and_dreamplace_configs( create_flow_json, create_step_dir, create_workspace_config, + plain_records, ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") @@ -341,15 +329,15 @@ def test_config_sizer_timing_opt_uses_db_and_dreamplace_configs( }, ) - rc = cli_main.run(["config", "timing optimization", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "timing optimization", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert [item["path"] for item in data["records"]] == [ + records = plain_records(capsys.readouterr().out) + assert [item["path"] for item in records] == [ "default/config/db_ecc.json", "default/config/dreamplace_ecc.json", ] - assert all(item["source"] == "workspace_config" for item in data["records"]) - assert all(item["step"] == "timing optimization" for item in data["records"]) + assert all(item["source"] == "workspace_config" for item in records) + assert all(item["step"] == "timing optimization" for item in records) def test_config_cli_tokens_use_internal_flow_step_names( self, @@ -358,6 +346,7 @@ def test_config_cli_tokens_use_internal_flow_step_names( create_flow_json, create_step_dir, create_ecc_workspace_config, + plain_records, ): cases = [ ("place", "placement", None), @@ -380,16 +369,16 @@ def test_config_cli_tokens_use_internal_flow_step_names( create_step_dir(run_dir, step_name, "ecc", subdirs=["output"]) create_ecc_workspace_config(run_dir, step_config or "filler_ecc.json") - rc = cli_main.run(["config", step_token, "--json", "--project", project_dir]) + rc = cli_main.run(["config", step_token, "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) + records = plain_records(capsys.readouterr().out) expected = [ "default/config/db_ecc.json", ] if step_config: expected.append(f"default/config/{step_config}") - assert [item["path"] for item in data["records"]] == expected - assert all(item["step"] == step_token for item in data["records"]) + assert [item["path"] for item in records] == expected + assert all(item["step"] == step_token for item in records) def test_config_sta_uses_rcx_and_sta_workspace_configs( self, @@ -399,6 +388,7 @@ def test_config_sta_uses_rcx_and_sta_workspace_configs( create_flow_json, create_step_dir, create_workspace_config, + plain_records, ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") @@ -423,15 +413,15 @@ def test_config_sta_uses_rcx_and_sta_workspace_configs( }, ) - rc = cli_main.run(["config", "sta", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "sta", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert [item["path"] for item in data["records"]] == [ + records = plain_records(capsys.readouterr().out) + assert [item["path"] for item in records] == [ "default/config/db_ecc.json", "default/config/rcx_ecc.json", "default/config/sta_ecc.json", ] - assert all(item["source"] == "workspace_config" for item in data["records"]) + assert all(item["source"] == "workspace_config" for item in records) def test_config_yosys_synthesis_does_not_report_ecc_workspace_configs( self, @@ -441,6 +431,7 @@ def test_config_yosys_synthesis_does_not_report_ecc_workspace_configs( create_flow_json, create_step_dir, create_workspace_config, + plain_records, ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") @@ -458,13 +449,13 @@ def test_config_yosys_synthesis_does_not_report_ecc_workspace_configs( create_step_dir(run_dir, "Synthesis", "yosys", subdirs=["output"]) create_workspace_config(run_dir, {"db_ecc.json": "{}"}) - rc = cli_main.run(["config", "synthesis", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "synthesis", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert len(data["records"]) == 1 - assert data["records"][0]["step"] == "synthesis" - assert data["records"][0]["config_status"] == "none" - assert "path" not in data["records"][0] + records = plain_records(capsys.readouterr().out) + assert len(records) == 1 + assert records[0]["step"] == "synthesis" + assert records[0]["config_status"] == "none" + assert "path" not in records[0] class TestEmptyStepConfigSentinel: @@ -485,19 +476,25 @@ def test_step_no_config_emits_sentinel_text( assert "ecc artifacts" not in out def test_step_no_config_emits_sentinel_json( - self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + plain_records, ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") create_flow_json(run_dir) create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) - rc = cli_main.run(["config", "cts", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "cts", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["step"] == "cts" - assert data["records"][0]["config_status"] == "none" - assert "artifacts" not in data["records"][0] + records = plain_records(capsys.readouterr().out) + assert records[0]["step"] == "cts" + assert records[0]["config_status"] == "none" + assert "artifacts" not in records[0] class TestDirectoryOnlyStepConfig: @@ -509,6 +506,7 @@ def test_dir_only_step_config_infers_tool_from_step_dir( create_flow_json, create_step_dir, create_cts_workspace_config, + plain_records, ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") @@ -521,10 +519,10 @@ def test_dir_only_step_config_infers_tool_from_step_dir( create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) create_cts_workspace_config(run_dir) - rc = cli_main.run(["config", "cts", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "cts", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert [item["path"] for item in data["records"]] == [ + records = plain_records(capsys.readouterr().out) + assert [item["path"] for item in records] == [ "default/config/db_ecc.json", "default/config/cts_ecc.json", ] @@ -536,6 +534,7 @@ def test_dir_only_routing_uses_internal_step_directory_prefix( create_flow_json, create_step_dir, create_ecc_workspace_config, + plain_records, ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") @@ -548,10 +547,10 @@ def test_dir_only_routing_uses_internal_step_directory_prefix( create_step_dir(run_dir, "route", "ecc", subdirs=["output"]) create_ecc_workspace_config(run_dir, "route_ecc.json") - rc = cli_main.run(["config", "routing", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "routing", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert [item["path"] for item in data["records"]] == [ + records = plain_records(capsys.readouterr().out) + assert [item["path"] for item in records] == [ "default/config/db_ecc.json", "default/config/route_ecc.json", ] @@ -565,6 +564,7 @@ def test_absolute_workspace_selector_rejected( monkeypatch, create_cli_project, mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -575,13 +575,13 @@ def test_absolute_workspace_selector_rejected( "config", "--workspace", str(external_run), - "--json", + "--plain", "--project", project_dir, ] ) assert rc == 1 - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert record["error"] == "invalid_workspace" assert "invalid_workspace" in record["reason"] @@ -602,15 +602,21 @@ def test_run_dir_text_uses_status_command( class TestConfigJsonDisclosure: def test_project_config_json_has_inspect_cmd( - self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() - rc = cli_main.run(["config", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - for item in data["records"]: + records = plain_records(capsys.readouterr().out) + for item in records: assert "inspect" in item, f"Missing inspect in item: {item['config']}" @@ -660,7 +666,9 @@ def test_named_flow_run_rejected(self, tmp_path, capsys, monkeypatch, mock_pdk_v assert rc == 1 assert "invalid_config" in capsys.readouterr().out - def test_invalid_flow_run_rejected(self, tmp_path, capsys, monkeypatch, mock_pdk_validation): + def test_invalid_flow_run_rejected( + self, tmp_path, capsys, monkeypatch, mock_pdk_validation, plain_records + ): mock_pdk_validation() project_dir = tmp_path / "bad_run" project_dir.mkdir() @@ -670,9 +678,9 @@ def test_invalid_flow_run_rejected(self, tmp_path, capsys, monkeypatch, mock_pdk assert rc == 1 assert "invalid_config" in capsys.readouterr().out - rc = cli_main.run(["check", "--json", "--project", str(project_dir)]) + rc = cli_main.run(["check", "--plain", "--project", str(project_dir)]) assert rc == 1 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) reasons = [record.get("reason", "") for record in records] assert any("[flow].run is not supported" in reason for reason in reasons) @@ -706,7 +714,12 @@ def test_empty_rtl_rejected(self, tmp_path, capsys, monkeypatch, mock_pdk_valida class TestRtlPathResolution: def test_absolute_rtl_resolved_correctly( - self, tmp_path, capsys, monkeypatch, mock_pdk_validation + self, + tmp_path, + capsys, + monkeypatch, + mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = tmp_path / "proj" @@ -729,10 +742,10 @@ def test_absolute_rtl_resolved_correctly( preset = "rtl2gds" ''') (tmp_path / "pdk").mkdir(exist_ok=True) - rc = cli_main.run(["config", "--json", "--project", str(project_dir)]) + rc = cli_main.run(["config", "--plain", "--project", str(project_dir)]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - rtl_item = next(i for i in data["records"] if i["config"] == "design.rtl.0") + records = plain_records(capsys.readouterr().out) + rtl_item = next(i for i in records if i["config"] == "design.rtl.0") assert rtl_item["resolved"] == str(rtl_dir / "gcd.v") @@ -742,6 +755,7 @@ def test_config_resolved_pdk_overrides_present( monkeypatch, create_cli_project, mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() @@ -749,13 +763,13 @@ def test_config_resolved_pdk_overrides_present( with open(toml_path, "a") as f: f.write('\n[pdk.overrides]\ndont_use = ["ICG*", "DFFSRQX*"]\n') - rc = cli_main.run(["config", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - keys = [item["config"] for item in data["records"]] + records = plain_records(capsys.readouterr().out) + keys = [item["config"] for item in records] assert "pdk.overrides" in keys - overrides_item = next(i for i in data["records"] if i["config"] == "pdk.overrides") - assert overrides_item["value"] == {"dont_use": ["ICG*", "DFFSRQX*"]} + overrides_item = next(i for i in records if i["config"] == "pdk.overrides") + assert overrides_item["value"] == "{'dont_use': ['ICG*', 'DFFSRQX*']}" def test_config_resolved_pdk_overrides_absent( @@ -764,17 +778,18 @@ def test_config_resolved_pdk_overrides_absent( monkeypatch, create_cli_project, mock_pdk_validation, + plain_records, ): mock_pdk_validation() project_dir = create_cli_project() - rc = cli_main.run(["config", "--json", "--project", project_dir]) + rc = cli_main.run(["config", "--plain", "--project", project_dir]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - keys = [item["config"] for item in data["records"]] + records = plain_records(capsys.readouterr().out) + keys = [item["config"] for item in records] assert "pdk.overrides" not in keys - def test_config_resolved_renders_manifest_only_project(self, tmp_path, capsys): + def test_config_resolved_renders_manifest_only_project(self, tmp_path, capsys, plain_records): project_dir = tmp_path / "proj" project_dir.mkdir() (project_dir / "rtl").mkdir() @@ -801,10 +816,10 @@ def test_config_resolved_renders_manifest_only_project(self, tmp_path, capsys): } (project_dir / "project.json").write_text(json.dumps(manifest)) - rc = cli_main.run(["config", "--project", str(project_dir), "--json"]) + rc = cli_main.run(["config", "--project", str(project_dir), "--plain"]) assert rc == 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) by_key = {r.get("key"): r for r in records if r.get("kind") == "config"} assert by_key["design.top"]["resolved"] == "gcd" assert by_key["design.top"]["source"] == "project.json" diff --git a/test/cli/inspect/test_config_strict.py b/test/cli/inspect/test_config_strict.py index 78a46b6e0..55ab98ecd 100644 --- a/test/cli/inspect/test_config_strict.py +++ b/test/cli/inspect/test_config_strict.py @@ -1,4 +1,3 @@ -import json import os import pytest @@ -20,6 +19,7 @@ def test_step_config_ignores_legacy_flow_run_with_selector( set_flow_run, toml_line, selector, + plain_records, ): """The step view reads only the workspace; a legacy [flow].run key in ecc.toml must not break step-scoped config listing.""" @@ -33,11 +33,11 @@ def test_step_config_ignores_legacy_flow_run_with_selector( args = ["config", "cts"] if selector is not None: args += ["--workspace", selector] - args += ["--project", project_dir, "--json"] + args += ["--project", project_dir, "--plain"] rc = cli_main.run(args) assert rc == 0 - records = json.loads(capsys.readouterr().out)["records"] + records = plain_records(capsys.readouterr().out) assert [item["path"] for item in records] == [ "default/config/db_ecc.json", "default/config/cts_ecc.json", @@ -46,7 +46,12 @@ def test_step_config_ignores_legacy_flow_run_with_selector( class TestConfigUnreadableFallback: def test_config_resolved_reports_invalid_config_on_unreadable_toml( - self, tmp_path, capsys, create_cli_project, monkeypatch + self, + tmp_path, + capsys, + create_cli_project, + monkeypatch, + plain_records, ): project_dir = create_cli_project() @@ -55,10 +60,10 @@ def deny(config_path): monkeypatch.setattr("chipcompiler.cli.project.config.load_project_config", deny) - rc = cli_main.run(["config", "--project", project_dir, "--json"]) + rc = cli_main.run(["config", "--project", project_dir, "--plain"]) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "invalid_config", @@ -67,16 +72,20 @@ def deny(config_path): ] def test_config_resolved_reports_invalid_config_on_non_utf8_toml( - self, tmp_path, capsys, create_cli_project + self, + tmp_path, + capsys, + create_cli_project, + plain_records, ): project_dir = create_cli_project() with open(os.path.join(project_dir, "ecc.toml"), "wb") as f: f.write(b'[flow]\nrun = "\xff\xfe"\n') - rc = cli_main.run(["config", "--project", project_dir, "--json"]) + rc = cli_main.run(["config", "--project", project_dir, "--plain"]) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ { "kind": "error", "error": "invalid_config", diff --git a/test/cli/params/test_commands.py b/test/cli/params/test_commands.py index 958260b00..abeeac71c 100644 --- a/test/cli/params/test_commands.py +++ b/test/cli/params/test_commands.py @@ -12,23 +12,6 @@ def test_param_list_text_output(self, tmp_path, capsys, create_cli_project): out = capsys.readouterr().out assert "place.target_density" in out - def test_param_list_json(self, tmp_path, capsys, create_cli_project): - project_dir = create_cli_project() - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - params = [r["param"] for r in data["records"]] - assert "place.target_density" in params - - def test_param_list_jsonl(self, tmp_path, capsys, create_cli_project): - project_dir = create_cli_project() - rc = cli_main.run(["param", "list", "--project", project_dir, "--jsonl"]) - assert rc == 0 - lines = capsys.readouterr().out.strip().split("\n") - objects = [json.loads(ln) for ln in lines] - assert len(objects) == 14 - def test_param_list_plain(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) @@ -52,16 +35,15 @@ def test_param_show_known_key(self, tmp_path, capsys, create_cli_project): out = capsys.readouterr().out assert "place.target_density" in out - def test_param_show_json(self, tmp_path, capsys, create_cli_project): + def test_param_show_records(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ["param", "show", "place.target_density", "--project", project_dir, "--plain"] ) assert rc == 0 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert record["param"] == "place.target_density" - assert record["default"] == 0.2 + assert record["default"] == "0.2" assert "source" in record assert "maps_to" in record @@ -85,18 +67,17 @@ def test_param_set_writes_toml(self, tmp_path, capsys, create_cli_project): assert "target_density" in content assert "0.65" in content - def test_param_set_then_show(self, tmp_path, capsys, create_cli_project): + def test_param_set_then_show(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) capsys.readouterr() # flush set output rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ["param", "show", "place.target_density", "--project", project_dir, "--plain"] ) assert rc == 0 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] - assert record["value"] == 0.65 + record = plain_records(capsys.readouterr().out)[0] + assert record["value"] == "0.65" assert record["source"] == "ecc.toml" def test_param_set_rejects_unknown_key(self, tmp_path, capsys, create_cli_project): @@ -109,13 +90,15 @@ def test_param_set_rejects_invalid_value(self, tmp_path, capsys, create_cli_proj rc = cli_main.run(["param", "set", "place.target_density", "1.5", "--project", project_dir]) assert rc == 1 - def test_param_set_accepts_negative_value(self, tmp_path, capsys, create_cli_project): + def test_param_set_accepts_negative_value( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() rc = cli_main.run( - ["param", "set", "--project", project_dir, "--json", "place.max_net_weight", "-1"] + ["param", "set", "--project", project_dir, "--plain", "place.max_net_weight", "-1"] ) assert rc == 0 - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert record["value"] == "-1" def test_param_set_preserves_other_sections(self, tmp_path, capsys, create_cli_project): @@ -147,7 +130,7 @@ def test_param_set_nested_config_writes_toml(self, tmp_path, capsys, create_cli_ assert 'mode = "die_size"' in content def test_pdk_path_param_writes_pdk_overrides( - self, tmp_path, monkeypatch, capsys, create_cli_project + self, tmp_path, monkeypatch, capsys, create_cli_project, plain_records ): project_dir = create_cli_project() monkeypatch.setattr( @@ -167,9 +150,9 @@ def test_pdk_path_param_writes_pdk_overrides( assert 'tech = "prtech/custom.lef"' in content capsys.readouterr() - rc = cli_main.run(["param", "show", "pdk.tech", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "show", "pdk.tech", "--project", project_dir, "--plain"]) assert rc == 0 - record = json.loads(capsys.readouterr().out)["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert record["value"] == "prtech/custom.lef" assert record["pdk_target"] == "pdk.overrides:tech" @@ -180,7 +163,9 @@ def test_pdk_path_param_writes_pdk_overrides( class TestParamUnset: - def test_param_unset_removes_override(self, tmp_path, capsys, create_cli_project): + def test_param_unset_removes_override( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) capsys.readouterr() # flush set output @@ -190,11 +175,10 @@ def test_param_unset_removes_override(self, tmp_path, capsys, create_cli_project capsys.readouterr() # flush unset output rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ["param", "show", "place.target_density", "--project", project_dir, "--plain"] ) assert rc == 0 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert record["source"] == "default" def test_param_unset_noop_when_absent(self, tmp_path, capsys, create_cli_project): @@ -206,24 +190,25 @@ def test_param_unset_noop_when_absent(self, tmp_path, capsys, create_cli_project class TestParamDiff: - def test_param_diff_shows_overrides(self, tmp_path, capsys, create_cli_project): + def test_param_diff_shows_overrides(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) capsys.readouterr() # flush set output - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "diff", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - records = data["records"] + records = plain_records(capsys.readouterr().out) assert len(records) == 1 assert records[0]["param"] == "place.target_density" - def test_param_diff_clean_when_no_overrides(self, tmp_path, capsys, create_cli_project): + def test_param_diff_clean_when_no_overrides( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "diff", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0].get("diff_status") == "clean" + records = plain_records(capsys.readouterr().out) + assert records[0].get("diff_status") == "clean" class TestRunSet: @@ -427,28 +412,6 @@ def test_plain_no_ansi(self, tmp_path, capsys, create_cli_project): out = capsys.readouterr().out assert "\033[" not in out - def test_json_no_ansi(self, tmp_path, capsys, create_cli_project): - project_dir = create_cli_project() - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) - assert rc == 0 - out = capsys.readouterr().out - assert "\033[" not in out - - def test_jsonl_no_ansi(self, tmp_path, capsys, create_cli_project): - project_dir = create_cli_project() - rc = cli_main.run(["param", "list", "--project", project_dir, "--jsonl"]) - assert rc == 0 - out = capsys.readouterr().out - assert "\033[" not in out - - def test_json_uses_records_envelope(self, tmp_path, capsys, create_cli_project): - project_dir = create_cli_project() - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - assert isinstance(data["records"], list) - def test_plain_is_line_oriented(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) @@ -460,7 +423,7 @@ def test_plain_is_line_oriented(self, tmp_path, capsys, create_cli_project): class TestConfigResolved: def test_config_resolved_includes_param_records( - self, tmp_path, monkeypatch, capsys, create_cli_project + self, tmp_path, monkeypatch, capsys, create_cli_project, plain_records ): project_dir = create_cli_project() monkeypatch.setattr( @@ -473,10 +436,9 @@ def test_config_resolved_includes_param_records( with open(os.path.join(home, "flow.json"), "w") as f: json.dump({"steps": []}, f) - rc = cli_main.run(["config", "--project", project_dir, "--json"]) + rc = cli_main.run(["config", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - records = data["records"] + records = plain_records(capsys.readouterr().out) param_records = [r for r in records if r.get("kind") == "param"] assert len(param_records) == 14 first_param = param_records[0] @@ -484,7 +446,7 @@ def test_config_resolved_includes_param_records( assert "maps_to" in first_param def test_config_resolved_shows_toml_source( - self, tmp_path, monkeypatch, capsys, create_cli_project + self, tmp_path, monkeypatch, capsys, create_cli_project, plain_records ): project_dir = create_cli_project() monkeypatch.setattr( @@ -500,16 +462,17 @@ def test_config_resolved_shows_toml_source( with open(os.path.join(home, "flow.json"), "w") as f: json.dump({"steps": []}, f) - rc = cli_main.run(["config", "--project", project_dir, "--json"]) + rc = cli_main.run(["config", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - param_records = [r for r in data["records"] if r.get("kind") == "param"] + param_records = [ + r for r in plain_records(capsys.readouterr().out) if r.get("kind") == "param" + ] density = next(r for r in param_records if r["key"] == "place.target_density") - assert density["value"] == 0.65 + assert density["value"] == "0.65" assert density["source"] == "ecc.toml" def test_config_resolved_seeds_design_frequency( - self, tmp_path, monkeypatch, capsys, create_cli_project + self, tmp_path, monkeypatch, capsys, create_cli_project, plain_records ): project_dir = create_cli_project(freq=200.0) monkeypatch.setattr( @@ -522,12 +485,13 @@ def test_config_resolved_seeds_design_frequency( with open(os.path.join(home, "flow.json"), "w") as f: json.dump({"steps": []}, f) - rc = cli_main.run(["config", "--project", project_dir, "--json"]) + rc = cli_main.run(["config", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - param_records = [r for r in data["records"] if r.get("kind") == "param"] + param_records = [ + r for r in plain_records(capsys.readouterr().out) if r.get("kind") == "param" + ] freq = next(r for r in param_records if r["key"] == "design.frequency_mhz") - assert freq["value"] == 200.0 + assert freq["value"] == "200.0" class TestPrettyOutput: @@ -577,30 +541,30 @@ def test_param_diff_default_is_pretty(self, tmp_path, capsys, create_cli_project class TestResolvedListValues: - def test_param_list_json_has_value_and_source(self, tmp_path, capsys, create_cli_project): + def test_param_list_records_have_value_and_source( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) capsys.readouterr() - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - records = data["records"] + records = plain_records(capsys.readouterr().out) density = next(r for r in records if r["param"] == "place.target_density") - assert density["value"] == 0.65 + assert density["value"] == "0.65" assert density["source"] == "ecc.toml" assert "default" in density assert "maps_to" in density assert "inspect" in density def test_param_list_default_source_when_no_overrides( - self, tmp_path, capsys, create_cli_project + self, tmp_path, capsys, create_cli_project, plain_records ): project_dir = create_cli_project() - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - for r in data["records"]: + for r in plain_records(capsys.readouterr().out): if r["param"] == "design.frequency_mhz": assert r["source"] == "ecc.toml" else: @@ -608,21 +572,24 @@ def test_param_list_default_source_when_no_overrides( class TestDiffFiltering: - def test_diff_only_shows_values_that_differ(self, tmp_path, capsys, create_cli_project): + def test_diff_only_shows_values_that_differ( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) capsys.readouterr() - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "diff", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - records = data["records"] + records = plain_records(capsys.readouterr().out) assert len(records) == 1 assert records[0]["param"] == "place.target_density" - assert records[0]["value"] == 0.65 - assert records[0]["default"] != 0.65 + assert records[0]["value"] == "0.65" + assert records[0]["default"] != records[0]["value"] - def test_diff_clean_when_set_to_default(self, tmp_path, capsys, create_cli_project): + def test_diff_clean_when_set_to_default( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() schema_default = 0.2 cli_main.run( @@ -630,23 +597,24 @@ def test_diff_clean_when_set_to_default(self, tmp_path, capsys, create_cli_proje ) capsys.readouterr() - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "diff", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0].get("diff_status") == "clean" + records = plain_records(capsys.readouterr().out) + assert records[0].get("diff_status") == "clean" class TestParamShowDisclosureCommands: """param show must include disclosure command fields.""" - def test_show_json_has_disclosure_commands(self, tmp_path, capsys, create_cli_project): + def test_show_records_have_disclosure_commands( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ["param", "show", "place.target_density", "--project", project_dir, "--plain"] ) assert rc == 0 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] + record = plain_records(capsys.readouterr().out)[0] assert "inspect" in record assert "set" in record assert "run" in record @@ -665,60 +633,63 @@ def test_show_text_has_disclosure_commands(self, tmp_path, capsys, create_cli_pr class TestListDefaultDiffFiltering: """param diff must not report list values equal to defaults.""" - def test_list_default_not_in_diff(self, tmp_path, capsys, create_cli_project): + def test_list_default_not_in_diff(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() cli_main.run(["param", "set", "floorplan.core_margin", "[2,2]", "--project", project_dir]) capsys.readouterr() - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "diff", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0].get("diff_status") == "clean" + records = plain_records(capsys.readouterr().out) + assert records[0].get("diff_status") == "clean" - def test_list_changed_value_in_diff(self, tmp_path, capsys, create_cli_project): + def test_list_changed_value_in_diff(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() cli_main.run(["param", "set", "floorplan.core_margin", "[4,4]", "--project", project_dir]) capsys.readouterr() - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "diff", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert len(data["records"]) >= 1 - margin = next( - (r for r in data["records"] if r.get("param") == "floorplan.core_margin"), None - ) + records = plain_records(capsys.readouterr().out) + assert len(records) >= 1 + margin = next((r for r in records if r.get("param") == "floorplan.core_margin"), None) assert margin is not None - assert margin["value"] == [4, 4] + assert margin["value"] == "[4, 4]" class TestDesignFrequencySeeded: """ecc param list/show must reflect [design] frequency_mhz.""" - def test_list_shows_design_frequency(self, tmp_path, capsys, create_cli_project): + def test_list_shows_design_frequency(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project(freq=200.0) - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - freq = next(r for r in data["records"] if r["param"] == "design.frequency_mhz") - assert freq["value"] == 200.0 + freq = next( + r + for r in plain_records(capsys.readouterr().out) + if r["param"] == "design.frequency_mhz" + ) + assert freq["value"] == "200.0" - def test_show_shows_design_frequency(self, tmp_path, capsys, create_cli_project): + def test_show_shows_design_frequency(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project(freq=200.0) rc = cli_main.run( - ["param", "show", "design.frequency_mhz", "--project", project_dir, "--json"] + ["param", "show", "design.frequency_mhz", "--project", project_dir, "--plain"] ) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["value"] == 200.0 + records = plain_records(capsys.readouterr().out) + assert records[0]["value"] == "200.0" - def test_param_override_beats_design_frequency(self, tmp_path, capsys, create_cli_project): + def test_param_override_beats_design_frequency( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project(freq=200.0) cli_main.run(["param", "set", "design.frequency_mhz", "300", "--project", project_dir]) capsys.readouterr() rc = cli_main.run( - ["param", "show", "design.frequency_mhz", "--project", project_dir, "--json"] + ["param", "show", "design.frequency_mhz", "--project", project_dir, "--plain"] ) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["value"] == 300.0 - assert data["records"][0]["source"] == "ecc.toml" + records = plain_records(capsys.readouterr().out) + assert records[0]["value"] == "300.0" + assert records[0]["source"] == "ecc.toml" diff --git a/test/cli/params/test_provenance.py b/test/cli/params/test_provenance.py index 29b9de166..2bcb8dcf3 100644 --- a/test/cli/params/test_provenance.py +++ b/test/cli/params/test_provenance.py @@ -67,7 +67,12 @@ def fake_create(**kwargs): assert data["cts.max_fanout"] == 16 def test_config_resolved_shows_cli_source( - self, tmp_path, monkeypatch, capsys, create_cli_project + self, + tmp_path, + monkeypatch, + capsys, + create_cli_project, + plain_records, ): from types import SimpleNamespace @@ -122,16 +127,21 @@ def fake_create(**kwargs): capsys.readouterr() # Now inspect config - rc = cli_main.run(["config", "--project", project_dir, "--json"]) + rc = cli_main.run(["config", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - param_records = [r for r in data["records"] if r.get("kind") == "param"] + records = plain_records(capsys.readouterr().out) + param_records = [r for r in records if r.get("kind") == "param"] fanout = next(r for r in param_records if r["key"] == "cts.max_fanout") - assert fanout["value"] == 16 + assert fanout["value"] == "16" assert fanout["source"] == "cli" def test_config_resolved_toml_plus_cli_precedence( - self, tmp_path, monkeypatch, capsys, create_cli_project + self, + tmp_path, + monkeypatch, + capsys, + create_cli_project, + plain_records, ): from types import SimpleNamespace @@ -189,10 +199,10 @@ def fake_create(**kwargs): assert rc == 0 capsys.readouterr() - rc = cli_main.run(["config", "--project", project_dir, "--json"]) + rc = cli_main.run(["config", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - param_records = [r for r in data["records"] if r.get("kind") == "param"] + records = plain_records(capsys.readouterr().out) + param_records = [r for r in records if r.get("kind") == "param"] fanout = next(r for r in param_records if r["key"] == "cts.max_fanout") - assert fanout["value"] == 32 + assert fanout["value"] == "32" assert fanout["source"] == "cli" diff --git a/test/cli/params/test_registry.py b/test/cli/params/test_registry.py index f5c67117d..16ad9c7da 100644 --- a/test/cli/params/test_registry.py +++ b/test/cli/params/test_registry.py @@ -507,3 +507,10 @@ def test_absent_keys_untouched(self): coerced, errors = coerce_manifest_parameters(canonical) assert errors == [] assert coerced == {"design": "gcd"} + + +def test_every_param_registry_entry_has_an_explicit_description(): + descriptions = {schema.param: schema.description for schema in PARAM_REGISTRY} + + assert all(description.strip() for description in descriptions.values()) + assert not any("configuration field" in description for description in descriptions.values()) diff --git a/test/cli/params/test_toml_editing.py b/test/cli/params/test_toml_editing.py index f067e5fd2..750f435e7 100644 --- a/test/cli/params/test_toml_editing.py +++ b/test/cli/params/test_toml_editing.py @@ -1,7 +1,25 @@ import json import os +import tomllib from chipcompiler.cli import main as cli_main +from chipcompiler.cli.project.toml_edit import remove_scoped_key, set_pdk_root, set_scoped_key + + +class TestSetPdkRoot: + def test_set_pdk_root_escapes_quotes_and_backslashes(self): + text = '[pdk]\nname = "ics55"\nroot = "/old"\n' + + result = set_pdk_root(text, 'C:\\pdk "special"') + + parsed = tomllib.loads(result) + assert parsed["pdk"]["root"] == 'C:\\pdk "special"' + assert parsed["pdk"]["name"] == "ics55" + + def test_set_pdk_root_creates_escaped_table_when_missing(self): + result = set_pdk_root('name = "proj"\n', 'weird "path"\\dir') + + assert tomllib.loads(result)["pdk"]["root"] == 'weird "path"\\dir' class TestScopedTomlEdit: @@ -51,7 +69,7 @@ def test_set_same_key_twice_has_one_assignment(self, tmp_path, capsys, create_cl assert "0.7" in content assert "0.65" not in content - def test_set_then_show_still_works(self, tmp_path, capsys, create_cli_project): + def test_set_then_show_still_works(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) @@ -61,11 +79,11 @@ def test_set_then_show_still_works(self, tmp_path, capsys, create_cli_project): capsys.readouterr() rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ["param", "show", "place.target_density", "--project", project_dir, "--plain"] ) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["value"] == 0.7 + records = plain_records(capsys.readouterr().out) + assert records[0]["value"] == "0.7" class TestIndentedTomlKeys: @@ -88,7 +106,7 @@ def test_set_replaces_indented_key(self, tmp_path, capsys, create_cli_project): assert after.count("target_density") == 1 assert "0.7" in after - def test_set_then_show_indented(self, tmp_path, capsys, create_cli_project): + def test_set_then_show_indented(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() toml_path = os.path.join(project_dir, "ecc.toml") with open(toml_path) as f: @@ -101,11 +119,11 @@ def test_set_then_show_indented(self, tmp_path, capsys, create_cli_project): capsys.readouterr() rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ["param", "show", "place.target_density", "--project", project_dir, "--plain"] ) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["value"] == 0.7 + records = plain_records(capsys.readouterr().out) + assert records[0]["value"] == "0.7" def test_unset_removes_indented_key(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() @@ -144,6 +162,35 @@ def test_set_indented_preserves_other_sections(self, tmp_path, capsys, create_cl class TestMultilineTomlValues: """Scoped TOML edit must handle multiline array values.""" + def test_brackets_inside_strings_are_not_structure(self): + import tomllib + + text = '[params.floorplan]\ntarget = "alu[rev"\nmode = "slide"\n' + + after = set_scoped_key(text, "params.floorplan", "target", "new") + + parsed = tomllib.loads(after) + assert parsed["params"]["floorplan"] == {"target": "new", "mode": "slide"} + + def test_bracket_in_comment_does_not_swallow_keys(self): + import tomllib + + text = '[params.floorplan]\ncore_margin = [2, 2] # [docs]\nmode = "slide"\n' + + after = set_scoped_key(text, "params.floorplan", "core_margin", [4, 4]) + + parsed = tomllib.loads(after) + assert parsed["params"]["floorplan"] == {"core_margin": [4, 4], "mode": "slide"} + + def test_unset_with_unbalanced_bracket_in_string(self): + import tomllib + + text = '[pdk]\nroot = "a[\\b"\nname = "ics55"\n' + + after = remove_scoped_key(text, "pdk", "root") + + assert tomllib.loads(after) == {"pdk": {"name": "ics55"}} + def test_set_replaces_multiline_array(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() toml_path = os.path.join(project_dir, "ecc.toml") @@ -178,7 +225,7 @@ def test_unset_removes_multiline_array(self, tmp_path, capsys, create_cli_projec after = f.read() assert "core_margin" not in after - def test_set_multiline_then_show(self, tmp_path, capsys, create_cli_project): + def test_set_multiline_then_show(self, tmp_path, capsys, create_cli_project, plain_records): project_dir = create_cli_project() toml_path = os.path.join(project_dir, "ecc.toml") with open(toml_path) as f: @@ -191,11 +238,11 @@ def test_set_multiline_then_show(self, tmp_path, capsys, create_cli_project): capsys.readouterr() rc = cli_main.run( - ["param", "show", "floorplan.core_margin", "--project", project_dir, "--json"] + ["param", "show", "floorplan.core_margin", "--project", project_dir, "--plain"] ) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["value"] == [4, 4] + records = plain_records(capsys.readouterr().out) + assert records[0]["value"] == "[4, 4]" def test_set_preserves_adjacent_key_after_multiline(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() @@ -226,7 +273,12 @@ def _setup_run_dir(self, project_dir): return run_dir def test_malformed_json_provenance_fails( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + plain_records, ): project_dir = create_cli_project() run_dir = self._setup_run_dir(project_dir) @@ -236,10 +288,10 @@ def test_malformed_json_provenance_fails( "chipcompiler.cli.project.config._validate_pdk_contents", lambda name, root, overrides=None: [], ) - rc = cli_main.run(["config", "--project", project_dir, "--json"]) + rc = cli_main.run(["config", "--project", project_dir, "--plain"]) assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["error"] == "invalid_config" + records = plain_records(capsys.readouterr().out) + assert records[0]["error"] == "invalid_config" def test_non_dict_provenance_fails(self, tmp_path, capsys, monkeypatch, create_cli_project): project_dir = create_cli_project() @@ -250,11 +302,16 @@ def test_non_dict_provenance_fails(self, tmp_path, capsys, monkeypatch, create_c "chipcompiler.cli.project.config._validate_pdk_contents", lambda name, root, overrides=None: [], ) - rc = cli_main.run(["config", "--project", project_dir, "--json"]) + rc = cli_main.run(["config", "--project", project_dir, "--plain"]) assert rc == 1 def test_unknown_key_in_provenance_fails( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + plain_records, ): project_dir = create_cli_project() run_dir = self._setup_run_dir(project_dir) @@ -264,15 +321,54 @@ def test_unknown_key_in_provenance_fails( "chipcompiler.cli.project.config._validate_pdk_contents", lambda name, root, overrides=None: [], ) - rc = cli_main.run(["config", "--project", project_dir, "--json"]) + rc = cli_main.run(["config", "--project", project_dir, "--plain"]) assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["error"] == "invalid_config" + records = plain_records(capsys.readouterr().out) + assert records[0]["error"] == "invalid_config" class TestSafeTomlSectionParsing: """Scoped TOML edits must handle comments and indented headers safely.""" + def test_set_does_not_insert_before_header_like_text_in_a_string(self): + text = '[params]\nnote = """\nheader-like text:\n[flow]\n"""\n\n[pdk]\nname = "ics55"\n' + + result = set_scoped_key(text, "params.place", "target_density", 0.7) + + parsed = tomllib.loads(result) + assert parsed["params"]["place"]["target_density"] == 0.7 + assert parsed["params"]["note"].strip() == "header-like text:\n[flow]" + # The new table must land before the real [pdk] header, not the + # header-like line inside the multiline string. + assert result.index("[params.place]") > result.index('"""') + + def test_set_matches_quoted_table_header(self): + text = '[params."place"]\ntarget_density = 0.65\n' + + result = set_scoped_key(text, "params.place", "target_density", 0.7) + + parsed = tomllib.loads(result) + assert parsed["params"]["place"]["target_density"] == 0.7 + assert result.count("[params") == 1 + + def test_set_replaces_quoted_assignment_key(self): + text = '[params.place]\n"target_density" = 0.65\n' + + result = set_scoped_key(text, "params.place", "target_density", 0.7) + + parsed = tomllib.loads(result) + assert parsed["params"]["place"]["target_density"] == 0.7 + assert result.count("target_density") == 1 + + def test_unset_removes_quoted_assignment_key(self): + text = '[params.place]\n"target_density" = 0.65\nother = 1\n' + + result = remove_scoped_key(text, "params.place", "target_density") + + parsed = tomllib.loads(result) + assert "target_density" not in parsed["params"]["place"] + assert parsed["params"]["place"]["other"] == 1 + def test_set_ignores_commented_section_header(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() toml_path = os.path.join(project_dir, "ecc.toml") @@ -308,7 +404,9 @@ def test_set_ignores_indented_next_section_header(self, tmp_path, capsys, create assert "0.7" in after assert 'preset = "rtl2gds"' in after - def test_set_then_show_after_commented_header(self, tmp_path, capsys, create_cli_project): + def test_set_then_show_after_commented_header( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() toml_path = os.path.join(project_dir, "ecc.toml") with open(toml_path) as f: @@ -321,11 +419,11 @@ def test_set_then_show_after_commented_header(self, tmp_path, capsys, create_cli capsys.readouterr() rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ["param", "show", "place.target_density", "--project", project_dir, "--plain"] ) assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["value"] == 0.7 + records = plain_records(capsys.readouterr().out) + assert records[0]["value"] == "0.7" def test_unset_ignores_commented_section_header(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() diff --git a/test/cli/params/test_validation.py b/test/cli/params/test_validation.py index 61d0d9214..ca5fd271c 100644 --- a/test/cli/params/test_validation.py +++ b/test/cli/params/test_validation.py @@ -1,4 +1,3 @@ -import json import os from chipcompiler.cli import main as cli_main @@ -15,12 +14,14 @@ def _create_project_with_invalid_param(self, create_cli_project): f.write(content) return project_dir - def test_check_fails_invalid_param_type(self, tmp_path, capsys, create_cli_project): + def test_check_fails_invalid_param_type( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = self._create_project_with_invalid_param(create_cli_project) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 1 - data = json.loads(capsys.readouterr().out) - reasons = [r.get("reason", "") for r in data["records"]] + records = plain_records(capsys.readouterr().out) + reasons = [r.get("reason", "") for r in records] assert any("params" in r for r in reasons) def test_check_fails_unknown_param_key(self, tmp_path, capsys, create_cli_project): @@ -31,7 +32,7 @@ def test_check_fails_unknown_param_key(self, tmp_path, capsys, create_cli_projec content += "\n[params.bogus]\nkey = 5\n" with open(toml_path, "w") as f: f.write(content) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 1 def test_run_fails_invalid_param_type(self, tmp_path, create_cli_project): @@ -49,7 +50,7 @@ def test_check_rejects_float_for_int(self, tmp_path, capsys, create_cli_project) content += "\n[params.cts]\nmax_fanout = 16.5\n" with open(toml_path, "w") as f: f.write(content) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 1 def test_check_rejects_bool_for_int(self, tmp_path, capsys, create_cli_project): @@ -60,7 +61,7 @@ def test_check_rejects_bool_for_int(self, tmp_path, capsys, create_cli_project): content += "\n[params.cts]\nmax_fanout = true\n" with open(toml_path, "w") as f: f.write(content) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 1 def test_check_rejects_float_in_list_int(self, tmp_path, capsys, create_cli_project): @@ -71,7 +72,7 @@ def test_check_rejects_float_in_list_int(self, tmp_path, capsys, create_cli_proj content += "\n[params.floorplan]\ncore_margin = [2.5, 3]\n" with open(toml_path, "w") as f: f.write(content) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 1 def test_check_accepts_valid_int(self, tmp_path, capsys, monkeypatch, create_cli_project): @@ -86,7 +87,7 @@ def test_check_accepts_valid_int(self, tmp_path, capsys, monkeypatch, create_cli "chipcompiler.cli.project.config._validate_pdk_contents", lambda name, root, overrides=None: [], ) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 0 @@ -101,31 +102,38 @@ def _append_flow_param(self, create_cli_project, value): f.write(content) return project_dir - def test_check_rejects_bad_bool_string(self, tmp_path, capsys, create_cli_project): + def test_check_rejects_bad_bool_string( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = self._append_flow_param(create_cli_project, '"maybe"') - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 1 - data = json.loads(capsys.readouterr().out) - reasons = [r.get("reason", "") for r in data["records"]] + records = plain_records(capsys.readouterr().out) + reasons = [r.get("reason", "") for r in records] assert any("params" in r for r in reasons) def test_bool_like_string_accepted_and_coerced( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + plain_records, ): project_dir = self._append_flow_param(create_cli_project, '"false"') monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", lambda name, root, overrides=None: [], ) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 0 capsys.readouterr() - rc = cli_main.run(["config", "--project", project_dir, "--json"]) + rc = cli_main.run(["config", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) - records = [r for r in data["records"] if r.get("key") == "flow.run_analysis"] + records = plain_records(capsys.readouterr().out) + records = [r for r in records if r.get("key") == "flow.run_analysis"] assert len(records) == 1 - assert records[0]["value"] is False + assert records[0]["value"] == "False" assert records[0]["source"] == "ecc.toml" @@ -140,29 +148,35 @@ def _write_invalid_toml(self, project_dir): with open(toml_path, "w") as f: f.write(content) - def test_param_list_rejects_invalid_toml(self, tmp_path, capsys, create_cli_project): + def test_param_list_rejects_invalid_toml( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() self._write_invalid_toml(project_dir) - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["error"] == "invalid_param_config" + records = plain_records(capsys.readouterr().out) + assert records[0]["error"] == "invalid_param_config" - def test_param_show_rejects_invalid_toml(self, tmp_path, capsys, create_cli_project): + def test_param_show_rejects_invalid_toml( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() self._write_invalid_toml(project_dir) - rc = cli_main.run(["param", "show", "cts.max_fanout", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "show", "cts.max_fanout", "--project", project_dir, "--plain"]) assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["error"] == "invalid_param_config" + records = plain_records(capsys.readouterr().out) + assert records[0]["error"] == "invalid_param_config" - def test_param_diff_rejects_invalid_toml(self, tmp_path, capsys, create_cli_project): + def test_param_diff_rejects_invalid_toml( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() self._write_invalid_toml(project_dir) - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "diff", "--project", project_dir, "--plain"]) assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["error"] == "invalid_param_config" + records = plain_records(capsys.readouterr().out) + assert records[0]["error"] == "invalid_param_config" class TestZeroFrequencyRejected: @@ -195,24 +209,26 @@ def _write_malformed_toml(self, project_dir): with open(toml_path, "w") as f: f.write('[design\nname = "gcd"\n') - def test_param_list_rejects_malformed(self, tmp_path, capsys, create_cli_project): + def test_param_list_rejects_malformed( + self, tmp_path, capsys, create_cli_project, plain_records + ): project_dir = create_cli_project() self._write_malformed_toml(project_dir) - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["error"] == "invalid_param_config" + records = plain_records(capsys.readouterr().out) + assert records[0]["error"] == "invalid_param_config" def test_param_show_rejects_malformed(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() self._write_malformed_toml(project_dir) rc = cli_main.run( - ["param", "show", "design.frequency_mhz", "--project", project_dir, "--json"] + ["param", "show", "design.frequency_mhz", "--project", project_dir, "--plain"] ) assert rc == 1 def test_param_diff_rejects_malformed(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() self._write_malformed_toml(project_dir) - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + rc = cli_main.run(["param", "diff", "--project", project_dir, "--plain"]) assert rc == 1 diff --git a/test/cli/params/test_workspace_commands.py b/test/cli/params/test_workspace_commands.py index da6ad5cb7..684988f2d 100644 --- a/test/cli/params/test_workspace_commands.py +++ b/test/cli/params/test_workspace_commands.py @@ -71,7 +71,7 @@ def _write_manifest(project_dir: str) -> None: def test_workspace_param_set_persists_and_invalidates_suffix( - capsys, create_cli_project, monkeypatch + capsys, create_cli_project, monkeypatch, plain_records ): project_dir = create_cli_project() workspace_dir = Path(project_dir) / "baseline" @@ -91,15 +91,15 @@ def test_workspace_param_set_persists_and_invalidates_suffix( "baseline", "--project", project_dir, - "--json", + "--plain", ] ) assert rc == 0 - record = json.loads(capsys.readouterr().out)["records"][0] - assert record["value"] == 0.65 + record = plain_records(capsys.readouterr().out)[0] + assert record["value"] == "0.65" assert record["from_step"] == "place" - assert record["invalidated_steps"] == ["place", "route"] + assert record["invalidated_steps"] == "['place', 'route']" assert workspace.parameters.data["dreamplace"]["target_density"] == 0.65 assert [step["state"] for step in workspace.flow.data["steps"]] == [ "Success", @@ -117,16 +117,62 @@ def test_workspace_param_set_persists_and_invalidates_suffix( "baseline", "--project", project_dir, - "--json", + "--plain", ] ) assert rc == 0 - assert json.loads(capsys.readouterr().out)["records"][0]["value"] == 0.2 + assert plain_records(capsys.readouterr().out)[0]["value"] == "0.2" assert workspace.parameters.data["dreamplace"]["target_density"] == 0.2 -def test_workspace_param_list_honors_step_filter(capsys, create_cli_project, monkeypatch): +def test_workspace_param_refresh_failure_rolls_back_params( + capsys, create_cli_project, monkeypatch, plain_records +): + project_dir = create_cli_project() + workspace_dir = Path(project_dir) / "baseline" + _write_manifest(project_dir) + workspace = _workspace(workspace_dir) + monkeypatch.setattr("chipcompiler.data.load_workspace", lambda _path: workspace) + + def fail_refresh(_workspace): + raise RuntimeError("config regeneration exploded") + + monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", fail_refresh) + monkeypatch.setattr("chipcompiler.engine.EngineFlow", _Flow) + + rc = cli_main.run( + [ + "param", + "set", + "place.target_density", + "0.65", + "--workspace", + "baseline", + "--project", + project_dir, + "--plain", + ] + ) + + assert rc == 1 + record = plain_records(capsys.readouterr().out)[0] + assert record["error"] == "workspace_param_refresh_failed" + # The parameter mutation was already persisted when the refresh failed; + # the rollback must put params.toml back, not leave new parameters + # paired with old configs and an untouched ledger. params.toml did not + # exist before the mutation, so restoring it means removing it again. + assert not workspace.parameters.path.exists() + assert [step["state"] for step in workspace.flow.data["steps"]] == [ + "Success", + "Success", + "Success", + ] + + +def test_workspace_param_list_honors_step_filter( + capsys, create_cli_project, monkeypatch, plain_records +): project_dir = create_cli_project() workspace_dir = Path(project_dir) / "baseline" _write_manifest(project_dir) @@ -146,11 +192,11 @@ def test_workspace_param_list_honors_step_filter(capsys, create_cli_project, mon "cts", "--project", project_dir, - "--json", + "--plain", ] ) assert rc == 0 - assert json.loads(capsys.readouterr().out)["records"] == [ + assert plain_records(capsys.readouterr().out) == [ {"param": "list", "status": "clean", "workspace": "baseline"} ] diff --git a/test/cli/project/test_manifest.py b/test/cli/project/test_manifest.py index 918ab8754..4832d8ab6 100644 --- a/test/cli/project/test_manifest.py +++ b/test/cli/project/test_manifest.py @@ -7,14 +7,9 @@ from chipcompiler.cli.project.manifest import ( ManifestError, assemble_config, - build_manifest_document, classify_project, load_manifest, - pre_register_workspace, resolved_base_parameters, - update_manifest, - write_back_workspace_status, - write_manifest_if_absent, ) @@ -193,113 +188,6 @@ def test_classify_project(tmp_path): assert classify_project(str(tmp_path)) == "manifest" -def test_write_manifest_if_absent_wins_and_loses_race(tmp_path): - document = build_manifest_document( - str(tmp_path), - design_name="gcd", - base_design={"pdk": "ics55", "parameters": {"design": "gcd"}}, - workspace_id="default", - workspace_path=str(tmp_path / "default"), - start_step="Synth", - end_step="Filler", - ) - assert write_manifest_if_absent(str(tmp_path), document) is True - assert write_manifest_if_absent(str(tmp_path), document) is False - - written = json.loads((tmp_path / "project.json").read_text()) - assert written["schema_version"] == 1 - assert written["design_name"] == "gcd" - assert written["root_path"] == str(tmp_path) - assert written["qor_baseline"]["workspace_id"] == "default" - (entry,) = written["workspaces"] - assert entry["workspace_id"] == "default" - assert entry["start_step"] == "Synth" - assert entry["end_step"] == "Filler" - assert entry["status"] == "running" - - -def test_update_manifest_preserves_unrelated_fields(tmp_path): - document = build_manifest_document( - str(tmp_path), - design_name="gcd", - base_design={"parameters": {"design": "gcd"}}, - workspace_id="default", - workspace_path=str(tmp_path / "default"), - start_step="Synth", - end_step="Filler", - ) - write_manifest_if_absent(str(tmp_path), document) - - def mutate(doc): - doc["workspaces"][0]["status"] = "success" - doc["custom_gui_field"] = {"kept": True} - - assert update_manifest(str(tmp_path), mutate) is True - - written = json.loads((tmp_path / "project.json").read_text()) - assert written["workspaces"][0]["status"] == "success" - assert written["custom_gui_field"] == {"kept": True} - - -def test_update_manifest_missing_file_returns_false(tmp_path): - assert update_manifest(str(tmp_path), lambda doc: None) is False - - -def test_write_back_workspace_status(tmp_path): - document = build_manifest_document( - str(tmp_path), - design_name="gcd", - base_design={"parameters": {"design": "gcd"}}, - workspace_id="default", - workspace_path=str(tmp_path / "default"), - start_step="Synth", - end_step="Filler", - ) - write_manifest_if_absent(str(tmp_path), document) - - assert write_back_workspace_status(str(tmp_path), "default", "failed") is True - written = json.loads((tmp_path / "project.json").read_text()) - assert written["workspaces"][0]["status"] == "failed" - - # Unknown workspace ids degrade to a no-op, not an error. - assert write_back_workspace_status(str(tmp_path), "unknown", "failed") is True - - -def test_pre_register_workspace_writes_a_manifest_entry_before_workspace_creation(tmp_path): - from chipcompiler.cli.project.config import ProjectConfig - - cfg = ProjectConfig( - design_name="gcd", - design_top="gcd", - design_clock_port="clk", - design_frequency_mhz=100.0, - design_netlist="input/gcd.v", - design_def="input/gcd.def", - pdk_name="ics55", - flow_preset="rtl2gds", - project_dir=str(tmp_path), - ) - - result = pre_register_workspace( - str(tmp_path), - cfg=cfg, - pdk_root="/pdk", - workspace_id="cts-only", - workspace_path=str(tmp_path / "cts-only"), - flow_config={"start_step": "CTS", "end_step": "CTS"}, - ) - - assert result == "registered" - assert not (tmp_path / "cts-only").exists() - document = json.loads((tmp_path / "project.json").read_text()) - entry = document["workspaces"][0] - assert entry["workspace_id"] == "cts-only" - assert entry["start_step"] == "CTS" - assert entry["end_step"] == "CTS" - assert entry["status"] == "not_started" - assert "input_snapshot" not in entry - - def test_load_manifest_rejects_malformed_mpc(tmp_path): _write_manifest(tmp_path, _minimal_document(tmp_path, mpc={"resource_id": "bogus"})) with pytest.raises(ManifestError): @@ -336,61 +224,6 @@ def test_resolved_base_parameters_gui_flat_vocabulary(): assert "core" not in parameters -def test_update_manifest_preserves_interleaved_unrelated_change(tmp_path): - document = build_manifest_document( - str(tmp_path), - design_name="gcd", - base_design={"parameters": {"design": "gcd"}}, - workspace_id="default", - workspace_path=str(tmp_path / "default"), - start_step="Synth", - end_step="Filler", - ) - write_manifest_if_absent(str(tmp_path), document) - - def mutate(doc): - # A concurrent writer lands an unrelated edit mid-update. - fresh = json.loads((tmp_path / "project.json").read_text()) - fresh["custom_gui_field"] = {"concurrent": True} - (tmp_path / "project.json").write_text(json.dumps(fresh)) - doc["workspaces"][0]["status"] = "failed" - - assert update_manifest(str(tmp_path), mutate) is True - - written = json.loads((tmp_path / "project.json").read_text()) - # Both our status change and the interleaved GUI edit survive. - assert written["workspaces"][0]["status"] == "failed" - assert written["custom_gui_field"] == {"concurrent": True} - - -def test_status_write_back_touches_only_target_entry(tmp_path): - document = build_manifest_document( - str(tmp_path), - design_name="gcd", - base_design={"parameters": {"design": "gcd"}}, - workspace_id="default", - workspace_path=str(tmp_path / "default"), - start_step="Synth", - end_step="Filler", - ) - write_manifest_if_absent(str(tmp_path), document) - before = json.loads((tmp_path / "project.json").read_text()) - - assert write_back_workspace_status(str(tmp_path), "default", "success") is True - - after = json.loads((tmp_path / "project.json").read_text()) - changed = [] - for key in after: - if after[key] != before[key]: - changed.append(key) - # Only the workspaces array changes, and within it only status/updated_at. - assert changed == ["workspaces"] - entry_before, entry_after = before["workspaces"][0], after["workspaces"][0] - changed_entry_keys = [k for k in entry_after if entry_after[k] != entry_before.get(k)] - assert sorted(changed_entry_keys) == ["status", "updated_at"] - assert entry_after["status"] == "success" - - def test_resolved_base_parameters_whole_object(): from chipcompiler.cli.project.config import ProjectConfig @@ -545,15 +378,6 @@ def test_load_manifest_tolerates_huge_integer_mpc_design_index(tmp_path): assert manifest.design_name == "gcd" -def test_update_manifest_degrades_when_lock_is_unopenable(tmp_path): - _write_manifest(tmp_path, _minimal_document(tmp_path)) - # A directory at the lock path: flock cannot be taken — degrade to - # False (callers warn/roll back), never an uncaught OSError. - (tmp_path / ".manifest.lock").mkdir() - - assert update_manifest(str(tmp_path), lambda document: None) is False - - def test_load_manifest_stores_canonical_workspace_path_through_symlink(tmp_path): real_dir = tmp_path / "proj" / "ws_0001" real_dir.mkdir(parents=True) diff --git a/test/cli/project/test_manifest_write.py b/test/cli/project/test_manifest_write.py new file mode 100644 index 000000000..fdd00a653 --- /dev/null +++ b/test/cli/project/test_manifest_write.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python + +import json + +from chipcompiler.cli.project.manifest_write import ( + build_manifest_document, + pre_register_workspace, + update_manifest, + write_back_workspace_status, + write_manifest_if_absent, +) + + +def _write_manifest(project_dir, document): + path = project_dir / "project.json" + path.write_text(json.dumps(document)) + return path + + +def _minimal_document(project_dir, **overrides): + document = { + "schema_version": 1, + "design_name": "gcd", + "root_path": str(project_dir), + "workspaces": [], + } + document.update(overrides) + return document + + +def test_write_manifest_if_absent_wins_and_loses_race(tmp_path): + document = build_manifest_document( + str(tmp_path), + design_name="gcd", + base_design={"pdk": "ics55", "parameters": {"design": "gcd"}}, + workspace_id="default", + workspace_path=str(tmp_path / "default"), + start_step="Synth", + end_step="Filler", + ) + assert write_manifest_if_absent(str(tmp_path), document) is True + assert write_manifest_if_absent(str(tmp_path), document) is False + + written = json.loads((tmp_path / "project.json").read_text()) + assert written["schema_version"] == 1 + assert written["design_name"] == "gcd" + assert written["root_path"] == str(tmp_path) + assert written["qor_baseline"]["workspace_id"] == "default" + (entry,) = written["workspaces"] + assert entry["workspace_id"] == "default" + assert entry["start_step"] == "Synth" + assert entry["end_step"] == "Filler" + assert entry["status"] == "running" + + +def test_update_manifest_preserves_unrelated_fields(tmp_path): + document = build_manifest_document( + str(tmp_path), + design_name="gcd", + base_design={"parameters": {"design": "gcd"}}, + workspace_id="default", + workspace_path=str(tmp_path / "default"), + start_step="Synth", + end_step="Filler", + ) + write_manifest_if_absent(str(tmp_path), document) + + def mutate(doc): + doc["workspaces"][0]["status"] = "success" + doc["custom_gui_field"] = {"kept": True} + + assert update_manifest(str(tmp_path), mutate) is True + + written = json.loads((tmp_path / "project.json").read_text()) + assert written["workspaces"][0]["status"] == "success" + assert written["custom_gui_field"] == {"kept": True} + + +def test_update_manifest_missing_file_returns_false(tmp_path): + assert update_manifest(str(tmp_path), lambda doc: None) is False + + +def test_write_back_workspace_status(tmp_path): + document = build_manifest_document( + str(tmp_path), + design_name="gcd", + base_design={"parameters": {"design": "gcd"}}, + workspace_id="default", + workspace_path=str(tmp_path / "default"), + start_step="Synth", + end_step="Filler", + ) + write_manifest_if_absent(str(tmp_path), document) + + assert write_back_workspace_status(str(tmp_path), "default", "failed") is True + written = json.loads((tmp_path / "project.json").read_text()) + assert written["workspaces"][0]["status"] == "failed" + + # Unknown workspace ids degrade to a no-op, not an error. + assert write_back_workspace_status(str(tmp_path), "unknown", "failed") is True + + +def test_pre_register_workspace_writes_a_manifest_entry_before_workspace_creation(tmp_path): + from chipcompiler.cli.project.config import ProjectConfig + + cfg = ProjectConfig( + design_name="gcd", + design_top="gcd", + design_clock_port="clk", + design_frequency_mhz=100.0, + design_netlist="input/gcd.v", + design_def="input/gcd.def", + pdk_name="ics55", + flow_preset="rtl2gds", + project_dir=str(tmp_path), + ) + + result = pre_register_workspace( + str(tmp_path), + cfg=cfg, + pdk_root="/pdk", + workspace_id="cts-only", + workspace_path=str(tmp_path / "cts-only"), + flow_config={"start_step": "CTS", "end_step": "CTS"}, + ) + + assert result == "registered" + assert not (tmp_path / "cts-only").exists() + document = json.loads((tmp_path / "project.json").read_text()) + entry = document["workspaces"][0] + assert entry["workspace_id"] == "cts-only" + assert entry["start_step"] == "CTS" + assert entry["end_step"] == "CTS" + assert entry["status"] == "not_started" + assert "input_snapshot" not in entry + + +def test_update_manifest_preserves_interleaved_unrelated_change(tmp_path): + document = build_manifest_document( + str(tmp_path), + design_name="gcd", + base_design={"parameters": {"design": "gcd"}}, + workspace_id="default", + workspace_path=str(tmp_path / "default"), + start_step="Synth", + end_step="Filler", + ) + write_manifest_if_absent(str(tmp_path), document) + + def mutate(doc): + # A concurrent writer lands an unrelated edit mid-update. + fresh = json.loads((tmp_path / "project.json").read_text()) + fresh["custom_gui_field"] = {"concurrent": True} + (tmp_path / "project.json").write_text(json.dumps(fresh)) + doc["workspaces"][0]["status"] = "failed" + + assert update_manifest(str(tmp_path), mutate) is True + + written = json.loads((tmp_path / "project.json").read_text()) + # Both our status change and the interleaved GUI edit survive. + assert written["workspaces"][0]["status"] == "failed" + assert written["custom_gui_field"] == {"concurrent": True} + + +def test_status_write_back_touches_only_target_entry(tmp_path): + document = build_manifest_document( + str(tmp_path), + design_name="gcd", + base_design={"parameters": {"design": "gcd"}}, + workspace_id="default", + workspace_path=str(tmp_path / "default"), + start_step="Synth", + end_step="Filler", + ) + write_manifest_if_absent(str(tmp_path), document) + before = json.loads((tmp_path / "project.json").read_text()) + + assert write_back_workspace_status(str(tmp_path), "default", "success") is True + + after = json.loads((tmp_path / "project.json").read_text()) + changed = [] + for key in after: + if after[key] != before[key]: + changed.append(key) + # Only the workspaces array changes, and within it only status/updated_at. + assert changed == ["workspaces"] + entry_before, entry_after = before["workspaces"][0], after["workspaces"][0] + changed_entry_keys = [k for k in entry_after if entry_after[k] != entry_before.get(k)] + assert sorted(changed_entry_keys) == ["status", "updated_at"] + assert entry_after["status"] == "success" + + +def test_update_manifest_degrades_when_lock_is_unopenable(tmp_path): + _write_manifest(tmp_path, _minimal_document(tmp_path)) + # A directory at the lock path: flock cannot be taken — degrade to + # False (callers warn/roll back), never an uncaught OSError. + (tmp_path / ".manifest.lock").mkdir() + + assert update_manifest(str(tmp_path), lambda document: None) is False diff --git a/test/cli/rendering/test_pretty.py b/test/cli/rendering/test_pretty.py index ddfcf28ef..58e2739b6 100644 --- a/test/cli/rendering/test_pretty.py +++ b/test/cli/rendering/test_pretty.py @@ -1,5 +1,4 @@ import io -import json import os from io import StringIO @@ -276,22 +275,6 @@ def run_steps(self): assert "success" in out -# --------------------------------------------------------------------------- -# JSON/JSONL unaffected by pretty changes -# --------------------------------------------------------------------------- - - -class TestJsonUnchanged: - def test_status_json_unchanged(self, tmp_path, capsys, create_cli_project, create_flow_json): - project_dir = create_cli_project() - create_flow_json(os.path.join(project_dir, "default"), profile="pretty") - rc = cli_main.run(["status", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - assert data["records"][0]["workspace_id"] == "default" - - # --------------------------------------------------------------------------- # Regression: multi-record error rendering (Codex Round 1 finding) # --------------------------------------------------------------------------- @@ -420,7 +403,6 @@ def test_pretty_supports_color_machine_mode(self): from chipcompiler.cli.core.types import OutputMode from chipcompiler.cli.rendering.pretty import supports_color - assert not supports_color(mode=OutputMode.JSON) assert not supports_color(mode=OutputMode.PLAIN) def test_progress_supports_color_delegates(self): diff --git a/test/cli/rendering/test_progress.py b/test/cli/rendering/test_progress.py index 5260904aa..e3aa90f5f 100644 --- a/test/cli/rendering/test_progress.py +++ b/test/cli/rendering/test_progress.py @@ -104,12 +104,6 @@ def test_disabled_term_dumb(self): env = {"TERM": "dumb"} assert supports_color(FakeTTYStderr(isatty_value=True), OutputMode.TEXT, env) is False - def test_disabled_json(self): - assert supports_color(FakeTTYStderr(isatty_value=True), OutputMode.JSON) is False - - def test_disabled_jsonl(self): - assert supports_color(FakeTTYStderr(isatty_value=True), OutputMode.JSONL) is False - def test_enabled_with_clean_env(self): env = {"TERM": "xterm-256color"} assert supports_color(FakeTTYStderr(isatty_value=True), OutputMode.TEXT, env) is True @@ -135,14 +129,6 @@ def test_enabled_text_tty(self): ctx = _make_ctx(OutputMode.TEXT) assert should_enable_run_progress(ctx, FakeTTYStderr(isatty_value=True)) is True - def test_disabled_json(self): - ctx = _make_ctx(OutputMode.JSON) - assert should_enable_run_progress(ctx, FakeTTYStderr(isatty_value=True)) is False - - def test_disabled_jsonl(self): - ctx = _make_ctx(OutputMode.JSONL) - assert should_enable_run_progress(ctx, FakeTTYStderr(isatty_value=True)) is False - def test_disabled_plain(self): ctx = _make_ctx(OutputMode.PLAIN) assert should_enable_run_progress(ctx, FakeTTYStderr(isatty_value=True)) is False @@ -672,24 +658,6 @@ def check_state_fn(self, name, tool, state): class TestRunFlowWithProgress: - def test_synthesis_lec_warning_continues_to_next_step(self): - def fake_run_step(self, s): - return StateEnum.Warning if s.name == "lec" else StateEnum.Success - - flow = _make_flow( - _make_ws(), - [_make_step("lec", "yosys_lec"), _make_step("Floorplan", "ecc")], - fake_run_step, - ) - - buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), None, buf) - - assert result is True - plain = _strip_ansi("".join(buf.written)) - assert "! lec (yosys_lec) warning" in plain - assert "✓ floorplan (ecc)" in plain - def test_success_summary_format(self, tmp_path): flow = _make_flow( _make_ws(str(tmp_path)), diff --git a/test/cli/rendering/test_render.py b/test/cli/rendering/test_render.py index c9a84f428..4606cae36 100644 --- a/test/cli/rendering/test_render.py +++ b/test/cli/rendering/test_render.py @@ -1,6 +1,3 @@ -import json - - class TestRendererCmdStripping: def test_text_strips_cmd_suffix(self): from io import StringIO @@ -14,29 +11,3 @@ def test_text_strips_cmd_suffix(self): assert "log=" in line assert "inspect_cmd=" not in line assert "log_cmd=" not in line - - def test_json_preserves_cmd_keys(self): - from io import StringIO - - from chipcompiler.cli.core.types import CommandResult - from chipcompiler.cli.rendering.render import render_json - - buf = StringIO() - result = CommandResult(records=({"inspect_cmd": "ecc status", "log_cmd": "ecc log"},)) - render_json(result, file=buf) - data = json.loads(buf.getvalue()) - assert "inspect_cmd" in data["records"][0] - assert "log_cmd" in data["records"][0] - - def test_jsonl_preserves_cmd_keys(self): - from io import StringIO - - from chipcompiler.cli.core.types import CommandResult - from chipcompiler.cli.rendering.render import render_jsonl - - buf = StringIO() - result = CommandResult(records=({"inspect_cmd": "ecc status", "log_cmd": "ecc log"},)) - render_jsonl(result, file=buf) - record = json.loads(buf.getvalue().strip()) - assert "inspect_cmd" in record - assert "log_cmd" in record diff --git a/test/cli/test_doc.py b/test/cli/test_doc.py index 029061285..1fefe72dd 100644 --- a/test/cli/test_doc.py +++ b/test/cli/test_doc.py @@ -12,10 +12,10 @@ def test_guides_root_points_at_repository_docs_in_dev_mode(): repo_docs = Path(__file__).parents[2] / "chipcompiler" / "docs" assert guides_root() == repo_docs - assert (guides_root() / "ecc-cli-config.en.md").is_file() + assert (guides_root() / "ecc-config-ref.en.md").is_file() -def test_all_four_topics_resolve_in_both_languages(): +def test_all_topics_resolve_in_both_languages(): for topic in docs.GUIDE_STEMS: for lang in ("en", "cn"): text = docs.load_guide(topic, lang).decode("utf-8") @@ -34,11 +34,11 @@ def test_doc_config_plain_is_byte_identical_to_the_guide_file(capsysbinary): out = capsysbinary.readouterr().out assert rc == 0 - assert out == (guides_root() / "ecc-cli-config.en.md").read_bytes() + assert out == (guides_root() / "ecc-config-ref.en.md").read_bytes() def test_doc_plain_preserves_crlf_line_endings(tmp_path, monkeypatch, capsysbinary): - guide = tmp_path / "docs" / "ecc-cli-config.en.md" + guide = tmp_path / "docs" / "ecc-config-ref.en.md" guide.parent.mkdir() guide.write_bytes(b"# Packaged guide\r\n\r\ntext\r\n") monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path), raising=False) @@ -53,7 +53,7 @@ def test_doc_plain_preserves_crlf_line_endings(tmp_path, monkeypatch, capsysbina def test_rendered_output_survives_non_utf8_stdout_via_plain_fallback(tmp_path, monkeypatch, capsys): import io - guide = tmp_path / "docs" / "ecc-cli-config.en.md" + guide = tmp_path / "docs" / "ecc-config-ref.en.md" guide.parent.mkdir() guide.write_bytes("# Packaged guide\n\ntext with ünïcode\n".encode()) monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path), raising=False) @@ -70,7 +70,7 @@ def test_rendered_output_survives_non_utf8_stdout_via_plain_fallback(tmp_path, m def test_doc_uses_packaged_docs_when_frozen(tmp_path, monkeypatch, capsys): - guide = tmp_path / "docs" / "ecc-cli-config.en.md" + guide = tmp_path / "docs" / "ecc-config-ref.en.md" guide.parent.mkdir() guide.write_text("# Packaged guide\n", encoding="utf-8") monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path), raising=False) @@ -89,7 +89,7 @@ def test_missing_guide_resource_fails_with_exit_1(tmp_path, monkeypatch, capsys) captured = capsys.readouterr() assert rc == 1 - assert "Error: doc resource not found: ecc-cli-config.en.md" in captured.err + assert "Error: doc resource not found: ecc-config-ref.en.md" in captured.err def test_doc_chinese_language(capsys): @@ -133,6 +133,10 @@ def test_doc_pager_keeps_styles_when_color_is_supported(monkeypatch, capsys): monkeypatch.setattr(pydoc, "pager", paged.append) monkeypatch.setattr(sys.stdout, "isatty", lambda: True) monkeypatch.setattr("chipcompiler.cli.commands.doc.supports_color", lambda: True) + # Rich consults NO_COLOR and TERM independently of supports_color(); + # pin them so the styled path is hermetic. + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("TERM", "xterm-256color") rc = cli_main.run(["doc", "config"]) @@ -162,6 +166,7 @@ def test_doc_pager_defaults_less_and_restores_the_environment(monkeypatch, capsy "argv", [ ["doc", "bogus"], + ["doc", "dev"], ["doc", "CONFIG"], ["doc"], ["doc", "config", "--lang", "jp"], diff --git a/test/cli/test_typer_cli.py b/test/cli/test_typer_cli.py index 335e9bf0a..54efc15e3 100644 --- a/test/cli/test_typer_cli.py +++ b/test/cli/test_typer_cli.py @@ -1,5 +1,5 @@ -import dataclasses import json +import re from importlib import metadata import pytest @@ -36,6 +36,21 @@ def test_root_help_returns_zero_and_lists_commands(capsys): assert removed_command not in out +def test_root_help_lists_doc_right_after_version(capsys): + rc = cli_main.run(["--help"]) + + out = capsys.readouterr().out + assert rc == 0 + # Command rows look like "│ "; wrapped help lines start + # with whitespace after the border and never match this pattern. + order = [ + match.group(1) + for line in out.splitlines() + if (match := re.match(r"^│ (\w[\w-]*) +\S", line)) + ] + assert order[:3] == ["version", "doc", "layout-image"] + + def test_root_version_returns_single_line(capsys): rc = cli_main.run(["--version"]) @@ -82,48 +97,6 @@ def test_version_command_returns_json_payload(monkeypatch, capsys): assert data["tools"] == {"yosys": "0.68", "sizer": "unknown", "klayout": "not installed"} -def test_version_command_returns_jsonl_lines(monkeypatch, capsys): - monkeypatch.setattr( - "chipcompiler.cli.app.tool_versions", - lambda: {"yosys": "0.68", "sizer": "unknown", "klayout": "0.30.2"}, - ) - - rc = cli_main.run(["version", "--jsonl"]) - - lines = capsys.readouterr().out.splitlines() - assert rc == 0 - records = [json.loads(ln) for ln in lines] - assert [r["component"] for r in records] == [ - "ecc", - "dreamplace", - "ecc_tools", - "yosys", - "sizer", - "klayout", - ] - assert all(set(r) == {"component", "version"} for r in records) - assert records[3] == {"component": "yosys", "version": "0.68"} - assert records[4] == {"component": "sizer", "version": "unknown"} - assert records[5] == {"component": "klayout", "version": "0.30.2"} - - -def test_version_command_returns_plain_line(monkeypatch, capsys): - monkeypatch.setattr( - "chipcompiler.cli.app.tool_versions", - lambda: {"yosys": "0.68", "sizer": "unknown", "klayout": "0.30.2"}, - ) - - rc = cli_main.run(["version", "--plain"]) - - out = capsys.readouterr().out - assert rc == 0 - assert "schema_version=1" in out - assert 'runtime="ECC CLI"' in out - assert "yosys=0.68" in out - assert "sizer=unknown" in out - assert "klayout=0.30.2" in out - - def test_version_metadata_missing_uses_unknown(monkeypatch, capsys): def missing_version(distribution): raise metadata.PackageNotFoundError(distribution) @@ -180,14 +153,14 @@ def test_invalid_option_returns_nonzero_without_system_exit(capsys): assert "No such option" in capsys.readouterr().err -def test_config_without_resolved_reaches_the_config_handler(tmp_path, capsys): +def test_config_without_resolved_reaches_the_config_handler(tmp_path, capsys, plain_records): project = tmp_path / "project" project.mkdir() - rc = cli_main.run(["config", "--project", str(project), "--json"]) + rc = cli_main.run(["config", "--project", str(project), "--plain"]) assert rc == 1 - assert json.loads(capsys.readouterr().out)["records"][0]["error"] == "missing_config" + assert plain_records(capsys.readouterr().out)[0]["error"] == "missing_config" def test_removed_config_resolved_option_returns_unknown_option(capsys): @@ -211,42 +184,6 @@ def test_removed_signoff_report_command_returns_unknown_command(capsys): assert "No such command" in capsys.readouterr().err -def test_output_mode_priority_prefers_jsonl(monkeypatch, tmp_path, capsys): - seen = {} - - def fake_resolve_project_dir(project): - return str(tmp_path) - - def fake_status(command_input, ctx): - seen["input_type"] = type(command_input).__name__ - seen["frozen"] = dataclasses.is_dataclass(command_input) - seen["mode"] = ctx.output_mode.value - seen["json"] = command_input.output.json - seen["jsonl"] = command_input.output.jsonl - seen["plain"] = command_input.output.plain - return CommandResult.ok([{"status": "ok"}]) - - monkeypatch.setattr( - "chipcompiler.cli.core.invocation.resolve_project_dir", - fake_resolve_project_dir, - ) - monkeypatch.setattr("chipcompiler.cli.command_handlers.inspect.status", fake_status) - - rc = cli_main.run(["status", "--jsonl", "--json", "--plain"]) - - objects = [json.loads(line) for line in capsys.readouterr().out.splitlines()] - assert rc == 0 - assert objects == [{"status": "ok"}] - assert seen == { - "input_type": "StatusInput", - "frozen": True, - "mode": "jsonl", - "json": True, - "jsonl": True, - "plain": True, - } - - def test_run_set_remains_repeatable(monkeypatch, tmp_path): seen = {} @@ -337,7 +274,9 @@ def test_run_workspace_flag_reaches_workspace_validation(capsys): assert "invalid_workspace" in capsys.readouterr().out -def test_status_command_handler_still_returns_command_result(monkeypatch, tmp_path, capsys): +def test_status_command_handler_still_returns_command_result( + monkeypatch, tmp_path, capsys, plain_records +): monkeypatch.setattr( "chipcompiler.cli.core.invocation.resolve_project_dir", lambda project: str(tmp_path), @@ -348,14 +287,13 @@ def fake_status(command_input, ctx): monkeypatch.setattr("chipcompiler.cli.command_handlers.inspect.status", fake_status) - rc = cli_main.run(["status", "--json"]) + rc = cli_main.run(["status", "--plain"]) - data = json.loads(capsys.readouterr().out) assert rc == 0 - assert data == {"records": [{"command": "status", "status": "ok"}]} + assert plain_records(capsys.readouterr().out) == [{"command": "status", "status": "ok"}] -def test_param_callback_passes_typed_input(monkeypatch, tmp_path, capsys): +def test_param_callback_passes_typed_input(monkeypatch, tmp_path, capsys, plain_records): seen = {} monkeypatch.setattr( "chipcompiler.cli.core.invocation.resolve_project_dir", @@ -370,10 +308,10 @@ def fake_show(command_input, ctx): monkeypatch.setattr("chipcompiler.cli.commands.param.param_show_handler", fake_show) - rc = cli_main.run(["param", "show", "place.target_density", "--project", "gcd", "--json"]) + rc = cli_main.run(["param", "show", "place.target_density", "--project", "gcd", "--plain"]) assert rc == 0 - assert json.loads(capsys.readouterr().out) == {"records": [{"param": "place.target_density"}]} + assert plain_records(capsys.readouterr().out) == [{"param": "place.target_density"}] assert seen == { "input_type": "ParamShowInput", "key": "place.target_density", diff --git a/test/cli/test_workspace_cli_removed.py b/test/cli/test_workspace_cli_removed.py deleted file mode 100644 index dc95c8f05..000000000 --- a/test/cli/test_workspace_cli_removed.py +++ /dev/null @@ -1,35 +0,0 @@ -from pathlib import Path - -from chipcompiler.cli import main as cli_main - - -def test_workspace_command_exposes_refresh_only(capsys): - rc = cli_main.run(["workspace", "--help"]) - - assert rc == 0 - assert "refresh" in capsys.readouterr().out - - -def test_legacy_workspace_subcommands_are_not_forwarded(capsys): - for args in ( - ["workspace", "create", "--json"], - ["workspace", "run-flow", "--json"], - ["workspace", "run-step", "--json"], - ): - rc = cli_main.run(args) - - assert rc != 0 - assert "No such command" in capsys.readouterr().err - - -def test_current_docs_do_not_advertise_removed_workspace_subcommands(): - docs = [ - Path("docs/workspace-cli.md"), - Path("docs/specification/cli-design.md"), - ] - forbidden = ["ecc workspace create", "ecc workspace run-flow", "ecc workspace run-step"] - - for path in docs: - source = path.read_text(encoding="utf-8") - for marker in forbidden: - assert marker not in source, f"{path} still contains {marker}" diff --git a/test/conftest.py b/test/conftest.py index 2086ada8f..e9d097b57 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -22,8 +22,6 @@ def _load_complete_ics55_pdk_available(): PDK_REQUIRED_TESTS = { f"{FILELIST_INTEGRATION_PREFIX}::test_workspace_with_filelist": "", f"{FILELIST_INTEGRATION_PREFIX}::test_workspace_with_nested_filelist": "", - "test/integration/test_harden_flow.py::test_ics55_gcd": "../pdk/icsprout55-pdk", - "test/integration/test_rcx_flow.py::test_ics55_gcd": "", "test/integration/test_rtl2gds_flow.py::test_ics55_gcd": "", } diff --git a/test/data/test_config_coverage.py b/test/data/test_config_coverage.py index 140ff12bf..0b3ad0518 100644 --- a/test/data/test_config_coverage.py +++ b/test/data/test_config_coverage.py @@ -1,17 +1,109 @@ import json +from pathlib import Path from chipcompiler.data.config_params import ( CONFIG_PARAM_SCHEMAS, validate_config_registry, ) -from chipcompiler.data.config_params.coverage import ( - TEMPLATES, - covered_fields, - template_fields, -) +# Template location constants were vendored from the former +# chipcompiler.data.config_params.coverage module: they describe test-only +# traversal policy and belong beside the tests that consume them. +_PACKAGE_ROOT = Path(__file__).resolve().parents[2] / "chipcompiler" +TEMPLATES = { + "db": _PACKAGE_ROOT / "tools/ecc/configs/db_ecc.json", + "CTS": _PACKAGE_ROOT / "tools/ecc/configs/cts_ecc.json", + "Floorplan": _PACKAGE_ROOT / "tools/ecc/configs/floorplan_ecc.json", + "dreamplace": _PACKAGE_ROOT / "tools/ecc_dreamplace/configs/dreamplace_ecc.json", + "route": _PACKAGE_ROOT / "tools/ecc/configs/route_ecc.json", + "filler": _PACKAGE_ROOT / "tools/ecc/configs/filler_ecc.json", + "RCX": _PACKAGE_ROOT / "tools/ecc/configs/rcx_ecc.json", + "sta": _PACKAGE_ROOT / "tools/ecc/configs/sta_ecc.json", +} +# drc_ecc.json is empty today; add it here once it grows static fields so the +# coverage test starts pinning them. + +LEGACY_FIELDS = { + "db": {("LayerSettings", "routing_layer_1st")}, + "CTS": {("max_fanout",)}, + "Floorplan": { + ("die_builder", "die_util", "utilization"), + ("die_builder", "die_util", "aspect_ratio"), + ("die_builder", "margin", "left_micron"), + ("die_builder", "margin", "right_micron"), + ("die_builder", "margin", "top_micron"), + ("die_builder", "margin", "bottom_micron"), + }, + "dreamplace": { + ("target_density",), + ("stop_overflow",), + ("cell_padding_x",), + ("routability_opt_flag",), + }, + "route": { + ("RT", "-bottom_routing_layer"), + ("RT", "-top_routing_layer"), + }, +} + +PROTECTED_FIELDS = { + "db": { + ("INPUT", "tech_lef_path"), + ("INPUT", "lef_paths"), + ("INPUT", "def_path"), + ("INPUT", "verilog_path"), + ("INPUT", "lib_path"), + ("INPUT", "sdc_path"), + ("OUTPUT", "output_dir_path"), + }, + "Floorplan": { + ("ifp", "temp_directory_path"), + ("macro_placer", "macro_location_path"), + }, + "dreamplace": { + ("aux_input",), + ("base_design_name",), + ("def_input",), + ("lef_input",), + ("result_dir",), + ("verilog_input",), + }, + "route": {("RT", "-temp_directory_path")}, + "RCX": {("output",)}, + "sta": {("liberty",)}, +} + + +def template_fields() -> dict[str, set[tuple[str, ...]]]: + return {key: _config_fields(path) for key, path in TEMPLATES.items()} + + +def covered_fields() -> dict[str, set[tuple[str, ...]]]: + fields: dict[str, set[tuple[str, ...]]] = { + key: set(value) for key, value in LEGACY_FIELDS.items() + } + for schema in CONFIG_PARAM_SCHEMAS: + target = schema.config_target + if target is not None: + fields.setdefault(target.config_key, set()).add(target.json_path) + for key, protected in PROTECTED_FIELDS.items(): + fields.setdefault(key, set()).update(protected) + return fields + + +def _config_fields(path: Path) -> set[tuple[str, ...]]: + with path.open(encoding="utf-8") as file: + data = json.load(file) + return set(_iter_fields(data)) + + +def _iter_fields(value: object, path: tuple[str, ...] = ()): # noqa: ANN001 + if isinstance(value, dict): + for key, child in value.items(): + yield from _iter_fields(child, (*path, key)) + return + yield path -def test_config_schema_registry_is_valid(): assert validate_config_registry() == [] diff --git a/test/data/test_descriptions.py b/test/data/test_descriptions.py index b586f1c59..d9c3ed0af 100644 --- a/test/data/test_descriptions.py +++ b/test/data/test_descriptions.py @@ -1,10 +1,7 @@ import json -from inspect import Parameter, signature from pathlib import Path -from chipcompiler.cli.project.params import PARAM_REGISTRY from chipcompiler.data.config_params import CONFIG_PARAM_SCHEMAS -from chipcompiler.data.config_params.common import config_param _REPO_ROOT = Path(__file__).resolve().parents[2] _DREAMPLACE_PARAMETERS = ( @@ -12,17 +9,6 @@ ) -def test_every_schema_has_an_explicit_description(): - descriptions = {schema.param: schema.description for schema in PARAM_REGISTRY} - - assert all(description.strip() for description in descriptions.values()) - assert not any("configuration field" in description for description in descriptions.values()) - - -def test_config_param_requires_a_description(): - assert signature(config_param).parameters["description"].default is Parameter.empty - - def test_dreamplace_descriptions_match_upstream_metadata(): metadata = json.loads(_DREAMPLACE_PARAMETERS.read_text(encoding="utf-8")) schemas = [ diff --git a/test/data/test_workspace.py b/test/data/test_workspace.py index afd148e0a..198bc4413 100644 --- a/test/data/test_workspace.py +++ b/test/data/test_workspace.py @@ -247,6 +247,58 @@ def test_create_workspace_copies_external_lec_and_sta_inputs( assert (workspace_dir / "origin" / "gcd.spef").is_file() +def test_load_workspace_keeps_golden_prefixed_primary_netlist( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + # A primary netlist whose name starts with golden_ must keep its role: + # creation never declared a golden netlist, and the persisted flow + # ledger says so. + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + rtl_path = tmp_path / "golden_gcd.v" + rtl_path.write_text("module gcd; endmodule\n") + + workspace_dir = tmp_path / "workspace" + create_workspace( + directory=workspace_dir, + origin_def="", + origin_verilog=rtl_path, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={"start_step": "Synthesis", "end_step": "Floorplan"}, + ) + + loaded = load_workspace(str(workspace_dir)) + assert loaded.design.origin_verilog == workspace_dir / "origin" / "golden_gcd.v" + assert loaded.design.golden_verilog is None + + +def test_load_workspace_restores_golden_from_persisted_flow_info( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + netlist = tmp_path / "gcd.v" + golden = tmp_path / "gcd_golden.v" + netlist.write_text("module gcd; endmodule\n") + golden.write_text("module gcd; endmodule\n") + + workspace_dir = tmp_path / "workspace" + create_workspace( + directory=workspace_dir, + origin_def="", + origin_verilog=netlist, + golden_verilog=golden, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={"start_step": "lec", "end_step": "lec"}, + ) + + loaded = load_workspace(str(workspace_dir)) + assert loaded.design.origin_verilog == workspace_dir / "origin" / "gcd.v" + assert loaded.design.golden_verilog == workspace_dir / "origin" / "golden_gcd_golden.v" + + def test_create_workspace_non_contiguous_flow_seeds_both_stores_contiguous( tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters, caplog ): @@ -1003,6 +1055,25 @@ def test_refresh_workspace_config_preserves_nested_dreamplace_override_precedenc assert dreamplace["routability_opt_flag"] == 0 +def test_apply_config_overrides_validates_every_target_before_writing(tmp_path): + from chipcompiler.data.workspace.config_overrides import apply_config_overrides + + cts_path = tmp_path / "cts.json" + json_write(cts_path, {"skew_bound": "0.05"}) + parameters = { + "config_overrides": { + "cts.json": {"skew_bound": "0.20"}, + "bogus.json": {"threads": 1}, + } + } + + with pytest.raises(ValueError, match="unknown config override target"): + apply_config_overrides({"cts.json": cts_path}, parameters) + + # The valid first override is not persisted when a later target is invalid. + assert json_read(cts_path) == {"skew_bound": "0.05"} + + def test_refresh_workspace_config_reapplies_direct_config_overrides( tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters ): diff --git a/test/engine/test_reconcile.py b/test/engine/test_reconcile.py index 7b508fdb1..51a88a2ea 100644 --- a/test/engine/test_reconcile.py +++ b/test/engine/test_reconcile.py @@ -2,29 +2,16 @@ import json +from chipcompiler.data.workspace import _canonical_rtl2gds_flow_entries from chipcompiler.engine.reconcile import ( compare_flows, reconcile_workspace, resolve_target_section, ) -RTL2GDS_STEPS = [ - ("Synthesis", "yosys"), - ("lec", "yosys_lec"), - ("Floorplan", "ecc"), - ("place", "dreamplace"), - ("CTS", "ecc"), - ("legalization", "dreamplace"), - ("Timing optimization", "sizer"), - ("route", "ecc"), - ("filler", "ecc"), - ("RCX", "ecc"), - ("sta", "ecc"), - ("lvs", "ecc"), - ("postRouteLec", "yosys_lec"), - ("drc", "ecc"), - ("Harden", "ecc"), -] +# Derived from the canonical builder so reconciliation tests always +# exercise the real current topology, never a stale hand-copied chain. +RTL2GDS_STEPS = [(name, tool) for name, tool, _state in _canonical_rtl2gds_flow_entries()] LEGACY_RTL2GDS_STEPS = RTL2GDS_STEPS[:-3] FULL_FLOW_SUFFIX = RTL2GDS_STEPS[-3:] LEGACY_SYNTH_LEC_STEPS = [entry for entry in RTL2GDS_STEPS if entry[0] != "lec"] @@ -149,6 +136,100 @@ def test_equal_with_non_success_is_resume(self, tmp_path): assert result.outcome == "resume" + def test_equal_with_legacy_warned_lec_resumes(self, tmp_path): + # The removed terminal Warning state is not finished: a persisted + # warned LEC reconciles to a resume that re-runs it and its suffix. + lec_index = next( + index for index, (name, _tool) in enumerate(RTL2GDS_STEPS) if name == "lec" + ) + states = ["Success"] * len(RTL2GDS_STEPS) + states[lec_index] = "Warning" + workspace_dir = _write_workspace( + tmp_path, RTL2GDS_STEPS, states=states, flow_section={"preset": "rtl2gds"} + ) + + result = reconcile_workspace(workspace_dir, {"preset": "rtl2gds"}) + + assert result.outcome == "resume" + + # The pre-reorder chain: DRC/LVS before filler, RCX/STA/Harden as + # preset-only suffixes. + LEGACY_RTL2GDS_ORDER = [ + ("Synthesis", "yosys"), + ("lec", "yosys_lec"), + ("Floorplan", "ecc"), + ("place", "dreamplace"), + ("CTS", "ecc"), + ("legalization", "dreamplace"), + ("Timing optimization", "sizer"), + ("route", "ecc"), + ("drc", "ecc"), + ("lvs", "ecc"), + ("filler", "ecc"), + ("postRouteLec", "yosys_lec"), + ] + + def test_legacy_prereorder_ledger_migrates_instead_of_mismatch(self, tmp_path): + workspace_dir = _write_workspace( + tmp_path, + self.LEGACY_RTL2GDS_ORDER, + flow_section={"preset": "rtl2gds"}, + ) + + result = reconcile_workspace(workspace_dir, {"preset": "rtl2gds"}) + + assert result.outcome == "resume" + steps = _flow_steps(workspace_dir) + assert [(s["name"], s["tool"]) for s in steps] == RTL2GDS_STEPS + route_index = next( + index for index, (name, _tool) in enumerate(RTL2GDS_STEPS) if name == "route" + ) + # The identical Synthesis..route prefix keeps its Success states; + # post-route steps ran with pre-reorder inputs and restart. + assert all(s["state"] == "Success" for s in steps[: route_index + 1]) + assert [s["state"] for s in steps[route_index + 1 :]] == ["Unstart"] * ( + len(steps) - route_index - 1 + ) + assert set(result.appended) == {"RCX", "sta", "Harden"} + + def test_legacy_prereorder_rcx_preset_migrates_to_legacy_alias_range(self, tmp_path): + workspace_dir = _write_workspace( + tmp_path, + self.LEGACY_RTL2GDS_ORDER + [("RCX", "ecc"), ("sta", "ecc")], + flow_section={"preset": "rcx"}, + ) + + result = reconcile_workspace(workspace_dir, {"preset": "rcx"}) + + assert result.outcome == "resume" + steps = _flow_steps(workspace_dir) + assert [(s["name"], s["tool"]) for s in steps] == [ + ("Synthesis", "yosys"), + ("lec", "yosys_lec"), + ("Floorplan", "ecc"), + ("place", "dreamplace"), + ("CTS", "ecc"), + ("legalization", "dreamplace"), + ("Timing optimization", "sizer"), + ("route", "ecc"), + ("filler", "ecc"), + ("RCX", "ecc"), + ("sta", "ecc"), + ] + assert _flow_section(workspace_dir) == {"preset": "rcx"} + + def test_ledger_starting_off_synthesis_is_still_a_mismatch(self, tmp_path): + # Legacy presets always began at Synthesis: a ledger starting + # elsewhere is a foreign shape, not a migratable legacy one. + workspace_dir = _write_workspace( + tmp_path, [("place", "dreamplace")], flow_section={"preset": "rtl2gds"} + ) + + result = reconcile_workspace(workspace_dir, {"preset": "rtl2gds"}) + + assert result.outcome == "mismatch" + assert result.error == "flow_mismatch" + def test_target_prefix_keeps_extra_steps(self, tmp_path): workspace_dir = _write_workspace( tmp_path, diff --git a/test/engine/test_state_machine_regression.py b/test/engine/test_state_machine_regression.py index 99937c886..c878b2af6 100644 --- a/test/engine/test_state_machine_regression.py +++ b/test/engine/test_state_machine_regression.py @@ -411,6 +411,22 @@ def test_invalid_step_normalized_on_resume(self, tmp_path, monkeypatch): persisted = json.loads((tmp_path / "home" / "flow.json").read_text()) assert persisted["steps"][1]["state"] == StateEnum.Success.value + def test_legacy_warning_step_normalized_on_resume(self, tmp_path, monkeypatch): + """Persisted Warning state from the removed LEC downgrade — resume normalizes it.""" + flow = _make_resume_workspace( + tmp_path, + [("Synthesis", "Success"), ("Floorplan", "Warning")], + ) + monkeypatch.setattr(tools, "run_step", lambda **_kw: True) + monkeypatch.setattr(flow, "check_step_result", lambda **_kw: True) + + # This must NOT raise ValueError + result = flow.run_step(flow.workspace_steps[1], rerun=False) + assert result == StateEnum.Success + + persisted = json.loads((tmp_path / "home" / "flow.json").read_text()) + assert persisted["steps"][1]["state"] == StateEnum.Success.value + def test_ongoing_step_not_normalized(self, tmp_path, monkeypatch): """Ongoing step is NOT a terminal state — no normalization needed.""" flow = _make_resume_workspace( diff --git a/test/formal/test_state_machine.py b/test/formal/test_state_machine.py index 1f600702c..d7c22b190 100644 --- a/test/formal/test_state_machine.py +++ b/test/formal/test_state_machine.py @@ -42,36 +42,32 @@ StateEnum.Ongoing: 2, StateEnum.Success: 3, StateEnum.Imcomplete: 4, - StateEnum.Warning: 5, - StateEnum.Invalid: 6, + StateEnum.Invalid: 5, } STATE_COUNT: int = len(STATE_MAP) # Reference transition graph from spec. # Key = source state, value = set of valid target states. -# Terminal states (Success, Incomplete, Warning, Invalid) have no outgoing transitions. +# Terminal states (Success, Incomplete, Invalid) have no outgoing transitions. # Batch resets (clear_states, _invalidate_suffix) bypass set_state() and # can assign Unstart directly, including for terminal states. VALID_TRANSITIONS: dict[StateEnum, set[StateEnum]] = { - StateEnum.Unstart: {StateEnum.Ongoing, StateEnum.Imcomplete, StateEnum.Warning}, - StateEnum.Pending: {StateEnum.Ongoing, StateEnum.Imcomplete, StateEnum.Warning}, + StateEnum.Unstart: {StateEnum.Ongoing, StateEnum.Imcomplete}, + StateEnum.Pending: {StateEnum.Ongoing, StateEnum.Imcomplete}, StateEnum.Ongoing: { StateEnum.Success, StateEnum.Imcomplete, - StateEnum.Warning, StateEnum.Invalid, }, StateEnum.Success: set(), # terminal StateEnum.Imcomplete: set(), # terminal - StateEnum.Warning: set(), # terminal StateEnum.Invalid: set(), # terminal } TERMINAL_STATES: set[StateEnum] = { StateEnum.Success, StateEnum.Imcomplete, - StateEnum.Warning, StateEnum.Invalid, } diff --git a/test/packaging/test_cli_entrypoint.py b/test/packaging/test_cli_entrypoint.py index 36251e45a..de400ac1f 100644 --- a/test/packaging/test_cli_entrypoint.py +++ b/test/packaging/test_cli_entrypoint.py @@ -43,9 +43,9 @@ def test_pyinstaller_spec_collects_doc_guides(self): source = f.read() assert "datas.extend(collect_doc_guides())" in source - for stem in ("config", "ug", "tutorial", "dev"): + for stem in ("config-ref", "user-guide", "tutorial"): for lang in ("en", "cn"): - assert f"chipcompiler/docs/ecc-cli-{stem}.{lang}.md" in source + assert f"chipcompiler/docs/ecc-{stem}.{lang}.md" in source def test_pyinstaller_spec_collects_rich_unicode_data_modules(self): project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) diff --git a/test/packaging/test_wheel_contents.py b/test/packaging/test_wheel_contents.py index f23dfeff9..ba77d0059 100644 --- a/test/packaging/test_wheel_contents.py +++ b/test/packaging/test_wheel_contents.py @@ -19,6 +19,6 @@ def test_wheel_ships_all_doc_guides(tmp_path): wheel = next(tmp_path.glob("ecc-*.whl")) names = zipfile.ZipFile(wheel).namelist() - for stem in ("config", "ug", "tutorial", "dev"): + for stem in ("config-ref", "user-guide", "tutorial"): for lang in ("en", "cn"): - assert f"chipcompiler/docs/ecc-cli-{stem}.{lang}.md" in names + assert f"chipcompiler/docs/ecc-{stem}.{lang}.md" in names diff --git a/test/runtime/test_transport.py b/test/runtime/test_transport.py index b248c42e0..9b88db82d 100644 --- a/test/runtime/test_transport.py +++ b/test/runtime/test_transport.py @@ -58,7 +58,7 @@ def test_oversize_payload_is_transport_error(): def test_workspace_rpc_doc_content_lengths_match_payloads(): - source = Path("docs/workspace-cli.md").read_text(encoding="utf-8") + source = Path("docs/rpc-guide.md").read_text(encoding="utf-8") frames = re.findall(r"Content-Length: (\d+)\n\n({\"jsonrpc\"[^\n]+})", source) assert frames @@ -67,7 +67,7 @@ def test_workspace_rpc_doc_content_lengths_match_payloads(): def test_workspace_rpc_docs_cover_opt_in_persistent_db_surface(): - source = Path("docs/workspace-cli.md").read_text(encoding="utf-8") + source = Path("docs/rpc-guide.md").read_text(encoding="utf-8") cli_design = Path("docs/specification/cli-design.md").read_text(encoding="utf-8") for text in (source, cli_design): diff --git a/test/test_engine_flow.py b/test/test_engine_flow.py index 82a9448ae..6f2d78dd2 100644 --- a/test/test_engine_flow.py +++ b/test/test_engine_flow.py @@ -115,7 +115,7 @@ def test_engine_flow_does_not_delay_short_step_before_return(monkeypatch, tmp_pa assert sleep_calls == [] -def test_failed_synthesis_lec_is_persisted_as_warning(monkeypatch, tmp_path): +def test_failed_synthesis_lec_is_persisted_as_incomplete(monkeypatch, tmp_path): workspace = Workspace(directory=tmp_path) workspace.flow.path = tmp_path / "flow.json" workspace.flow.data = { @@ -136,11 +136,11 @@ def test_failed_synthesis_lec_is_persisted_as_warning(monkeypatch, tmp_path): ) monkeypatch.setattr(engine_flow, "check_step_result", lambda **_kwargs: False) - assert engine_flow.run_step(workspace_step) is StateEnum.Warning - assert workspace.flow.data["steps"][0]["state"] == StateEnum.Warning.value + assert engine_flow.run_step(workspace_step) is StateEnum.Imcomplete + assert workspace.flow.data["steps"][0]["state"] == StateEnum.Imcomplete.value -def test_run_steps_continues_after_synthesis_lec_warning(monkeypatch, tmp_path): +def test_run_steps_stops_after_synthesis_lec_failure(monkeypatch, tmp_path): workspace = Workspace(directory=tmp_path) workspace.flow.data = { "steps": [ @@ -157,14 +157,14 @@ def test_run_steps_continues_after_synthesis_lec_warning(monkeypatch, tmp_path): def fake_run_step(step, **_kwargs): calls.append(step.name) - return StateEnum.Warning if step.name == StepEnum.LEC.value else StateEnum.Success + return StateEnum.Imcomplete if step.name == StepEnum.LEC.value else StateEnum.Success monkeypatch.setattr(engine_flow, "run_step", fake_run_step) monkeypatch.setattr(engine_flow, "init_db_engine", lambda: True) monkeypatch.setattr(flow_module, "log_flow", lambda **_kwargs: None) - assert engine_flow.run_steps() is True - assert calls == [StepEnum.LEC.value, StepEnum.FLOORPLAN.value] + assert engine_flow.run_steps() is False + assert calls == [StepEnum.LEC.value] def test_check_step_result_synthesis_uses_common_verilog(tmp_path): diff --git a/test/test_engine_rerun.py b/test/test_engine_rerun.py index 8fa96de7d..b7248232b 100644 --- a/test/test_engine_rerun.py +++ b/test/test_engine_rerun.py @@ -89,6 +89,34 @@ def test_resume_all_success_selects_nothing(self, tmp_path): assert rerun.selected_step_names(flow) == [] + def test_resume_reexecutes_legacy_warning_steps(self, tmp_path): + # The removed terminal Warning state (pre-rework synthesis LEC) is not + # a finished state: a plain resume re-executes it and its suffix. + flow = _make_run_flow( + tmp_path, + [ + ("Synthesis", "Success"), + ("lec", "Warning"), + ("Floorplan", "Success"), + ("CTS", "Success"), + ], + ) + + assert rerun.selected_step_names(flow) == ["lec", "Floorplan", "CTS"] + + def test_only_legacy_warning_step_does_not_require_force(self, tmp_path): + flow = _make_run_flow(tmp_path, [("lec", "Warning")]) + + assert rerun.selected_step_names(flow, only="lec") == ["lec"] + + def test_resume_still_selects_incomplete_suffix(self, tmp_path): + flow = _make_run_flow( + tmp_path, + [("Synthesis", "Success"), ("place", "Incomplete"), ("CTS", "Success")], + ) + + assert rerun.selected_step_names(flow) == ["place", "CTS"] + def test_from_selects_suffix(self, tmp_path): flow = _make_run_flow( tmp_path, @@ -144,20 +172,6 @@ def test_selectors_accept_alias_and_case_spellings(self, tmp_path): class TestRunFrom: - def test_synthesis_lec_warning_does_not_stop_resume(self, monkeypatch, tmp_path): - flow = _make_run_flow( - tmp_path, - [("lec", "Unstart"), ("route", "Unstart")], - tools_by_name={"lec": "yosys_lec"}, - ) - calls = _fake_execution(flow, monkeypatch, outcomes={"lec": StateEnum.Warning}) - - result = rerun.run_from(flow, "lec") - - assert result.ok - assert result.executed == ("lec", "route") - assert calls == [("lec", True), ("route", True)] - def test_reexecutes_suffix_and_clears_only_executed_outputs(self, monkeypatch, tmp_path): flow = _make_run_flow( tmp_path, @@ -401,6 +415,23 @@ def test_reuses_initialized_engine(self, tmp_path): assert flow.init_db_engine_for_step(flow.workspace_steps[0]) is True assert flow.engine_db is engine_db + def test_lec_step_never_initializes_a_native_db(self, tmp_path, monkeypatch): + from chipcompiler.engine import EngineDB + + # LEC compares netlists and has no ECC DB: explicit reruns + # (--only lec) must not build one from the LEC workspace. + flow = _make_run_flow(tmp_path, [("lec", "Incomplete")], tools_by_name={"lec": "yosys_lec"}) + created = [] + + def create_db_engine(self, step): + created.append(step) + return True + + monkeypatch.setattr(EngineDB, "create_db_engine", create_db_engine) + + assert flow.init_db_engine_for_step(flow.workspace_steps[0]) is True + assert created == [] + class TestBoundedResume: def test_resume_bounded_to_target_end_keeps_beyond_target_outputs(self, monkeypatch, tmp_path): diff --git a/test/test_qor_report.py b/test/test_qor_report.py index 4d01ec4f3..bd63e7f44 100644 --- a/test/test_qor_report.py +++ b/test/test_qor_report.py @@ -202,7 +202,9 @@ def _make_workspace(tmp_path, *, with_metrics=True, with_checklist=True): "blocked": True, "summary": "drc_count=2 (required == 0)", "source": {}, - "evidence": ["drc_ecc/analysis/qor_summary.json"], + "evidence": [ + {"kind": "feature", "path": "drc_ecc/analysis/qor_summary.json"} + ], }, { "id": "harden.gds", @@ -290,10 +292,24 @@ def test_trend_only_records_are_not_selected_for_score(self, tmp_path): by_label = {d.label: d for d in report.dimension_scores} assert by_label["Routability / Physical"].metric_count == 1 + def test_stale_metrics_of_unstarted_steps_do_not_score(self, tmp_path): + # Invalidation resets a step to Unstart but keeps its analysis + # outputs on disk; the obsolete metrics must not score. + workspace = _make_workspace(tmp_path) + for step in workspace.flow.data["steps"]: + if step["name"] == "drc": + step["state"] = "Unstart" + + report = build_qor_report(workspace) + + assert [m for m in report.metrics if m.step == "DRC"] == [] + by_label = {d.label: d for d in report.dimension_scores} + assert "Routability / Physical" not in by_label + def test_empty_workspace_report(self, tmp_path): report = build_qor_report(_make_workspace(tmp_path, with_metrics=False)) assert report.overall_score is None - assert report.status in ("Blocked", "Green") + assert report.status == "Blocked" text = generate_qor_report(_make_workspace(tmp_path, with_metrics=False)) assert "NOT RATED" in text assert "no project-level QoR metrics available" in text @@ -316,6 +332,66 @@ def test_text_report_layout(self, tmp_path): assert "weights not renormalized" in text +class TestFlowStepOrder: + def test_flow_steps_follow_the_canonical_chain_order(self): + from chipcompiler.engine.qor_report import FLOW_STEPS + + assert FLOW_STEPS == ( + "Synth", + "Floor", + "Place", + "CTS", + "Legal", + "Route", + "Filler", + "RCX", + "STA", + "LVS", + "DRC", + "Harden", + ) + + def test_area_scoring_uses_the_latest_scored_step_in_chain_order(self): + from chipcompiler.engine.qor_report import QorMetricRecord, _resolve_area_scoring_step + + def record(step): + return QorMetricRecord( + step=step, + metric_name="die_area", + display_name="die_area", + value=1.0, + dimension="area_cost", + rating_score=True, + ) + + flow_states = {"STA": "Success", "DRC": "Success"} + assert _resolve_area_scoring_step([record("STA"), record("DRC")], flow_states) == "DRC" + + +class TestFlowCompletionState: + def test_states_are_derived_explicitly(self): + from chipcompiler.engine.qor_report import _flow_completion_state + + assert _flow_completion_state([]) == "not_started" + assert _flow_completion_state(["Unstart"]) == "not_started" + assert _flow_completion_state(["Success", "Ongoing"]) == "running" + assert _flow_completion_state(["Success", "Unstart"]) == "in_progress" + assert _flow_completion_state(["Success", "Incomplete"]) == "failed" + assert _flow_completion_state(["Invalid"]) == "failed" + assert _flow_completion_state(["Success"] * 5) == "complete" + # A legacy persisted Warning (removed terminal state) is unfinished. + assert _flow_completion_state(["Success", "Warning"]) == "in_progress" + + def test_nonterminal_workspaces_are_blocked(self, tmp_path): + for state in ("Ongoing", "Unstart", "Pending"): + workspace = _make_workspace(tmp_path / state, with_metrics=False) + flow = workspace.flow.data + for step in flow["steps"][:3]: + step["state"] = state + report = build_qor_report(workspace) + assert report.status == "Blocked", state + + class TestChecklistReport: def test_build_from_checklist_json(self, tmp_path): report = build_checklist_report(_make_workspace(tmp_path)) diff --git a/test/test_signoff_package.py b/test/test_signoff_package.py index 647e23093..c2c2bfec3 100644 --- a/test/test_signoff_package.py +++ b/test/test_signoff_package.py @@ -103,9 +103,10 @@ def _make_signoff_workspace( _write(workspace_dir / "filler_ecc" / "output" / f"{design}_filler.def.gz") _write(workspace_dir / "filler_ecc" / "output" / f"{design}_filler.gds") _write(workspace_dir / "filler_ecc" / "output" / f"{design}_filler.png") + _write(workspace_dir / "lvs_ecc" / "output" / f"{design}_lvs.v.gz") _write(workspace_dir / "RCX_ecc" / "output" / f"{top_module}_RCworst_125C.spef") golden = workspace_dir / "Synthesis_yosys" / "output" / f"{design}_Synthesis.v.gz" - gate = workspace_dir / "filler_ecc" / "output" / f"{design}_filler.v.gz" + gate = workspace_dir / "lvs_ecc" / "output" / f"{design}_lvs.v.gz" golden_digest = file_digest(golden) gate_digest = file_digest(gate) _write_json( @@ -302,8 +303,8 @@ def test_collect_signoff_package_requires_synthesis_verilog(tmp_path): def test_collect_signoff_package_rejects_stale_post_route_lec_proof(tmp_path): workspace_dir = _make_signoff_workspace(tmp_path) - filler = workspace_dir / "filler_ecc" / "output" / "gcd_filler.v.gz" - filler.write_text("module gcd; updated\n") + gate = workspace_dir / "lvs_ecc" / "output" / "gcd_lvs.v.gz" + gate.write_text("module gcd; updated\n") result = _make_engine_flow(workspace_dir).collect_signoff_package( SignoffPackageOptions(archive=False, materialize=False) @@ -322,9 +323,9 @@ def test_collect_signoff_package_rejects_stale_post_route_lec_proof(tmp_path): def test_collect_signoff_package_rejects_lec_proof_bound_to_copied_netlists(tmp_path): workspace_dir = _make_signoff_workspace(tmp_path) old_golden = tmp_path / "old" / "gcd_Synthesis.v.gz" - old_gate = tmp_path / "old" / "gcd_filler.v.gz" + old_gate = tmp_path / "old" / "gcd_lvs.v.gz" current_golden = workspace_dir / "Synthesis_yosys" / "output" / "gcd_Synthesis.v.gz" - current_gate = workspace_dir / "filler_ecc" / "output" / "gcd_filler.v.gz" + current_gate = workspace_dir / "lvs_ecc" / "output" / "gcd_lvs.v.gz" _write(old_golden, current_golden.read_text()) _write(old_gate, current_gate.read_text()) golden_digest = file_digest(old_golden) @@ -462,7 +463,7 @@ def test_collect_signoff_package_uses_origin_rtl_for_floorplan_start(tmp_path): ) engine_flow = _make_engine_flow(workspace_dir) engine_flow.workspace.design.origin_verilog = origin - gate = workspace_dir / "filler_ecc" / "output" / "gcd_filler.v.gz" + gate = workspace_dir / "lvs_ecc" / "output" / "gcd_lvs.v.gz" _bind_post_route_lec(workspace_dir, origin, gate) result = engine_flow.collect_signoff_package(SignoffPackageOptions(archive=True)) @@ -491,7 +492,7 @@ def test_collect_signoff_package_ignores_leftover_synthesis_for_floorplan_start( ) engine_flow = _make_engine_flow(workspace_dir) engine_flow.workspace.design.origin_verilog = origin - gate = workspace_dir / "filler_ecc" / "output" / "gcd_filler.v.gz" + gate = workspace_dir / "lvs_ecc" / "output" / "gcd_lvs.v.gz" _bind_post_route_lec(workspace_dir, origin, gate) result = engine_flow.collect_signoff_package(SignoffPackageOptions(archive=True)) diff --git a/test/test_signoff_report.py b/test/test_signoff_report.py index c4fa58cd0..dce5776a0 100644 --- a/test/test_signoff_report.py +++ b/test/test_signoff_report.py @@ -166,7 +166,21 @@ def test_corner_rollup_min_wns(self): assert data.multi_corner_timing[0].temperature_c == 125.0 assert data.multi_corner_timing[1].temperature_c == -40.0 assert data.timing.setup_wns_ns == -0.3 # min rollup - assert data.timing.violating_endpoints_setup == 0.0 + # No corner reported an endpoint count, so none is invented. + assert data.timing.violating_endpoints_setup is None + + def test_corner_without_slack_is_unknown(self): + data = extract_design_report_data( + self._inputs( + sta_corner_reports={ + "MAX_125/RCworst": {"frequency_mhz": 200.0}, + } + ) + ) + (corner,) = data.multi_corner_timing + assert corner.status == "unknown" + assert corner.setup_wns_ns is None + assert corner.violating_endpoints_setup is None def test_drc_status_from_metrics(self): data = extract_design_report_data( @@ -364,7 +378,9 @@ def fake_generate(workspace): seen["workspace"] = workspace return "REPORT" - monkeypatch.setattr("chipcompiler.engine.signoff.generate_text_report", fake_generate) + monkeypatch.setattr( + "chipcompiler.engine.signoff.collector.generate_text_report", fake_generate + ) workspace = _make_workspace(tmp_path) assert SignoffPackageCollector(workspace).text_report() == "REPORT" assert seen["workspace"] is workspace diff --git a/test/tools/ecc/test_runner.py b/test/tools/ecc/test_runner.py index ee9b2378a..ccb9a7ed7 100644 --- a/test/tools/ecc/test_runner.py +++ b/test/tools/ecc/test_runner.py @@ -604,6 +604,37 @@ def test_sta_signoff_items_use_top_module_for_rcx_spef(tmp_path): assert items[0]["spef_file"] == str(tmp_path / "RCX_ecc" / "output" / "gcd_Cworst_125C.spef") +def test_sta_entry_workspace_uses_declared_spef(tmp_path): + config_dir = tmp_path / "config" + config_dir.mkdir() + sta_config = config_dir / "sta_ecc.json" + sta_config.write_text( + json.dumps( + { + "liberty": [{"corner": "MAX", "temperature": 125, "path": ["max.lib"]}], + "signoff": [{"MAX": ["Cworst"]}], + } + ) + ) + declared_spef = tmp_path / "origin" / "gcd.spef" + declared_spef.parent.mkdir() + declared_spef.write_text("*SPEF\n", encoding="utf-8") + workspace = Workspace( + directory=tmp_path, + design=OriginDesign(name="gcd", top_module="gcd"), + config={"sta": sta_config}, + ) + workspace.pdk.spef = declared_spef + # An STA-entry flow has no RCX step. + workspace.flow.data = { + "steps": [{"name": StepEnum.STA.value, "tool": "ecc", "state": "Unstart"}] + } + + items = ecc_runner.collect_sta_signoff_items(workspace) + + assert [item["spef_file"] for item in items] == [str(declared_spef)] + + def test_copy_rcx_spef_outputs_publishes_to_step_output_dir(tmp_path): data_dir = tmp_path / "RCX_ecc" / "data" output_dir = tmp_path / "RCX_ecc" / "output" diff --git a/test/tools/ecc/test_signoff_checklist.py b/test/tools/ecc/test_signoff_checklist.py index f9af9ea90..3e7690a8a 100644 --- a/test/tools/ecc/test_signoff_checklist.py +++ b/test/tools/ecc/test_signoff_checklist.py @@ -88,7 +88,7 @@ def test_quality_gates_only_include_final_drc_lvs_rcx_and_sta(tmp_path): ) -def test_synthesis_lec_failure_is_warning_but_post_route_lec_stays_blocking(monkeypatch, tmp_path): +def test_lec_failure_blocks_export_for_both_lec_steps(monkeypatch, tmp_path): result = tmp_path / "lec-result.json" result.write_text("{}", encoding="utf-8") monkeypatch.setattr( @@ -102,12 +102,10 @@ def test_synthesis_lec_failure_is_warning_but_post_route_lec_stays_blocking(monk workspace, StepEnum.POST_ROUTE_LEC.value, result, None, None )[0] - assert synthesis_item["state"] == "warning" - assert synthesis_item["policy"] == "warn" - assert synthesis_item["blocked"] is False - assert post_route_item["state"] == "failed" - assert post_route_item["policy"] == "block" - assert post_route_item["blocked"] is True + for item in (synthesis_item, post_route_item): + assert item["state"] == "failed" + assert item["policy"] == "block" + assert item["blocked"] is True def test_sta_quality_gates_require_all_corner_coverage_and_closure(tmp_path): @@ -622,9 +620,9 @@ def test_home_checklist_uses_origin_golden_when_flow_has_no_synthesis(tmp_path): leftover = tmp_path / "Synthesis_yosys" / "output" / "gcd_Synthesis.v.gz" leftover.parent.mkdir(parents=True) leftover.write_text("module gcd; leftover synthesis\nendmodule\n", encoding="utf-8") - filler = tmp_path / "filler_ecc" / "output" / "gcd_filler.v.gz" - filler.parent.mkdir(parents=True) - filler.write_text("module gcd; filler\nendmodule\n", encoding="utf-8") + gate = tmp_path / "lvs_ecc" / "output" / "gcd_lvs.v.gz" + gate.parent.mkdir(parents=True) + gate.write_text("module gcd; lvs\nendmodule\n", encoding="utf-8") workspace = Workspace( directory=tmp_path, design=OriginDesign(name="gcd", origin_verilog=origin), @@ -680,23 +678,23 @@ def test_home_checklist_uses_current_post_route_lec_result_not_stale_snapshot(tm origin = tmp_path / "origin" / "gcd.v" origin.parent.mkdir() origin.write_text("module gcd; imported mapped netlist\nendmodule\n", encoding="utf-8") - filler = tmp_path / "filler_ecc" / "output" / "gcd_filler.v.gz" - filler.parent.mkdir(parents=True) - filler.write_text("module gcd; filler\nendmodule\n", encoding="utf-8") + gate = tmp_path / "lvs_ecc" / "output" / "gcd_lvs.v.gz" + gate.parent.mkdir(parents=True) + gate.write_text("module gcd; lvs\nendmodule\n", encoding="utf-8") result_json = tmp_path / "postRouteLec_yosys_lec" / "output" / "gcd_postRouteLec_result.json" result_json.parent.mkdir(parents=True) origin_digest = file_digest(origin) - filler_digest = file_digest(filler) + gate_digest = file_digest(gate) result_json.write_text( json.dumps( { "status": "proven", "golden_verilog": str(origin), - "gate_verilog": str(filler), + "gate_verilog": str(gate), "golden_sha256": origin_digest[0], - "gate_sha256": filler_digest[0], + "gate_sha256": gate_digest[0], "golden_size_bytes": origin_digest[1], - "gate_size_bytes": filler_digest[1], + "gate_size_bytes": gate_digest[1], } ), encoding="utf-8", @@ -736,12 +734,13 @@ def test_home_checklist_uses_current_post_route_lec_result_not_stale_snapshot(tm {"name": step.value, "tool": "ecc", "state": StateEnum.Success.value} for step in ( StepEnum.FILLER, + StepEnum.LVS, StepEnum.POST_ROUTE_LEC, StepEnum.HARDEN, ) ] } - workspace.flow.data["steps"][1]["tool"] = "yosys_lec" + workspace.flow.data["steps"][2]["tool"] = "yosys_lec" home_items = {item["id"]: item for item in rebuild_home_checklist(workspace)["checklist"]} assert home_items["artifact.postroutelec.result"]["state"] == "pass" diff --git a/test/tools/ecc_sizer/test_module.py b/test/tools/ecc_sizer/test_module.py index c9cc35f45..2bd2ef49e 100644 --- a/test/tools/ecc_sizer/test_module.py +++ b/test/tools/ecc_sizer/test_module.py @@ -369,6 +369,27 @@ def test_sizer_command_resolves_from_env_root_before_path(tmp_path, monkeypatch) assert get_sizer_command() == [str(path_sizer)] +def test_sizer_command_skips_non_executable_env_override(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer.utility import get_sizer_command + + env_root = tmp_path / "ecc-sizer" + (env_root / "bin").mkdir(parents=True) + env_sizer = env_root / "bin" / "Sizer" + env_sizer.write_text("#!/bin/sh\n", encoding="utf-8") + env_sizer.chmod(0o644) + + path_dir = tmp_path / "on-path" + path_dir.mkdir() + path_sizer = path_dir / "Sizer" + path_sizer.write_text("#!/bin/sh\n", encoding="utf-8") + path_sizer.chmod(0o755) + + monkeypatch.setenv("PATH", str(path_dir)) + monkeypatch.setenv("CHIPCOMPILER_ECC_SIZER_ROOT", str(env_root)) + + assert get_sizer_command() == [str(path_sizer)] + + def test_sizer_command_resolves_from_path_only(tmp_path, monkeypatch): from chipcompiler.tools.ecc_sizer.utility import get_sizer_command, is_eda_exist diff --git a/test/utility/test_file.py b/test/utility/test_file.py new file mode 100644 index 000000000..afa5df5f8 --- /dev/null +++ b/test/utility/test_file.py @@ -0,0 +1,64 @@ +import os +import stat + +import pytest + +from chipcompiler.utility.file import write_text_atomic + + +def test_write_text_atomic_replaces_content(tmp_path): + target = tmp_path / "config.toml" + target.write_text("old = 1\n") + + write_text_atomic(str(target), "new = 2\n") + + assert target.read_text() == "new = 2\n" + assert [p.name for p in tmp_path.iterdir() if p.name.startswith(".")] == [] + + +def test_write_text_atomic_preserves_existing_mode(tmp_path): + target = tmp_path / "ecc.toml" + target.write_text("a = 1\n") + os.chmod(target, 0o664) + + write_text_atomic(str(target), "a = 2\n") + + # mkstemp creates 0600; the replacement must not silently narrow a + # shared config file's permissions. + assert stat.S_IMODE(target.stat().st_mode) == 0o664 + + +def test_write_text_atomic_new_file_gets_umask_default(tmp_path, monkeypatch): + monkeypatch.setattr(os, "umask", lambda mask: 0o022, raising=False) + + target = tmp_path / "new.toml" + write_text_atomic(str(target), "a = 1\n") + + assert stat.S_IMODE(target.stat().st_mode) == 0o644 + + +@pytest.mark.parametrize("broken_step", ["write", "replace"]) +def test_write_text_atomic_failure_keeps_original(tmp_path, monkeypatch, broken_step): + target = tmp_path / "ecc.toml" + target.write_text("a = 1\n") + + if broken_step == "write": + + def broken_fdopen(*args, **kwargs): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(os, "fdopen", broken_fdopen) + else: + + def broken_replace(src, dst): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(os, "replace", broken_replace) + + with pytest.raises(OSError): + write_text_atomic(str(target), "a = 2\n") + + # The original content survives the failed write, and no temp litter or + # truncated target is left behind. + assert target.read_text() == "a = 1\n" + assert [p.name for p in tmp_path.iterdir()] == ["ecc.toml"]