Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
78e9727
refactor(data): split step types into data/types.py and merge step_di…
Emin017 Sep 14, 2026
6a00536
feat(data): split skippable steps into SkippableStepEnum
Emin017 Sep 14, 2026
09bc32a
feat(rtl2gds): resolve skip policy and filter ledgers at build time
Emin017 Sep 14, 2026
8cb936c
feat(cli): carry skip_steps through the configuration surfaces
Emin017 Sep 14, 2026
981c7e2
feat(data): validate skip policy before any creation mutation
Emin017 Sep 14, 2026
268f9a5
feat(engine): policy-driven reconcile targets and preset conflict error
Emin017 Sep 14, 2026
1cc7d8e
feat(signoff): gate required steps on ledger membership and preflight…
Emin017 Sep 14, 2026
ac923fc
test(data): ledger chaining behavior for skipped steps
Emin017 Sep 14, 2026
f4caeba
docs(flow): document skippable steps, precedence chain, and LEC default
Emin017 Sep 14, 2026
9447d0f
fix(flow): address plan-review findings for skippable steps
Emin017 Sep 14, 2026
de05150
refactor: address plan-review round 2 findings
Emin017 Sep 14, 2026
b1dca46
fix(cli): close plan-review round 3 findings
Emin017 Sep 14, 2026
18e74a7
fix(cli): resolve ledgers before mutation and honor manifest skip pol…
Emin017 Sep 14, 2026
2536557
docs(index): update rtl2gds flow step count and default LEC skip
Emin017 Sep 14, 2026
cc54105
refactor: address whole-branch review findings
Emin017 Sep 14, 2026
1616cde
fix(cli): address whole-branch review round 2 findings
Emin017 Sep 14, 2026
1d552c7
fix(engine): policy-aware migration contiguity and name-based target …
Emin017 Sep 14, 2026
46db65b
fix(cli): explicit empty skip policy is authoritative for migration c…
Emin017 Sep 14, 2026
efd39cd
fix: adapt main-side code to skippable step enums and step module con…
Emin017 Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion chipcompiler/analysis/qor/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from chipcompiler.analysis.qor.metric_registry import SCORED_STEP_VALUES
from chipcompiler.data import StateEnum, StepEnum
from chipcompiler.data.step_dirs import STEP_DIRECTORIES
from chipcompiler.data.step import STEP_DIRECTORIES
from chipcompiler.tools.ecc.sta_qor import (
POST_SYNTHESIS_STA_CORNER,
STA_POWER_SUMMARY_FILENAME,
Expand Down
84 changes: 74 additions & 10 deletions chipcompiler/cli/command_handlers/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ def init(command_input: InitInput, ctx: CommandContext) -> CommandResult:
[flow]
# preset: rtl2gds | syn_sta | synthesis_lec
preset = "rtl2gds"
# LEC is skipped by default; clear the list to enable it.
skip_steps = ["lec"]
"""

with open(config_path, "w") as f:
Expand Down Expand Up @@ -189,14 +191,21 @@ def check(command_input: CheckInput, ctx: CommandContext) -> CommandResult:
return CommandResult.ok(records)


def _preflight_environment(preset: str | None, project: str | None) -> CommandResult | None:
def _preflight_environment(
preset: str | None, project: str | None, flow_config: dict | None = None
) -> CommandResult | None:
"""Fail fast when the tools a fresh flow target needs are missing.

The probe set comes from the preset's builder output filtered by the
effective skip policy, so a skipped step's tool is never probed.

None means ready.
"""
from chipcompiler.cli.inspection import env_probe
from chipcompiler.rtl2gds import resolve_skip_steps

probes = env_probe.probe_environment(env_probe.probe_components_for_preset(preset))
skip = resolve_skip_steps(flow_config)
probes = env_probe.probe_environment(env_probe.probe_components_for_preset(preset, skip=skip))
return _preflight_failures(probes, project, preset)


Expand All @@ -205,13 +214,18 @@ def _preflight_flow_range(flow_config: dict, project: str | None) -> CommandResu

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.
creation — not a discovery made mid-creation. The range is sliced
from the policy-filtered chain, matching ledger creation.
"""
from chipcompiler.cli.inspection import env_probe
from chipcompiler.rtl2gds import build_flow_range
from chipcompiler.rtl2gds import build_flow_range, resolve_skip_steps

try:
steps = build_flow_range(flow_config["start_step"], flow_config["end_step"])
steps = build_flow_range(
flow_config["start_step"],
flow_config["end_step"],
skip=resolve_skip_steps(flow_config),
)
except ValueError:
# Range spellings are validated where they are declared (CLI ranges
# during argument handling, manifest ranges at load time); an
Expand Down Expand Up @@ -356,13 +370,25 @@ def error(kind: str, **fields) -> CommandResult:
)

