From 3aadd6117d947264c816015072eb0af6fbb6dd67 Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 7 Sep 2026 23:56:25 +0800 Subject: [PATCH 01/47] refactor(cli): drop --json/--jsonl output modes from ecc subcommands The record model keeps exactly one machine-readable surface: --plain (key=value). --json/--jsonl had no remaining consumer after the desktop app moved to rpc serve; ecc version keeps --json as a hidden flag for the desktop appInfoService call. Tests now assert structured results through --plain via the plain_records fixture instead of parsing JSON stdout; pure json/jsonl-surface tests are removed. --- chipcompiler/cli/app.py | 16 +- chipcompiler/cli/command_handlers/project.py | 6 +- chipcompiler/cli/commands/doctor.py | 6 +- chipcompiler/cli/commands/param.py | 22 +- chipcompiler/cli/commands/pdk.py | 18 +- chipcompiler/cli/commands/project.py | 32 +-- chipcompiler/cli/commands/project_config.py | 22 +- chipcompiler/cli/commands/report.py | 18 +- chipcompiler/cli/commands/signoff.py | 10 +- chipcompiler/cli/commands/workspace.py | 6 +- chipcompiler/cli/core/inputs.py | 6 +- chipcompiler/cli/core/invocation.py | 12 +- chipcompiler/cli/core/options.py | 2 - chipcompiler/cli/core/types.py | 2 - chipcompiler/cli/inspection/config_view.py | 4 +- chipcompiler/cli/rendering/render.py | 18 +- test/cli/commands/conftest.py | 6 +- test/cli/commands/test_check.py | 141 ++++++---- test/cli/commands/test_config_layers.py | 44 +-- test/cli/commands/test_doctor.py | 83 +++--- test/cli/commands/test_effective_config.py | 121 ++++---- test/cli/commands/test_flow_continuation.py | 76 +++-- test/cli/commands/test_legacy_readonly.py | 23 +- test/cli/commands/test_log.py | 266 ++++++------------ test/cli/commands/test_manifest_discovery.py | 52 ++-- test/cli/commands/test_manifest_run.py | 48 ++-- test/cli/commands/test_migrate.py | 76 +++-- test/cli/commands/test_migrate_guards.py | 56 ++-- test/cli/commands/test_migrate_safety.py | 76 +++-- test/cli/commands/test_overwrite_guard.py | 61 ++-- .../test_partial_workspace_recovery.py | 32 ++- test/cli/commands/test_pdk_config.py | 191 ++++++++----- test/cli/commands/test_project_config.py | 40 +-- test/cli/commands/test_readonly_workspace.py | 67 ++--- test/cli/commands/test_report.py | 79 +++--- test/cli/commands/test_report_step.py | 198 +++++++------ test/cli/commands/test_run.py | 121 ++++---- test/cli/commands/test_signoff.py | 50 ++-- test/cli/commands/test_status.py | 58 ++-- test/cli/commands/test_workspace_range.py | 20 +- test/cli/commands/test_workspace_refresh.py | 5 +- test/cli/conftest.py | 28 ++ test/cli/inspect/test_config.py | 263 +++++++++-------- test/cli/inspect/test_config_strict.py | 27 +- test/cli/params/test_commands.py | 231 +++++++-------- test/cli/params/test_provenance.py | 30 +- test/cli/params/test_toml_editing.py | 62 ++-- test/cli/params/test_validation.py | 92 +++--- test/cli/params/test_workspace_commands.py | 22 +- test/cli/rendering/test_pretty.py | 18 -- test/cli/rendering/test_progress.py | 14 - test/cli/rendering/test_render.py | 29 -- test/cli/test_typer_cli.py | 100 +------ 53 files changed, 1509 insertions(+), 1597 deletions(-) diff --git a/chipcompiler/cli/app.py b/chipcompiler/cli/app.py index 5dad46840..80149cf0a 100644 --- a/chipcompiler/cli/app.py +++ b/chipcompiler/cli/app.py @@ -46,23 +46,13 @@ 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)) diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index 64664430b..a53a7e3ec 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), } ) 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..702587d04 100644 --- a/chipcompiler/cli/commands/pdk.py +++ b/chipcompiler/cli/commands/pdk.py @@ -14,8 +14,6 @@ ) from chipcompiler.cli.core.invocation import execute_command from chipcompiler.cli.core.options import ( - JsonlOption, - JsonOption, PlainOption, ProjectOption, ) @@ -37,12 +35,10 @@ def setup_cmd( ), ] = 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), + output=output_options(plain=plain), project=project_options(project), path=path, ) @@ -57,8 +53,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 +65,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 +76,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 +89,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/inputs.py b/chipcompiler/cli/core/inputs.py index dfda3221c..908211067 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 @@ -231,8 +229,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..f7b9a773b 100644 --- a/chipcompiler/cli/core/invocation.py +++ b/chipcompiler/cli/core/invocation.py @@ -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 @@ -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/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/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/test/cli/commands/conftest.py b/test/cli/commands/conftest.py index 40c7abfd6..95ba87b75 100644 --- a/test/cli/commands/conftest.py +++ b/test/cli/commands/conftest.py @@ -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..7c7db299e 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): @@ -173,19 +184,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 +276,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 +283,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 +498,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 +515,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 +533,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 +567,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 +581,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 +607,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 +621,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 +645,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 +668,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..2b755edef 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" @@ -316,9 +317,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..e083f9586 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() @@ -148,7 +148,7 @@ def test_virgin_run_fails_loud_when_manifest_registration_fails( 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") @@ -208,7 +208,7 @@ def test_write_back_failure_degrades_to_warning( 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() @@ -484,7 +484,7 @@ def losing_write(project_dir_arg, document): "chipcompiler.cli.project.manifest.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() @@ -517,7 +517,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 +541,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 +572,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 +601,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..596559619 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) @@ -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 @@ -395,7 +410,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 +431,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 +448,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 +467,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 +486,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 +505,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 +532,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 +557,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 +571,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 +587,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 +614,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 +653,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 +662,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 +692,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 +730,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 +789,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..90ff27180 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", @@ -83,6 +85,7 @@ 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() @@ -91,11 +94,11 @@ def test_failed_creation_after_overwrite_removes_partial( 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", @@ -113,6 +116,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 +126,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 +142,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 +163,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..79adc72e2 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,11 +200,11 @@ 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" @@ -189,7 +218,12 @@ def __init__(self, returncode=0, stderr="", stdout=""): class TestPdkSetup: def test_setup_complete_checkout_only_sets_root( - 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="") pdk_dir = tmp_path / "ready-pdk" @@ -199,16 +233,21 @@ def test_setup_complete_checkout_only_sets_root( lambda name, root, overrides=None: None, ) - rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + data = plain_records(capsys.readouterr().out) assert rc == 0 - assert data["records"][0]["status"] == "ready" - assert data["records"][0]["actions"] == [] # nothing fetched + assert data[0]["status"] == "ready" + assert data[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 + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + plain_records, ): import subprocess as real_subprocess @@ -236,16 +275,18 @@ def fake_validate(name, root, overrides=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"]) + rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + data = plain_records(capsys.readouterr().out) assert rc == 0 - assert data["records"][0]["actions"] == ["clone", "unzip"] + assert data[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): + def test_setup_clone_failure( + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records + ): import subprocess as real_subprocess project_dir = create_cli_project(pdk_root="") @@ -258,15 +299,20 @@ def fake_run(cmd, **kwargs): monkeypatch.setattr("subprocess.run", fake_run) - rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--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"] == "clone_failed" assert "repository not found" in record["reason"] def test_setup_unzip_retries_then_fails( - self, tmp_path, capsys, monkeypatch, create_cli_project + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + plain_records, ): import subprocess as real_subprocess @@ -287,14 +333,16 @@ def fake_run(cmd, cwd=None, **kwargs): lambda name, root, overrides=None: "PDK has no liberty files", ) - rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + data = plain_records(capsys.readouterr().out) assert rc == 1 assert len(make_calls) == 3 # retried three times - assert data["records"][0]["error"] == "unzip_failed" + assert data[0]["error"] == "unzip_failed" - def test_setup_unzip_recovers_on_retry(self, tmp_path, capsys, monkeypatch, create_cli_project): + def test_setup_unzip_recovers_on_retry( + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records + ): import subprocess as real_subprocess project_dir = create_cli_project(pdk_root="") @@ -316,12 +364,12 @@ def fake_validate(name, root, overrides=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"]) + rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--plain"]) - data = json.loads(capsys.readouterr().out) + data = plain_records(capsys.readouterr().out) assert rc == 0 assert attempts["n"] == 2 - assert data["records"][0]["actions"] == ["unzip"] + assert data[0]["actions"] == "[unzip]" def test_setup_forwards_gh_proxy_to_make( self, tmp_path, capsys, monkeypatch, create_cli_project @@ -347,7 +395,7 @@ def fake_validate(name, root, overrides=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"]) + rc = cli_main.run(["pdk", "setup", str(pdk_dir), "--project", project_dir, "--plain"]) assert rc == 0 assert seen["cmd"] == [ @@ -358,7 +406,12 @@ def fake_validate(name, root, overrides=None): ] def test_setup_default_path_when_argument_omitted( - 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="") monkeypatch.setattr( @@ -370,8 +423,8 @@ def test_setup_default_path_when_argument_omitted( lambda name, root, overrides=None: None, ) - rc = cli_main.run(["pdk", "setup", "--project", project_dir, "--json"]) + rc = cli_main.run(["pdk", "setup", "--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(tmp_path / "default-pdk") + assert data[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..c57d48323 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 @@ -82,39 +82,38 @@ def report_mocks(monkeypatch): 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_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 +129,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")) @@ -175,16 +173,16 @@ def test_checklist_unavailable_maps_to_error( ) monkeypatch.setattr(checklist_module, "generate_checklist_report", lambda ws: "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 +196,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 +211,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 +220,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 +240,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 +257,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..b9a131491 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 ): @@ -107,7 +95,7 @@ def test_status_normalizes_step_names( 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 + 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") @@ -120,10 +108,10 @@ def test_status_reports_warning_when_flow_has_non_blocking_warning( ], ) - 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 data["records"][0]["status"] == "warning" + records = plain_records(capsys.readouterr().out) + assert records[0]["status"] == "warning" def test_status_missing_run(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() @@ -149,7 +137,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 +145,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 +158,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: @@ -191,7 +179,7 @@ def flow(*states): 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 +190,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..a249872e1 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,10 +19,10 @@ 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 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_toml_editing.py b/test/cli/params/test_toml_editing.py index f067e5fd2..3f7526a9e 100644 --- a/test/cli/params/test_toml_editing.py +++ b/test/cli/params/test_toml_editing.py @@ -51,7 +51,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 +61,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 +88,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 +101,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() @@ -178,7 +178,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 +191,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 +226,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 +241,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 +255,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,10 +274,10 @@ 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: @@ -308,7 +318,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 +333,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..e7092b349 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,18 @@ 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_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 +148,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/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..8d4230353 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 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_typer_cli.py b/test/cli/test_typer_cli.py index 335e9bf0a..a66dbd908 100644 --- a/test/cli/test_typer_cli.py +++ b/test/cli/test_typer_cli.py @@ -1,4 +1,3 @@ -import dataclasses import json from importlib import metadata @@ -82,48 +81,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 +137,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 +168,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 +258,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 +271,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 +292,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", From 1eabdbc2ec79fb5da3e4f8e386aeb52f981ccf8f Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 7 Sep 2026 23:56:34 +0800 Subject: [PATCH 02/47] docs(cli): document --plain as the only structured output mode Drop --json/--jsonl from all command synopses and examples in the user guide, config reference, tutorial, and dev guide; the dev guide records the hidden ecc version --json flag reserved for the desktop app. Status and config examples now show real --plain output. --- chipcompiler/docs/ecc-cli-config.cn.md | 2 +- chipcompiler/docs/ecc-cli-config.en.md | 2 +- chipcompiler/docs/ecc-cli-dev.cn.md | 22 ++++----- chipcompiler/docs/ecc-cli-dev.en.md | 22 ++++----- chipcompiler/docs/ecc-cli-tutorial.cn.md | 2 +- chipcompiler/docs/ecc-cli-tutorial.en.md | 2 +- chipcompiler/docs/ecc-cli-ug.cn.md | 56 ++++++++++------------- chipcompiler/docs/ecc-cli-ug.en.md | 58 +++++++++++------------- 8 files changed, 73 insertions(+), 93 deletions(-) diff --git a/chipcompiler/docs/ecc-cli-config.cn.md b/chipcompiler/docs/ecc-cli-config.cn.md index 1114bda03..ed18d7007 100644 --- a/chipcompiler/docs/ecc-cli-config.cn.md +++ b/chipcompiler/docs/ecc-cli-config.cn.md @@ -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` 输出(便于脚本解析);缺省为人类可读文本 | 两种作用域的行为差异: diff --git a/chipcompiler/docs/ecc-cli-config.en.md b/chipcompiler/docs/ecc-cli-config.en.md index d9dcc2750..c4abd482a 100644 --- a/chipcompiler/docs/ecc-cli-config.en.md +++ b/chipcompiler/docs/ecc-cli-config.en.md @@ -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: diff --git a/chipcompiler/docs/ecc-cli-dev.cn.md b/chipcompiler/docs/ecc-cli-dev.cn.md index cb17028f4..92ea178a6 100644 --- a/chipcompiler/docs/ecc-cli-dev.cn.md +++ b/chipcompiler/docs/ecc-cli-dev.cn.md @@ -54,7 +54,7 @@ chipcompiler/engine/qor_report.py # QoR 总分计分(GUI 规则移植,见 § ## 2. 一次命令调用的完整链路 -以 `ecc check --project gcd --json` 为例: +以 `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`),然后调用: @@ -62,7 +62,7 @@ chipcompiler/engine/qor_report.py # QoR 总分计分(GUI 规则移植,见 § 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`)。 + - `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()`。 @@ -74,15 +74,13 @@ chipcompiler/engine/qor_report.py # QoR 总分计分(GUI 规则移植,见 § 经 `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; +- 两种输出模式(见 `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`、`--jsonl` 和 `--plain`,使用版本专用 schema。`ecc rpc serve` 与 `ecc layout-image` 有意不使用 records 渲染器输出模式。 +`ecc version` 直接格式化版本元数据;另有一个隐藏的 `--json` 选项(单对象、版本专用 schema)预留给桌面应用,不出现在 `--help` 中。`ecc rpc serve` 与 `ecc layout-image` 有意不使用 records 渲染器输出模式。 ## 4. 新增一个顶层命令(Step by Step) @@ -119,7 +117,7 @@ def check(command_input: CheckInput, ctx: CommandContext) -> CommandResult: 在 `cli/commands/project.py`(或新模块)声明命令函数并注册,共享选项直接用 `cli/core/options.py` 的别名: ```python -from chipcompiler.cli.core.options import JsonlOption, JsonOption, PlainOption, ProjectOption +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) @@ -127,12 +125,10 @@ def register_project_commands(app: typer.Typer) -> None: 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), + output=output_options(plain=plain), project=project_options(project), ) execute_command("check", command_input, project_handlers.check) @@ -147,7 +143,7 @@ def check_cmd( - 单命令:在 `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 无需任何定制。 +PLAIN 无需任何定制。 ### 4.5 补测试 @@ -158,9 +154,9 @@ CLI 测试全部位于 `ecc/test/cli/`,目录按所有权划分(仓库 CLAUD ```python from chipcompiler.cli import main as cli_main - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) + 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。 diff --git a/chipcompiler/docs/ecc-cli-dev.en.md b/chipcompiler/docs/ecc-cli-dev.en.md index 52282a412..ff0398b3e 100644 --- a/chipcompiler/docs/ecc-cli-dev.en.md +++ b/chipcompiler/docs/ecc-cli-dev.en.md @@ -54,7 +54,7 @@ Public command ownership is strict: `ecc signoff` owns package readiness and arc ## 2. The full path of one command invocation -Using `ecc check --project gcd --json` as the example: +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: @@ -62,7 +62,7 @@ Using `ecc check --project gcd --json` as the example: 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`). + - `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()`. @@ -74,15 +74,13 @@ Using `ecc check --project gcd --json` as the example: 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; +- 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, 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. +`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. ## 4. Adding a new top-level command (step by step) @@ -119,7 +117,7 @@ Conventions: handlers do not print directly and do not parse command-line string 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 +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) @@ -127,12 +125,10 @@ def register_project_commands(app: typer.Typer) -> None: 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), + output=output_options(plain=plain), project=project_options(project), ) execute_command("check", command_input, project_handlers.check) @@ -147,7 +143,7 @@ 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. +PLAIN needs no customization at all. ### 4.5 Add tests @@ -158,9 +154,9 @@ All CLI tests live under `ecc/test/cli/`, organized by ownership (repository CLA ```python from chipcompiler.cli import main as cli_main - rc = cli_main.run(["check", "--project", project_dir, "--json"]) + rc = cli_main.run(["check", "--project", project_dir, "--plain"]) assert rc == 0 - data = json.loads(capsys.readouterr().out) + 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 `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. diff --git a/chipcompiler/docs/ecc-cli-tutorial.cn.md b/chipcompiler/docs/ecc-cli-tutorial.cn.md index 572dadfd5..44a4bb763 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.cn.md +++ b/chipcompiler/docs/ecc-cli-tutorial.cn.md @@ -242,7 +242,7 @@ $ ecc check run: ecc run rtl: pass path: rtl/gcd.v - inspect: ecc check --json + inspect: ecc check rc=0 ``` diff --git a/chipcompiler/docs/ecc-cli-tutorial.en.md b/chipcompiler/docs/ecc-cli-tutorial.en.md index 4f7568760..4f45445a9 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.en.md +++ b/chipcompiler/docs/ecc-cli-tutorial.en.md @@ -243,7 +243,7 @@ $ ecc check run: ecc run rtl: pass path: rtl/gcd.v - inspect: ecc check --json + inspect: ecc check rc=0 ``` diff --git a/chipcompiler/docs/ecc-cli-ug.cn.md b/chipcompiler/docs/ecc-cli-ug.cn.md index 69c8e11d2..f316128aa 100644 --- a/chipcompiler/docs/ecc-cli-ug.cn.md +++ b/chipcompiler/docs/ecc-cli-ug.cn.md @@ -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`; @@ -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,7 +208,7 @@ 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`): @@ -223,7 +217,7 @@ ecc check [--project DIR] [--json | --jsonl | --plain] $ 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 @@ -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,10 +531,10 @@ $ 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" ... ``` @@ -549,7 +543,7 @@ run 级状态取全部步骤的聚合:`success / warning / failed / ongoing / ## 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 @@ -771,7 +765,7 @@ tech = "prtech/techLEF/N551P6M_ecos.lef" 接入 PDK 有两条路:`ecc pdk setup` 一条到位(自动 clone + `make unzip`,已就绪的目录则跳过下载只接入),或对已就绪的 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 @@ -795,12 +789,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 +822,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 +850,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 与输出选项] ``` diff --git a/chipcompiler/docs/ecc-cli-ug.en.md b/chipcompiler/docs/ecc-cli-ug.en.md index 660df3c50..900e84e99 100644 --- a/chipcompiler/docs/ecc-cli-ug.en.md +++ b/chipcompiler/docs/ecc-cli-ug.en.md @@ -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`; @@ -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,7 +209,7 @@ 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`): @@ -224,7 +218,7 @@ Validates required `ecc.toml` fields (design/pdk/flow), the PDK name and content $ 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 @@ -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,10 +533,10 @@ $ 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" ... ``` @@ -551,7 +545,7 @@ The run-level status aggregates all steps: `success / warning / failed / ongoing ## 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 @@ -817,7 +811,7 @@ a ready-made checkout directly — `[pdk] root` in `ecc.toml` (the path is expan 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 @@ -841,12 +835,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 +868,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 +896,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] ``` From 504cb62f7a81b6ca872c7aafb4a882494624f551 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 01:14:39 +0800 Subject: [PATCH 03/47] docs(cli): align output mode docs with the plain-only contract Drop the removed --json/--jsonl modes from the design spec's goals, structured-output section, capability tables, and phase checklists, and from the development quickstart and both READMEs; disclosure examples now show the inspect hints the CLI actually emits. The hidden ecc version --json desktop contract stays documented. --- README.cn.md | 2 +- README.md | 2 +- docs/development.md | 2 +- docs/specification/cli-design.md | 62 ++++++++++++++------------------ 4 files changed, 30 insertions(+), 38 deletions(-) diff --git a/README.cn.md b/README.cn.md index a409dd0e1..3daaeb60d 100644 --- a/README.cn.md +++ b/README.cn.md @@ -141,7 +141,7 @@ ecc log --project gcd | `ecc layout-image` | 将 GDS 文件渲染为版图图像 | 项目命令均接受 `--project `(默认为当前目录)。大多数命令支持 -`--plain`、`--json` 和 `--jsonl` 输出,便于脚本化。 +`--plain` 输出,便于脚本化。 完整的命令模型——`ecc.toml` 参考、流程预设、步骤级重跑 (`--resume`、`--from`、`--only`)和参数覆盖——请参阅 diff --git a/README.md b/README.md index 0140c8d22..8e72e0ea8 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Run `ecc --help` (or `ecc --help`) for full usage. Common commands: | `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. For the full command model — `ecc.toml` reference, flow presets, step-level rerun (`--resume`, `--from`, `--only`), and parameter overrides — see the diff --git a/docs/development.md b/docs/development.md index fb59c19d3..bd3a141e8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -270,7 +270,7 @@ 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 diff --git a/docs/specification/cli-design.md b/docs/specification/cli-design.md index 0c97323e3..9c0add752 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 setup/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 @@ -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 @@ -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 From 32ff619cea8fcb93f852e6b6be7d82a3513c4e9d Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 01:14:46 +0800 Subject: [PATCH 04/47] fix(cli): escape pdk root values written into ecc.toml set_pdk_root interpolated the path straight into a TOML basic string, so directories containing a double quote or backslash produced invalid TOML. Serialize through format_toml_value and pin both replacement and table-creation branches with tomllib round-trips. --- chipcompiler/cli/project/toml_edit.py | 31 ++++++++++++++++++++++++--- test/cli/params/test_toml_editing.py | 18 ++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/chipcompiler/cli/project/toml_edit.py b/chipcompiler/cli/project/toml_edit.py index 6abd726de..5938f54f5 100644 --- a/chipcompiler/cli/project/toml_edit.py +++ b/chipcompiler/cli/project/toml_edit.py @@ -4,11 +4,35 @@ so repeated `param set`/`pdk set-root` calls do not churn the config file. """ +import os import re +import tempfile _TABLE_HEADER_RE = re.compile(r"^[ \t]*\[([^\]]+)\][ \t]*(?:#.*)?$", re.MULTILINE) +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 can destroy the existing ecc.toml; the sibling temp file keeps the + old content intact until the fully written replacement can be renamed in. + """ + directory = os.path.dirname(os.path.abspath(path)) + 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.replace(tmp_path, path) + except BaseException: + os.unlink(tmp_path) + raise + + 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): @@ -149,9 +173,10 @@ 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] @@ -160,9 +185,9 @@ def set_pdk_root(text: str, value: str) -> str: if key_match: new_section = ( section[: key_match.start()] - + f'{key_match.group(1)}root = "{value}"' + + f"{key_match.group(1)}root = {value_str}" + section[key_match.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/test/cli/params/test_toml_editing.py b/test/cli/params/test_toml_editing.py index 3f7526a9e..23a6f05a8 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 set_pdk_root + + +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: From 3dab955cf313a43d2ed5612efab9d37c92a42d91 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 01:14:51 +0800 Subject: [PATCH 05/47] fix(cli): make ecc.toml rewrites atomic The param, pdk, and project handlers rewrote ecc.toml with a truncating open('w'), so an interruption or write failure after truncation could destroy the project config. Add write_text_atomic (sibling temp file, flush/fsync, os.replace) next to the shared text editors and route all five rewrite sites through it. --- chipcompiler/cli/command_handlers/param.py | 7 ++----- chipcompiler/cli/command_handlers/pdk.py | 7 ++----- chipcompiler/cli/command_handlers/project_config.py | 8 +++----- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/chipcompiler/cli/command_handlers/param.py b/chipcompiler/cli/command_handlers/param.py index e4988c942..7b62b51f7 100644 --- a/chipcompiler/cli/command_handlers/param.py +++ b/chipcompiler/cli/command_handlers/param.py @@ -375,9 +375,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) + toml_edit.write_text_atomic(config_path, new_text) def _remove_param_from_toml(config_path: str, schema) -> bool: @@ -394,6 +392,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) + toml_edit.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..128b2250d 100644 --- a/chipcompiler/cli/command_handlers/pdk.py +++ b/chipcompiler/cli/command_handlers/pdk.py @@ -4,7 +4,7 @@ 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.cli.project.toml_edit import set_pdk_root, write_text_atomic def _write_pdk_root(config_path: str, value: str) -> None: @@ -12,10 +12,7 @@ def _write_pdk_root(config_path: str, value: str) -> None: with open(config_path) as f: original = f.read() - new_text = set_pdk_root(original, value) - - with open(config_path, "w") as f: - f.write(new_text) + write_text_atomic(config_path, set_pdk_root(original, value)) def _resolve_root_source(cfg, project_dir: str) -> tuple[str, str]: diff --git a/chipcompiler/cli/command_handlers/project_config.py b/chipcompiler/cli/command_handlers/project_config.py index c1c0887e3..70d155028 100644 --- a/chipcompiler/cli/command_handlers/project_config.py +++ b/chipcompiler/cli/command_handlers/project_config.py @@ -6,7 +6,7 @@ from chipcompiler.cli.core.records import error_record 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.cli.project.toml_edit import remove_scoped_key, set_scoped_key, write_text_atomic def project_set(args, ctx: CommandContext) -> CommandResult: @@ -37,8 +37,7 @@ def project_unset(args, ctx: CommandContext) -> CommandResult: changed = remove_scoped_key(file.read(), field.table, field.name) if changed is None: return CommandResult.ok([_record(field.key, None, "no_value")]) - with open(config_path, "w") as file: - file.write(changed) + write_text_atomic(config_path, changed) return CommandResult.ok([_record(field.key, None, "unset")]) @@ -130,8 +129,7 @@ def _change_rtl(args, ctx: CommandContext, *, add: bool) -> CommandResult: 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) + write_text_atomic(config_path, updated) def _field_or_error(key: str): From 09d7870919aa285d615ae0b55571040ccdc343cb Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 01:14:55 +0800 Subject: [PATCH 06/47] fix(data): stage config overrides before writing any workspace config apply_config_overrides validated and persisted in the same loop, so a valid override was already written when a later unknown target or malformed patch raised. Validate and stage every merged document first, then commit the staged files. --- .../data/workspace/config_overrides.py | 4 ++++ test/data/test_workspace.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/chipcompiler/data/workspace/config_overrides.py b/chipcompiler/data/workspace/config_overrides.py index 1164a14c0..a57b29d21 100644 --- a/chipcompiler/data/workspace/config_overrides.py +++ b/chipcompiler/data/workspace/config_overrides.py @@ -15,6 +15,7 @@ def apply_config_overrides(config_paths: dict[str, Path], parameters: dict) -> N if not isinstance(overrides, dict): return + staged: list[tuple[Path, dict]] = [] for config_key, patch in overrides.items(): config_path = _config_path_for_key(config_paths, config_key) if config_path is None: @@ -23,6 +24,9 @@ def apply_config_overrides(config_paths: dict[str, Path], parameters: dict) -> N raise ValueError(f"config override patch must be an object: {config_key}") config = json_read(config_path) _merge_config_patch(config, patch) + staged.append((config_path, config)) + + for config_path, config in staged: if not json_write(config_path, config): raise OSError(f"Failed to write config override: {config_path}") diff --git a/test/data/test_workspace.py b/test/data/test_workspace.py index afd148e0a..a05219c27 100644 --- a/test/data/test_workspace.py +++ b/test/data/test_workspace.py @@ -1003,6 +1003,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 ): From 0ee8fb8b36f0fdb2aef666f7ec155ccc8ca0fd7a Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 01:15:01 +0800 Subject: [PATCH 07/47] refactor(cli): move manifest writes into manifest_write manifest.py crossed the 700-line review threshold after the flow-range and registration work. Extract the write/mutation/registration half (build_manifest_document, write_manifest_if_absent, update_manifest, write_back_workspace_status, manifest_range_for_flow, pre_register_workspace) into chipcompiler.cli.project.manifest_write, keeping manifest.py on loading, normalization, and classification, and move the write-side tests and monkeypatch targets with the code. --- chipcompiler/cli/project/manifest.py | 322 +------------------ chipcompiler/cli/project/manifest_write.py | 343 +++++++++++++++++++++ chipcompiler/cli/project/migrate.py | 5 +- chipcompiler/cli/project/migrate_plan.py | 4 +- chipcompiler/cli/project/run_dispatch.py | 2 +- chipcompiler/cli/project/run_prepare.py | 2 +- test/cli/commands/test_manifest_run.py | 6 +- test/cli/commands/test_migrate.py | 2 +- test/cli/project/test_manifest.py | 176 ----------- test/cli/project/test_manifest_write.py | 199 ++++++++++++ 10 files changed, 558 insertions(+), 503 deletions(-) create mode 100644 chipcompiler/cli/project/manifest_write.py create mode 100644 test/cli/project/test_manifest_write.py diff --git a/chipcompiler/cli/project/manifest.py b/chipcompiler/cli/project/manifest.py index 0b9b3cead..01c588b9c 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. @@ -75,18 +69,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 +414,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..ce0f94b5e --- /dev/null +++ b/chipcompiler/cli/project/manifest_write.py @@ -0,0 +1,343 @@ +#!/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()) + 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/migrate.py b/chipcompiler/cli/project/migrate.py index 07892598a..0df223cb8 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] = [] diff --git a/chipcompiler/cli/project/migrate_plan.py b/chipcompiler/cli/project/migrate_plan.py index 9ca7cd688..3c5942588 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, ) diff --git a/chipcompiler/cli/project/run_dispatch.py b/chipcompiler/cli/project/run_dispatch.py index d692eb3a0..32c38af7a 100644 --- a/chipcompiler/cli/project/run_dispatch.py +++ b/chipcompiler/cli/project/run_dispatch.py @@ -264,7 +264,7 @@ def fresh_run(*, owns_target: bool) -> CommandResult: 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 + from chipcompiler.cli.project.manifest_write import pre_register_workspace registration = pre_register_workspace( project_dir, diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index 709cbfb22..46a8b5bfb 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -121,7 +121,7 @@ def _workspace_failed_result(run_name: str, run_dir: str, reason: str | 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( diff --git a/test/cli/commands/test_manifest_run.py b/test/cli/commands/test_manifest_run.py index e083f9586..0599833fb 100644 --- a/test/cli/commands/test_manifest_run.py +++ b/test/cli/commands/test_manifest_run.py @@ -144,7 +144,7 @@ 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, ) @@ -204,7 +204,7 @@ 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, ) @@ -481,7 +481,7 @@ 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, "--plain"]) diff --git a/test/cli/commands/test_migrate.py b/test/cli/commands/test_migrate.py index 596559619..99e093f52 100644 --- a/test/cli/commands/test_migrate.py +++ b/test/cli/commands/test_migrate.py @@ -280,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( 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 From cbbfab3c129607dee9be09695403308edd1a01fe Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 01:15:09 +0800 Subject: [PATCH 08/47] test(cli): make doc pager and pdk setup tests hermetic The styled doc pager test left Rich's independent NO_COLOR and TERM inputs untouched, so it failed on dumb-terminal hosts even with supports_color mocked to true. The pdk setup tests mocked subprocess but not shutil.which, so hosts without make exited through missing_tool before exercising the clone/retry/proxy behavior. Pin both inputs and command discovery locally, add a missing-tool branch test, and fix a formatting drift in records.py. --- test/cli/commands/test_pdk_config.py | 22 ++++++++++++++++++++++ test/cli/test_doc.py | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/test/cli/commands/test_pdk_config.py b/test/cli/commands/test_pdk_config.py index 79adc72e2..133fe68e4 100644 --- a/test/cli/commands/test_pdk_config.py +++ b/test/cli/commands/test_pdk_config.py @@ -1,5 +1,7 @@ import os +import pytest + from chipcompiler.cli import main as cli_main @@ -217,6 +219,26 @@ def __init__(self, returncode=0, stderr="", stdout=""): class TestPdkSetup: + @pytest.fixture(autouse=True) + def available_tools(self, monkeypatch): + """Simulate a host with git/make so tests exercise clone/unzip behavior.""" + monkeypatch.setattr("shutil.which", lambda name: f"/usr/bin/{name}") + + def test_setup_missing_tool_fails_before_clone( + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records + ): + project_dir = create_cli_project(pdk_root="") + monkeypatch.setattr("shutil.which", lambda name: None if name == "git" else name) + + rc = cli_main.run( + ["pdk", "setup", str(tmp_path / "fresh-pdk"), "--project", project_dir, "--plain"] + ) + + record = plain_records(capsys.readouterr().out)[0] + assert rc == 1 + assert record["error"] == "missing_tool" + assert "git" in record["reason"] + def test_setup_complete_checkout_only_sets_root( self, tmp_path, diff --git a/test/cli/test_doc.py b/test/cli/test_doc.py index 029061285..b90d8dfe1 100644 --- a/test/cli/test_doc.py +++ b/test/cli/test_doc.py @@ -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"]) From 0cfeeb6daed361583b578758b83ba730cf770701 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 03:00:05 +0800 Subject: [PATCH 09/47] fix(engine): treat warned non-blocking steps as finished Warning is a terminal state (a non-blocking synthesis LEC that did not prove equivalence lets the flow continue), but resume selection, run_only, reconciliation, and run_step skipping all compared against Success alone, so a completed flow with a warned LEC re-ran the LEC and its whole physical suffix on every bare run. Introduce one canonical finished-state predicate in data.step and use it across selectors, reconciliation, and skipping. --- chipcompiler/data/__init__.py | 4 ++++ chipcompiler/data/step.py | 14 ++++++++++++++ chipcompiler/engine/flow.py | 20 +++++++++++++++----- chipcompiler/engine/reconcile.py | 28 +++++++++++++++++----------- chipcompiler/engine/rerun.py | 23 +++++++++++++++-------- test/engine/test_reconcile.py | 16 ++++++++++++++++ test/test_engine_rerun.py | 29 +++++++++++++++++++++++++++++ 7 files changed, 110 insertions(+), 24 deletions(-) diff --git a/chipcompiler/data/__init__.py b/chipcompiler/data/__init__.py index f1a019882..308f99877 100644 --- a/chipcompiler/data/__init__.py +++ b/chipcompiler/data/__init__.py @@ -11,9 +11,11 @@ ) from .pdk import PDK, get_pdk from .step import ( + FINISHED_STEP_STATES, StateEnum, StepEnum, StepMetrics, + is_finished_step_state, is_non_blocking_step, load_metrics, save_metrics, @@ -96,6 +98,8 @@ "YosysReport", "YosysLecReport", "EccReport", + "FINISHED_STEP_STATES", + "is_finished_step_state", "is_non_blocking_step", "LogPaths", "ScriptPaths", diff --git a/chipcompiler/data/step.py b/chipcompiler/data/step.py index f55a3c4e5..4a1b41aef 100644 --- a/chipcompiler/data/step.py +++ b/chipcompiler/data/step.py @@ -51,6 +51,20 @@ def is_non_blocking_step(step) -> bool: ) +FINISHED_STEP_STATES = frozenset({StateEnum.Success.value, StateEnum.Warning.value}) + + +def is_finished_step_state(state: object) -> bool: + """Whether a persisted step state counts as done for selection and skipping. + + Warning is a terminal state: a non-blocking check (the synthesis LEC) + that did not prove equivalence still lets the flow continue, so a warned + step must not be re-selected by a plain resume. Incomplete/Invalid steps + are unfinished: resume and rerun selectors re-execute them. + """ + return state in FINISHED_STEP_STATES + + ########################################################################### # step definition for chip design flow in json format # step_definition = diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 8982c3fd4..c815f8ee3 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -564,11 +564,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. Batch resets (_invalidate_suffix, clear_states) handle + rerun paths; this handles the rerun=False resume path. Warning is + not reset: it is a finished state a plain resume skips. """ old_step = self.get_step(name=workspace_step.name, tool=workspace_step.tool) if old_step is None: @@ -576,7 +577,6 @@ 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, }: logger.warning( @@ -614,6 +614,16 @@ def run_step( _notify_flow_observer(observer, "on_step_skipped", workspace_step) return StateEnum.Success + if not rerun and self.check_state( + name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Warning + ): + # A warned non-blocking step (synthesis LEC) is terminal: the + # flow continued past it, so a plain resume skips it too. + self.workspace.logger.info("[SKIP] %s finished with a non-blocking warning", step_tag) + self.clear_db_engine_after_step(workspace_step, StateEnum.Warning) + _notify_flow_observer(observer, "on_step_skipped", workspace_step) + return StateEnum.Warning + self._normalize_legacy_terminal_state(workspace_step, step_tag) # set state ongoing diff --git a/chipcompiler/engine/reconcile.py b/chipcompiler/engine/reconcile.py index e825a4401..634563beb 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, or Warning for a non-blocking check). +- ``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 @@ -234,9 +234,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; a warned + # step is finished (non-blocking check), 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 +246,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, @@ -408,16 +412,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 +431,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..060ebb4b5 100644 --- a/chipcompiler/engine/rerun.py +++ b/chipcompiler/engine/rerun.py @@ -13,7 +13,14 @@ 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, + is_non_blocking_step, + log_flow, +) from chipcompiler.utility.log import redirect_stdio_to_file from chipcompiler.utility.path import path_is_within @@ -41,7 +48,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 +58,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 +68,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 +76,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 +114,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) diff --git a/test/engine/test_reconcile.py b/test/engine/test_reconcile.py index 7b508fdb1..d5239a88c 100644 --- a/test/engine/test_reconcile.py +++ b/test/engine/test_reconcile.py @@ -149,6 +149,22 @@ def test_equal_with_non_success_is_resume(self, tmp_path): assert result.outcome == "resume" + def test_equal_with_warned_lec_is_no_op(self, tmp_path): + # Warning is a finished state: a completed flow whose synthesis LEC + # warned must not reconcile to a resume that reruns the 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 == "no_op" + def test_target_prefix_keeps_extra_steps(self, tmp_path): workspace_dir = _write_workspace( tmp_path, diff --git a/test/test_engine_rerun.py b/test/test_engine_rerun.py index 8fa96de7d..0c9189831 100644 --- a/test/test_engine_rerun.py +++ b/test/test_engine_rerun.py @@ -89,6 +89,35 @@ def test_resume_all_success_selects_nothing(self, tmp_path): assert rerun.selected_step_names(flow) == [] + def test_resume_treats_warning_as_finished(self, tmp_path): + # A warned synthesis LEC is terminal: a plain resume must not + # re-execute it and its physical suffix on every run. + flow = _make_run_flow( + tmp_path, + [ + ("Synthesis", "Success"), + ("lec", "Warning"), + ("Floorplan", "Success"), + ("CTS", "Success"), + ], + ) + + assert rerun.selected_step_names(flow) == [] + + def test_only_warning_step_requires_force(self, tmp_path): + flow = _make_run_flow(tmp_path, [("lec", "Warning")]) + + assert rerun.selected_step_names(flow, only="lec") == [] + assert rerun.selected_step_names(flow, only="lec", force=True) == ["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, From 4e487eda33d601689e2c7ec84f680e4e62a877ec Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 03:00:16 +0800 Subject: [PATCH 10/47] refactor(data): own step-directory names in one canonical table The step->directory mapping existed as four hand-maintained copies (checklist, signoff package collector, report extract, QoR). Move it to data.step_dirs, next to the step enums, and derive the consumers from it; report-specific GUI labels stay local to the QoR reporter. --- chipcompiler/data/step_dirs.py | 29 +++++++++++++++++++ chipcompiler/engine/signoff/__init__.py | 19 ++---------- chipcompiler/engine/signoff/report_extract.py | 20 +------------ chipcompiler/tools/ecc/signoff_checklist.py | 26 ++++------------- 4 files changed, 38 insertions(+), 56 deletions(-) create mode 100644 chipcompiler/data/step_dirs.py diff --git a/chipcompiler/data/step_dirs.py b/chipcompiler/data/step_dirs.py new file mode 100644 index 000000000..4d247d0f9 --- /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.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", +} diff --git a/chipcompiler/engine/signoff/__init__.py b/chipcompiler/engine/signoff/__init__.py index f1a1f20b2..ea024a188 100644 --- a/chipcompiler/engine/signoff/__init__.py +++ b/chipcompiler/engine/signoff/__init__.py @@ -1296,22 +1296,9 @@ def _temperature_token(self, temperature) -> str: 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", - } + from chipcompiler.data.step_dirs import STEP_DIRECTORIES + + return STEP_DIRECTORIES # Public entry point for the text design summary; the implementation lives in 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/tools/ecc/signoff_checklist.py b/chipcompiler/tools/ecc/signoff_checklist.py index 7662fdf63..9dcbf062e 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) @@ -658,8 +642,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 +662,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, From a915f2e25fdceb715d06da084c3acb8e3042bd3d Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 03:00:16 +0800 Subject: [PATCH 11/47] fix(engine): derive QoR flow states explicitly and report unknown timing honestly Any nonempty ledger without a failure counted as a complete flow, so running or partially progressed workspaces could score Green while the status mapping's blocked branches were unreachable. Classify not_started/running/in_progress/failed/complete explicitly. Corner timing with no measured slack is now unknown instead of pass, and endpoint-count rollups stay unmeasured instead of summing absent values as zero, so missing evidence is never rendered as success. --- chipcompiler/engine/qor_report.py | 83 +++++++++++--------- chipcompiler/engine/signoff/report_timing.py | 35 ++++++--- test/test_qor_report.py | 26 +++++- test/test_signoff_report.py | 16 +++- 4 files changed, 111 insertions(+), 49 deletions(-) diff --git a/chipcompiler/engine/qor_report.py b/chipcompiler/engine/qor_report.py index 380bfc36a..8cf086425 100644 --- a/chipcompiler/engine/qor_report.py +++ b/chipcompiler/engine/qor_report.py @@ -19,22 +19,32 @@ 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) @@ -320,6 +330,28 @@ def _gate_status(flow_steps_by_label) -> str: return "pass" +def _flow_completion_state(states) -> str: + """Classify a workspace's step-state set explicitly. + + Warning counts as finished (a non-blocking check warning still lets the + flow continue); only an all-finished 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 +416,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 "") @@ -438,14 +454,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) diff --git a/chipcompiler/engine/signoff/report_timing.py b/chipcompiler/engine/signoff/report_timing.py index 4bf6bee85..7519152ef 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): diff --git a/test/test_qor_report.py b/test/test_qor_report.py index 4d01ec4f3..fb12947d8 100644 --- a/test/test_qor_report.py +++ b/test/test_qor_report.py @@ -293,7 +293,7 @@ def test_trend_only_records_are_not_selected_for_score(self, tmp_path): 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 +316,30 @@ def test_text_report_layout(self, tmp_path): assert "weights not renormalized" in text +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" + # Warning is finished: a non-blocking check does not block completion. + assert _flow_completion_state(["Success", "Warning"]) == "complete" + + 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_report.py b/test/test_signoff_report.py index c4fa58cd0..c1813906a 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( From 126a13dc1d16c01f12c5b68fef36b574c5f17e5d Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 03:00:26 +0800 Subject: [PATCH 12/47] fix(cli): canonical atomic text writer, honest pdk setup checks, safe toml scanning Move write_text_atomic to utility.file as the canonical writer and route report output through it: a failed report write no longer destroys the existing file and surfaces as report_write_failed instead of a traceback. pdk setup now validates the candidate root with the project's configured pdk overrides resolved against that root, so a valid custom layout is not rejected and overridden paths are actually checked. The TOML editor's bracket scanner skips strings and comments, so a value like "alu[rev" no longer looks multiline and swallows following keys through the next table header. --- chipcompiler/cli/command_handlers/param.py | 5 +- chipcompiler/cli/command_handlers/pdk.py | 10 +- .../cli/command_handlers/project_config.py | 3 +- chipcompiler/cli/command_handlers/report.py | 20 +++- chipcompiler/cli/project/config.py | 17 ++-- chipcompiler/cli/project/toml_edit.py | 97 +++++++++++-------- chipcompiler/utility/file.py | 24 +++++ test/cli/commands/test_report.py | 32 ++++++ test/cli/params/test_toml_editing.py | 31 +++++- 9 files changed, 185 insertions(+), 54 deletions(-) diff --git a/chipcompiler/cli/command_handlers/param.py b/chipcompiler/cli/command_handlers/param.py index 7b62b51f7..06e141554 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: @@ -375,7 +376,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) - toml_edit.write_text_atomic(config_path, new_text) + write_text_atomic(config_path, new_text) def _remove_param_from_toml(config_path: str, schema) -> bool: @@ -392,5 +393,5 @@ def _remove_param_from_toml(config_path: str, schema) -> bool: if result is None: return False - toml_edit.write_text_atomic(config_path, 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 128b2250d..04e366494 100644 --- a/chipcompiler/cli/command_handlers/pdk.py +++ b/chipcompiler/cli/command_handlers/pdk.py @@ -4,7 +4,8 @@ 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, write_text_atomic +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: @@ -189,6 +190,7 @@ def setup(command_input, ctx: CommandContext) -> CommandResult: _validate_pdk_contents, find_config_path, load_project_config, + resolve_pdk_overrides, ) config_path = find_config_path(ctx.project_dir) @@ -205,7 +207,11 @@ def setup(command_input, ctx: CommandContext) -> CommandResult: actions: list[str] = [] def contents_problem() -> str | None: - return _validate_pdk_contents(pdk_name, path, None) + # Validate the layout the project actually uses: configured + # [pdk.overrides] content paths resolve against the candidate root + # being set up, not the generic default layout. + overrides = resolve_pdk_overrides(cfg, pdk_root=path) if cfg is not None else None + return _validate_pdk_contents(pdk_name, path, overrides) if not os.path.isdir(path): missing_tools = [tool for tool in ("git", "make") if shutil.which(tool) is None] diff --git a/chipcompiler/cli/command_handlers/project_config.py b/chipcompiler/cli/command_handlers/project_config.py index 70d155028..5b0ad35bd 100644 --- a/chipcompiler/cli/command_handlers/project_config.py +++ b/chipcompiler/cli/command_handlers/project_config.py @@ -6,7 +6,8 @@ from chipcompiler.cli.core.records import error_record 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, write_text_atomic +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: diff --git a/chipcompiler/cli/command_handlers/report.py b/chipcompiler/cli/command_handlers/report.py index b89f7ee32..e23a6e25e 100644 --- a/chipcompiler/cli/command_handlers/report.py +++ b/chipcompiler/cli/command_handlers/report.py @@ -15,14 +15,28 @@ 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) + 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, diff --git a/chipcompiler/cli/project/config.py b/chipcompiler/cli/project/config.py index c11ca802a..e0a488861 100644 --- a/chipcompiler/cli/project/config.py +++ b/chipcompiler/cli/project/config.py @@ -289,22 +289,27 @@ 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, + *, + pdk_root: str | 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 PDK root — + *pdk_root* when given (e.g. a candidate root being validated by + `pdk setup`), otherwise the configured 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) if pdk_root is None else pdk_root 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/toml_edit.py b/chipcompiler/cli/project/toml_edit.py index 5938f54f5..b55e0c16c 100644 --- a/chipcompiler/cli/project/toml_edit.py +++ b/chipcompiler/cli/project/toml_edit.py @@ -4,35 +4,11 @@ so repeated `param set`/`pdk set-root` calls do not churn the config file. """ -import os import re -import tempfile _TABLE_HEADER_RE = re.compile(r"^[ \t]*\[([^\]]+)\][ \t]*(?:#.*)?$", re.MULTILINE) -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 can destroy the existing ecc.toml; the sibling temp file keeps the - old content intact until the fully written replacement can be renamed in. - """ - directory = os.path.dirname(os.path.abspath(path)) - 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.replace(tmp_path, path) - except BaseException: - os.unlink(tmp_path) - raise - - 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): @@ -46,6 +22,59 @@ def find_table_span(text: str, table_name: str) -> tuple[int, int] | None: return None +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) + + +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. + """ + 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: """Extend match end past continuation lines for multiline TOML values. @@ -55,26 +84,16 @@ def _extend_multiline_value(text: str, match_end: int) -> int: line_start = text.rfind("\n", 0, match_end) + 1 matched_line = text[line_start:match_end] - 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 - + depth = _toml_code_depth(matched_line) if depth <= 0: return match_end pos = match_end while pos < len(text) and depth > 0: - ch = text[pos] - if ch in ("[", "{"): - depth += 1 - elif ch in ("]", "}"): - depth -= 1 - pos += 1 + nl = text.find("\n", pos) + line_end = len(text) if nl == -1 else nl + 1 + depth += _toml_code_depth(text[pos:line_end]) + pos = line_end while pos < len(text) and text[pos] in (" ", "\t"): pos += 1 diff --git a/chipcompiler/utility/file.py b/chipcompiler/utility/file.py index 987f386e3..c3d60dedd 100644 --- a/chipcompiler/utility/file.py +++ b/chipcompiler/utility/file.py @@ -2,10 +2,34 @@ 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. + """ + directory = os.path.dirname(os.path.abspath(path)) + 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.replace(tmp_path, path) + except BaseException: + os.unlink(tmp_path) + raise + + def chmod_folder(folder: str, mode: int = 0o777): def _try_chmod(path): with suppress(Exception): diff --git a/test/cli/commands/test_report.py b/test/cli/commands/test_report.py index c57d48323..60346e83b 100644 --- a/test/cli/commands/test_report.py +++ b/test/cli/commands/test_report.py @@ -102,6 +102,38 @@ def test_qor_writes_default_destination( 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, plain_records ): diff --git a/test/cli/params/test_toml_editing.py b/test/cli/params/test_toml_editing.py index 23a6f05a8..845eec98b 100644 --- a/test/cli/params/test_toml_editing.py +++ b/test/cli/params/test_toml_editing.py @@ -3,7 +3,7 @@ import tomllib from chipcompiler.cli import main as cli_main -from chipcompiler.cli.project.toml_edit import set_pdk_root +from chipcompiler.cli.project.toml_edit import remove_scoped_key, set_pdk_root, set_scoped_key class TestSetPdkRoot: @@ -162,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") From dfafe554525cdc500cfcd45705180b4f30d405fd Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 03:00:35 +0800 Subject: [PATCH 13/47] fix(cli): preflight flow ranges and make overwrite recoverable Fresh flow ranges skipped environment preflight solely because they have no named preset, so missing tools surfaced only after manifest registration and workspace creation had started; derive the probe set from build_flow_range and preflight before any mutation. Overwrite deleted the previous workspace before building its replacement, so any mid-creation failure permanently lost the old artifacts (workspace refresh made this the primary path). Rename the old tree to a sibling backup under the lock, restore it when creation or step-workspace construction fails, and discard the backup only once the replacement is verified; a refresh whose construction stopped early now reports failure instead of an unconditional refreshed. --- chipcompiler/cli/command_handlers/project.py | 51 +++++++++++++++-- chipcompiler/cli/inspection/env_probe.py | 11 +++- chipcompiler/cli/project/run_dispatch.py | 56 +++++++++---------- chipcompiler/cli/project/run_prepare.py | 49 ++++++++++++---- test/cli/commands/conftest.py | 2 +- .../test_partial_workspace_recovery.py | 11 +++- test/cli/commands/test_workspace_refresh.py | 38 +++++++++++++ 7 files changed, 169 insertions(+), 49 deletions(-) diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index a53a7e3ec..720566210 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -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 diff --git a/chipcompiler/cli/inspection/env_probe.py b/chipcompiler/cli/inspection/env_probe.py index 42dadee7f..14ee3d25c 100644 --- a/chipcompiler/cli/inspection/env_probe.py +++ b/chipcompiler/cli/inspection/env_probe.py @@ -199,7 +199,16 @@ 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]()} + 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. + + The same minimum set probe_components_for_preset derives: a fresh flow + range resolves to the same kind of chain through build_flow_range. + """ + tools = {tool for _step, tool, _state in steps} components = ["ecc-tools"] if "yosys" in tools: components.append("yosys") diff --git a/chipcompiler/cli/project/run_dispatch.py b/chipcompiler/cli/project/run_dispatch.py index 32c38af7a..be8f857c1 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 @@ -86,23 +87,23 @@ def _existing_target_guard(run_dir: str, project_dir: str, run_name: str) -> Com def _prepare_run_target(command_input, ctx, run_dir: str, run_name: str): - """Overwrite-delete + atomic create of the run target (the caller holds + """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. + 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 +116,23 @@ 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) + 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( @@ -148,6 +144,9 @@ def _prepare_run_target(command_input, ctx, run_dir: str, run_name: str): ] ) except OSError: + if backup_path is not None: + with contextlib.suppress(OSError): + os.replace(backup_path, run_dir) return False @@ -212,7 +211,7 @@ 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) -> CommandResult: return execute_fresh_run( command_input, ctx, @@ -225,6 +224,7 @@ def fresh_run(*, owns_target: bool) -> CommandResult: warning_records, workspace_registered=workspace_registered, owns_target=owns_target, + backup_path=backup_path, execute_flow=execute_flow, ) @@ -247,7 +247,7 @@ def fresh_run(*, owns_target: bool) -> CommandResult: prepared = _prepare_run_target(command_input, ctx, run_dir, run_name) if isinstance(prepared, CommandResult): return prepared - return fresh_run(owns_target=prepared) + return fresh_run(owns_target=prepared[0], backup_path=prepared[1]) # The overwrite-delete + atomic create run inside the shared project # lock: an `ecc migrate` holding the exclusive lock sees them as one @@ -298,10 +298,10 @@ def fresh_run(*, owns_target: bool) -> CommandResult: prepared = _prepare_run_target(command_input, ctx, run_dir, run_name) if isinstance(prepared, CommandResult): return prepared - owns_target = prepared + owns_target, backup_path = 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) + return fresh_run(owns_target=owns_target, backup_path=backup_path) diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index 46a8b5bfb..a70ddacd0 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -191,13 +191,17 @@ def execute_fresh_run( *, workspace_registered: bool, owns_target: bool, + backup_path: str | None = None, 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. """ import shutil @@ -216,6 +220,16 @@ def execute_fresh_run( project = ctx.project project_dir = ctx.project_dir + def cleanup_failed_target(): + """Remove a partially created target and put a renamed-aside + workspace back, so the previous artifacts survive the failure.""" + 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) + base = None if cfg.manifest_parameters: # Manifest base layer: ecc.toml/--set values overlay it, not the @@ -235,8 +249,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( [ { @@ -249,7 +262,10 @@ def execute_fresh_run( ) def failed_workspace(reason: str | None) -> CommandResult: - if workspace_registered: + cleanup_failed_target() + if backup_path is None 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) return _workspace_failed_result(run_name, run_dir, reason) @@ -269,8 +285,6 @@ def failed_workspace(reason: str | None) -> CommandResult: 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 = "" @@ -333,8 +347,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,8 +354,6 @@ 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: @@ -376,6 +386,22 @@ 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: the previous workspace's + # backup is obsolete and the new tree owns the target from here on. + if backup_path is not None: + shutil.rmtree(backup_path, ignore_errors=True) + if not execute_flow: if workspace_registered: _write_back_status(project_dir, run_name, "not_started", warning_records) @@ -417,7 +443,8 @@ def failed_workspace(reason: str | None) -> CommandResult: except Exception as exc: from chipcompiler.cli.core.records import error_record - if workspace_registered: + cleanup_failed_target() + if backup_path is None and workspace_registered: _write_back_status(project_dir, run_name, "failed", warning_records) return CommandResult.err( warning_records diff --git a/test/cli/commands/conftest.py b/test/cli/commands/conftest.py index 95ba87b75..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 diff --git a/test/cli/commands/test_partial_workspace_recovery.py b/test/cli/commands/test_partial_workspace_recovery.py index 90ff27180..87c881c95 100644 --- a/test/cli/commands/test_partial_workspace_recovery.py +++ b/test/cli/commands/test_partial_workspace_recovery.py @@ -77,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, @@ -91,6 +91,8 @@ def test_failed_creation_after_overwrite_removes_partial( 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( @@ -107,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, diff --git a/test/cli/commands/test_workspace_refresh.py b/test/cli/commands/test_workspace_refresh.py index a249872e1..bc35a9697 100644 --- a/test/cli/commands/test_workspace_refresh.py +++ b/test/cli/commands/test_workspace_refresh.py @@ -28,3 +28,41 @@ def test_workspace_refresh_recreates_without_running( 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" From 27d0ca6810943c0b9ecb4c79abf7df42c2c271f0 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 03:00:43 +0800 Subject: [PATCH 14/47] fix(cli): roll back workspace parameter mutations that fail to refresh param set/unset persisted the new parameters before regenerating the derived configs and invalidating the flow ledger, so a later failure left new parameters paired with stale configs and a Success ledger that let the next run no-op. Snapshot params.toml, the config directory, and the ledger first; restore them when the refresh or invalidation fails. Also drop a test that only introspected a statically declared function-signature default, per the repo rule against tests for statically defined values. --- .../cli/command_handlers/workspace_params.py | 37 ++++++++++++++++ test/cli/params/test_workspace_commands.py | 44 +++++++++++++++++++ test/data/test_descriptions.py | 6 --- 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/chipcompiler/cli/command_handlers/workspace_params.py b/chipcompiler/cli/command_handlers/workspace_params.py index 3902e95fa..5f80cc7a6 100644 --- a/chipcompiler/cli/command_handlers/workspace_params.py +++ b/chipcompiler/cli/command_handlers/workspace_params.py @@ -101,6 +101,41 @@ 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 persisted file the mutation sequence may touch. + + 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. + """ + from pathlib import Path + + workspace_dir = Path(workspace.directory) + paths = [workspace_dir / "home" / "params.toml", workspace_dir / "home" / "flow.json"] + config_dir = workspace_dir / "config" + if config_dir.is_dir(): + paths.extend(path for path in config_dir.iterdir() if path.is_file()) + snapshot = {} + for path in paths: + try: + snapshot[path] = path.read_bytes() if path.is_file() else None + except OSError: + continue + return snapshot + + +def _restore_transaction(snapshot: dict) -> None: + for path, content in snapshot.items(): + try: + if content is None: + path.unlink(missing_ok=True) + else: + path.write_bytes(content) + except OSError: + continue + + def _mutate( ctx: CommandContext, schema, mutation, requested_value: object, status: str ) -> CommandResult: @@ -126,6 +161,7 @@ def _mutate( if result is None: return CommandResult.ok([_record(ctx, schema.param, None, "no_override")]) value, step = result + snapshot = _snapshot_transaction(workspace) if not save_parameter(workspace.parameters): return CommandResult.err( [error_record("workspace_param_save_failed", param=schema.param)] @@ -135,6 +171,7 @@ def _mutate( flow = EngineFlow(workspace=workspace) invalidated = rerun.invalidate_from(flow, step) except Exception as exc: + _restore_transaction(snapshot) return CommandResult.err( [ error_record( diff --git a/test/cli/params/test_workspace_commands.py b/test/cli/params/test_workspace_commands.py index e7092b349..684988f2d 100644 --- a/test/cli/params/test_workspace_commands.py +++ b/test/cli/params/test_workspace_commands.py @@ -126,6 +126,50 @@ def test_workspace_param_set_persists_and_invalidates_suffix( assert workspace.parameters.data["dreamplace"]["target_density"] == 0.2 +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 ): diff --git a/test/data/test_descriptions.py b/test/data/test_descriptions.py index b586f1c59..8f053ba55 100644 --- a/test/data/test_descriptions.py +++ b/test/data/test_descriptions.py @@ -1,10 +1,8 @@ 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 = ( @@ -19,10 +17,6 @@ def test_every_schema_has_an_explicit_description(): 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 = [ From aa7a6086fa07c9c3b5ef39b353f85a4f9d2c8f0d Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 03:59:20 +0800 Subject: [PATCH 15/47] refactor(engine): split the signoff collector out of the package init The package __init__ carried the full 1305-line collector. Move the public data model into models.py, artifact discovery into discovery.py, analysis refresh and issue validation into analysis.py, and keep only orchestration, materialization, and archiving in collector.py; the package __init__ is now just the public re-exports. --- chipcompiler/engine/signoff/__init__.py | 1320 +--------------------- chipcompiler/engine/signoff/analysis.py | 316 ++++++ chipcompiler/engine/signoff/collector.py | 866 ++++++++++++++ chipcompiler/engine/signoff/discovery.py | 126 +++ chipcompiler/engine/signoff/models.py | 49 + test/test_signoff_report.py | 4 +- 6 files changed, 1380 insertions(+), 1301 deletions(-) create mode 100644 chipcompiler/engine/signoff/analysis.py create mode 100644 chipcompiler/engine/signoff/collector.py create mode 100644 chipcompiler/engine/signoff/discovery.py create mode 100644 chipcompiler/engine/signoff/models.py diff --git a/chipcompiler/engine/signoff/__init__.py b/chipcompiler/engine/signoff/__init__.py index ea024a188..c706ae777 100644 --- a/chipcompiler/engine/signoff/__init__.py +++ b/chipcompiler/engine/signoff/__init__.py @@ -1,1306 +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]: - from chipcompiler.data.step_dirs import STEP_DIRECTORIES - - return STEP_DIRECTORIES +__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..8fa0ed685 --- /dev/null +++ b/chipcompiler/engine/signoff/analysis.py @@ -0,0 +1,316 @@ +"""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 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 + 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") diff --git a/chipcompiler/engine/signoff/collector.py b/chipcompiler/engine/signoff/collector.py new file mode 100644 index 000000000..50c815037 --- /dev/null +++ b/chipcompiler/engine/signoff/collector.py @@ -0,0 +1,866 @@ +"""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 + 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 _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..2781a7229 --- /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, + 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()) 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/test/test_signoff_report.py b/test/test_signoff_report.py index c1813906a..dce5776a0 100644 --- a/test/test_signoff_report.py +++ b/test/test_signoff_report.py @@ -378,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 From 6aab239c1a2dfbdd1cb9e24b1cff79bfbcd134e3 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 03:59:31 +0800 Subject: [PATCH 16/47] refactor(data): move workspace input persistence and SDC logic to submodules create_workspace's origin-input copying and the SDC generation/refresh helpers lived inline in the 1397-line workspace package root. Extract them to workspace/inputs.py and workspace/sdc.py so new workspace functionality stops extending the already oversized module. --- chipcompiler/data/workspace/__init__.py | 110 ++++-------------------- chipcompiler/data/workspace/inputs.py | 82 ++++++++++++++++++ chipcompiler/data/workspace/sdc.py | 49 +++++++++++ 3 files changed, 146 insertions(+), 95 deletions(-) create mode 100644 chipcompiler/data/workspace/inputs.py create mode 100644 chipcompiler/data/workspace/sdc.py diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py index 9d4cc5424..ac1fe40ad 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 @@ -1358,40 +1315,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/inputs.py b/chipcompiler/data/workspace/inputs.py new file mode 100644 index 000000000..ecda35ab5 --- /dev/null +++ b/chipcompiler/data/workspace/inputs.py @@ -0,0 +1,82 @@ +"""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" + + 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 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) From 15ba7ddc81f4f14e27357db73f64b91471c34902 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 03:59:31 +0800 Subject: [PATCH 17/47] fix(engine): seed the DB engine from the first unfinished step init_db_engine still treated only Success as done, so after a warned synthesis LEC it selected the LEC step and returned before initializing the engine DB, handing the next physical step an uninitialized engine. Use the canonical finished-state predicate; a warned LEC now follows the same completed-flow path as a successful one. --- chipcompiler/engine/flow.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index c815f8ee3..e6954a71c 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -12,6 +12,7 @@ StepEnum, Workspace, WorkspaceStep, + is_finished_step_state, is_non_blocking_step, log_flow, ) @@ -429,17 +430,20 @@ 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 # LEC is a netlist comparison step and does not expose an ECC DB # input. Keep any existing DB alive, but do not try to initialize one - # from the Yosys LEC workspace. + # from the Yosys LEC workspace. A warned LEC is finished, so it is + # skipped above and never lands here. if workspace_step is not None and workspace_step.tool == "yosys_lec": return True From f90ccf071ee88b79ab481d98391ad4d3e10f797c Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 03:59:40 +0800 Subject: [PATCH 18/47] fix(cli): hold the workspace lock across overwrite and register after ownership Two fixes to the fresh-run target lifecycle: the workspace lock is now entered for the overwrite rename and stays held through the replacement being built, so concurrent overwrite runs can no longer interleave and a failed cleanup destroy another run's completed workspace; and manifest registration happens after the target ownership check, so a refused run never strands a project.json entry for a directory it never owned. A lost first-manifest creation race now falls through to the locked registration mutation instead of aborting. --- chipcompiler/cli/project/manifest_write.py | 6 +- chipcompiler/cli/project/run_dispatch.py | 187 +++++++++++++-------- chipcompiler/cli/project/run_prepare.py | 21 ++- test/cli/commands/test_manifest_run.py | 4 +- 4 files changed, 138 insertions(+), 80 deletions(-) diff --git a/chipcompiler/cli/project/manifest_write.py b/chipcompiler/cli/project/manifest_write.py index ce0f94b5e..48fda039e 100644 --- a/chipcompiler/cli/project/manifest_write.py +++ b/chipcompiler/cli/project/manifest_write.py @@ -305,7 +305,11 @@ def pre_register_workspace( end_step=end_step, status="not_started", ) - return "registered" if write_manifest_if_absent(project_dir, document) else "failed" + 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" diff --git a/chipcompiler/cli/project/run_dispatch.py b/chipcompiler/cli/project/run_dispatch.py index be8f857c1..db0a6294b 100644 --- a/chipcompiler/cli/project/run_dispatch.py +++ b/chipcompiler/cli/project/run_dispatch.py @@ -86,10 +86,16 @@ 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): +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). + 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 @@ -120,12 +126,13 @@ def _prepare_run_target(command_input, ctx, run_dir: str, run_name: str): # flock blocks until the running engine releases the sibling lock # (.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)): - 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) + # 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) @@ -150,6 +157,19 @@ def _prepare_run_target(command_input, ctx, run_dir: str, run_name: str): return False +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: """Fail-loud guard inside the shared project lock. @@ -211,7 +231,9 @@ def existing_workspace_run() -> CommandResult: workspace_registered=workspace_registered, ) - def fresh_run(*, owns_target: bool, backup_path: str | None = None) -> CommandResult: + def fresh_run( + *, owns_target: bool, backup_path: str | None = None, ws_locks=None + ) -> CommandResult: return execute_fresh_run( command_input, ctx, @@ -225,6 +247,7 @@ def fresh_run(*, owns_target: bool, backup_path: str | None = None) -> CommandRe workspace_registered=workspace_registered, owns_target=owns_target, backup_path=backup_path, + ws_locks=ws_locks, execute_flow=execute_flow, ) @@ -235,73 +258,91 @@ def fresh_run(*, owns_target: bool, backup_path: str | None = None) -> CommandRe # 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 + 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[0], backup_path=prepared[1]) - - # 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_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, - ) - 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, backup_path = 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, backup_path=backup_path) + if registration == "conflict": + 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 + 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, + ) + finally: + ws_locks.close() diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index a70ddacd0..5cefaee6f 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -192,6 +192,7 @@ def execute_fresh_run( workspace_registered: bool, owns_target: bool, backup_path: str | None = None, + ws_locks=None, execute_flow: bool = True, ) -> CommandResult: """Create the workspace, seed it, execute the flow, and map the result. @@ -201,7 +202,9 @@ def execute_fresh_run( 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. + 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 @@ -326,11 +329,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, @@ -458,7 +466,10 @@ def failed_workspace(reason: str | None) -> CommandResult: ] ) 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/test/cli/commands/test_manifest_run.py b/test/cli/commands/test_manifest_run.py index 0599833fb..e17e62fc5 100644 --- a/test/cli/commands/test_manifest_run.py +++ b/test/cli/commands/test_manifest_run.py @@ -488,7 +488,9 @@ def losing_write(project_dir_arg, document): 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. From b5c93bc33b15c12a8c45bb149093c50a7a4b71ae Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 03:59:51 +0800 Subject: [PATCH 19/47] fix(cli): complete workspace run lifecycles and hardened command edges Workspace --resume/--from/--only runs now write running and terminal status back to project.json (including the no-op path) instead of leaving stale success/failed labels. The parameter transaction snapshot includes the auto-generated SDC, which the refresh rewrites before its validation can fail. RTL source validation keeps checking remaining sources after one failure, signoff export maps filesystem errors to a structured signoff_export_failed record, and the atomic writer preserves an existing file's permissions instead of silently narrowing them to mkstemp's 0600. --- chipcompiler/cli/command_handlers/project.py | 6 +- chipcompiler/cli/command_handlers/signoff.py | 12 ++++ .../cli/command_handlers/workspace_params.py | 5 ++ chipcompiler/cli/project/design_inputs.py | 7 +- chipcompiler/cli/project/run_workspace.py | 34 ++++++++-- chipcompiler/utility/file.py | 17 ++++- test/utility/test_file.py | 64 +++++++++++++++++++ 7 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 test/utility/test_file.py diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index 720566210..6ea8a5973 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -545,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/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 5f80cc7a6..0833845b5 100644 --- a/chipcompiler/cli/command_handlers/workspace_params.py +++ b/chipcompiler/cli/command_handlers/workspace_params.py @@ -108,6 +108,8 @@ def _snapshot_transaction(workspace) -> dict: 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 auto-generated SDC is rewritten by the refresh before config + validation can fail, so it belongs to the same transaction. """ from pathlib import Path @@ -116,6 +118,9 @@ def _snapshot_transaction(workspace) -> dict: config_dir = workspace_dir / "config" if config_dir.is_dir(): paths.extend(path for path in config_dir.iterdir() if path.is_file()) + sdc = getattr(getattr(workspace, "pdk", None), "sdc", None) + if sdc: + paths.append(Path(sdc)) snapshot = {} for path in paths: try: 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/run_workspace.py b/chipcompiler/cli/project/run_workspace.py index 9352a50e3..5d293daf0 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,6 +135,8 @@ def mismatch_error(reason: str) -> CommandResult: ] ) + write_status("running") + try: engine_flow = EngineFlow(workspace=workspace) except Exception as exc: @@ -162,6 +187,7 @@ def mismatch_error(reason: str) -> CommandResult: except Exception as exc: return error("flow_failed", workspace=workspace_path, reason=str(exc)) + 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/utility/file.py b/chipcompiler/utility/file.py index c3d60dedd..3dc6a1879 100644 --- a/chipcompiler/utility/file.py +++ b/chipcompiler/utility/file.py @@ -13,9 +13,16 @@ def write_text_atomic(path: str, text: str) -> None: 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. + 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" ) @@ -24,12 +31,20 @@ def write_text_atomic(path: str, text: str) -> None: 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/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"] From 42dff88d266d718ae5f00a7773203a8f0c3345a6 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 04:48:05 +0800 Subject: [PATCH 20/47] fix(cli): close overwrite lifecycle gaps and finish warning-state consistency Round-4 review fixes. Overwrite: the replacement is committed only after construction is verified, so a later execution failure keeps the new tree instead of cleanup deleting it with the backup already gone; a failed target create returns a typed workspace_create_failed error instead of a bare False that callers unpacked; the registration conflict path abandons the prepared target like the failed path. Workspace runs write failed status on every post-running failure so a manifest entry can never stick at running. Warning now completes normally: the deferred/existing executable sets use the finished-state predicate, and the LEC warning normalization clears the fatal error handed to observers. The TOML editor tokenizes multiline strings and escapes control characters, and the parameter transaction snapshots the declared config set and reports incomplete rollbacks. Also route signoff inspect rendering to the caller's stream, build report content from a single snapshot, own the sizer deferred step shape in its builder, document the vendored DreamPlace description mirror, and move the CLI registry description test to the CLI test layer. --- chipcompiler/cli/command_handlers/report.py | 7 +- .../cli/command_handlers/workspace_params.py | 41 ++++--- chipcompiler/cli/project/run_dispatch.py | 14 ++- chipcompiler/cli/project/run_existing.py | 3 +- chipcompiler/cli/project/run_prepare.py | 29 +++-- chipcompiler/cli/project/run_workspace.py | 16 ++- chipcompiler/cli/project/toml_edit.py | 100 +++++++++++++----- chipcompiler/cli/rendering/pretty.py | 14 +-- chipcompiler/data/config_params/dreamplace.py | 6 ++ chipcompiler/docs/ecc-cli-tutorial.cn.md | 2 +- chipcompiler/docs/ecc-cli-tutorial.en.md | 2 +- chipcompiler/engine/flow.py | 4 + chipcompiler/engine/qor_report.py | 10 +- .../engine/signoff/report_checklist.py | 10 +- chipcompiler/tools/ecc_sizer/builder.py | 63 +++++++++-- chipcompiler/tools/eda.py | 40 ++++--- test/cli/commands/test_report.py | 10 +- test/cli/params/test_registry.py | 7 ++ test/data/test_descriptions.py | 8 -- 19 files changed, 272 insertions(+), 114 deletions(-) diff --git a/chipcompiler/cli/command_handlers/report.py b/chipcompiler/cli/command_handlers/report.py index e23a6e25e..714eafd12 100644 --- a/chipcompiler/cli/command_handlers/report.py +++ b/chipcompiler/cli/command_handlers/report.py @@ -56,8 +56,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))]) @@ -98,7 +101,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/workspace_params.py b/chipcompiler/cli/command_handlers/workspace_params.py index 0833845b5..0052506f5 100644 --- a/chipcompiler/cli/command_handlers/workspace_params.py +++ b/chipcompiler/cli/command_handlers/workspace_params.py @@ -1,5 +1,6 @@ """Workspace-scoped variants of the schema-backed parameter commands.""" +import logging from pathlib import Path from chipcompiler.cli.core.records import error_record @@ -102,43 +103,52 @@ def param_diff(args, ctx: CommandContext) -> CommandResult: def _snapshot_transaction(workspace) -> dict: - """Capture every persisted file the mutation sequence may touch. + """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 auto-generated SDC is rewritten by the refresh before config - validation can fail, so it belongs to the same transaction. + 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"] - config_dir = workspace_dir / "config" - if config_dir.is_dir(): - paths.extend(path for path in config_dir.iterdir() if path.is_file()) + 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 = {} + snapshot: dict = {} for path in paths: try: snapshot[path] = path.read_bytes() if path.is_file() else None - except OSError: - continue + except OSError as exc: + snapshot[path] = None + logging.getLogger(__name__).warning("cannot snapshot %s: %s", path, exc) return snapshot -def _restore_transaction(snapshot: dict) -> None: +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: - continue + except OSError as exc: + failures.append(f"{path}: {exc}") + return failures def _mutate( @@ -176,11 +186,14 @@ def _mutate( flow = EngineFlow(workspace=workspace) invalidated = rerun.invalidate_from(flow, step) except Exception as exc: - _restore_transaction(snapshot) + 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/project/run_dispatch.py b/chipcompiler/cli/project/run_dispatch.py index db0a6294b..4da804c9b 100644 --- a/chipcompiler/cli/project/run_dispatch.py +++ b/chipcompiler/cli/project/run_dispatch.py @@ -150,11 +150,20 @@ def _prepare_run_target(command_input, ctx, run_dir: str, run_name: str, ws_lock ) ] ) - except OSError: + except OSError as exc: if backup_path is not None: with contextlib.suppress(OSError): os.replace(backup_path, run_dir) - return False + 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: @@ -313,6 +322,7 @@ def fresh_run( flow_config=flow_config, ) if registration == "conflict": + _abandon_prepared_target(backup_path, run_dir, owns_target=owns_target) return CommandResult.err( [ error_record( 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 5cefaee6f..6874f6665 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -223,10 +223,25 @@ 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(): """Remove a partially created target and put a renamed-aside workspace back, so the previous artifacts survive the failure.""" - if not owns_target: + if not owns_target or committed_state["value"]: return shutil.rmtree(run_dir, ignore_errors=True) if backup_path is not None: @@ -266,7 +281,7 @@ def cleanup_failed_target(): def failed_workspace(reason: str | None) -> CommandResult: cleanup_failed_target() - if backup_path is None and workspace_registered: + 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) @@ -405,10 +420,10 @@ def failed_workspace(reason: str | None) -> CommandResult: missing = persisted_steps[len(created_steps)].get("name") return failed_workspace(f"step workspace creation failed at {missing}") - # The replacement is fully constructed: the previous workspace's - # backup is obsolete and the new tree owns the target from here on. - if backup_path is not None: - shutil.rmtree(backup_path, ignore_errors=True) + # 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 not execute_flow: if workspace_registered: @@ -452,7 +467,7 @@ def failed_workspace(reason: str | None) -> CommandResult: from chipcompiler.cli.core.records import error_record cleanup_failed_target() - if backup_path is None and workspace_registered: + if terminal_failure() and workspace_registered: _write_back_status(project_dir, run_name, "failed", warning_records) return CommandResult.err( warning_records diff --git a/chipcompiler/cli/project/run_workspace.py b/chipcompiler/cli/project/run_workspace.py index 5d293daf0..853095a06 100644 --- a/chipcompiler/cli/project/run_workspace.py +++ b/chipcompiler/cli/project/run_workspace.py @@ -137,12 +137,18 @@ 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( @@ -160,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 @@ -183,9 +189,9 @@ 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)) write_status("success" if result.ok else "failed") record = { diff --git a/chipcompiler/cli/project/toml_edit.py b/chipcompiler/cli/project/toml_edit.py index b55e0c16c..3e8af24d3 100644 --- a/chipcompiler/cli/project/toml_edit.py +++ b/chipcompiler/cli/project/toml_edit.py @@ -76,31 +76,70 @@ def _toml_code_depth(segment: str) -> int: def _extend_multiline_value(text: str, match_end: int) -> int: - """Extend match end past continuation lines for multiline TOML values. + """Return the end of the (possibly multiline) TOML value at the match. - After matching `key = ...` on one line, consume subsequent lines if the - value has unclosed brackets (arrays or inline tables). + 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. """ - line_start = text.rfind("\n", 0, match_end) + 1 - matched_line = text[line_start:match_end] - - depth = _toml_code_depth(matched_line) - if depth <= 0: - return match_end - - pos = match_end - while pos < len(text) and depth > 0: - nl = text.find("\n", pos) - line_end = len(text) if nl == -1 else nl + 1 - depth += _toml_code_depth(text[pos:line_end]) - pos = line_end - - while pos < len(text) and text[pos] in (" ", "\t"): - pos += 1 - if pos < len(text) and text[pos] == "\n": + pos = text.rfind("\n", 0, match_end) + 1 + n = len(text) + depth = 0 + state = None # None, or the opening quote: '"' | "'" | '"""' | "'''" + while pos < n: + ch = text[pos] + 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 == "#": + nl = text.find("\n", pos) + if nl == -1: + return n + return nl + 1 + 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 "]}": + depth -= 1 pos += 1 + return n - return pos + +_ESCAPES = { + '"': '\\"', + "\\": "\\\\", + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", +} def format_toml_value(val: object) -> str: @@ -109,8 +148,16 @@ 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}]" @@ -164,15 +211,14 @@ 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) + # Match only the value's first line; _extend_multiline_value walks to the + # true end of a multiline value, including its terminating newline. + key_pattern = re.compile(rf"^\s*{re.escape(name)}\s*=[^\n]*$", re.MULTILINE) key_match = key_pattern.search(section_body) 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: 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/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/docs/ecc-cli-tutorial.cn.md b/chipcompiler/docs/ecc-cli-tutorial.cn.md index 44a4bb763..243bfcac9 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.cn.md +++ b/chipcompiler/docs/ecc-cli-tutorial.cn.md @@ -628,7 +628,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 重建输入/配置,但不运行 diff --git a/chipcompiler/docs/ecc-cli-tutorial.en.md b/chipcompiler/docs/ecc-cli-tutorial.en.md index 4f45445a9..6451132fa 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.en.md +++ b/chipcompiler/docs/ecc-cli-tutorial.en.md @@ -629,7 +629,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 diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index e6954a71c..11a30f56f 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -708,6 +708,9 @@ def run_step( if is_non_blocking_step(workspace_step) and state == StateEnum.Imcomplete: state = StateEnum.Warning + # Warning is a terminal completion, not a failure: the + # observer must not retain a fatal tool error for it. + step_error = None if flow_step is not None and not self.set_state( name=workspace_step.name, @@ -769,6 +772,7 @@ def run_step( ) if is_non_blocking_step(workspace_step): state = StateEnum.Warning + step_error = None if flow_step is not None and not self.set_state( name=workspace_step.name, tool=workspace_step.tool, diff --git a/chipcompiler/engine/qor_report.py b/chipcompiler/engine/qor_report.py index 8cf086425..9526777f7 100644 --- a/chipcompiler/engine/qor_report.py +++ b/chipcompiler/engine/qor_report.py @@ -513,9 +513,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/signoff/report_checklist.py b/chipcompiler/engine/signoff/report_checklist.py index ba767d0c6..24f80745f 100644 --- a/chipcompiler/engine/signoff/report_checklist.py +++ b/chipcompiler/engine/signoff/report_checklist.py @@ -114,9 +114,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/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/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/test/cli/commands/test_report.py b/test/cli/commands/test_report.py index 60346e83b..b29ef133c 100644 --- a/test/cli/commands/test_report.py +++ b/test/cli/commands/test_report.py @@ -74,9 +74,11 @@ 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) @@ -203,7 +205,9 @@ 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, "--plain"]) 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/data/test_descriptions.py b/test/data/test_descriptions.py index 8f053ba55..d9c3ed0af 100644 --- a/test/data/test_descriptions.py +++ b/test/data/test_descriptions.py @@ -1,7 +1,6 @@ import json from pathlib import Path -from chipcompiler.cli.project.params import PARAM_REGISTRY from chipcompiler.data.config_params import CONFIG_PARAM_SCHEMAS _REPO_ROOT = Path(__file__).resolve().parents[2] @@ -10,13 +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_dreamplace_descriptions_match_upstream_metadata(): metadata = json.loads(_DREAMPLACE_PARAMETERS.read_text(encoding="utf-8")) schemas = [ From 6a42d7bfe3f8201d40ca96d7cfbe78763608307b Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 05:33:41 +0800 Subject: [PATCH 21/47] fix(cli): round-5 hardening for preflight, overrides, setup, and seeding Workspace seeding (provenance and flow-target writes) joined the rollback-protected construction phase with its save result checked, and the running marker is written only after the replacement is committed, so no failure path strands an inconsistent manifest or a partial target. pdk setup runs make unzip only inside a git checkout, refusing unrecognized non-empty directories with a structured error. The parameter transaction aborts before mutating when a snapshot read fails, instead of rolling back to a deletion. Config overrides use strict JSON reads (a corrupt target aborts the set instead of being replaced by the patch) and commit atomically with rollback. Preflight components derive from an explicit tool-to-runtime mapping, so a Yosys-only chain preflights Yosys without requiring unrelated components. Golden-netlist persistence rejects generated-name collisions, report timing no longer invents zero violations from met slack or misread constraint aliases, and missing slack renders as unknown rather than VIOLATION. --- chipcompiler/cli/command_handlers/pdk.py | 16 +++++ .../cli/command_handlers/workspace_params.py | 13 +++- chipcompiler/cli/inspection/env_probe.py | 27 +++++--- chipcompiler/cli/project/run_prepare.py | 12 ++-- .../data/workspace/config_overrides.py | 63 ++++++++++++++++--- chipcompiler/data/workspace/inputs.py | 5 ++ chipcompiler/engine/signoff/report_text.py | 4 ++ chipcompiler/engine/signoff/report_timing.py | 10 +-- test/cli/commands/test_doctor.py | 5 +- test/cli/commands/test_pdk_config.py | 19 ++++++ 10 files changed, 142 insertions(+), 32 deletions(-) diff --git a/chipcompiler/cli/command_handlers/pdk.py b/chipcompiler/cli/command_handlers/pdk.py index 04e366494..14097b898 100644 --- a/chipcompiler/cli/command_handlers/pdk.py +++ b/chipcompiler/cli/command_handlers/pdk.py @@ -252,6 +252,22 @@ def contents_problem() -> str | None: return CommandResult.err( [error_record("missing_tool", reason="required for setup: make")] ) + if not os.path.isdir(os.path.join(path, ".git")): + # `make unzip` executes repository-provided commands, so run it + # only inside a checkout attributable to the PDK repository — + # never in an arbitrary directory that merely fails validation. + return CommandResult.err( + [ + error_record( + "invalid_pdk_dir", + path=path, + reason=( + "directory is not an icsprout55-pdk git checkout; " + "point setup at a fresh clone or an existing checkout" + ), + ) + ] + ) make_cmd = ["make", "unzip"] gh_proxy = os.environ.get("GH_PROXY", "").strip() if gh_proxy: diff --git a/chipcompiler/cli/command_handlers/workspace_params.py b/chipcompiler/cli/command_handlers/workspace_params.py index 0052506f5..8a7d6e4b5 100644 --- a/chipcompiler/cli/command_handlers/workspace_params.py +++ b/chipcompiler/cli/command_handlers/workspace_params.py @@ -176,7 +176,18 @@ def _mutate( if result is None: return CommandResult.ok([_record(ctx, schema.param, None, "no_override")]) value, step = result - snapshot = _snapshot_transaction(workspace) + 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)] diff --git a/chipcompiler/cli/inspection/env_probe.py b/chipcompiler/cli/inspection/env_probe.py index 14ee3d25c..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. @@ -205,15 +216,13 @@ def probe_components_for_preset(preset: str) -> tuple[str, ...]: def probe_components_for_steps(steps) -> tuple[str, ...]: """Components a concrete (step, tool, state) chain needs before it can start. - The same minimum set probe_components_for_preset derives: a fresh flow - range resolves to the same kind of chain through build_flow_range. + 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 = ["ecc-tools"] - if "yosys" in tools: - components.append("yosys") - if "dreamplace" in tools: - components.append("dreamplace") - if "sizer" in tools: - components.append("sizer") + 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/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index 6874f6665..5b6dc9b80 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -379,6 +379,9 @@ def failed_workspace(reason: str | None) -> CommandResult: if workspace is None: return failed_workspace(None) + # Seeding writes are part of the replacement construction: they run + # before the commit point so a failure here still restores the + # renamed-aside previous workspace. if cli_overrides: import json @@ -392,10 +395,8 @@ def failed_workspace(reason: str | None) -> CommandResult: 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) + if not save_parameter(workspace_parameters): + return failed_workspace("failed to persist the flow target in params.toml") # Engine execution still holds the workspace lock taken before # creation: a second `ecc run` taking the existing-workspace path @@ -425,6 +426,9 @@ def failed_workspace(reason: str | None) -> CommandResult: # 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) diff --git a/chipcompiler/data/workspace/config_overrides.py b/chipcompiler/data/workspace/config_overrides.py index a57b29d21..04969076b 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,20 +22,58 @@ def apply_config_overrides(config_paths: dict[str, Path], parameters: dict) -> N if not isinstance(overrides, dict): return - staged: list[tuple[Path, dict]] = [] + 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) - staged.append((config_path, config)) + 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: + for config_path, original in reversed(written): + if original is None: + config_path.unlink(missing_ok=True) + else: + config_path.write_bytes(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 + return data if isinstance(data, dict) else {} + - for config_path, config in staged: - if not json_write(config_path, config): - raise OSError(f"Failed to write config override: {config_path}") +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 index ecda35ab5..614d34263 100644 --- a/chipcompiler/data/workspace/inputs.py +++ b/chipcompiler/data/workspace/inputs.py @@ -44,6 +44,11 @@ def persist_origin_inputs( 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 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 7519152ef..3b5e6454b 100644 --- a/chipcompiler/engine/signoff/report_timing.py +++ b/chipcompiler/engine/signoff/report_timing.py @@ -320,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", @@ -335,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", @@ -352,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", diff --git a/test/cli/commands/test_doctor.py b/test/cli/commands/test_doctor.py index 2b755edef..2c67d7b96 100644 --- a/test/cli/commands/test_doctor.py +++ b/test/cli/commands/test_doctor.py @@ -244,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", diff --git a/test/cli/commands/test_pdk_config.py b/test/cli/commands/test_pdk_config.py index 133fe68e4..cd69628f5 100644 --- a/test/cli/commands/test_pdk_config.py +++ b/test/cli/commands/test_pdk_config.py @@ -239,6 +239,21 @@ def test_setup_missing_tool_fails_before_clone( assert record["error"] == "missing_tool" assert "git" in record["reason"] + def test_setup_refuses_unrecognized_nonempty_directory( + self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records + ): + project_dir = create_cli_project(pdk_root="") + stranger = tmp_path / "stranger" + stranger.mkdir() + (stranger / "Makefile").write_text("all:\n\techo pwned\n") + + rc = cli_main.run(["pdk", "setup", str(stranger), "--project", project_dir, "--plain"]) + + record = plain_records(capsys.readouterr().out)[0] + assert rc == 1 + assert record["error"] == "invalid_pdk_dir" + assert not list(stranger.iterdir()) or list(stranger.iterdir()) == [stranger / "Makefile"] + def test_setup_complete_checkout_only_sets_root( self, tmp_path, @@ -281,6 +296,7 @@ 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 + (pdk_dir / ".git").mkdir() return _FakeResult() if cmd[0] == "make": calls["make"].append((cmd, cwd)) @@ -341,6 +357,7 @@ def test_setup_unzip_retries_then_fails( project_dir = create_cli_project(pdk_root="") pdk_dir = tmp_path / "stubborn-pdk" pdk_dir.mkdir() + (pdk_dir / ".git").mkdir() make_calls = [] def fake_run(cmd, cwd=None, **kwargs): @@ -370,6 +387,7 @@ def test_setup_unzip_recovers_on_retry( project_dir = create_cli_project(pdk_root="") pdk_dir = tmp_path / "flaky-pdk" pdk_dir.mkdir() + (pdk_dir / ".git").mkdir() attempts = {"n": 0} def fake_run(cmd, cwd=None, **kwargs): @@ -401,6 +419,7 @@ def test_setup_forwards_gh_proxy_to_make( project_dir = create_cli_project(pdk_root="") pdk_dir = tmp_path / "proxy-pdk" pdk_dir.mkdir() + (pdk_dir / ".git").mkdir() seen = {} def fake_run(cmd, cwd=None, **kwargs): From 2575d1760b72910df6667ff083ebb69acc507739 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 06:19:53 +0800 Subject: [PATCH 22/47] fix(cli): round-6 hardening for toml scanning, seeding recovery, and honest evidence The TOML value tokenizer skips inline comments inside unterminated bracket collections, set_pdk_root reuses the same multiline value-range logic, and format_toml_value escapes control characters. Workspace seeding writes run inside the recovery boundary so a failed write restores the renamed-aside previous workspace. Parameter transactions abort before mutating when a snapshotted file cannot be read, and the golden netlist is persisted after the filelist bulk copy so a generated name collision is refused instead of overwritten. QoR parsing rejects NaN/Infinity metric values, missing slack renders as unknown instead of VIOLATION, and ecc.spec documents libfontconfig as a verified host prerequisite of the bundle. --- .../cli/command_handlers/project_config.py | 48 ++++++++++++++----- .../cli/command_handlers/workspace_params.py | 12 +++-- chipcompiler/cli/project/run_prepare.py | 37 +++++++------- chipcompiler/cli/project/toml_edit.py | 24 +++++++--- chipcompiler/data/workspace/inputs.py | 28 ++++++----- chipcompiler/engine/qor_report.py | 8 +++- ecc.spec | 7 +++ 7 files changed, 110 insertions(+), 54 deletions(-) diff --git a/chipcompiler/cli/command_handlers/project_config.py b/chipcompiler/cli/command_handlers/project_config.py index 5b0ad35bd..62d162c05 100644 --- a/chipcompiler/cli/command_handlers/project_config.py +++ b/chipcompiler/cli/command_handlers/project_config.py @@ -23,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")]) @@ -34,11 +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")]) - write_text_atomic(config_path, 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,14 +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) - write_text_atomic(config_path, updated) +def _set_value(config_path: str, field, value: object) -> CommandResult | None: + """Apply one field edit; returns a structured error 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 _io_error(config_path, exc) + try: + write_text_atomic(config_path, updated) + except OSError as exc: + return _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/workspace_params.py b/chipcompiler/cli/command_handlers/workspace_params.py index 8a7d6e4b5..8c576c77e 100644 --- a/chipcompiler/cli/command_handlers/workspace_params.py +++ b/chipcompiler/cli/command_handlers/workspace_params.py @@ -1,6 +1,5 @@ """Workspace-scoped variants of the schema-backed parameter commands.""" -import logging from pathlib import Path from chipcompiler.cli.core.records import error_record @@ -129,11 +128,16 @@ def _snapshot_transaction(workspace) -> dict: 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() if path.is_file() else None + snapshot[path] = path.read_bytes() except OSError as exc: - snapshot[path] = None - logging.getLogger(__name__).warning("cannot snapshot %s: %s", path, 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 diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index 5b6dc9b80..c01f1b74c 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -380,23 +380,26 @@ def failed_workspace(reason: str | None) -> CommandResult: return failed_workspace(None) # Seeding writes are part of the replacement construction: they run - # before the commit point so a failure here still restores the - # renamed-aside previous workspace. - 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") + # 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 diff --git a/chipcompiler/cli/project/toml_edit.py b/chipcompiler/cli/project/toml_edit.py index 3e8af24d3..cc5f362af 100644 --- a/chipcompiler/cli/project/toml_edit.py +++ b/chipcompiler/cli/project/toml_edit.py @@ -106,10 +106,19 @@ def _extend_multiline_value(text: str, match_end: int) -> int: 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 - return nl + 1 + pos = nl + 1 + continue if ch in ('"', "'"): triple = text[pos : pos + 3] if triple in ('"""', "'''"): @@ -248,11 +257,14 @@ def set_pdk_root(text: str, value: str) -> str: key_pattern = re.compile(r"^(\s*)root\s*=[^\n]*$", re.MULTILINE) key_match = key_pattern.search(section) if key_match: - new_section = ( - section[: key_match.start()] - + f"{key_match.group(1)}root = {value_str}" - + 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_str}\n" + section return text[:body_start] + new_section + text[body_end:] diff --git a/chipcompiler/data/workspace/inputs.py b/chipcompiler/data/workspace/inputs.py index 614d34263..aff8b65fe 100644 --- a/chipcompiler/data/workspace/inputs.py +++ b/chipcompiler/data/workspace/inputs.py @@ -41,18 +41,11 @@ def persist_origin_inputs( 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}" - 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 - - # Copy filelist and all referenced source files + # 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: @@ -72,6 +65,17 @@ def persist_origin_inputs( 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) diff --git a/chipcompiler/engine/qor_report.py b/chipcompiler/engine/qor_report.py index 9526777f7..936bce501 100644 --- a/chipcompiler/engine/qor_report.py +++ b/chipcompiler/engine/qor_report.py @@ -16,6 +16,7 @@ """ import dataclasses +import math from pathlib import Path from chipcompiler.data import StateEnum, StepEnum @@ -164,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 diff --git a/ecc.spec b/ecc.spec index 528444ce0..f924846f8 100644 --- a/ecc.spec +++ b/ecc.spec @@ -239,6 +239,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 From 4e4e68198c0e002db64e420fb2542ca03cf455e9 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 07:24:58 +0800 Subject: [PATCH 23/47] fix(cli): round-7 correctness fixes across workspace resolution and editing Config overrides reject JSON documents whose root is not an object instead of silently overwriting them with only the patch. Report filenames sanitize design-derived names into safe basenames so a crafted design value cannot escape the signoff directory. Gate status requires every gate step before issuing a pass: partial ledgers report incomplete. pdk setup verifies checkout provenance through the git config remote before running make unzip. The clock skew extractor unpacks the source key from MetricStore.query correctly, so explicit _ns keys keep their units. Table-header discovery runs on masked text so bracket text inside multiline strings is never mistaken for a [table] header, workspace resolution rejects malformed manifests instead of falling through to /default, and the run-input capability check uses isinstance instead of a class-name literal. --- chipcompiler/cli/command_handlers/pdk.py | 16 +++++- chipcompiler/cli/command_handlers/report.py | 15 +++++- chipcompiler/cli/core/invocation.py | 4 +- chipcompiler/cli/inspection/discovery.py | 15 +++++- chipcompiler/cli/project/run_prepare.py | 31 ++++++++++- chipcompiler/cli/project/toml_edit.py | 52 ++++++++++++++++++- .../data/workspace/config_overrides.py | 6 ++- chipcompiler/engine/flow.py | 2 - chipcompiler/engine/qor_report.py | 5 ++ chipcompiler/engine/signoff/report_timing.py | 4 +- test/cli/commands/test_pdk_config.py | 14 ++++- 11 files changed, 150 insertions(+), 14 deletions(-) diff --git a/chipcompiler/cli/command_handlers/pdk.py b/chipcompiler/cli/command_handlers/pdk.py index 14097b898..155250709 100644 --- a/chipcompiler/cli/command_handlers/pdk.py +++ b/chipcompiler/cli/command_handlers/pdk.py @@ -252,7 +252,7 @@ def contents_problem() -> str | None: return CommandResult.err( [error_record("missing_tool", reason="required for setup: make")] ) - if not os.path.isdir(os.path.join(path, ".git")): + if not _is_pdk_checkout(path): # `make unzip` executes repository-provided commands, so run it # only inside a checkout attributable to the PDK repository — # never in an arbitrary directory that merely fails validation. @@ -306,3 +306,17 @@ def contents_problem() -> str | None: "check": "ecc check", } return CommandResult.ok([summary] + action_records) + + +def _is_pdk_checkout(path: str) -> bool: + """Whether `path` looks like an icsprout55-pdk git checkout. + + Provenance heuristic: a .git directory whose config references the PDK + repository name. Read-only — no git subprocess is executed. + """ + git_config = os.path.join(path, ".git", "config") + try: + with open(git_config, encoding="utf-8") as file: + return "icsprout55-pdk" in file.read() + except OSError: + return False diff --git a/chipcompiler/cli/command_handlers/report.py b/chipcompiler/cli/command_handlers/report.py index 714eafd12..2bf2d531f 100644 --- a/chipcompiler/cli/command_handlers/report.py +++ b/chipcompiler/cli/command_handlers/report.py @@ -13,6 +13,17 @@ ) +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 @@ -21,7 +32,9 @@ def _write_report(report_name, default_filename, content, command_input, ctx, ex 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) + 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 diff --git a/chipcompiler/cli/core/invocation.py b/chipcompiler/cli/core/invocation.py index f7b9a773b..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, @@ -118,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 diff --git a/chipcompiler/cli/inspection/discovery.py b/chipcompiler/cli/inspection/discovery.py index 9d28da32b..e8219c6c8 100644 --- a/chipcompiler/cli/inspection/discovery.py +++ b/chipcompiler/cli/inspection/discovery.py @@ -204,8 +204,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/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index c01f1b74c..85335e429 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -118,6 +118,27 @@ 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 @@ -292,11 +313,17 @@ 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. diff --git a/chipcompiler/cli/project/toml_edit.py b/chipcompiler/cli/project/toml_edit.py index cc5f362af..62c6ec7d7 100644 --- a/chipcompiler/cli/project/toml_edit.py +++ b/chipcompiler/cli/project/toml_edit.py @@ -9,14 +9,62 @@ _TABLE_HEADER_RE = re.compile(r"^[ \t]*\[([^\]]+)\][ \t]*(?:#.*)?$", re.MULTILINE) +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): + masked = _mask_strings_and_comments(text) + for m in _TABLE_HEADER_RE.finditer(masked): if m.group(1).strip() == 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 diff --git a/chipcompiler/data/workspace/config_overrides.py b/chipcompiler/data/workspace/config_overrides.py index 04969076b..207aeb3ed 100644 --- a/chipcompiler/data/workspace/config_overrides.py +++ b/chipcompiler/data/workspace/config_overrides.py @@ -59,7 +59,11 @@ def _read_json_strict(path: Path) -> dict: 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 - return data if isinstance(data, dict) else {} + 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 _write_json_strict(path: Path, config: dict) -> None: diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 11a30f56f..a256beac0 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -501,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: diff --git a/chipcompiler/engine/qor_report.py b/chipcompiler/engine/qor_report.py index 936bce501..14d49ba63 100644 --- a/chipcompiler/engine/qor_report.py +++ b/chipcompiler/engine/qor_report.py @@ -323,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" @@ -331,6 +334,8 @@ 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" diff --git a/chipcompiler/engine/signoff/report_timing.py b/chipcompiler/engine/signoff/report_timing.py index 3b5e6454b..152579e79 100644 --- a/chipcompiler/engine/signoff/report_timing.py +++ b/chipcompiler/engine/signoff/report_timing.py @@ -395,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/test/cli/commands/test_pdk_config.py b/test/cli/commands/test_pdk_config.py index cd69628f5..9e3e76e33 100644 --- a/test/cli/commands/test_pdk_config.py +++ b/test/cli/commands/test_pdk_config.py @@ -295,8 +295,11 @@ def test_setup_clones_and_unzips_missing_checkout( 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 + pdk_dir.mkdir(exist_ok=True) # pretend the clone created the checkout (pdk_dir / ".git").mkdir() + (pdk_dir / ".git" / "config").write_text( + '[remote "origin"]\n\turl = https://github.com/ecos-studio/icsprout55-pdk.git\n' + ) return _FakeResult() if cmd[0] == "make": calls["make"].append((cmd, cwd)) @@ -358,6 +361,9 @@ def test_setup_unzip_retries_then_fails( pdk_dir = tmp_path / "stubborn-pdk" pdk_dir.mkdir() (pdk_dir / ".git").mkdir() + (pdk_dir / ".git" / "config").write_text( + '[remote "origin"]\n\turl = https://github.com/ecos-studio/icsprout55-pdk.git\n' + ) make_calls = [] def fake_run(cmd, cwd=None, **kwargs): @@ -388,6 +394,9 @@ def test_setup_unzip_recovers_on_retry( pdk_dir = tmp_path / "flaky-pdk" pdk_dir.mkdir() (pdk_dir / ".git").mkdir() + (pdk_dir / ".git" / "config").write_text( + '[remote "origin"]\n\turl = https://github.com/ecos-studio/icsprout55-pdk.git\n' + ) attempts = {"n": 0} def fake_run(cmd, cwd=None, **kwargs): @@ -420,6 +429,9 @@ def test_setup_forwards_gh_proxy_to_make( pdk_dir = tmp_path / "proxy-pdk" pdk_dir.mkdir() (pdk_dir / ".git").mkdir() + (pdk_dir / ".git" / "config").write_text( + '[remote "origin"]\n\turl = https://github.com/ecos-studio/icsprout55-pdk.git\n' + ) seen = {} def fake_run(cmd, cwd=None, **kwargs): From 2ab312d21789f3b93b3cbb6c3d41f9f861c433dd Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 08:23:56 +0800 Subject: [PATCH 24/47] fix(cli): distinguish LEC failure classes and complete transaction rollbacks A synthesis LEC that runs to completion and reports inequivalence is a non-blocking warning; infrastructure failures (missing binary, spawn error, exception) now stay Incomplete and block the flow, and the fatal error is cleared only on the warning path. When an overwrite run against an undeclared workspace fails before construction, the fresh manifest entry this invocation created is removed instead of shadowing the restored previous workspace. Key assignments in ecc.toml are located on string-masked text so key-like content inside multiline strings is never edited, inline-table keys are escaped and TOML-untypeable values are rejected before any write, and pdk root writes map I/O failures to a structured config_error instead of a traceback. --- chipcompiler/cli/command_handlers/pdk.py | 34 ++++++++++++++++++++-- chipcompiler/cli/project/manifest_write.py | 21 +++++++++++++ chipcompiler/cli/project/run_dispatch.py | 10 ++++++- chipcompiler/cli/project/run_prepare.py | 7 +++++ chipcompiler/cli/project/toml_edit.py | 19 ++++++++---- chipcompiler/engine/flow.py | 15 +++++++--- 6 files changed, 93 insertions(+), 13 deletions(-) diff --git a/chipcompiler/cli/command_handlers/pdk.py b/chipcompiler/cli/command_handlers/pdk.py index 155250709..7f3d188d3 100644 --- a/chipcompiler/cli/command_handlers/pdk.py +++ b/chipcompiler/cli/command_handlers/pdk.py @@ -9,13 +9,37 @@ 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() write_text_atomic(config_path, set_pdk_root(original, value)) +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]: """Return (resolved_root, source) where source names the winning resolver.""" if cfg is not None and cfg.pdk_root: @@ -159,7 +183,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( [ { @@ -296,7 +322,9 @@ def contents_problem() -> str | None: actions.append("unzip") action_records.append({"pdk": "unzip", "status": "extracted", "path": path}) - _write_pdk_root(config_path, path) + error = _write_root_or_error(config_path, path, ctx.project) + if error is not None: + return error summary = { "pdk": "setup", "status": "ready", diff --git a/chipcompiler/cli/project/manifest_write.py b/chipcompiler/cli/project/manifest_write.py index 48fda039e..41fba798a 100644 --- a/chipcompiler/cli/project/manifest_write.py +++ b/chipcompiler/cli/project/manifest_write.py @@ -259,6 +259,27 @@ def mutate(document: dict) -> None: 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"): diff --git a/chipcompiler/cli/project/run_dispatch.py b/chipcompiler/cli/project/run_dispatch.py index 4da804c9b..fc84189a9 100644 --- a/chipcompiler/cli/project/run_dispatch.py +++ b/chipcompiler/cli/project/run_dispatch.py @@ -241,7 +241,11 @@ def existing_workspace_run() -> CommandResult: ) def fresh_run( - *, owns_target: bool, backup_path: str | None = None, ws_locks=None + *, + owns_target: bool, + backup_path: str | None = None, + ws_locks=None, + registration_created: bool = False, ) -> CommandResult: return execute_fresh_run( command_input, @@ -257,6 +261,7 @@ def fresh_run( owns_target=owns_target, backup_path=backup_path, ws_locks=ws_locks, + registration_created=registration_created, execute_flow=execute_flow, ) @@ -295,6 +300,7 @@ def fresh_run( # 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): @@ -344,6 +350,7 @@ def fresh_run( ] ) 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 @@ -353,6 +360,7 @@ def 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_prepare.py b/chipcompiler/cli/project/run_prepare.py index 85335e429..96c77c1f7 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -214,6 +214,7 @@ def execute_fresh_run( 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. @@ -306,6 +307,12 @@ def failed_workspace(reason: str | None) -> CommandResult: # 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) return _workspace_failed_result(run_name, run_dir, reason) from chipcompiler.cli.project.design_inputs import resolve_design_inputs diff --git a/chipcompiler/cli/project/toml_edit.py b/chipcompiler/cli/project/toml_edit.py index 62c6ec7d7..826abd7a2 100644 --- a/chipcompiler/cli/project/toml_edit.py +++ b/chipcompiler/cli/project/toml_edit.py @@ -219,9 +219,12 @@ def format_toml_value(val: object) -> str: 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 @@ -245,8 +248,11 @@ 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] + # Match assignments on the masked body: key-like text inside a multiline + # string must never be edited as if it were a real assignment. + masked_body = _mask_strings_and_comments(section_body) key_pattern = re.compile(rf"^(\s*){re.escape(name)}\s*=[^\n]*$", re.MULTILINE) - key_match = key_pattern.search(section_body) + key_match = key_pattern.search(masked_body) if key_match: indent = key_match.group(1) @@ -270,8 +276,10 @@ def remove_scoped_key(text: str, target_table: str, name: str) -> str | None: section_body = text[body_start:body_end] # 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_pattern = re.compile(rf"^\s*{re.escape(name)}\s*=[^\n]*$", re.MULTILINE) - key_match = key_pattern.search(section_body) + key_match = key_pattern.search(masked_body) if not key_match: return None @@ -302,8 +310,9 @@ def set_pdk_root(text: str, value: str) -> str: body_start, body_end = span section = text[body_start:body_end] + masked_section = _mask_strings_and_comments(section) key_pattern = re.compile(r"^(\s*)root\s*=[^\n]*$", re.MULTILINE) - key_match = key_pattern.search(section) + key_match = key_pattern.search(masked_section) if key_match: # Same value-range logic as set_scoped_key: a multiline value must # be replaced whole, never leaving its tail behind. diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index a256beac0..d7c4e6ebc 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -670,6 +670,9 @@ def run_step( started_at=start_time, ) step_error = execution.error + # An infrastructure failure (missing binary, spawn error, nonzero + # exit) is distinct from a produced-but-failed check result. + tool_failed = step_error is not None elapsed = execution.elapsed_seconds peak_memory_mb = execution.peak_memory_mb runtime = execution.runtime @@ -704,7 +707,14 @@ def run_step( save_layout_image(workspace=self.workspace, step=workspace_step) - if is_non_blocking_step(workspace_step) and state == StateEnum.Imcomplete: + # Only a completed-but-inequivalent LEC check is a non-blocking + # warning. An infrastructure failure (tool_failed) stays + # Incomplete and blocks the flow like any other step. + if ( + is_non_blocking_step(workspace_step) + and state == StateEnum.Imcomplete + and not tool_failed + ): state = StateEnum.Warning # Warning is a terminal completion, not a failure: the # observer must not retain a fatal tool error for it. @@ -768,9 +778,6 @@ def run_step( runtime, peak_memory_mb, ) - if is_non_blocking_step(workspace_step): - state = StateEnum.Warning - step_error = None if flow_step is not None and not self.set_state( name=workspace_step.name, tool=workspace_step.tool, From 70f2041346ac3cec9047a1b928c78ba458391fba Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 09:12:48 +0800 Subject: [PATCH 25/47] fix(engine): terminal-warning discipline and atomic override rollback Only a terminal Warning state continues a flow: the Incomplete + non-blocking-step fallbacks in run_steps, rerun selection, and the progress renderer are gone, so a missing tool or process failure can no longer produce an overall successful run. Workspace run failures write the terminal manifest status while still holding the workspace lock. Overwrite rollback is now checked: removal/restore problems are surfaced in the error reason with the stranded backup path. Config override rollback restores via atomic replaces instead of truncating write_bytes. The coverage policy module moved out of the runtime package into the single test that consumes it, and reconciliation tests derive the canonical chain from the real builder. --- chipcompiler/cli/command_handlers/pdk.py | 4 +- .../cli/command_handlers/project_config.py | 6 +- chipcompiler/cli/project/manifest_write.py | 4 + chipcompiler/cli/project/run_prepare.py | 19 +++- chipcompiler/cli/project/run_workspace.py | 4 +- chipcompiler/cli/rendering/progress.py | 4 +- chipcompiler/data/config_params/coverage.py | 99 ----------------- .../data/workspace/config_overrides.py | 17 ++- chipcompiler/engine/flow.py | 10 +- chipcompiler/engine/rerun.py | 6 +- test/data/test_config_coverage.py | 104 +++++++++++++++++- test/engine/test_reconcile.py | 21 +--- 12 files changed, 153 insertions(+), 145 deletions(-) delete mode 100644 chipcompiler/data/config_params/coverage.py diff --git a/chipcompiler/cli/command_handlers/pdk.py b/chipcompiler/cli/command_handlers/pdk.py index 7f3d188d3..932f2d991 100644 --- a/chipcompiler/cli/command_handlers/pdk.py +++ b/chipcompiler/cli/command_handlers/pdk.py @@ -89,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 = [ { diff --git a/chipcompiler/cli/command_handlers/project_config.py b/chipcompiler/cli/command_handlers/project_config.py index 62d162c05..7f1716c60 100644 --- a/chipcompiler/cli/command_handlers/project_config.py +++ b/chipcompiler/cli/command_handlers/project_config.py @@ -138,16 +138,16 @@ def _change_rtl(args, ctx: CommandContext, *, add: bool) -> CommandResult: def _set_value(config_path: str, field, value: object) -> CommandResult | None: - """Apply one field edit; returns a structured error when the edit fails.""" + """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 _io_error(config_path, exc) + return CommandResult.err([_io_error(config_path, exc)]) try: write_text_atomic(config_path, updated) except OSError as exc: - return _io_error(config_path, exc) + return CommandResult.err([_io_error(config_path, exc)]) return None diff --git a/chipcompiler/cli/project/manifest_write.py b/chipcompiler/cli/project/manifest_write.py index 41fba798a..c1d422dbf 100644 --- a/chipcompiler/cli/project/manifest_write.py +++ b/chipcompiler/cli/project/manifest_write.py @@ -238,6 +238,10 @@ def _update_manifest_locked(path: str, mutator) -> bool: 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: diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index 96c77c1f7..7301cb2e8 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -260,15 +260,22 @@ def terminal_failure() -> bool: restored backup keeps its prior manifest status.""" return committed_state["value"] or backup_path is None - def cleanup_failed_target(): + 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.""" + 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 + 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: - with contextlib.suppress(OSError): + 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: @@ -302,7 +309,9 @@ def cleanup_failed_target(): ) def failed_workspace(reason: str | None) -> CommandResult: - cleanup_failed_target() + rollback_problems = cleanup_failed_target() + if rollback_problems: + reason = f"{reason}; rollback incomplete: {'; '.join(rollback_problems)}" 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. diff --git a/chipcompiler/cli/project/run_workspace.py b/chipcompiler/cli/project/run_workspace.py index 853095a06..d80cb2324 100644 --- a/chipcompiler/cli/project/run_workspace.py +++ b/chipcompiler/cli/project/run_workspace.py @@ -193,7 +193,9 @@ def run_failed(kind: str, reason: str | None = None) -> CommandResult: except Exception as exc: return run_failed("flow_failed", str(exc)) - write_status("success" if result.ok else "failed") + # 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/rendering/progress.py b/chipcompiler/cli/rendering/progress.py index 6bfc33cf7..6c3a46a72 100644 --- a/chipcompiler/cli/rendering/progress.py +++ b/chipcompiler/cli/rendering/progress.py @@ -510,9 +510,7 @@ 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) - ) + is_warning = state == StateEnum.Warning renderer.finish_step( step_token, tool, 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/workspace/config_overrides.py b/chipcompiler/data/workspace/config_overrides.py index 207aeb3ed..39a91fcd5 100644 --- a/chipcompiler/data/workspace/config_overrides.py +++ b/chipcompiler/data/workspace/config_overrides.py @@ -43,11 +43,13 @@ def apply_config_overrides(config_paths: dict[str, Path], parameters: dict) -> N _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: - config_path.write_bytes(original) + _atomic_write_bytes(config_path, original) raise @@ -66,6 +68,19 @@ def _read_json_strict(path: Path) -> dict: 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: diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index d7c4e6ebc..67da25092 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -536,12 +536,10 @@ 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. Only the terminal Warning + # state (a completed LEC reporting inequivalence) + # continues. return False case StateEnum.Warning: self.workspace.logger.warning( diff --git a/chipcompiler/engine/rerun.py b/chipcompiler/engine/rerun.py index 060ebb4b5..fd3ba1fb9 100644 --- a/chipcompiler/engine/rerun.py +++ b/chipcompiler/engine/rerun.py @@ -196,9 +196,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 not in {StateEnum.Success, StateEnum.Warning}: + # A persisted Incomplete blocks the rerun: only a terminal + # Warning may continue. return StepRunResult(ok=False, executed=tuple(executed), failed=workspace_step.name) if state != StateEnum.Success: flow.workspace.logger.warning( 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/engine/test_reconcile.py b/test/engine/test_reconcile.py index d5239a88c..79e21ed51 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"] From e0afa387d543f951df36221287ad2fda6d475620 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 09:48:59 +0800 Subject: [PATCH 26/47] refactor(cli): remove the ecc pdk setup subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-click installer (--with-toolchain) now provisions the PDK, so the clone + make unzip orchestration in pdk setup is a duplicate acquisition path — and the only CLI command doing network downloads. Attaching a PDK stays with ecc pdk set-root, CHIPCOMPILER_ICS55_PDK_ROOT, or the repo default; show/unset are unchanged. Also drop the pdk_root keyword from resolve_pdk_overrides: setup was its only caller. --- chipcompiler/cli/command_handlers/pdk.py | 153 ------------- chipcompiler/cli/commands/pdk.py | 21 -- chipcompiler/cli/core/inputs.py | 7 - chipcompiler/cli/project/config.py | 12 +- chipcompiler/docs/ecc-cli-tutorial.cn.md | 7 +- chipcompiler/docs/ecc-cli-tutorial.en.md | 7 +- chipcompiler/docs/ecc-cli-ug.cn.md | 5 +- chipcompiler/docs/ecc-cli-ug.en.md | 9 +- test/cli/commands/test_pdk_config.py | 274 ----------------------- 9 files changed, 16 insertions(+), 479 deletions(-) diff --git a/chipcompiler/cli/command_handlers/pdk.py b/chipcompiler/cli/command_handlers/pdk.py index 932f2d991..ef22a91e9 100644 --- a/chipcompiler/cli/command_handlers/pdk.py +++ b/chipcompiler/cli/command_handlers/pdk.py @@ -197,156 +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, - resolve_pdk_overrides, - ) - - 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: - # Validate the layout the project actually uses: configured - # [pdk.overrides] content paths resolve against the candidate root - # being set up, not the generic default layout. - overrides = resolve_pdk_overrides(cfg, pdk_root=path) if cfg is not None else None - return _validate_pdk_contents(pdk_name, path, overrides) - - 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")] - ) - if not _is_pdk_checkout(path): - # `make unzip` executes repository-provided commands, so run it - # only inside a checkout attributable to the PDK repository — - # never in an arbitrary directory that merely fails validation. - return CommandResult.err( - [ - error_record( - "invalid_pdk_dir", - path=path, - reason=( - "directory is not an icsprout55-pdk git checkout; " - "point setup at a fresh clone or an existing checkout" - ), - ) - ] - ) - 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}) - - error = _write_root_or_error(config_path, path, ctx.project) - if error is not None: - return error - summary = { - "pdk": "setup", - "status": "ready", - "path": path, - "actions": actions, - "config": "ecc.toml", - "check": "ecc check", - } - return CommandResult.ok([summary] + action_records) - - -def _is_pdk_checkout(path: str) -> bool: - """Whether `path` looks like an icsprout55-pdk git checkout. - - Provenance heuristic: a .git directory whose config references the PDK - repository name. Read-only — no git subprocess is executed. - """ - git_config = os.path.join(path, ".git", "config") - try: - with open(git_config, encoding="utf-8") as file: - return "icsprout55-pdk" in file.read() - except OSError: - return False diff --git a/chipcompiler/cli/commands/pdk.py b/chipcompiler/cli/commands/pdk.py index 702587d04..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, @@ -25,26 +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, - plain: PlainOption = False, -) -> None: - command_input = PdkSetupInput( - output=output_options(plain=plain), - project=project_options(project), - path=path, - ) - _finish("setup", command_input, pdk_handlers.setup) - - @pdk_app.command("set-root") def set_root_cmd( *, diff --git a/chipcompiler/cli/core/inputs.py b/chipcompiler/cli/core/inputs.py index 908211067..637f7407a 100644 --- a/chipcompiler/cli/core/inputs.py +++ b/chipcompiler/cli/core/inputs.py @@ -83,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 diff --git a/chipcompiler/cli/project/config.py b/chipcompiler/cli/project/config.py index e0a488861..b6f03476f 100644 --- a/chipcompiler/cli/project/config.py +++ b/chipcompiler/cli/project/config.py @@ -291,23 +291,19 @@ def _resolve_pdk_root(cfg: ProjectConfig) -> str: def resolve_pdk_overrides( cfg: ProjectConfig, additional_overrides: dict[str, object] | None = None, - *, - pdk_root: str | 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 — - *pdk_root* when given (e.g. a candidate root being validated by - `pdk setup`), otherwise the configured 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) - base_root = _resolve_pdk_root(cfg) if pdk_root is None else pdk_root + base_root = _resolve_pdk_root(cfg) for key, value in resolved.items(): base = base_root if key in PDK_CONTENT_PATH_FIELDS else cfg.project_dir if key in PATH_SCALAR_FIELDS and isinstance(value, str): diff --git a/chipcompiler/docs/ecc-cli-tutorial.cn.md b/chipcompiler/docs/ecc-cli-tutorial.cn.md index 243bfcac9..ff22049f7 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.cn.md +++ b/chipcompiler/docs/ecc-cli-tutorial.cn.md @@ -91,10 +91,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 +139,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. 创建第一个项目 @@ -704,7 +703,7 @@ ecc config --plain # 项目级配置(键值 + 解析后绝对路径) | `[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 提示源码构建 | diff --git a/chipcompiler/docs/ecc-cli-tutorial.en.md b/chipcompiler/docs/ecc-cli-tutorial.en.md index 6451132fa..d746dd7c8 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.en.md +++ b/chipcompiler/docs/ecc-cli-tutorial.en.md @@ -91,10 +91,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 +139,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 @@ -705,7 +704,7 @@ ecc config --plain # project-level config (key=value + resolved absolute pa | `[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 | diff --git a/chipcompiler/docs/ecc-cli-ug.cn.md b/chipcompiler/docs/ecc-cli-ug.cn.md index f316128aa..0ee307c51 100644 --- a/chipcompiler/docs/ecc-cli-ug.cn.md +++ b/chipcompiler/docs/ecc-cli-ug.cn.md @@ -763,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` 和 `--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(目录不存在) diff --git a/chipcompiler/docs/ecc-cli-ug.en.md b/chipcompiler/docs/ecc-cli-ug.en.md index 900e84e99..5b48bab3d 100644 --- a/chipcompiler/docs/ecc-cli-ug.en.md +++ b/chipcompiler/docs/ecc-cli-ug.en.md @@ -805,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 `--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) diff --git a/test/cli/commands/test_pdk_config.py b/test/cli/commands/test_pdk_config.py index 9e3e76e33..d5f207159 100644 --- a/test/cli/commands/test_pdk_config.py +++ b/test/cli/commands/test_pdk_config.py @@ -1,7 +1,5 @@ import os -import pytest - from chipcompiler.cli import main as cli_main @@ -209,275 +207,3 @@ def test_unset_then_show_falls_back_to_env( 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: - @pytest.fixture(autouse=True) - def available_tools(self, monkeypatch): - """Simulate a host with git/make so tests exercise clone/unzip behavior.""" - monkeypatch.setattr("shutil.which", lambda name: f"/usr/bin/{name}") - - def test_setup_missing_tool_fails_before_clone( - self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records - ): - project_dir = create_cli_project(pdk_root="") - monkeypatch.setattr("shutil.which", lambda name: None if name == "git" else name) - - rc = cli_main.run( - ["pdk", "setup", str(tmp_path / "fresh-pdk"), "--project", project_dir, "--plain"] - ) - - record = plain_records(capsys.readouterr().out)[0] - assert rc == 1 - assert record["error"] == "missing_tool" - assert "git" in record["reason"] - - def test_setup_refuses_unrecognized_nonempty_directory( - self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records - ): - project_dir = create_cli_project(pdk_root="") - stranger = tmp_path / "stranger" - stranger.mkdir() - (stranger / "Makefile").write_text("all:\n\techo pwned\n") - - rc = cli_main.run(["pdk", "setup", str(stranger), "--project", project_dir, "--plain"]) - - record = plain_records(capsys.readouterr().out)[0] - assert rc == 1 - assert record["error"] == "invalid_pdk_dir" - assert not list(stranger.iterdir()) or list(stranger.iterdir()) == [stranger / "Makefile"] - - def test_setup_complete_checkout_only_sets_root( - self, - tmp_path, - capsys, - monkeypatch, - create_cli_project, - plain_records, - ): - 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, "--plain"]) - - data = plain_records(capsys.readouterr().out) - assert rc == 0 - assert data[0]["status"] == "ready" - assert data[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, - plain_records, - ): - 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(exist_ok=True) # pretend the clone created the checkout - (pdk_dir / ".git").mkdir() - (pdk_dir / ".git" / "config").write_text( - '[remote "origin"]\n\turl = https://github.com/ecos-studio/icsprout55-pdk.git\n' - ) - 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, "--plain"]) - - data = plain_records(capsys.readouterr().out) - assert rc == 0 - assert data[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, plain_records - ): - 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, "--plain"]) - - record = plain_records(capsys.readouterr().out)[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, - plain_records, - ): - import subprocess as real_subprocess - - project_dir = create_cli_project(pdk_root="") - pdk_dir = tmp_path / "stubborn-pdk" - pdk_dir.mkdir() - (pdk_dir / ".git").mkdir() - (pdk_dir / ".git" / "config").write_text( - '[remote "origin"]\n\turl = https://github.com/ecos-studio/icsprout55-pdk.git\n' - ) - 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, "--plain"]) - - data = plain_records(capsys.readouterr().out) - assert rc == 1 - assert len(make_calls) == 3 # retried three times - assert data[0]["error"] == "unzip_failed" - - def test_setup_unzip_recovers_on_retry( - self, tmp_path, capsys, monkeypatch, create_cli_project, plain_records - ): - import subprocess as real_subprocess - - project_dir = create_cli_project(pdk_root="") - pdk_dir = tmp_path / "flaky-pdk" - pdk_dir.mkdir() - (pdk_dir / ".git").mkdir() - (pdk_dir / ".git" / "config").write_text( - '[remote "origin"]\n\turl = https://github.com/ecos-studio/icsprout55-pdk.git\n' - ) - 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, "--plain"]) - - data = plain_records(capsys.readouterr().out) - assert rc == 0 - assert attempts["n"] == 2 - assert data[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() - (pdk_dir / ".git").mkdir() - (pdk_dir / ".git" / "config").write_text( - '[remote "origin"]\n\turl = https://github.com/ecos-studio/icsprout55-pdk.git\n' - ) - 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, "--plain"]) - - 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, - plain_records, - ): - 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, "--plain"]) - - data = plain_records(capsys.readouterr().out) - assert rc == 0 - assert data[0]["path"] == str(tmp_path / "default-pdk") From a6370ba54518dc6b55b2e71c1a6ec2f593132ac2 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 10:03:36 +0800 Subject: [PATCH 27/47] refactor(cli): drop the dev topic from ecc doc and keep it repo-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ecc-cli-dev.{en,cn}.md document CLI extension development — contributor material, not end-user reference. Move them back to docs/ (linked from development.md), out of the wheel and the PyInstaller bundle, and remove the dev topic from ecc doc. Also restore the devcontainer CHIPCOMPILER_ICS55_PDK_ROOT env line and clean up stale pdk setup references in README, development.md, and cli-design.md that review of ae03f4b1 surfaced. --- .devcontainer/Dockerfile | 1 + README.cn.md | 2 +- README.md | 2 +- chipcompiler/cli/commands/doc.py | 5 +---- chipcompiler/cli/core/docs.py | 1 - chipcompiler/docs/ecc-cli-tutorial.cn.md | 2 +- chipcompiler/docs/ecc-cli-tutorial.en.md | 2 +- chipcompiler/docs/ecc-cli-ug.cn.md | 8 ++++---- chipcompiler/docs/ecc-cli-ug.en.md | 8 ++++---- docs/development.md | 5 ++--- {chipcompiler/docs => docs}/ecc-cli-dev.cn.md | 14 +++++++------- {chipcompiler/docs => docs}/ecc-cli-dev.en.md | 12 ++++++------ docs/index.md | 4 ++-- docs/specification/cli-design.md | 8 ++++---- ecc.spec | 2 -- test/cli/test_doc.py | 3 ++- test/packaging/test_cli_entrypoint.py | 2 +- test/packaging/test_wheel_contents.py | 2 +- 18 files changed, 39 insertions(+), 44 deletions(-) rename {chipcompiler/docs => docs}/ecc-cli-dev.cn.md (95%) rename {chipcompiler/docs => docs}/ecc-cli-dev.en.md (96%) 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/README.cn.md b/README.cn.md index 3daaeb60d..c404f6850 100644 --- a/README.cn.md +++ b/README.cn.md @@ -134,7 +134,7 @@ ecc log --project gcd | `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 signoff` | 检查签核就绪度并导出签核包 | | `ecc report` | 生成设计总结、QoR、签核清单和步骤报告 | | `ecc version` | 显示 ECC 运行时和组件版本 | diff --git a/README.md b/README.md index 8e72e0ea8..aa8379d93 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Run `ecc --help` (or `ecc --help`) for full usage. Common commands: | `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 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 | diff --git a/chipcompiler/cli/commands/doc.py b/chipcompiler/cli/commands/doc.py index 6fc6d1e97..61ef7b4b9 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,9 +22,7 @@ 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/ug/tutorial) in the terminal")(doc_cmd) def doc_cmd( diff --git a/chipcompiler/cli/core/docs.py b/chipcompiler/cli/core/docs.py index f669731af..ff0d57427 100644 --- a/chipcompiler/cli/core/docs.py +++ b/chipcompiler/cli/core/docs.py @@ -9,7 +9,6 @@ "config": "ecc-cli-config", "ug": "ecc-cli-ug", "tutorial": "ecc-cli-tutorial", - "dev": "ecc-cli-dev", } diff --git a/chipcompiler/docs/ecc-cli-tutorial.cn.md b/chipcompiler/docs/ecc-cli-tutorial.cn.md index ff22049f7..eb36e9715 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.cn.md +++ b/chipcompiler/docs/ecc-cli-tutorial.cn.md @@ -714,7 +714,7 @@ ecc config --plain # 项目级配置(键值 + 解析后绝对路径) - 换你自己的设计:改 `ecc.toml` 的 `top`/`rtl`/`clock_port`/`frequency_mhz`,多文件用 [filelist](../../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); +- 全部命令细节见 **[ECC CLI 用户指南](ecc-cli-ug.cn.md)**;CLI 扩展开发见 [ecc-cli-dev.cn.md](../../docs/ecc-cli-dev.cn.md); - 用 Python API 直接编排 flow(`EngineFlow`)见 [examples/gcd/ics55flow.py](../../docs/examples/gcd/ics55flow.py)。 --- diff --git a/chipcompiler/docs/ecc-cli-tutorial.en.md b/chipcompiler/docs/ecc-cli-tutorial.en.md index d746dd7c8..e5c40e4e8 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.en.md +++ b/chipcompiler/docs/ecc-cli-tutorial.en.md @@ -715,7 +715,7 @@ ecc config --plain # project-level config (key=value + resolved absolute pa - 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; - 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); +- 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](../../docs/ecc-cli-dev.en.md); - Driving the flow directly via the Python API (`EngineFlow`): [examples/gcd/ics55flow.py](../../docs/examples/gcd/ics55flow.py). --- diff --git a/chipcompiler/docs/ecc-cli-ug.cn.md b/chipcompiler/docs/ecc-cli-ug.cn.md index 0ee307c51..70fa597a3 100644 --- a/chipcompiler/docs/ecc-cli-ug.cn.md +++ b/chipcompiler/docs/ecc-cli-ug.cn.md @@ -3,7 +3,7 @@ `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) +- 命令扩展开发方式见 [ecc-cli-dev.cn.md](../../docs/ecc-cli-dev.cn.md) - RPC sidecar 协议详见 [workspace-cli.md](../../docs/workspace-cli.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 安装,改源码下次导入即生效);重新运行安装脚本会装回官方发行版,未发布的新行为随之消失,属预期回退。 +> 官方最新 Release(v0.1.0-alpha.11)已包含本文全部命令,含 `doctor`/`signoff`/`report` 与 `run` 的 workspace/范围选择器。当源码领先于最近一次 Release 时(两次发布之间的新行为),按 [ecc-cli-dev.cn.md](../../docs/ecc-cli-dev.cn.md) 的源码开发方式用 `uv run ecc` 即可体验(editable 安装,改源码下次导入即生效);重新运行安装脚本会装回官方发行版,未发布的新行为随之消失,属预期回退。 > 注:`ecc` 的项目定位默认取当前目录(`ecc.toml` 所在处),所以「任意文件夹启动」是常态用法;在其他目录操作项目时加 `--project ` 即可。 @@ -104,7 +104,7 @@ 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 + doc Show a bundled guide (config/ug/tutorial) 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`、`tutorial`;`--lang` 选择 `en`(默认)或 `cn`。 - 终端下渲染输出带高亮并进入分页器翻阅(`$PAGER`,回退到 `less`/`more`;未设置 `LESS` 时默认 `LESS=FRX`,保证 `less` 下颜色生效);管道场景全量直出、不带颜色。 - 非法的主题/语言取值由参数校验拒绝(退出码 2)。 - 管道输出保留 unicode 渲染版式;`--plain` 原样输出原始 markdown,适合脚本处理。 diff --git a/chipcompiler/docs/ecc-cli-ug.en.md b/chipcompiler/docs/ecc-cli-ug.en.md index 5b48bab3d..417cea48a 100644 --- a/chipcompiler/docs/ecc-cli-ug.en.md +++ b/chipcompiler/docs/ecc-cli-ug.en.md @@ -3,7 +3,7 @@ `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) +- For how to extend the CLI with new commands, see [ecc-cli-dev.en.md](../../docs/ecc-cli-dev.en.md) - RPC sidecar protocol: [workspace-cli.md](../../docs/workspace-cli.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. +> 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](../../docs/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. > `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 `. @@ -104,7 +104,7 @@ 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 + doc Show a bundled guide (config/ug/tutorial) 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`, `ug`, `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). diff --git a/docs/development.md b/docs/development.md index bd3a141e8..043a0a776 100644 --- a/docs/development.md +++ b/docs/development.md @@ -275,9 +275,7 @@ 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` / @@ -510,4 +508,5 @@ For Python-level debugging, invoke the same CLI module directly: ## Related Documentation - [Architecture](architecture.md) - System design and patterns +- [ECC CLI Command Extension Developer Guide](ecc-cli-dev.en.md) / [中文](ecc-cli-dev.cn.md) - Adding or modifying CLI commands - [Examples](examples/) - Example projects and CLI usage diff --git a/chipcompiler/docs/ecc-cli-dev.cn.md b/docs/ecc-cli-dev.cn.md similarity index 95% rename from chipcompiler/docs/ecc-cli-dev.cn.md rename to docs/ecc-cli-dev.cn.md index 92ea178a6..187f316ee 100644 --- a/chipcompiler/docs/ecc-cli-dev.cn.md +++ b/docs/ecc-cli-dev.cn.md @@ -2,7 +2,7 @@ 本文面向需要在 `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)(仓库约定)。 +相关文档:[architecture.md](architecture.md)(架构)、[development.md](development.md)(开发工作流)、[workspace-cli.md](workspace-cli.md)(RPC sidecar 协议)、[../CLAUDE.md](../CLAUDE.md)(仓库约定)。 ## 1. 入口与整体结构 @@ -14,7 +14,7 @@ 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) + ├── 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) @@ -25,7 +25,7 @@ chipcompiler/cli/command_handlers/ # 业务处理层(唯一的处理器包, ├── 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 来源解析) + ├── 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 @@ -223,7 +223,7 @@ config_param( ### 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 层。 +`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](workspace-cli.md)。新增方法 = 加一个 `RuntimeMethodSpec` + 对应 API 方法 + 请求模型,无需改 CLI 层。 ### 5.7 扩展项目声明(`ecc project *` / `ecc workspace refresh`) @@ -246,14 +246,14 @@ 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`)。 +回退官方发行版:重新运行 [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 节)。 +- **模块体积**:文件超过约 800 LoC 时新功能放新模块,不要继续堆([../CLAUDE.md](../CLAUDE.md) 第 6 节)。 - **Python 3+**:不用 `__future__`;最低版本看 `pyproject.toml` 的 `requires-python`。 - **测试放置**按所有权边界;优先整对象比较;不为静态定义的值写测试;不为已删除的逻辑保留负向测试。 -- **代码评审**必须执行 [review-guidelines.md](../../docs/review-guidelines.md) 的附加标准。 +- **代码评审**必须执行 [review-guidelines.md](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/docs/ecc-cli-dev.en.md similarity index 96% rename from chipcompiler/docs/ecc-cli-dev.en.md rename to docs/ecc-cli-dev.en.md index ff0398b3e..99dd9bd9b 100644 --- a/chipcompiler/docs/ecc-cli-dev.en.md +++ b/docs/ecc-cli-dev.en.md @@ -2,7 +2,7 @@ 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). +Related documents: [architecture.md](architecture.md) (architecture), [development.md](development.md) (development workflow), [workspace-cli.md](workspace-cli.md) (RPC sidecar protocol), [../CLAUDE.md](../CLAUDE.md) (repository conventions). ## 1. Entry point and overall structure @@ -14,7 +14,7 @@ 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) + ├── 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) @@ -25,7 +25,7 @@ chipcompiler/cli/command_handlers/ # business logic layer (stateful / heavy) ├── 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) + ├── 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 @@ -223,7 +223,7 @@ Project preset sequences are defined in `chipcompiler/rtl2gds/builder.py` (`buil ### 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. +`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](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`) @@ -246,14 +246,14 @@ 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`). +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). +- **Code review** must enforce the additional standards in [review-guidelines.md](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/docs/index.md b/docs/index.md index a53387d2e..0d21e5f73 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,7 +14,7 @@ The `ecc` command-line tool ships bilingual guides (`.en.md` / `.cn.md`): - 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 +- **[CLI Dev Guide](ecc-cli-dev.en.md)** / **[中文开发指南](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`) ## Core Documentation @@ -61,7 +61,7 @@ ChipCompiler supports various EDA file formats. Technical specifications for par - **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) +- **Extend the CLI with new commands** → [CLI Dev Guide](ecc-cli-dev.en.md) - **Use legacy workspace commands** → [Workspace CLI Guide](workspace-cli.md) - **Understand the architecture** → [Architecture](architecture.md) - **Set up development environment** → [Development Guide](development.md) diff --git a/docs/specification/cli-design.md b/docs/specification/cli-design.md index 9c0add752..de2f42a6d 100644 --- a/docs/specification/cli-design.md +++ b/docs/specification/cli-design.md @@ -129,7 +129,7 @@ Current implementation status: | `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 setup/set-root/show/unset` | `--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` | @@ -227,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 | @@ -254,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 | @@ -279,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 diff --git a/ecc.spec b/ecc.spec index f924846f8..e6413a60a 100644 --- a/ecc.spec +++ b/ecc.spec @@ -59,8 +59,6 @@ DOC_GUIDES = ( "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", ) LINUX_RUNTIME_LIBS = ( diff --git a/test/cli/test_doc.py b/test/cli/test_doc.py index b90d8dfe1..abb9cc649 100644 --- a/test/cli/test_doc.py +++ b/test/cli/test_doc.py @@ -15,7 +15,7 @@ def test_guides_root_points_at_repository_docs_in_dev_mode(): assert (guides_root() / "ecc-cli-config.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") @@ -166,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/packaging/test_cli_entrypoint.py b/test/packaging/test_cli_entrypoint.py index 36251e45a..3d32e5339 100644 --- a/test/packaging/test_cli_entrypoint.py +++ b/test/packaging/test_cli_entrypoint.py @@ -43,7 +43,7 @@ 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", "ug", "tutorial"): for lang in ("en", "cn"): assert f"chipcompiler/docs/ecc-cli-{stem}.{lang}.md" in source diff --git a/test/packaging/test_wheel_contents.py b/test/packaging/test_wheel_contents.py index f23dfeff9..1ed2fa758 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", "ug", "tutorial"): for lang in ("en", "cn"): assert f"chipcompiler/docs/ecc-cli-{stem}.{lang}.md" in names From f164dd360d5fb847f51d8ae9bf82fe642f30ff6b Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 10:11:39 +0800 Subject: [PATCH 28/47] docs: rename workspace-cli.md to rpc-guide.md Signed-off-by: Emin --- docs/{workspace-cli.md => rpc-guide.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{workspace-cli.md => rpc-guide.md} (100%) 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 From 584bb9f84076d0acd72c569214079546a7730f6a Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 10:38:34 +0800 Subject: [PATCH 29/47] docs: merge the CLI dev guide into development.md and add a Chinese edition The repo-only ecc-cli-dev.{en,cn}.md become the 'Extending The CLI' section of the development guide; conventions already covered by CLAUDE.md and review-guidelines.md are linked instead of repeated (progressive disclosure). development.cn.md is the merged Chinese counterpart, so contributor docs now have language parity with the user guides. The PyInstaller bundle build instructions move to the README build-from-source section (user-facing), and the stale ~3.6G bundle size is corrected to the measured ~0.9G. Also drop test_workspace_cli_removed.py: it asserted on removed workspace subcommands, which the test guidelines rule out (no negative tests for deleted logic; refresh itself is covered by test_workspace_refresh.py). --- README.cn.md | 25 +- README.md | 25 +- chipcompiler/docs/ecc-cli-tutorial.cn.md | 4 +- chipcompiler/docs/ecc-cli-tutorial.en.md | 4 +- chipcompiler/docs/ecc-cli-ug.cn.md | 8 +- chipcompiler/docs/ecc-cli-ug.en.md | 8 +- docs/development.cn.md | 664 +++++++++++++++++++++++ docs/development.md | 375 ++++++++++++- docs/ecc-cli-dev.cn.md | 259 --------- docs/ecc-cli-dev.en.md | 259 --------- docs/index.md | 17 +- test/cli/test_workspace_cli_removed.py | 35 -- 12 files changed, 1096 insertions(+), 587 deletions(-) create mode 100644 docs/development.cn.md delete mode 100644 docs/ecc-cli-dev.cn.md delete mode 100644 docs/ecc-cli-dev.en.md delete mode 100644 test/cli/test_workspace_cli_removed.py diff --git a/README.cn.md b/README.cn.md index c404f6850..91fa5462c 100644 --- a/README.cn.md +++ b/README.cn.md @@ -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 # 验证应有的命令已列出 +``` ## 快速开始 @@ -167,13 +185,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)。 +欢迎贡献!配置说明请参阅 [开发指南](docs/development.cn.md)。 ## 致谢 diff --git a/README.md b/README.md index aa8379d93..4f1c12086 100644 --- a/README.md +++ b/README.md @@ -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 @@ -170,7 +190,6 @@ rerun (`--resume`, `--from`, `--only`), and parameter overrides — see the - [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 diff --git a/chipcompiler/docs/ecc-cli-tutorial.cn.md b/chipcompiler/docs/ecc-cli-tutorial.cn.md index eb36e9715..09adb3458 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.cn.md +++ b/chipcompiler/docs/ecc-cli-tutorial.cn.md @@ -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 包内 | @@ -714,7 +714,7 @@ ecc config --plain # 项目级配置(键值 + 解析后绝对路径) - 换你自己的设计:改 `ecc.toml` 的 `top`/`rtl`/`clock_port`/`frequency_mhz`,多文件用 [filelist](../../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](../../docs/ecc-cli-dev.cn.md); +- 全部命令细节见 **[ECC CLI 用户指南](ecc-cli-ug.cn.md)**;CLI 扩展开发见 [development.cn.md](../../docs/development.cn.md#扩展-cli); - 用 Python API 直接编排 flow(`EngineFlow`)见 [examples/gcd/ics55flow.py](../../docs/examples/gcd/ics55flow.py)。 --- diff --git a/chipcompiler/docs/ecc-cli-tutorial.en.md b/chipcompiler/docs/ecc-cli-tutorial.en.md index e5c40e4e8..9cd050026 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.en.md +++ b/chipcompiler/docs/ecc-cli-tutorial.en.md @@ -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 | @@ -715,7 +715,7 @@ ecc config --plain # project-level config (key=value + resolved absolute pa - 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; - 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](../../docs/ecc-cli-dev.en.md); +- Full command details in the **[ECC CLI User Guide](ecc-cli-ug.en.md)**; extending the CLI is covered in [development.md](../../docs/development.md#extending-the-cli); - Driving the flow directly via the Python API (`EngineFlow`): [examples/gcd/ics55flow.py](../../docs/examples/gcd/ics55flow.py). --- diff --git a/chipcompiler/docs/ecc-cli-ug.cn.md b/chipcompiler/docs/ecc-cli-ug.cn.md index 70fa597a3..8fe96f381 100644 --- a/chipcompiler/docs/ecc-cli-ug.cn.md +++ b/chipcompiler/docs/ecc-cli-ug.cn.md @@ -3,8 +3,8 @@ `ecc` 是 ECOS Chip Compiler 的项目制命令行入口,覆盖 RTL-to-GDS 流水的建项、校验、运行、状态/日志/配置查询、参数管理、签核与报告。本文基于 `ecc/` 子模块当前源码(v0.1.0-alpha.11)整理,所有示例输出均为真实执行结果(示例中的 run 状态为手工构造的演示数据)。 - 源码位置:[chipcompiler/cli/](../../chipcompiler/cli/) -- 命令扩展开发方式见 [ecc-cli-dev.cn.md](../../docs/ecc-cli-dev.cn.md) -- RPC sidecar 协议详见 [workspace-cli.md](../../docs/workspace-cli.md) +- 命令扩展开发方式见 [development.cn.md](../../docs/development.cn.md#扩展-cli) +- RPC sidecar 协议详见 [rpc-guide.md](../../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](../../docs/ecc-cli-dev.cn.md) 的源码开发方式用 `uv run ecc` 即可体验(editable 安装,改源码下次导入即生效);重新运行安装脚本会装回官方发行版,未发布的新行为随之消失,属预期回退。 +> 官方最新 Release(v0.1.0-alpha.11)已包含本文全部命令,含 `doctor`/`signoff`/`report` 与 `run` 的 workspace/范围选择器。当源码领先于最近一次 Release 时(两次发布之间的新行为),按 [development.cn.md](../../docs/development.cn.md#扩展-cli) 的源码开发方式用 `uv run ecc` 即可体验(editable 安装,改源码下次导入即生效);重新运行安装脚本会装回官方发行版,未发布的新行为随之消失,属预期回退。 > 注:`ecc` 的项目定位默认取当前目录(`ecc.toml` 所在处),所以「任意文件夹启动」是常态用法;在其他目录操作项目时加 `--project ` 即可。 @@ -964,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](../../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-cli-ug.en.md index 417cea48a..112e09b67 100644 --- a/chipcompiler/docs/ecc-cli-ug.en.md +++ b/chipcompiler/docs/ecc-cli-ug.en.md @@ -3,8 +3,8 @@ `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](../../docs/ecc-cli-dev.en.md) -- RPC sidecar protocol: [workspace-cli.md](../../docs/workspace-cli.md) +- For how to extend the CLI with new commands, see [development.md](../../docs/development.md#extending-the-cli) +- RPC sidecar protocol: [rpc-guide.md](../../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](../../docs/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. +> 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 [development.md](../../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 `. @@ -1012,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](../../docs/rpc-guide.md)): ```console → {"jsonrpc":"2.0","method":"rpc.hello","params":{"version":1},"id":"hello-1"} diff --git a/docs/development.cn.md b/docs/development.cn.md new file mode 100644 index 000000000..ee2666fd5 --- /dev/null +++ b/docs/development.cn.md @@ -0,0 +1,664 @@ +# 开发指南 + +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` 阶段(约定式提交检查)。 + +## 测试 + +```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 chipcompiler.data import Workspace, WorkspaceStep, StepEnum + +def build_step(workspace: Workspace, step: StepEnum) -> WorkspaceStep: + return WorkspaceStep(workspace=workspace, step=step, tool="") + +def build_step_space(workspace_step: WorkspaceStep) -> None: + workspace_step.create_directories() + +def build_step_config(workspace_step: WorkspaceStep) -> None: + config = {"input": workspace_step.input_path, "output": workspace_step.output_path} + workspace_step.write_config(config) +``` + +`runner.py`: + +```python +import subprocess +from chipcompiler.data import WorkspaceStep, StateEnum + +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_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 +``` + +`__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 043a0a776..a20cbd4e1 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: @@ -240,6 +245,363 @@ 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 `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). + +### 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: @@ -505,8 +867,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 -- [ECC CLI Command Extension Developer Guide](ecc-cli-dev.en.md) / [中文](ecc-cli-dev.cn.md) - Adding or modifying CLI commands +- [RPC Guide](rpc-guide.md) - RPC sidecar protocol - [Examples](examples/) - Example projects and CLI usage +- [中文开发指南](development.cn.md) diff --git a/docs/ecc-cli-dev.cn.md b/docs/ecc-cli-dev.cn.md deleted file mode 100644 index 187f316ee..000000000 --- a/docs/ecc-cli-dev.cn.md +++ /dev/null @@ -1,259 +0,0 @@ -# ECC CLI 命令扩展开发指南 - -本文面向需要在 `ecc` CLI 中新增/修改命令的开发者,基于 `ecc/` 子模块当前源码(`chipcompiler` 包,v0.1.0-alpha.11)整理。代码路径均相对 `ecc/` 子模块根目录。 - -相关文档:[architecture.md](architecture.md)(架构)、[development.md](development.md)(开发工作流)、[workspace-cli.md](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 子应用(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 --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([...])` 能拿到返回值。 - -## 3. 输出约定(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 渲染器输出模式。 - -## 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 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.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 无需任何定制。 - -### 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, "--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 元组)里登记。 - -运行方式(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](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](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/docs/ecc-cli-dev.en.md b/docs/ecc-cli-dev.en.md deleted file mode 100644 index 99dd9bd9b..000000000 --- a/docs/ecc-cli-dev.en.md +++ /dev/null @@ -1,259 +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](architecture.md) (architecture), [development.md](development.md) (development workflow), [workspace-cli.md](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 (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 §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 --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([...])`. - -## 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. -- 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. - -## 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 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.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. - -### 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, "--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 `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](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](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/docs/index.md b/docs/index.md index 0d21e5f73..698be67dd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,22 +14,16 @@ The `ecc` command-line tool ships bilingual guides (`.en.md` / `.cn.md`): - 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](ecc-cli-dev.en.md)** / **[中文开发指南](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`) +- **[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 @@ -61,9 +55,8 @@ ChipCompiler supports various EDA file formats. Technical specifications for par - **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](ecc-cli-dev.en.md) -- **Use legacy workspace commands** → [Workspace CLI Guide](workspace-cli.md) -- **Understand the architecture** → [Architecture](architecture.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/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}" From b11472370d98068a6b770621d467e02077d6e79e Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 10:39:18 +0800 Subject: [PATCH 30/47] docs: drop architecture.md and fix rpc-guide references Follow-through on the docs cleanup: remove the stale architecture.md, drop its CLAUDE.md pointer, and point all workspace-cli.md references (including the garbled rpc-guide.md.md paths) at docs/rpc-guide.md. --- CLAUDE.md | 2 +- docs/architecture.md | 178 ------------------------------- docs/specification/cli-design.md | 2 +- test/runtime/test_transport.py | 4 +- 4 files changed, 4 insertions(+), 182 deletions(-) delete mode 100644 docs/architecture.md 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/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/specification/cli-design.md b/docs/specification/cli-design.md index de2f42a6d..d678d39c1 100644 --- a/docs/specification/cli-design.md +++ b/docs/specification/cli-design.md @@ -475,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 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): From 95bada253b1ea331871803bc0021b413c3835382 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 10:46:07 +0800 Subject: [PATCH 31/47] docs(guides): make shipped-guide links terminal- and package-friendly Relative ../../ links in the bundled guides are dead weight for the primary audience: ecc doc renders them unclickable in a terminal, and in wheel/PyInstaller installs the targets do not exist. Links to repository files now use absolute github.com/openecos-projects/ecc URLs (pointing at the post-merge main layout), while guide-to-guide references keep their relative links for GitHub and gain an ecc doc hint for terminal readers. --- chipcompiler/docs/ecc-cli-config.cn.md | 12 ++++++------ chipcompiler/docs/ecc-cli-config.en.md | 12 ++++++------ chipcompiler/docs/ecc-cli-tutorial.cn.md | 16 ++++++++-------- chipcompiler/docs/ecc-cli-tutorial.en.md | 16 ++++++++-------- chipcompiler/docs/ecc-cli-ug.cn.md | 10 +++++----- chipcompiler/docs/ecc-cli-ug.en.md | 10 +++++----- 6 files changed, 38 insertions(+), 38 deletions(-) diff --git a/chipcompiler/docs/ecc-cli-config.cn.md b/chipcompiler/docs/ecc-cli-config.cn.md index ed18d7007..dc434a209 100644 --- a/chipcompiler/docs/ecc-cli-config.cn.md +++ b/chipcompiler/docs/ecc-cli-config.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/blob/main/chipcompiler/tools/ecc/configs/))与一次真实的 gcd@ics55 harden 运行。 -- 想了解命令用法 → [ECC CLI 用户指南](ecc-cli-ug.cn.md);从零上手 → [入门教程](ecc-cli-tutorial.cn.md) +- 想了解命令用法 → [ECC CLI 用户指南](ecc-cli-ug.cn.md)(终端:`ecc doc ug`);从零上手 → [入门教程](ecc-cli-tutorial.cn.md)(终端:`ecc doc tutorial`) - 配置查看命令:`ecc config `(列出该步骤实际生效的配置文件);参数查看与修改命令:`ecc param`(见 §1.4) ## 0. 配置体系总览 @@ -61,7 +61,7 @@ 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 | 专属配置 | 说明 | |---|---|---|---| @@ -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)): | 命令 | 作用 | |---|---| @@ -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-cli-ug.cn.md)(终端:`ecc doc ug`)。 ## 2. 公共配置:db_ecc.json diff --git a/chipcompiler/docs/ecc-cli-config.en.md b/chipcompiler/docs/ecc-cli-config.en.md index c4abd482a..e4cf158f3 100644 --- a/chipcompiler/docs/ecc-cli-config.en.md +++ b/chipcompiler/docs/ecc-cli-config.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/blob/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-cli-ug.en.md) (`ecc doc ug`); to get started from scratch, see the [Tutorial](ecc-cli-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,7 +61,7 @@ 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 | |---|---|---|---| @@ -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 | |---|---| @@ -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-cli-ug.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-cli-tutorial.cn.md index 09adb3458..83ca36434 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.cn.md +++ b/chipcompiler/docs/ecc-cli-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+ 文件); @@ -59,7 +59,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.md) 配置 `uv` 工作区: ```bash git clone --recursive https://github.com/openecos-projects/ecc.git @@ -183,7 +183,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 @@ -212,7 +212,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-cli-ug.cn.md#85-project--workspace--编辑项目资源与刷新-workspace)(终端:`ecc doc ug`)): ```bash ecc project set design.top my_chip # 设置一条声明 @@ -592,7 +592,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-cli-ug.cn.md#9-param--参数管理)(终端:`ecc doc ug`)。 `--workspace` 局部设置会把参数所属步骤及其后缀标记为待执行,下一次 `ecc run --workspace exp1` 只重跑这一段——只想微调一个参数时,比 `--overwrite` 整体重建便宜得多。注意只支持已审核参数(`ecc param list --all`),且参数所属步骤必须存在于该 workspace 的 flow 中。 @@ -712,10 +712,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 扩展开发见 [development.cn.md](../../docs/development.cn.md#扩展-cli); -- 用 Python API 直接编排 flow(`EngineFlow`)见 [examples/gcd/ics55flow.py](../../docs/examples/gcd/ics55flow.py)。 +- 全部命令细节见 **[ECC CLI 用户指南](ecc-cli-ug.cn.md)**(终端:`ecc doc ug`);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-cli-tutorial.en.md index 9cd050026..12b5e56df 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.en.md +++ b/chipcompiler/docs/ecc-cli-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); @@ -59,7 +59,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 @@ -184,7 +184,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 @@ -213,7 +213,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-cli-ug.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 @@ -593,7 +593,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-cli-ug.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. @@ -713,10 +713,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 [development.md](../../docs/development.md#extending-the-cli); -- 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-cli-ug.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-cli-ug.cn.md index 8fe96f381..dc58586a0 100644 --- a/chipcompiler/docs/ecc-cli-ug.cn.md +++ b/chipcompiler/docs/ecc-cli-ug.cn.md @@ -2,9 +2,9 @@ `ecc` 是 ECOS Chip Compiler 的项目制命令行入口,覆盖 RTL-to-GDS 流水的建项、校验、运行、状态/日志/配置查询、参数管理、签核与报告。本文基于 `ecc/` 子模块当前源码(v0.1.0-alpha.11)整理,所有示例输出均为真实执行结果(示例中的 run 状态为手工构造的演示数据)。 -- 源码位置:[chipcompiler/cli/](../../chipcompiler/cli/) -- 命令扩展开发方式见 [development.cn.md](../../docs/development.cn.md#扩展-cli) -- RPC sidecar 协议详见 [rpc-guide.md](../../docs/rpc-guide.md) +- 源码位置:[chipcompiler/cli/](https://github.com/openecos-projects/ecc/blob/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 时(两次发布之间的新行为),按 [development.cn.md](../../docs/development.cn.md#扩展-cli) 的源码开发方式用 `uv run ecc` 即可体验(editable 安装,改源码下次导入即生效);重新运行安装脚本会装回官方发行版,未发布的新行为随之消失,属预期回退。 +> 官方最新 Release(v0.1.0-alpha.11)已包含本文全部命令,含 `doctor`/`signoff`/`report` 与 `run` 的 workspace/范围选择器。当源码领先于最近一次 Release 时(两次发布之间的新行为),按 [development.cn.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md#扩展-cli) 的源码开发方式用 `uv run ecc` 即可体验(editable 安装,改源码下次导入即生效);重新运行安装脚本会装回官方发行版,未发布的新行为随之消失,属预期回退。 > 注:`ecc` 的项目定位默认取当前目录(`ecc.toml` 所在处),所以「任意文件夹启动」是常态用法;在其他目录操作项目时加 `--project ` 即可。 @@ -964,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.*` 系列方法。握手与调用示例(完整方法列表和参数见 [rpc-guide.md](../../docs/rpc-guide.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-cli-ug.en.md index 112e09b67..12ea33d94 100644 --- a/chipcompiler/docs/ecc-cli-ug.en.md +++ b/chipcompiler/docs/ecc-cli-ug.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 [development.md](../../docs/development.md#extending-the-cli) -- RPC sidecar protocol: [rpc-guide.md](../../docs/rpc-guide.md) +- Source code: [chipcompiler/cli/](https://github.com/openecos-projects/ecc/blob/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 [development.md](../../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. +> 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 [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 `. @@ -1012,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 [rpc-guide.md](../../docs/rpc-guide.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"} From c28e126a2a072fbb79a1643b5dc765294f578af1 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 10:57:09 +0800 Subject: [PATCH 32/47] docs(guides): rename the bundled guides to self-explanatory stems ecc-cli-config -> ecc-config-ref, ecc-cli-ug -> ecc-user-guide, ecc-cli-tutorial -> ecc-tutorial. ecc doc topics stay config/ug/tutorial (short to type); the command help and the user guide now spell out that ug means user guide. --- chipcompiler/cli/commands/doc.py | 10 ++++++++-- chipcompiler/cli/core/docs.py | 6 +++--- .../{ecc-cli-config.cn.md => ecc-config-ref.cn.md} | 4 ++-- .../{ecc-cli-config.en.md => ecc-config-ref.en.md} | 4 ++-- .../{ecc-cli-tutorial.cn.md => ecc-tutorial.cn.md} | 6 +++--- .../{ecc-cli-tutorial.en.md => ecc-tutorial.en.md} | 6 +++--- .../docs/{ecc-cli-ug.cn.md => ecc-user-guide.cn.md} | 4 ++-- .../docs/{ecc-cli-ug.en.md => ecc-user-guide.en.md} | 4 ++-- docs/index.md | 12 ++++++------ ecc.spec | 12 ++++++------ test/cli/test_doc.py | 12 ++++++------ test/packaging/test_cli_entrypoint.py | 4 ++-- test/packaging/test_wheel_contents.py | 4 ++-- 13 files changed, 47 insertions(+), 41 deletions(-) rename chipcompiler/docs/{ecc-cli-config.cn.md => ecc-config-ref.cn.md} (99%) rename chipcompiler/docs/{ecc-cli-config.en.md => ecc-config-ref.en.md} (99%) rename chipcompiler/docs/{ecc-cli-tutorial.cn.md => ecc-tutorial.cn.md} (98%) rename chipcompiler/docs/{ecc-cli-tutorial.en.md => ecc-tutorial.en.md} (98%) rename chipcompiler/docs/{ecc-cli-ug.cn.md => ecc-user-guide.cn.md} (99%) rename chipcompiler/docs/{ecc-cli-ug.en.md => ecc-user-guide.en.md} (99%) diff --git a/chipcompiler/cli/commands/doc.py b/chipcompiler/cli/commands/doc.py index 61ef7b4b9..2eeeeb031 100644 --- a/chipcompiler/cli/commands/doc.py +++ b/chipcompiler/cli/commands/doc.py @@ -22,11 +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) 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/core/docs.py b/chipcompiler/cli/core/docs.py index ff0d57427..fa837d04f 100644 --- a/chipcompiler/cli/core/docs.py +++ b/chipcompiler/cli/core/docs.py @@ -6,9 +6,9 @@ from pathlib import Path GUIDE_STEMS = { - "config": "ecc-cli-config", - "ug": "ecc-cli-ug", - "tutorial": "ecc-cli-tutorial", + "config": "ecc-config-ref", + "ug": "ecc-user-guide", + "tutorial": "ecc-tutorial", } diff --git a/chipcompiler/docs/ecc-cli-config.cn.md b/chipcompiler/docs/ecc-config-ref.cn.md similarity index 99% rename from chipcompiler/docs/ecc-cli-config.cn.md rename to chipcompiler/docs/ecc-config-ref.cn.md index dc434a209..f8796cfea 100644 --- a/chipcompiler/docs/ecc-cli-config.cn.md +++ b/chipcompiler/docs/ecc-config-ref.cn.md @@ -2,7 +2,7 @@ 本文整理 ECC RTL-to-Harden 流程中**每一步实际使用的工具配置文件、全部参数及其含义**。配置取值与生成逻辑均核对自 v0.1.0-alpha.11 源码(rebase main 之后;模板位于 [chipcompiler/tools/*/configs/](https://github.com/openecos-projects/ecc/blob/main/chipcompiler/tools/ecc/configs/))与一次真实的 gcd@ics55 harden 运行。 -- 想了解命令用法 → [ECC CLI 用户指南](ecc-cli-ug.cn.md)(终端:`ecc doc ug`);从零上手 → [入门教程](ecc-cli-tutorial.cn.md)(终端:`ecc doc tutorial`) +- 想了解命令用法 → [ECC CLI 用户指南](ecc-user-guide.cn.md)(终端:`ecc doc ug`);从零上手 → [入门教程](ecc-tutorial.cn.md)(终端:`ecc doc tutorial`) - 配置查看命令:`ecc config `(列出该步骤实际生效的配置文件);参数查看与修改命令:`ecc param`(见 §1.4) ## 0. 配置体系总览 @@ -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 doc ug`)。 +完整命令输出示例见 [ECC CLI 用户指南 §9](ecc-user-guide.cn.md)(终端:`ecc doc ug`)。 ## 2. 公共配置:db_ecc.json diff --git a/chipcompiler/docs/ecc-cli-config.en.md b/chipcompiler/docs/ecc-config-ref.en.md similarity index 99% rename from chipcompiler/docs/ecc-cli-config.en.md rename to chipcompiler/docs/ecc-config-ref.en.md index e4cf158f3..6815a40d5 100644 --- a/chipcompiler/docs/ecc-cli-config.en.md +++ b/chipcompiler/docs/ecc-config-ref.en.md @@ -2,7 +2,7 @@ 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/blob/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) (`ecc doc ug`); to get started from scratch, see the [Tutorial](ecc-cli-tutorial.en.md) (`ecc doc tutorial`) +- 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 @@ -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) (`ecc doc ug`). +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 98% rename from chipcompiler/docs/ecc-cli-tutorial.cn.md rename to chipcompiler/docs/ecc-tutorial.cn.md index 83ca36434..c185cea9a 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.cn.md +++ b/chipcompiler/docs/ecc-tutorial.cn.md @@ -212,7 +212,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 doc ug`)): +除了用编辑器改 `ecc.toml`,也可以用 `ecc project` 命令组直接改声明(写入 `ecc.toml`,保留注释;详见[用户指南 §8.5](ecc-user-guide.cn.md#85-project--workspace--编辑项目资源与刷新-workspace)(终端:`ecc doc ug`)): ```bash ecc project set design.top my_chip # 设置一条声明 @@ -592,7 +592,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--参数管理)(终端:`ecc doc ug`)。 +常用旧参数:`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`)。 `--workspace` 局部设置会把参数所属步骤及其后缀标记为待执行,下一次 `ecc run --workspace exp1` 只重跑这一段——只想微调一个参数时,比 `--overwrite` 整体重建便宜得多。注意只支持已审核参数(`ecc param list --all`),且参数所属步骤必须存在于该 workspace 的 flow 中。 @@ -714,7 +714,7 @@ ecc config --plain # 项目级配置(键值 + 解析后绝对路径) - 换你自己的设计:改 `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)**(终端:`ecc doc ug`);CLI 扩展开发见 [development.cn.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md#扩展-cli); +- 全部命令细节见 **[ECC CLI 用户指南](ecc-user-guide.cn.md)**(终端:`ecc doc ug`);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 98% rename from chipcompiler/docs/ecc-cli-tutorial.en.md rename to chipcompiler/docs/ecc-tutorial.en.md index 12b5e56df..dec55da0b 100644 --- a/chipcompiler/docs/ecc-cli-tutorial.en.md +++ b/chipcompiler/docs/ecc-tutorial.en.md @@ -213,7 +213,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) (`ecc doc ug`)): +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 @@ -593,7 +593,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) (`ecc doc ug`) 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. @@ -715,7 +715,7 @@ ecc config --plain # project-level config (key=value + resolved absolute pa - 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)** (`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); +- 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 99% rename from chipcompiler/docs/ecc-cli-ug.cn.md rename to chipcompiler/docs/ecc-user-guide.cn.md index dc58586a0..aaf5d930b 100644 --- a/chipcompiler/docs/ecc-cli-ug.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -104,7 +104,7 @@ 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) in the terminal + doc Show a bundled guide (config = config reference, ug = user guide, tutorial) 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`;`--lang` 选择 `en`(默认)或 `cn`。 +- 主题:`config`(配置参考)、`ug`(用户指南,即 user guide)、`tutorial`(教程);`--lang` 选择 `en`(默认)或 `cn`。 - 终端下渲染输出带高亮并进入分页器翻阅(`$PAGER`,回退到 `less`/`more`;未设置 `LESS` 时默认 `LESS=FRX`,保证 `less` 下颜色生效);管道场景全量直出、不带颜色。 - 非法的主题/语言取值由参数校验拒绝(退出码 2)。 - 管道输出保留 unicode 渲染版式;`--plain` 原样输出原始 markdown,适合脚本处理。 diff --git a/chipcompiler/docs/ecc-cli-ug.en.md b/chipcompiler/docs/ecc-user-guide.en.md similarity index 99% rename from chipcompiler/docs/ecc-cli-ug.en.md rename to chipcompiler/docs/ecc-user-guide.en.md index 12ea33d94..972e5b3a0 100644 --- a/chipcompiler/docs/ecc-cli-ug.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -104,7 +104,7 @@ 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) in the terminal + doc Show a bundled guide (config = config reference, ug = user guide, tutorial) 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`; `--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). diff --git a/docs/index.md b/docs/index.md index 698be67dd..72997594c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,14 +6,14 @@ 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 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 @@ -52,9 +52,9 @@ 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) +- **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) diff --git a/ecc.spec b/ecc.spec index e6413a60a..0f1dba9b5 100644 --- a/ecc.spec +++ b/ecc.spec @@ -53,12 +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-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 = ( diff --git a/test/cli/test_doc.py b/test/cli/test_doc.py index abb9cc649..1fefe72dd 100644 --- a/test/cli/test_doc.py +++ b/test/cli/test_doc.py @@ -12,7 +12,7 @@ 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_topics_resolve_in_both_languages(): @@ -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): diff --git a/test/packaging/test_cli_entrypoint.py b/test/packaging/test_cli_entrypoint.py index 3d32e5339..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"): + 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 1ed2fa758..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"): + 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 From 41f34bd501a2d4be55f5c487b13e7e27a1bdf6af Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 10:58:38 +0800 Subject: [PATCH 33/47] feat(cli): list ecc doc third in --help for discoverability Typer lists commands in registration order; registering the doc command right after the two root-level commands puts it at position 3 instead of buried mid-list. The ug guide's help listing is updated to match. --- chipcompiler/cli/app.py | 2 +- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- chipcompiler/docs/ecc-user-guide.en.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/chipcompiler/cli/app.py b/chipcompiler/cli/app.py index 80149cf0a..73bf0e9bd 100644 --- a/chipcompiler/cli/app.py +++ b/chipcompiler/cli/app.py @@ -71,9 +71,9 @@ def layout_image_cmd( raise typer.Exit(1) +register_doc_commands(app) 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/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index aaf5d930b..82eca0342 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -96,6 +96,7 @@ $ ecc --help Commands: version Show ECC runtime, component, and installed tool versions layout-image Render a GDS file into a layout image + doc Show a bundled guide (config = config reference, ug = user guide, tutorial) init Create a new ECC project check Validate the current project setup run Run the configured RTL-to-GDS flow @@ -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 = config reference, ug = user guide, tutorial) param Manage EDA parameters pdk Show and configure the PDK path used by this project project Edit project declarations in ecc.toml diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index 972e5b3a0..341944ea4 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -96,6 +96,7 @@ $ ecc --help Commands: version Show ECC runtime, component, and installed tool versions layout-image Render a GDS file into a layout image + doc Show a bundled guide (config = config reference, ug = user guide, tutorial) init Create a new ECC project check Validate the current project setup run Run the configured RTL-to-GDS flow @@ -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 = config reference, ug = user guide, tutorial) param Manage EDA parameters pdk Show and configure the PDK path used by this project project Edit project declarations in ecc.toml From d4c9548d25a7273067b598b6c01cd75e1473c4bf Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 11:01:34 +0800 Subject: [PATCH 34/47] feat(cli): list ecc doc second in --help, right after version --- chipcompiler/cli/app.py | 4 +++- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- chipcompiler/docs/ecc-user-guide.en.md | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/chipcompiler/cli/app.py b/chipcompiler/cli/app.py index 73bf0e9bd..57b606568 100644 --- a/chipcompiler/cli/app.py +++ b/chipcompiler/cli/app.py @@ -57,6 +57,9 @@ def version_cmd( 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")], @@ -71,7 +74,6 @@ def layout_image_cmd( raise typer.Exit(1) -register_doc_commands(app) register_project_commands(app) register_doctor_commands(app) app.add_typer(param_app, name="param") diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index 82eca0342..31e6cf927 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -95,8 +95,8 @@ uv run ecc --help $ ecc --help Commands: version Show ECC runtime, component, and installed tool versions - layout-image Render a GDS file into a layout image 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 run Run the configured RTL-to-GDS flow diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index 341944ea4..397c2aaf9 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -95,8 +95,8 @@ Command overview: $ ecc --help Commands: version Show ECC runtime, component, and installed tool versions - layout-image Render a GDS file into a layout image 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 run Run the configured RTL-to-GDS flow From 0649bee491bcf7b63e0b8b2c54e4ab0b80ec196b Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 11:04:48 +0800 Subject: [PATCH 35/47] docs(readme): refresh the CLI command table and point readers to ecc doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the missing doc/project/workspace rows, note that the guides ship with the CLI and work offline (ecc doc ug/config/tutorial), and drop the cli-design.md spec links — the design specification is contributor material, not user-facing documentation. --- README.cn.md | 10 ++++++---- README.md | 11 +++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/README.cn.md b/README.cn.md index 91fa5462c..8c38316d7 100644 --- a/README.cn.md +++ b/README.cn.md @@ -146,6 +146,7 @@ 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]` | 显示可用日志或步骤日志内容 | @@ -153,6 +154,8 @@ ecc log --project gcd | `ecc migrate` | 将旧版 `runs/` 项目迁移到 manifest 布局 | | `ecc param` | 管理参数覆盖(`list`、`show`、`set`、`unset`、`diff`) | | `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 运行时和组件版本 | @@ -161,9 +164,9 @@ ecc log --project gcd 项目命令均接受 `--project `(默认为当前目录)。大多数命令支持 `--plain` 输出,便于脚本化。 -完整的命令模型——`ecc.toml` 参考、流程预设、步骤级重跑 -(`--resume`、`--from`、`--only`)和参数覆盖——请参阅 -[CLI 设计规范](docs/specification/cli-design.md)。 +完整指南随 CLI 分发、可离线阅读:`ecc doc ug`(用户指南)、 +`ecc doc config`(配置参考)、`ecc doc tutorial`(从零上手的教程)。 + ## 功能特性 @@ -184,7 +187,6 @@ ecc log --project gcd ## 文档 - [文档索引](docs/index.md) - 完整导航 -- [CLI 设计规范](docs/specification/cli-design.md) - 命令接口和 `ecc.toml` 参考 - [开发指南](docs/development.cn.md) - 配置和工作流 - [示例](docs/examples/) - 使用示例 diff --git a/README.md b/README.md index 4f1c12086..3d551e1a3 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ 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 | @@ -158,6 +159,8 @@ Run `ecc --help` (or `ecc --help`) for full usage. Common commands: | `ecc migrate` | Migrate a legacy `runs/` project to the manifest layout | | `ecc param` | Manage parameter overrides (`list`, `show`, `set`, `unset`, `diff`) | | `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 | @@ -166,9 +169,10 @@ Run `ecc --help` (or `ecc --help`) for full usage. Common commands: Project commands accept `--project ` (defaults to the current directory). Most commands support `--plain` output for scripting. -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). +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). + ## Features @@ -189,7 +193,6 @@ 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 - [Development Guide](docs/development.md) - Setup and workflows - [Examples](docs/examples/) - Usage examples From cc8d93413a2b8d37ef70c41207cd272c52800e12 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 11:11:05 +0800 Subject: [PATCH 36/47] test(cli): pin the root help command order (version, doc, layout-image) --- test/cli/test_typer_cli.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/cli/test_typer_cli.py b/test/cli/test_typer_cli.py index a66dbd908..54efc15e3 100644 --- a/test/cli/test_typer_cli.py +++ b/test/cli/test_typer_cli.py @@ -1,4 +1,5 @@ import json +import re from importlib import metadata import pytest @@ -35,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"]) From a5962b0e33c268260bef4f77b59a6346338d5cbf Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 11:11:06 +0800 Subject: [PATCH 37/47] docs: document the explicit prek install form and point contributors at CONTRIBUTING.md prek install alone already registers both hook stages via the config's default_install_hook_types; the explicit --hook-type ... --overwrite form repairs an existing or partial install. README contributing sections now lead with CONTRIBUTING.md (rules and review expectations) and keep the development guide for setup. --- README.cn.md | 2 +- README.md | 4 +++- docs/development.cn.md | 6 +++++- docs/development.md | 8 +++++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/README.cn.md b/README.cn.md index 8c38316d7..660a60d58 100644 --- a/README.cn.md +++ b/README.cn.md @@ -192,7 +192,7 @@ ecc log --project gcd ## 参与贡献 -欢迎贡献!配置说明请参阅 [开发指南](docs/development.cn.md)。 +欢迎贡献!贡献规则与评审要求见 [CONTRIBUTING.md](CONTRIBUTING.md),环境搭建见 [开发指南](docs/development.cn.md)。 ## 致谢 diff --git a/README.md b/README.md index 3d551e1a3..0aee1b5b8 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,9 @@ The full guides ship with the CLI and work offline: `ecc doc ug` (user guide, ## 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/docs/development.cn.md b/docs/development.cn.md index ee2666fd5..25a58934c 100644 --- a/docs/development.cn.md +++ b/docs/development.cn.md @@ -96,7 +96,11 @@ uv run isort chipcompiler/ test/ uv run prek install ``` -这会注册 `.pre-commit-config.yaml` 中的 `pre-commit` 阶段(ruff lint + ruff format)和 `commit-msg` 阶段(约定式提交检查)。 +这会注册 `.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 +``` ## 测试 diff --git a/docs/development.md b/docs/development.md index a20cbd4e1..423f5cdb0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -107,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 From cd241514a26b937742a1817f48bd1439f9301292 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 11:28:45 +0800 Subject: [PATCH 38/47] docs(guides): use tree URLs for directories and --lang cn in Chinese hints Codex review follow-ups: directory links need tree/main (blob is for files), the ecc doc hints in the Chinese docs must pass --lang cn or they open the English guides, and the Chinese tutorial's development guide link now points at development.cn.md. --- README.cn.md | 4 ++-- chipcompiler/docs/ecc-config-ref.cn.md | 6 +++--- chipcompiler/docs/ecc-config-ref.en.md | 2 +- chipcompiler/docs/ecc-tutorial.cn.md | 8 ++++---- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- chipcompiler/docs/ecc-user-guide.en.md | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.cn.md b/README.cn.md index 660a60d58..9135fd76c 100644 --- a/README.cn.md +++ b/README.cn.md @@ -164,8 +164,8 @@ ecc log --project gcd 项目命令均接受 `--project `(默认为当前目录)。大多数命令支持 `--plain` 输出,便于脚本化。 -完整指南随 CLI 分发、可离线阅读:`ecc doc ug`(用户指南)、 -`ecc doc config`(配置参考)、`ecc doc tutorial`(从零上手的教程)。 +完整指南随 CLI 分发、可离线阅读:`ecc doc ug --lang cn`(用户指南)、 +`ecc doc config --lang cn`(配置参考)、`ecc doc tutorial --lang cn`(从零上手的教程)。 ## 功能特性 diff --git a/chipcompiler/docs/ecc-config-ref.cn.md b/chipcompiler/docs/ecc-config-ref.cn.md index f8796cfea..6691e0e8f 100644 --- a/chipcompiler/docs/ecc-config-ref.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/](https://github.com/openecos-projects/ecc/blob/main/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-user-guide.cn.md)(终端:`ecc doc ug`);从零上手 → [入门教程](ecc-tutorial.cn.md)(终端:`ecc doc tutorial`) +- 想了解命令用法 → [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. 配置体系总览 @@ -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-user-guide.cn.md)(终端:`ecc doc ug`)。 +完整命令输出示例见 [ECC CLI 用户指南 §9](ecc-user-guide.cn.md)(终端:`ecc doc ug --lang cn`)。 ## 2. 公共配置:db_ecc.json diff --git a/chipcompiler/docs/ecc-config-ref.en.md b/chipcompiler/docs/ecc-config-ref.en.md index 6815a40d5..2e5081fa4 100644 --- a/chipcompiler/docs/ecc-config-ref.en.md +++ b/chipcompiler/docs/ecc-config-ref.en.md @@ -1,6 +1,6 @@ # 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/](https://github.com/openecos-projects/ecc/blob/main/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-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) diff --git a/chipcompiler/docs/ecc-tutorial.cn.md b/chipcompiler/docs/ecc-tutorial.cn.md index c185cea9a..749b49d6a 100644 --- a/chipcompiler/docs/ecc-tutorial.cn.md +++ b/chipcompiler/docs/ecc-tutorial.cn.md @@ -59,7 +59,7 @@ export PATH="$HOME/.local/bin:$PATH" ### 2.2 从源码运行(可选) -按 [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.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 @@ -212,7 +212,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-user-guide.cn.md#85-project--workspace--编辑项目资源与刷新-workspace)(终端:`ecc doc ug`)): +除了用编辑器改 `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 # 设置一条声明 @@ -592,7 +592,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-user-guide.cn.md#9-param--参数管理)(终端:`ecc doc ug`)。 +常用旧参数:`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 中。 @@ -714,7 +714,7 @@ ecc config --plain # 项目级配置(键值 + 解析后绝对路径) - 换你自己的设计:改 `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-user-guide.cn.md)**(终端:`ecc doc ug`);CLI 扩展开发见 [development.cn.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md#扩展-cli); +- 全部命令细节见 **[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-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index 31e6cf927..1d416a60b 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -2,7 +2,7 @@ `ecc` 是 ECOS Chip Compiler 的项目制命令行入口,覆盖 RTL-to-GDS 流水的建项、校验、运行、状态/日志/配置查询、参数管理、签核与报告。本文基于 `ecc/` 子模块当前源码(v0.1.0-alpha.11)整理,所有示例输出均为真实执行结果(示例中的 run 状态为手工构造的演示数据)。 -- 源码位置:[chipcompiler/cli/](https://github.com/openecos-projects/ecc/blob/main/chipcompiler/cli/) +- 源码位置:[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) diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index 397c2aaf9..7d282d8c7 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -2,7 +2,7 @@ `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/](https://github.com/openecos-projects/ecc/blob/main/chipcompiler/cli/) +- 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) From 6dafcf34a7a1ae2f0f43a88a4e14c7dd4cc0a1ff Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 11:28:52 +0800 Subject: [PATCH 39/47] feat(signoff): block the export checklist on unproven synthesis LEC results The pre-route LEC checklist item was downgraded to policy=warn so a failed or stale proof only surfaced as an export warning. Drop the downgrade: both LEC steps now record unproven results as failed with policy=block, which blocks signoff package export like any other required check. --- chipcompiler/tools/ecc/signoff_checklist.py | 9 +++------ test/tools/ecc/test_signoff_checklist.py | 12 +++++------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/chipcompiler/tools/ecc/signoff_checklist.py b/chipcompiler/tools/ecc/signoff_checklist.py index 9dcbf062e..7e334ceb9 100644 --- a/chipcompiler/tools/ecc/signoff_checklist.py +++ b/chipcompiler/tools/ecc/signoff_checklist.py @@ -455,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( @@ -475,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, diff --git a/test/tools/ecc/test_signoff_checklist.py b/test/tools/ecc/test_signoff_checklist.py index f9af9ea90..49811eb6d 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): From 8ad56deddc5cbb96fe40332401d38f320b46d73b Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 11:54:53 +0800 Subject: [PATCH 40/47] feat(flow): fail the flow on an unproven synthesis LEC Remove the Warning terminal state and its non-blocking machinery (StateEnum.Warning, is_non_blocking_step, warning rendering, warning run status): an unproven synthesis LEC is persisted as Incomplete and stops the flow like any other failed step. Persisted legacy Warning states are normalized to Unstart on resume and re-selected by rerun selectors, so existing workspaces re-run the LEC instead of skipping it. --- chipcompiler/cli/core/output.py | 1 - chipcompiler/cli/inspection/discovery.py | 4 +- chipcompiler/cli/project/migrate_plan.py | 2 +- chipcompiler/cli/rendering/progress.py | 33 ++---------- chipcompiler/data/__init__.py | 2 - chipcompiler/data/step.py | 19 ++----- chipcompiler/docs/ecc-config-ref.cn.md | 2 +- chipcompiler/docs/ecc-config-ref.en.md | 2 +- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- chipcompiler/docs/ecc-user-guide.en.md | 2 +- chipcompiler/engine/flow.py | 53 +++----------------- chipcompiler/engine/qor_report.py | 3 +- chipcompiler/engine/reconcile.py | 2 +- chipcompiler/engine/rerun.py | 14 +----- test/cli/commands/test_status.py | 20 -------- test/cli/rendering/test_progress.py | 18 ------- test/engine/test_reconcile.py | 8 +-- test/engine/test_state_machine_regression.py | 16 ++++++ test/formal/test_state_machine.py | 12 ++--- test/test_engine_flow.py | 14 +++--- test/test_engine_rerun.py | 27 +++------- test/test_qor_report.py | 4 +- 22 files changed, 65 insertions(+), 195 deletions(-) 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/inspection/discovery.py b/chipcompiler/cli/inspection/discovery.py index e8219c6c8..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. diff --git a/chipcompiler/cli/project/migrate_plan.py b/chipcompiler/cli/project/migrate_plan.py index 3c5942588..d097420c6 100644 --- a/chipcompiler/cli/project/migrate_plan.py +++ b/chipcompiler/cli/project/migrate_plan.py @@ -114,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/rendering/progress.py b/chipcompiler/cli/rendering/progress.py index 6c3a46a72..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,27 +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.Warning - 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/data/__init__.py b/chipcompiler/data/__init__.py index 308f99877..11b3e9b10 100644 --- a/chipcompiler/data/__init__.py +++ b/chipcompiler/data/__init__.py @@ -16,7 +16,6 @@ StepEnum, StepMetrics, is_finished_step_state, - is_non_blocking_step, load_metrics, save_metrics, ) @@ -100,7 +99,6 @@ "EccReport", "FINISHED_STEP_STATES", "is_finished_step_state", - "is_non_blocking_step", "LogPaths", "ScriptPaths", "EccScript", diff --git a/chipcompiler/data/step.py b/chipcompiler/data/step.py index 4a1b41aef..e4bcbd4b5 100644 --- a/chipcompiler/data/step.py +++ b/chipcompiler/data/step.py @@ -39,28 +39,19 @@ 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, StateEnum.Warning.value}) +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. - Warning is a terminal state: a non-blocking check (the synthesis LEC) - that did not prove equivalence still lets the flow continue, so a warned - step must not be re-selected by a plain resume. Incomplete/Invalid steps - are unfinished: resume and rerun selectors re-execute them. + 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/docs/ecc-config-ref.cn.md b/chipcompiler/docs/ecc-config-ref.cn.md index 6691e0e8f..b638c571a 100644 --- a/chipcompiler/docs/ecc-config-ref.cn.md +++ b/chipcompiler/docs/ecc-config-ref.cn.md @@ -66,7 +66,7 @@ graph LR | 步骤 | 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` | | diff --git a/chipcompiler/docs/ecc-config-ref.en.md b/chipcompiler/docs/ecc-config-ref.en.md index 2e5081fa4..c3c790ce1 100644 --- a/chipcompiler/docs/ecc-config-ref.en.md +++ b/chipcompiler/docs/ecc-config-ref.en.md @@ -66,7 +66,7 @@ Distilled from real `ecc config ` output (maps to the source `_STEP_CONFIG | 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` | | diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index 1d416a60b..4008a27ba 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -538,7 +538,7 @@ step=lec tool=yosys_lec status=success runtime=0:0:1 log_cmd="ecc log lec --work ... ``` -run 级状态取全部步骤的聚合:`success / warning / failed / ongoing / unstart`(flow.json 缺失/损坏时为 `missing / corrupt`);步骤级状态为 `success / warning / incomplete / unstart / ongoing / pending / invalid`。综合级 LEC 未证明时为 `warning`,但仍保留 LEC 证据并继续物理流程。 +run 级状态取全部步骤的聚合:`success / failed / ongoing / unstart`(flow.json 缺失/损坏时为 `missing / corrupt`);步骤级状态为 `success / incomplete / unstart / ongoing / pending / invalid`。综合级 LEC 未证明时步骤失败并终止流程。 ## 7. log — 查看日志 diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index 7d282d8c7..264a22874 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -540,7 +540,7 @@ step=lec tool=yosys_lec status=success runtime=0:0:1 log_cmd="ecc log lec --work ... ``` -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 / failed / ongoing / unstart` (`missing / corrupt` when flow.json is absent or damaged); 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 diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 67da25092..cac5652d7 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -13,7 +13,6 @@ Workspace, WorkspaceStep, is_finished_step_state, - is_non_blocking_step, log_flow, ) from chipcompiler.engine import EngineDB @@ -36,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. @@ -54,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(), } @@ -537,16 +532,8 @@ def run_steps( return False case StateEnum.Imcomplete: # An Incomplete step is an infrastructure or check - # failure: it blocks the flow. Only the terminal Warning - # state (a completed LEC reporting inequivalence) - # continues. + # 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: @@ -567,9 +554,9 @@ def _normalize_legacy_terminal_state(self, workspace_step, step_tag): """Reset stuck terminal states from pre-guard workspaces to Unstart. Pre-guard workspaces may have steps stuck in Incomplete/Invalid from - earlier runs. Batch resets (_invalidate_suffix, clear_states) handle - rerun paths; this handles the rerun=False resume path. Warning is - not reset: it is a finished state a plain resume skips. + 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: @@ -578,6 +565,7 @@ def _normalize_legacy_terminal_state(self, workspace_step, step_tag): if persisted in { StateEnum.Imcomplete.value, StateEnum.Invalid.value, + "Warning", }: logger.warning( "Normalizing legacy %s state '%s' → Unstart before rerun", @@ -614,16 +602,6 @@ def run_step( _notify_flow_observer(observer, "on_step_skipped", workspace_step) return StateEnum.Success - if not rerun and self.check_state( - name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Warning - ): - # A warned non-blocking step (synthesis LEC) is terminal: the - # flow continued past it, so a plain resume skips it too. - self.workspace.logger.info("[SKIP] %s finished with a non-blocking warning", step_tag) - self.clear_db_engine_after_step(workspace_step, StateEnum.Warning) - _notify_flow_observer(observer, "on_step_skipped", workspace_step) - return StateEnum.Warning - self._normalize_legacy_terminal_state(workspace_step, step_tag) # set state ongoing @@ -668,9 +646,6 @@ def run_step( started_at=start_time, ) step_error = execution.error - # An infrastructure failure (missing binary, spawn error, nonzero - # exit) is distinct from a produced-but-failed check result. - tool_failed = step_error is not None elapsed = execution.elapsed_seconds peak_memory_mb = execution.peak_memory_mb runtime = execution.runtime @@ -697,27 +672,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) - # Only a completed-but-inequivalent LEC check is a non-blocking - # warning. An infrastructure failure (tool_failed) stays - # Incomplete and blocks the flow like any other step. - if ( - is_non_blocking_step(workspace_step) - and state == StateEnum.Imcomplete - and not tool_failed - ): - state = StateEnum.Warning - # Warning is a terminal completion, not a failure: the - # observer must not retain a fatal tool error for it. - step_error = None - if flow_step is not None and not self.set_state( name=workspace_step.name, tool=workspace_step.tool, diff --git a/chipcompiler/engine/qor_report.py b/chipcompiler/engine/qor_report.py index 14d49ba63..af3c9a59a 100644 --- a/chipcompiler/engine/qor_report.py +++ b/chipcompiler/engine/qor_report.py @@ -342,8 +342,7 @@ def _gate_status(flow_steps_by_label) -> str: def _flow_completion_state(states) -> str: """Classify a workspace's step-state set explicitly. - Warning counts as finished (a non-blocking check warning still lets the - flow continue); only an all-finished ledger completes a flow. + Only an all-Success ledger completes a flow. """ from chipcompiler.data.step import FINISHED_STEP_STATES diff --git a/chipcompiler/engine/reconcile.py b/chipcompiler/engine/reconcile.py index 634563beb..204bc2880 100644 --- a/chipcompiler/engine/reconcile.py +++ b/chipcompiler/engine/reconcile.py @@ -17,7 +17,7 @@ Outcomes: - ``no_op``: persisted == target (or target is a prefix of persisted) and - every step finished (Success, or Warning for a non-blocking check). + 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 diff --git a/chipcompiler/engine/rerun.py b/chipcompiler/engine/rerun.py index fd3ba1fb9..00c425ce8 100644 --- a/chipcompiler/engine/rerun.py +++ b/chipcompiler/engine/rerun.py @@ -18,7 +18,6 @@ Workspace, WorkspaceStep, is_finished_step_state, - is_non_blocking_step, log_flow, ) from chipcompiler.utility.log import redirect_stdio_to_file @@ -196,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}: - # A persisted Incomplete blocks the rerun: only a terminal - # Warning may continue. + 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/test/cli/commands/test_status.py b/test/cli/commands/test_status.py index b9a131491..99f458619 100644 --- a/test/cli/commands/test_status.py +++ b/test/cli/commands/test_status.py @@ -94,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, plain_records - ): - 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, "--plain"]) - assert rc == 0 - records = plain_records(capsys.readouterr().out) - assert records[0]["status"] == "warning" - def test_status_missing_run(self, tmp_path, capsys, create_cli_project): project_dir = create_cli_project() @@ -172,7 +153,6 @@ 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" diff --git a/test/cli/rendering/test_progress.py b/test/cli/rendering/test_progress.py index 8d4230353..e3aa90f5f 100644 --- a/test/cli/rendering/test_progress.py +++ b/test/cli/rendering/test_progress.py @@ -658,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/engine/test_reconcile.py b/test/engine/test_reconcile.py index 79e21ed51..afee02ade 100644 --- a/test/engine/test_reconcile.py +++ b/test/engine/test_reconcile.py @@ -136,9 +136,9 @@ def test_equal_with_non_success_is_resume(self, tmp_path): assert result.outcome == "resume" - def test_equal_with_warned_lec_is_no_op(self, tmp_path): - # Warning is a finished state: a completed flow whose synthesis LEC - # warned must not reconcile to a resume that reruns the suffix. + 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" ) @@ -150,7 +150,7 @@ def test_equal_with_warned_lec_is_no_op(self, tmp_path): result = reconcile_workspace(workspace_dir, {"preset": "rtl2gds"}) - assert result.outcome == "no_op" + assert result.outcome == "resume" def test_target_prefix_keeps_extra_steps(self, tmp_path): workspace_dir = _write_workspace( 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/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 0c9189831..b0ec4d0be 100644 --- a/test/test_engine_rerun.py +++ b/test/test_engine_rerun.py @@ -89,9 +89,9 @@ def test_resume_all_success_selects_nothing(self, tmp_path): assert rerun.selected_step_names(flow) == [] - def test_resume_treats_warning_as_finished(self, tmp_path): - # A warned synthesis LEC is terminal: a plain resume must not - # re-execute it and its physical suffix on every run. + 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, [ @@ -102,13 +102,12 @@ def test_resume_treats_warning_as_finished(self, tmp_path): ], ) - assert rerun.selected_step_names(flow) == [] + assert rerun.selected_step_names(flow) == ["lec", "Floorplan", "CTS"] - def test_only_warning_step_requires_force(self, tmp_path): + 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") == [] - assert rerun.selected_step_names(flow, only="lec", force=True) == ["lec"] + assert rerun.selected_step_names(flow, only="lec") == ["lec"] def test_resume_still_selects_incomplete_suffix(self, tmp_path): flow = _make_run_flow( @@ -173,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, diff --git a/test/test_qor_report.py b/test/test_qor_report.py index fb12947d8..11d0263cc 100644 --- a/test/test_qor_report.py +++ b/test/test_qor_report.py @@ -327,8 +327,8 @@ def test_states_are_derived_explicitly(self): assert _flow_completion_state(["Success", "Incomplete"]) == "failed" assert _flow_completion_state(["Invalid"]) == "failed" assert _flow_completion_state(["Success"] * 5) == "complete" - # Warning is finished: a non-blocking check does not block completion. - assert _flow_completion_state(["Success", "Warning"]) == "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"): From 7e0e1356c8ca5d9449304837669d4c10e8c45772 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 12:58:37 +0800 Subject: [PATCH 41/47] fix(signoff): bind post-route LEC checks to the LVS gate netlist Codex review of the branch surfaced four defects: - The post-route LEC freshness/requirement checks bound the proof to filler_ecc output, but the canonical chain wires postRouteLec's gate input to the LVS output netlist, so a proven proof was judged stale and blocked export. Bind the checklist, collector, and discovery helpers to lvs_ecc/output/_lvs.v.gz and require the LVS step instead of filler for the LEC requirement. - The checklist text report dropped every evidence entry because it only kept plain strings while schema-v3 producers store {kind, path} objects; extract the path from dict entries. - The signoff review grouper matched step 'Route' although the canonical step name is 'route', mis-routing routing checklist items into the Reports group. - Two comments still claimed a warned LEC is finished and skipped, contradicting the Warning-state removal. --- chipcompiler/engine/flow.py | 3 +-- chipcompiler/engine/reconcile.py | 6 ++--- chipcompiler/engine/signoff/collector.py | 6 +++-- chipcompiler/engine/signoff/discovery.py | 4 ++-- .../engine/signoff/report_checklist.py | 13 ++++++++++- chipcompiler/runtime/signoff_export.py | 2 +- chipcompiler/tools/ecc/signoff_checklist.py | 14 ++++++----- test/test_qor_report.py | 4 +++- test/test_signoff_package.py | 15 ++++++------ test/tools/ecc/test_signoff_checklist.py | 23 ++++++++++--------- 10 files changed, 54 insertions(+), 36 deletions(-) diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index cac5652d7..671a386f4 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -437,8 +437,7 @@ def init_db_engine(self) -> bool: # LEC is a netlist comparison step and does not expose an ECC DB # input. Keep any existing DB alive, but do not try to initialize one - # from the Yosys LEC workspace. A warned LEC is finished, so it is - # skipped above and never lands here. + # from the Yosys LEC workspace. if workspace_step is not None and workspace_step.tool == "yosys_lec": return True diff --git a/chipcompiler/engine/reconcile.py b/chipcompiler/engine/reconcile.py index 204bc2880..87fd08f51 100644 --- a/chipcompiler/engine/reconcile.py +++ b/chipcompiler/engine/reconcile.py @@ -234,9 +234,9 @@ 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 finished; a warned - # step is finished (non-blocking check), an unfinished one 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 = { diff --git a/chipcompiler/engine/signoff/collector.py b/chipcompiler/engine/signoff/collector.py index 50c815037..aa34097e1 100644 --- a/chipcompiler/engine/signoff/collector.py +++ b/chipcompiler/engine/signoff/collector.py @@ -126,7 +126,9 @@ def add_file( 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) + # 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: @@ -394,7 +396,7 @@ def add_file( lec_status = lec_result_status( lec_result, golden_verilog=lec_golden, - gate_verilog=filler_verilog, + gate_verilog=lec_gate, ) if lec_result.is_file() and lec_status != "proven": missing_required.append("final/reports/postRouteLec/result.json") diff --git a/chipcompiler/engine/signoff/discovery.py b/chipcompiler/engine/signoff/discovery.py index 2781a7229..67343304f 100644 --- a/chipcompiler/engine/signoff/discovery.py +++ b/chipcompiler/engine/signoff/discovery.py @@ -119,8 +119,8 @@ def _required_step_states(self, *, require_lec: bool) -> dict: def _requires_post_route_lec( self, golden_verilog: Path | None, - filler_verilog: Path | None, + gate_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()) + return bool(gate_verilog and Path(gate_verilog).is_file()) diff --git a/chipcompiler/engine/signoff/report_checklist.py b/chipcompiler/engine/signoff/report_checklist.py index 24f80745f..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 (), ) 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/signoff_checklist.py b/chipcompiler/tools/ecc/signoff_checklist.py index 7e334ceb9..20306a2b9 100644 --- a/chipcompiler/tools/ecc/signoff_checklist.py +++ b/chipcompiler/tools/ecc/signoff_checklist.py @@ -497,22 +497,24 @@ 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 = 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 + 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]: diff --git a/test/test_qor_report.py b/test/test_qor_report.py index 11d0263cc..b3aefadfb 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", 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/tools/ecc/test_signoff_checklist.py b/test/tools/ecc/test_signoff_checklist.py index 49811eb6d..3e7690a8a 100644 --- a/test/tools/ecc/test_signoff_checklist.py +++ b/test/tools/ecc/test_signoff_checklist.py @@ -620,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), @@ -678,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", @@ -734,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" From 1a7fb2240e3269349b719511edbffe040473cbf4 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 13:20:36 +0800 Subject: [PATCH 42/47] docs: align guides with the packaged-tool prerequisites and tool contract Codex review round 2 surfaced three documentation defects: - The installer docs promised a bare glibc 2.34+ host, but the bundle now filters out libfontconfig (ecc.spec filter_host_fontconfig), so DreamPlace placement needs the host's fontconfig; state the prerequisite in the READMEs and tutorials. - The development guides' tool-interface example used signatures the loader never calls (build_step(workspace, step), run_step(workspace_step) -> StateEnum) and referenced the nonexistent StateEnum.Incomplete; rewrite both examples to the real contract (keyword-expanded build_step, run_step(workspace, step, ecc_module=None) -> bool). - development.md described create_flow_json as fabricating runs//home/flow.json although the fixture writes home/flow.json beneath the given workspace directory. --- README.cn.md | 2 +- README.md | 2 +- chipcompiler/docs/ecc-tutorial.cn.md | 2 +- chipcompiler/docs/ecc-tutorial.en.md | 2 +- docs/development.cn.md | 49 +++++++++++++++----------- docs/development.md | 52 ++++++++++++++++------------ 6 files changed, 62 insertions(+), 47 deletions(-) diff --git a/README.cn.md b/README.cn.md index 9135fd76c..388a03041 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 diff --git a/README.md b/README.md index 0aee1b5b8..e669a5b63 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 diff --git a/chipcompiler/docs/ecc-tutorial.cn.md b/chipcompiler/docs/ecc-tutorial.cn.md index 749b49d6a..c2d13c74e 100644 --- a/chipcompiler/docs/ecc-tutorial.cn.md +++ b/chipcompiler/docs/ecc-tutorial.cn.md @@ -37,7 +37,7 @@ graph LR ### 2.1 一键安装(推荐) -使用官方安装脚本安装 `ecc` CLI(Linux x86_64,glibc 2.34+): +使用官方安装脚本安装 `ecc` CLI(Linux x86_64,glibc 2.34+,fontconfig): ```bash curl -fsSL http://release.openecos.com/installers/ecc/latest/ecc-installer.sh | sh diff --git a/chipcompiler/docs/ecc-tutorial.en.md b/chipcompiler/docs/ecc-tutorial.en.md index dec55da0b..9b28b689a 100644 --- a/chipcompiler/docs/ecc-tutorial.en.md +++ b/chipcompiler/docs/ecc-tutorial.en.md @@ -37,7 +37,7 @@ 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: ```bash curl -fsSL http://release.openecos.com/installers/ecc/latest/ecc-installer.sh | sh diff --git a/docs/development.cn.md b/docs/development.cn.md index 25a58934c..0b14b99a0 100644 --- a/docs/development.cn.md +++ b/docs/development.cn.md @@ -129,24 +129,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: + ... # 根据 workspace 参数写出该步骤的配置文件 ``` `runner.py`: ```python import subprocess -from chipcompiler.data import WorkspaceStep, StateEnum + +from chipcompiler.data import Workspace, WorkspaceStep def is_eda_exist() -> bool: try: @@ -155,18 +167,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`: diff --git a/docs/development.md b/docs/development.md index 423f5cdb0..bb23cba1d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -143,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: @@ -169,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`: @@ -438,7 +445,8 @@ are mandatory, the last 2 as needed): ``` - 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`, + (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 From 59e04d0d4aaf5373b92e3eb70847ff18c8c7af25 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 14:22:55 +0800 Subject: [PATCH 43/47] fix(cli,engine): repair legacy-flow compatibility gaps from the chain rework Codex review round 3 surfaced five defects: - Reconcile rejected every pre-reorder ledger (DRC/LVS before filler, RCX/STA/Harden as preset suffixes) as flow_mismatch; migrate them onto the canonical chain by step name instead, keeping the identical Synthesis..route prefix and restarting the post-route suffix. - The rcx/harden presets removed with the chain fold-in broke persisted configs; resolve them as legacy ranges (through sta / through Harden). - load_workspace inferred netlist roles from the golden_ filename prefix, flipping a primary netlist named golden_* to golden on reload; the golden path is now persisted in the flow ledger at creation/first run and trusted on reload, with the filename convention kept only for legacy ledgers. - QoR flow order followed STEP_DIRECTORIES insertion order, which predated the chain rework; the table now follows the canonical chain so area scoring picks the latest scored step in execution order. - README quick-start configs still carried the removed [flow].run key. --- README.cn.md | 1 - README.md | 1 - chipcompiler/cli/project/manifest.py | 4 ++ chipcompiler/data/step_dirs.py | 6 +- chipcompiler/data/workspace/__init__.py | 48 ++++++++++++-- chipcompiler/data/workspace_config.py | 14 +++- chipcompiler/engine/flow.py | 8 ++- chipcompiler/engine/reconcile.py | 87 ++++++++++++++++++++++++- test/data/test_workspace.py | 52 +++++++++++++++ test/engine/test_reconcile.py | 78 ++++++++++++++++++++++ test/test_qor_report.py | 36 ++++++++++ 11 files changed, 320 insertions(+), 15 deletions(-) diff --git a/README.cn.md b/README.cn.md index 388a03041..517153eeb 100644 --- a/README.cn.md +++ b/README.cn.md @@ -125,7 +125,6 @@ root = "/path/to/icsprout55-pdk" [flow] preset = "rtl2gds" # rtl2gds | syn_sta | synthesis_lec -run = "default" ``` 然后校验并运行: diff --git a/README.md b/README.md index e669a5b63..06f227c3e 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,6 @@ root = "/path/to/icsprout55-pdk" [flow] preset = "rtl2gds" # rtl2gds | syn_sta | synthesis_lec -run = "default" ``` Then validate and run: diff --git a/chipcompiler/cli/project/manifest.py b/chipcompiler/cli/project/manifest.py index 01c588b9c..aaf329c85 100644 --- a/chipcompiler/cli/project/manifest.py +++ b/chipcompiler/cli/project/manifest.py @@ -45,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 = { diff --git a/chipcompiler/data/step_dirs.py b/chipcompiler/data/step_dirs.py index 4d247d0f9..19125c287 100644 --- a/chipcompiler/data/step_dirs.py +++ b/chipcompiler/data/step_dirs.py @@ -19,11 +19,11 @@ 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.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 ac1fe40ad..d75f5d3d9 100644 --- a/chipcompiler/data/workspace/__init__.py +++ b/chipcompiler/data/workspace/__init__.py @@ -1163,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" @@ -1235,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(): 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/engine/flow.py b/chipcompiler/engine/flow.py index 671a386f4..9ba5d492f 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -101,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)) diff --git a/chipcompiler/engine/reconcile.py b/chipcompiler/engine/reconcile.py index 87fd08f51..09e3c8ef3 100644 --- a/chipcompiler/engine/reconcile.py +++ b/chipcompiler/engine/reconcile.py @@ -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, @@ -383,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 diff --git a/test/data/test_workspace.py b/test/data/test_workspace.py index a05219c27..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 ): diff --git a/test/engine/test_reconcile.py b/test/engine/test_reconcile.py index afee02ade..51a88a2ea 100644 --- a/test/engine/test_reconcile.py +++ b/test/engine/test_reconcile.py @@ -152,6 +152,84 @@ def test_equal_with_legacy_warned_lec_resumes(self, tmp_path): 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/test_qor_report.py b/test/test_qor_report.py index b3aefadfb..1a6842b21 100644 --- a/test/test_qor_report.py +++ b/test/test_qor_report.py @@ -318,6 +318,42 @@ 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 From 6ec81d7d14ed30d82f77ad7c5b86ccd0fce181a3 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 15:12:43 +0800 Subject: [PATCH 44/47] fix(cli,engine): close review round-4 correctness gaps Codex review round 4 surfaced seven defects: - toml_edit searched unmasked text for the next table header and matched table/assignment keys by raw spelling: header-like text inside multiline strings could become an insertion point, and quoted keys ([params."place"], "timeout" = ...) were duplicated instead of edited. Header and assignment matching now compare semantic keys on the original text at masked-candidate positions. - The reordered-ledger migration's rollback was keyed on 'appended', which is empty when a complete legacy ledger is only reordered; key the rollback on the flow_data snapshot instead. - run_prepare's generic exception path discarded rollback diagnostics and skipped the manifest-registration rollback; it now shares failed_workspace's registration cleanup and reports rollback problems in the failure reason. - QoR scoring consumed analysis metrics for steps no longer successful (invalidation keeps outputs on disk); only Success steps score now. - param set/unset let read/write failures escape as tracebacks; they now return structured config_error records. - The signoff step reconstructor did not apply the persisted info.spef projection for STA-entry workspaces, so refresh and execution saw different step models; it mirrors the execution-side projection now, and the signoff LEC golden precedence matches the engine (synthesis output, then declared golden, then origin RTL). --- chipcompiler/cli/command_handlers/param.py | 14 ++++- chipcompiler/cli/project/run_prepare.py | 21 ++++--- chipcompiler/cli/project/toml_edit.py | 69 +++++++++++++++++---- chipcompiler/engine/qor_report.py | 5 ++ chipcompiler/engine/reconcile.py | 4 +- chipcompiler/engine/signoff/analysis.py | 15 ++++- chipcompiler/engine/signoff/collector.py | 8 ++- chipcompiler/tools/ecc/signoff_checklist.py | 5 ++ test/cli/params/test_toml_editing.py | 39 ++++++++++++ test/test_qor_report.py | 14 +++++ 10 files changed, 169 insertions(+), 25 deletions(-) diff --git a/chipcompiler/cli/command_handlers/param.py b/chipcompiler/cli/command_handlers/param.py index 06e141554..8a56b08fd 100644 --- a/chipcompiler/cli/command_handlers/param.py +++ b/chipcompiler/cli/command_handlers/param.py @@ -208,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( [ @@ -256,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( diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index 7301cb2e8..bed0caf21 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -308,10 +308,7 @@ def cleanup_failed_target() -> list[str]: ] ) - def failed_workspace(reason: str | None) -> CommandResult: - rollback_problems = cleanup_failed_target() - if rollback_problems: - reason = f"{reason}; rollback incomplete: {'; '.join(rollback_problems)}" + 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. @@ -322,6 +319,12 @@ def failed_workspace(reason: str | None) -> CommandResult: 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 @@ -516,9 +519,11 @@ def failed_workspace(reason: str | None) -> CommandResult: except Exception as exc: from chipcompiler.cli.core.records import error_record - cleanup_failed_target() - if terminal_failure() and 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 + [ @@ -526,7 +531,7 @@ def failed_workspace(reason: str | None) -> CommandResult: "flow_failed", workspace_id=run_name, workspace=run_dir, - reason=str(exc), + reason=reason, ) ] ) diff --git a/chipcompiler/cli/project/toml_edit.py b/chipcompiler/cli/project/toml_edit.py index 826abd7a2..d42cbe1cd 100644 --- a/chipcompiler/cli/project/toml_edit.py +++ b/chipcompiler/cli/project/toml_edit.py @@ -8,6 +8,53 @@ _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. @@ -60,7 +107,7 @@ def find_table_span(text: str, table_name: str) -> tuple[int, int] | None: """Return (body_start, body_end) for a TOML table, or None.""" masked = _mask_strings_and_comments(text) for m in _TABLE_HEADER_RE.finditer(masked): - if m.group(1).strip() == table_name: + 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 @@ -240,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:] @@ -249,10 +296,11 @@ 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] # Match assignments on the masked body: key-like text inside a multiline - # string must never be edited as if it were a real assignment. + # 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_pattern = re.compile(rf"^(\s*){re.escape(name)}\s*=[^\n]*$", re.MULTILINE) - key_match = key_pattern.search(masked_body) + key_match = _find_assignment(masked_body, section_body, name) if key_match: indent = key_match.group(1) @@ -278,8 +326,7 @@ def remove_scoped_key(text: str, target_table: str, name: str) -> str | None: # 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_pattern = re.compile(rf"^\s*{re.escape(name)}\s*=[^\n]*$", re.MULTILINE) - key_match = key_pattern.search(masked_body) + key_match = _find_assignment(masked_body, section_body, name) if not key_match: return None @@ -287,9 +334,10 @@ def remove_scoped_key(text: str, target_table: str, name: str) -> str | None: 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: @@ -311,8 +359,7 @@ def set_pdk_root(text: str, value: str) -> str: body_start, body_end = span section = text[body_start:body_end] masked_section = _mask_strings_and_comments(section) - key_pattern = re.compile(r"^(\s*)root\s*=[^\n]*$", re.MULTILINE) - key_match = key_pattern.search(masked_section) + key_match = _find_assignment(masked_section, section, "root") if key_match: # Same value-range logic as set_scoped_key: a multiline value must # be replaced whole, never leaving its tail behind. diff --git a/chipcompiler/engine/qor_report.py b/chipcompiler/engine/qor_report.py index af3c9a59a..9e73ef92e 100644 --- a/chipcompiler/engine/qor_report.py +++ b/chipcompiler/engine/qor_report.py @@ -435,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 diff --git a/chipcompiler/engine/reconcile.py b/chipcompiler/engine/reconcile.py index 09e3c8ef3..3aad977a1 100644 --- a/chipcompiler/engine/reconcile.py +++ b/chipcompiler/engine/reconcile.py @@ -484,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", diff --git a/chipcompiler/engine/signoff/analysis.py b/chipcompiler/engine/signoff/analysis.py index 8fa0ed685..03d2f9fde 100644 --- a/chipcompiler/engine/signoff/analysis.py +++ b/chipcompiler/engine/signoff/analysis.py @@ -10,7 +10,7 @@ import importlib from pathlib import Path -from chipcompiler.data import StateEnum, StepEnum +from chipcompiler.data import EccOutput, StateEnum, StepEnum from chipcompiler.engine.signoff.models import SIGNOFF_REQUIRED_QOR_STEPS, SignoffPackageIssue @@ -98,13 +98,24 @@ def _build_workspace_step(self, flow_step: dict, previous_step): input_def = previous_step.output.def_ input_verilog = previous_step.output.verilog input_db = previous_step.output.db - return build_step( + 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": diff --git a/chipcompiler/engine/signoff/collector.py b/chipcompiler/engine/signoff/collector.py index aa34097e1..ba4fac133 100644 --- a/chipcompiler/engine/signoff/collector.py +++ b/chipcompiler/engine/signoff/collector.py @@ -124,7 +124,13 @@ def add_file( 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) + # 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" diff --git a/chipcompiler/tools/ecc/signoff_checklist.py b/chipcompiler/tools/ecc/signoff_checklist.py index 20306a2b9..25cd09847 100644 --- a/chipcompiler/tools/ecc/signoff_checklist.py +++ b/chipcompiler/tools/ecc/signoff_checklist.py @@ -496,6 +496,9 @@ 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) gate = None workspace_dir = Path(workspace.directory) if getattr(workspace, "directory", None) else None @@ -506,6 +509,8 @@ def _post_route_lec_netlists(workspace: Workspace) -> tuple[Path | None, Path | 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" + else: + golden = getattr(workspace.design, "golden_verilog", None) or golden return golden, gate diff --git a/test/cli/params/test_toml_editing.py b/test/cli/params/test_toml_editing.py index 845eec98b..750f435e7 100644 --- a/test/cli/params/test_toml_editing.py +++ b/test/cli/params/test_toml_editing.py @@ -330,6 +330,45 @@ def test_unknown_key_in_provenance_fails( 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") diff --git a/test/test_qor_report.py b/test/test_qor_report.py index 1a6842b21..bd63e7f44 100644 --- a/test/test_qor_report.py +++ b/test/test_qor_report.py @@ -292,6 +292,20 @@ 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 From 409ff77de9b815ce601f350771faed88a08c8c23 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 15:50:19 +0800 Subject: [PATCH 45/47] fix(cli,docs): close review round-5 correctness gaps Codex review round 5 surfaced five defects: - STA-entry workspaces declare design.spef, but collect_sta_signoff_items only read RCX_ecc output, so a declared parasitics file never reached the STA/Harden runs; flows without an RCX step now use the declared SPEF for every corner. - The user guides claimed release v0.1.0-alpha.11 ships the new command groups; that tag does not exist and alpha.9 predates them, so both guides now state the actual coverage. - The Sizer env-root probe accepted a non-executable file, shadowing a working PATH install; the override now requires the executable bit. - The user guides still claimed ecc check skips single-RTL-source existence; check validates every declared source now. - The test conftest kept PDK skip mappings for the deleted test_harden_flow.py/test_rcx_flow.py modules. --- chipcompiler/docs/ecc-user-guide.cn.md | 6 ++--- chipcompiler/docs/ecc-user-guide.en.md | 6 ++--- chipcompiler/tools/ecc/runner.py | 13 ++++++++++- chipcompiler/tools/ecc_sizer/utility.py | 3 ++- test/conftest.py | 2 -- test/tools/ecc/test_runner.py | 31 +++++++++++++++++++++++++ test/tools/ecc_sizer/test_module.py | 21 +++++++++++++++++ 7 files changed, 72 insertions(+), 10 deletions(-) diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index 4008a27ba..0027f2a26 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -63,7 +63,7 @@ which ecc && ecc --version # 任意目录下应输出 ecc <版本号> # 升级 = 用新包覆盖解压目录内容;方式 B/C 的软链接无需改动 ``` -> 官方最新 Release(v0.1.0-alpha.11)已包含本文全部命令,含 `doctor`/`signoff`/`report` 与 `run` 的 workspace/范围选择器。当源码领先于最近一次 Release 时(两次发布之间的新行为),按 [development.cn.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md#扩展-cli) 的源码开发方式用 `uv run ecc` 即可体验(editable 安装,改源码下次导入即生效);重新运行安装脚本会装回官方发行版,未发布的新行为随之消失,属预期回退。 +> 截至 v0.1.0-alpha.9 的官方 Release 尚未包含 `doctor`/`signoff`/`report` 命令组与 `run` 的 workspace/范围选择器;在新 Release 发布前,按 [development.cn.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md#扩展-cli) 的源码开发方式用 `uv run ecc` 即可体验(editable 安装,改源码下次导入即生效)。重新运行安装脚本会装回官方发行版,未发布的新行为随之消失,属预期回退。 > 注:`ecc` 的项目定位默认取当前目录(`ecc.toml` 所在处),所以「任意文件夹启动」是常态用法;在其他目录操作项目时加 `--project ` 即可。 @@ -211,7 +211,7 @@ preset = "rtl2gds" 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 未就绪时 @@ -280,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 源文件」,**不检查外部工具**。手动逐项确认: | 依赖 | 检查命令 | 就绪标志 | |---|---|---| diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index 264a22874..07be9ee27 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -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 [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. +> Releases up to v0.1.0-alpha.9 predate the `doctor`/`signoff`/`report` command groups and the `run` workspace/range selectors; until a newer release ships, 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 `. @@ -212,7 +212,7 @@ preset = "rtl2gds" 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 @@ -281,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 | |---|---|---| 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_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/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/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_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 From a311d89330a7e00a1a4a1bcad26a25a6fdb01ac7 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 16:08:06 +0800 Subject: [PATCH 46/47] docs(guides): state that the bundled guides and new commands ship with alpha.12 The user guides and tutorials now say the ecc doc guides, the doctor/signoff/report command groups, and the run workspace/range selectors become available with release v0.1.0-alpha.12, with the run-from-source path called out until then. --- chipcompiler/docs/ecc-tutorial.cn.md | 2 ++ chipcompiler/docs/ecc-tutorial.en.md | 2 ++ chipcompiler/docs/ecc-user-guide.cn.md | 2 +- chipcompiler/docs/ecc-user-guide.en.md | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/chipcompiler/docs/ecc-tutorial.cn.md b/chipcompiler/docs/ecc-tutorial.cn.md index c2d13c74e..4dd25483b 100644 --- a/chipcompiler/docs/ecc-tutorial.cn.md +++ b/chipcompiler/docs/ecc-tutorial.cn.md @@ -39,6 +39,8 @@ graph LR 使用官方安装脚本安装 `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 ``` diff --git a/chipcompiler/docs/ecc-tutorial.en.md b/chipcompiler/docs/ecc-tutorial.en.md index 9b28b689a..248040fd0 100644 --- a/chipcompiler/docs/ecc-tutorial.en.md +++ b/chipcompiler/docs/ecc-tutorial.en.md @@ -39,6 +39,8 @@ graph LR 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 ``` diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index 0027f2a26..26f197a19 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -63,7 +63,7 @@ which ecc && ecc --version # 任意目录下应输出 ecc <版本号> # 升级 = 用新包覆盖解压目录内容;方式 B/C 的软链接无需改动 ``` -> 截至 v0.1.0-alpha.9 的官方 Release 尚未包含 `doctor`/`signoff`/`report` 命令组与 `run` 的 workspace/范围选择器;在新 Release 发布前,按 [development.cn.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md#扩展-cli) 的源码开发方式用 `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 ` 即可。 diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index 07be9ee27..6ff87aff6 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -63,7 +63,7 @@ which ecc && ecc --version # from any directory, should print ecc Releases up to v0.1.0-alpha.9 predate the `doctor`/`signoff`/`report` command groups and the `run` workspace/range selectors; until a newer release ships, 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. +> 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 `. From f1f783b1201a0391609ddbfde91bd539bc0c8b9e Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 8 Sep 2026 17:00:27 +0800 Subject: [PATCH 47/47] fix(engine,cli,docs): close review round-6 correctness gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review round 6 surfaced five defects; four are fixed here: - Explicit step execution (ecc run --only lec) initialized a native ECC DB engine for yosys_lec, unlike the guarded batch path; the netlist-only LEC step now skips DB creation there too. - ecc migrate rebased home.json and PDK-config pointers but not the absolute origin/ paths persisted in flow.json step info (golden_verilog, spef), leaving moved workspaces pointing at the old runs/ location; both directions now rebase them. - The tutorials claimed existing-workspace selectors reject aliases, but _require_step_index normalizes them; the notes, examples, and troubleshooting rows now match the code. - The user guides' run-status vocabulary omitted 'partial'. Also: the legacy rcx/harden preset aliases added earlier only resolved in the workspace layer — ecc check/run validate against the builder set and still rejected them, so the validators now include the alias ranges. Deferred on request: runtime/requests.py workspace.create not accepting golden_verilog/spef inputs. --- chipcompiler/cli/project/config.py | 3 ++- chipcompiler/cli/project/migrate.py | 4 ++- chipcompiler/cli/project/migrate_fs.py | 37 ++++++++++++++++++++++++++ chipcompiler/docs/ecc-tutorial.cn.md | 8 +++--- chipcompiler/docs/ecc-tutorial.en.md | 8 +++--- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- chipcompiler/docs/ecc-user-guide.en.md | 2 +- chipcompiler/engine/flow.py | 5 ++++ test/cli/commands/test_check.py | 16 +++++++++++ test/cli/commands/test_migrate.py | 33 +++++++++++++++++++++++ test/test_engine_rerun.py | 17 ++++++++++++ 11 files changed, 123 insertions(+), 12 deletions(-) diff --git a/chipcompiler/cli/project/config.py b/chipcompiler/cli/project/config.py index b6f03476f..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]: diff --git a/chipcompiler/cli/project/migrate.py b/chipcompiler/cli/project/migrate.py index 0df223cb8..c1267fadc 100644 --- a/chipcompiler/cli/project/migrate.py +++ b/chipcompiler/cli/project/migrate.py @@ -496,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/docs/ecc-tutorial.cn.md b/chipcompiler/docs/ecc-tutorial.cn.md index 4dd25483b..dc843b356 100644 --- a/chipcompiler/docs/ecc-tutorial.cn.md +++ b/chipcompiler/docs/ecc-tutorial.cn.md @@ -676,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 > ``` @@ -701,7 +701,7 @@ 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` 排查失败步骤后重跑 | diff --git a/chipcompiler/docs/ecc-tutorial.en.md b/chipcompiler/docs/ecc-tutorial.en.md index 248040fd0..89f2d70e3 100644 --- a/chipcompiler/docs/ecc-tutorial.en.md +++ b/chipcompiler/docs/ecc-tutorial.en.md @@ -677,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 > ``` @@ -702,7 +702,7 @@ 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 | diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index 26f197a19..4cc5c1a1a 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -538,7 +538,7 @@ step=lec tool=yosys_lec status=success runtime=0:0:1 log_cmd="ecc log lec --work ... ``` -run 级状态取全部步骤的聚合:`success / failed / ongoing / unstart`(flow.json 缺失/损坏时为 `missing / corrupt`);步骤级状态为 `success / incomplete / unstart / ongoing / pending / invalid`。综合级 LEC 未证明时步骤失败并终止流程。 +run 级状态取全部步骤的聚合:`success / partial / failed / ongoing / unstart`(flow.json 缺失/损坏时为 `missing / corrupt`;`partial` 表示范围重跑后成功与未开始步骤并存);步骤级状态为 `success / incomplete / unstart / ongoing / pending / invalid`。综合级 LEC 未证明时步骤失败并终止流程。 ## 7. log — 查看日志 diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index 6ff87aff6..16abb4c33 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -540,7 +540,7 @@ step=lec tool=yosys_lec status=success runtime=0:0:1 log_cmd="ecc log lec --work ... ``` -The run-level status aggregates all steps: `success / failed / ongoing / unstart` (`missing / corrupt` when flow.json is absent or damaged); the step-level states are `success / incomplete / unstart / ongoing / pending / invalid`. An unproven synthesis-level LEC fails the step and stops the flow. +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 diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 9ba5d492f..1f61036f8 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -791,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/test/cli/commands/test_check.py b/test/cli/commands/test_check.py index 7c7db299e..0b247bedf 100644 --- a/test/cli/commands/test_check.py +++ b/test/cli/commands/test_check.py @@ -148,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") diff --git a/test/cli/commands/test_migrate.py b/test/cli/commands/test_migrate.py index 99e093f52..2bd767a07 100644 --- a/test/cli/commands/test_migrate.py +++ b/test/cli/commands/test_migrate.py @@ -380,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 diff --git a/test/test_engine_rerun.py b/test/test_engine_rerun.py index b0ec4d0be..b7248232b 100644 --- a/test/test_engine_rerun.py +++ b/test/test_engine_rerun.py @@ -415,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):