from chipcompiler.cli.project import effective_config
from chipcompiler.cli.project.effective_config import flow_config_selects_steps

if ctx.project_state == "manifest":
resolved_cfg = effective_config.resolve_effective_config(ctx, command_input.workspace, cfg)
if isinstance(resolved_cfg, CommandResult):
return resolved_cfg
cfg, flow_config, entry_warnings = resolved_cfg
layer_warnings.extend(entry_warnings)
else:
# Virgin projects have no manifest layer; an ecc.toml skip policy
# still rides on the flow config (policy-only when no range applies).
from chipcompiler.cli.project.effective_config import _attach_skip_steps

skip_steps = (
list(cfg.flow_skip_steps)
if "flow.skip_steps" in cfg._explicit_keys and cfg.flow_skip_steps is not None
else None
)
flow_config = _attach_skip_steps(flow_config, skip_steps)

flow_builders = rtl2gds_api.get_flow_builders()
effective_preset = command_input.preset or cfg.flow_preset
Expand All @@ -377,24 +403,32 @@ def error(kind: str, **fields) -> CommandResult:
)
]
)
# The resolved skip policy survives every target override below (the
# policy comes from configuration, never from the target spelling).
skip_policy = flow_config.get("skip_steps") if isinstance(flow_config, dict) else None
if command_input.preset is not None:
# The explicit CLI selection takes precedence over a manifest range
# for this invocation without changing either project config file.
cfg.flow_preset = effective_preset
cfg.manifest_driven = False
flow_config = None
flow_config = {"skip_steps": skip_policy} if skip_policy is not None else None

if command_input.from_step is not None and command_input.to_step is not None:
try:
from chipcompiler.rtl2gds import build_flow_range
from chipcompiler.rtl2gds import build_flow_range, resolve_skip_steps

build_flow_range(command_input.from_step, command_input.to_step)
skip = resolve_skip_steps(
{"skip_steps": skip_policy} if skip_policy is not None else None
)
build_flow_range(command_input.from_step, command_input.to_step, skip=skip)
except ValueError as exc:
return error("flow_range_invalid", reason=str(exc))
flow_config = {
"start_step": command_input.from_step,
"end_step": command_input.to_step,
}
if skip_policy is not None:
flow_config["skip_steps"] = skip_policy

cli_overrides = {}
raw_sets = command_input.param_set
Expand Down Expand Up @@ -480,6 +514,27 @@ def error(kind: str, **fields) -> CommandResult:
]
)

# A creation-time (fresh/overwrite only) configuration conflict: the
# synthesis_lec preset exists to run the LEC the effective policy skips.
# Existing ledgers are never re-filtered, so resume/rerun is unaffected.
if (
fresh_target
and effective_preset == "synthesis_lec"
and "lec" in resolve_skip_steps_for_flow_config(flow_config)
):
return CommandResult.err(
[
{
"kind": "error",
"error": "config_error",
"reason": (
"the synthesis_lec preset conflicts with the effective skip_steps "
"policy (lec is skipped); set skip_steps = [] to enable it"
),
}
]
)

protected = (project_dir, os.path.join(project_dir, "runs"))
spelled = {os.path.normpath(p) for p in protected}
canonical = {os.path.realpath(p) for p in protected}
Expand All @@ -503,10 +558,10 @@ def error(kind: str, **fields) -> CommandResult:
# 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:
if flow_config_selects_steps(flow_config):
preflight = _preflight_flow_range(flow_config, project)
elif effective_preset:
preflight = _preflight_environment(effective_preset, project)
preflight = _preflight_environment(effective_preset, project, flow_config)
else:
preflight = None
if preflight is not None:
Expand Down Expand Up @@ -541,6 +596,15 @@ def _config_error_code(reason: str) -> str:
return "config_error"


def resolve_skip_steps_for_flow_config(flow_config) -> tuple[str, ...]:
"""The effective skip policy of a creation flow config (default when none)."""
from chipcompiler.rtl2gds import resolve_skip_steps

return resolve_skip_steps(
flow_config if isinstance(flow_config, dict) and "skip_steps" in flow_config else None
)


def _run_workspace(command_input: RunInput, ctx: CommandContext) -> CommandResult:
def error(kind: str, **fields) -> CommandResult:
return CommandResult.err([{"kind": "error", "error": kind, **fields}])
Expand Down
20 changes: 20 additions & 0 deletions chipcompiler/cli/inspection/config_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,26 @@ def source_of(dotted: str) -> str:
else:
entries.append(("flow.preset", cfg.flow_preset, cfg.flow_preset, source_of("flow.preset")))

# Effective skip policy: the declared spelling with its winning layer,
# or the code default when no surface declares the key.
declared = None
source = "ecc.toml"
winner = getattr(cfg, "_skip_steps_source", None)
if resolved is not None and isinstance(flow_config, dict) and "skip_steps" in flow_config:
declared = list(flow_config["skip_steps"])
# The winner was resolved by effective-config: manifest entry beats
# ecc.toml for this key; fall back to layer presence for older
# call paths that did not record it.
source = winner or ("project.json" if "flow.skip_steps" not in explicit else "ecc.toml")
elif "flow.skip_steps" in explicit:
declared = list(cfg.flow_skip_steps or [])
if declared is None:
from chipcompiler.data import DEFAULT_SKIP_STEPS

entries.append(("flow.skip_steps", None, list(DEFAULT_SKIP_STEPS), "default"))
else:
entries.append(("flow.skip_steps", declared, declared, source))

inspect = disclosure_cmd("ecc config", project, run_id)

for key, value, resolved, source in entries:
Expand Down
8 changes: 5 additions & 3 deletions chipcompiler/cli/inspection/env_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,16 +201,18 @@ def probe_environment(components, *, cfg=None, include_slang=True) -> list[Probe
}


def probe_components_for_preset(preset: str) -> tuple[str, ...]:
def probe_components_for_preset(preset: str, *, skip: tuple[str, ...] = ()) -> tuple[str, ...]:
"""Components a flow preset needs at minimum before it can start.

The PDK is not probed here: `ecc run` already validates it through
validate_project_config, and the slang check is left to the synthesis
step's existing fail-fast so preflight stays fast.
step's existing fail-fast so preflight stays fast. Skipped steps are
filtered out first, so their tools are never probed.
"""
from chipcompiler import rtl2gds as rtl2gds_api

return probe_components_for_steps(rtl2gds_api.get_flow_builders()[preset]())
steps = rtl2gds_api.filter_flow_steps(rtl2gds_api.get_flow_builders()[preset](), skip)
return probe_components_for_steps(steps)


def probe_components_for_steps(steps) -> tuple[str, ...]:
Expand Down
20 changes: 20 additions & 0 deletions chipcompiler/cli/project/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class ProjectConfig:
pdk_overrides: dict[str, object] = field(default_factory=dict)

flow_preset: str = ""
# Declared [flow] skip_steps spelling; None when the key is absent (the
# code default then applies). The manifest's per-workspace value wins
# over this one for this key only.
flow_skip_steps: list[str] | None = None
config_path: str = ""
project_dir: str = ""

Expand All @@ -46,6 +50,9 @@ class ProjectConfig:
# ecc.toml): the workspace's own [flow] is then the run target source.
manifest_driven: bool = False

# Winning skip_steps layer recorded by effective-config resolution
# ("project.json" / "ecc.toml"); None when never resolved.
_skip_steps_source: str | None = field(default=None, init=False, repr=False)
_toml_error: str | None = field(default=None, init=False, repr=False)
_param_errors: list[str] = field(default_factory=list, init=False, repr=False)
_pdk_config_errors: list[str] = field(default_factory=list, init=False, repr=False)
Expand Down Expand Up @@ -99,6 +106,10 @@ def _str(val, default=""):
pdk_overrides = {} if not isinstance(pdk_overrides_raw, dict) else pdk_overrides_raw

raw_run = flow.get("run")
# Raw declared value (even an invalid one): presence is tracked through
# _explicit_keys, and validate_project_config surfaces invalid shapes
# instead of silently dropping them.
skip_steps = flow.get("skip_steps")

cfg = ProjectConfig(
design_name=_str(design.get("name", "")),
Expand All @@ -115,6 +126,7 @@ def _str(val, default=""):
pdk_root=_str(pdk.get("root", "")),
pdk_overrides=pdk_overrides,
flow_preset=_str(flow.get("preset", "")),
flow_skip_steps=skip_steps,
config_path=config_path,
project_dir=project_dir,
)
Expand Down Expand Up @@ -231,6 +243,14 @@ def validate_project_config(cfg: ProjectConfig) -> list[str]:
elif cfg.flow_preset not in _supported_flow_presets():
errors.append(f"unsupported flow.preset: {cfg.flow_preset}")

if "flow.skip_steps" in cfg._explicit_keys:
from chipcompiler.rtl2gds import resolve_skip_steps

try:
resolve_skip_steps({"skip_steps": cfg.flow_skip_steps})
except ValueError as exc:
errors.append(str(exc))

errors.extend(cfg._flow_config_errors)

return errors
Expand Down
50 changes: 49 additions & 1 deletion chipcompiler/cli/project/effective_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,43 @@ def _resolve_entry(manifest, run_name: str | None):
return active[0] if len(active) == 1 else None


def declared_skip_steps(entry, cfg) -> list | None:
"""The declared skip policy: project.json entry wins over ecc.toml.

The only key with this precedence direction — ecc.toml keeps winning
for every other field. None means neither surface declared a policy
(the code default applies downstream); [] is an explicit run-everything.
"""
if entry is not None and getattr(entry, "skip_steps", None) is not None:
return list(entry.skip_steps)
if "flow.skip_steps" in getattr(cfg, "_explicit_keys", frozenset()):
return list(getattr(cfg, "flow_skip_steps", None) or [])
return None


def _attach_skip_steps(flow_config: dict | None, skip_steps: list | None) -> dict | None:
"""Carry a declared skip policy on the flow config (policy-only when
the config selects no steps)."""
if skip_steps is None:
return flow_config
if flow_config is None:
return {"skip_steps": skip_steps}
flow_config = dict(flow_config)
flow_config["skip_steps"] = skip_steps
return flow_config


def flow_config_selects_steps(flow_config) -> bool:
"""Whether a flow config names steps (a range or explicit selection).

A policy-only config (just ``skip_steps``) selects nothing: it must
not satisfy a flow-target requirement nor trigger range preflight.
"""
if not isinstance(flow_config, dict):
return False
return bool(flow_config.get("start_step")) or bool(flow_config.get("steps"))


def resolve_effective_config(
ctx, run_name: str | None, cfg: "ProjectConfig | None"
) -> "CommandResult | tuple[ProjectConfig, dict | None, list[dict]]":
Expand Down Expand Up @@ -73,6 +110,17 @@ def resolve_effective_config(
if "flow.preset" not in cfg._explicit_keys:
flow_config = {"start_step": entry.start_step, "end_step": entry.end_step}

# skip_steps is the one key where the manifest layer outranks ecc.toml.
declared = declared_skip_steps(entry, cfg)
flow_config = _attach_skip_steps(flow_config, declared)
# Provenance for inspection surfaces: the winning layer's name.
if declared is not None:
cfg._skip_steps_source = (
"project.json"
if (entry is not None and getattr(entry, "skip_steps", None) is not None)
else "ecc.toml"
)

warnings = []
diverging = layer_divergences(cfg, assembled, entry)
if diverging:
Expand Down Expand Up @@ -243,7 +291,7 @@ def validate_effective(ctx, cfg, *, fresh: bool, flow_config, cli_overrides=None
sources = cfg.design_rtl if len(cfg.design_rtl) > 1 else cfg.design_rtl[1:]
for entry in sources:
errors.extend(_validate_rtl_source(cfg.project_dir, entry))
if fresh and not cfg.flow_preset and flow_config is None:
if fresh and not cfg.flow_preset and not flow_config_selects_steps(flow_config):
errors.append(
"no flow target: set flow.preset in ecc.toml or declare the workspace's "
"start/end range in project.json"
Expand Down
Loading
Loading