chipgraph 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chipgraph/__init__.py +7 -0
- chipgraph/adapters/__init__.py +1 -0
- chipgraph/adapters/format/__init__.py +6 -0
- chipgraph/adapters/format/__main__.py +16 -0
- chipgraph/adapters/format/_yaml_lines.py +54 -0
- chipgraph/adapters/format/chip_yaml.py +323 -0
- chipgraph/adapters/format/qsoc_contract.py +246 -0
- chipgraph/adapters/llm/__init__.py +58 -0
- chipgraph/adapters/llm/_budget.py +173 -0
- chipgraph/adapters/llm/_errors.py +50 -0
- chipgraph/adapters/llm/_nda.py +30 -0
- chipgraph/adapters/llm/_retry.py +85 -0
- chipgraph/adapters/llm/anthropic.py +247 -0
- chipgraph/adapters/llm/decide_backend.py +179 -0
- chipgraph/adapters/llm/fake.py +87 -0
- chipgraph/adapters/llm/guard.py +68 -0
- chipgraph/adapters/parser/__init__.py +22 -0
- chipgraph/adapters/parser/cocotb.py +304 -0
- chipgraph/adapters/parser/generic_regex.py +75 -0
- chipgraph/adapters/parser/verible.py +55 -0
- chipgraph/adapters/parser/verilator.py +46 -0
- chipgraph/adapters/review/__init__.py +7 -0
- chipgraph/adapters/review/file.py +123 -0
- chipgraph/adapters/review/github.py +886 -0
- chipgraph/adapters/runner/__init__.py +7 -0
- chipgraph/adapters/runner/local.py +122 -0
- chipgraph/adapters/runtime/__init__.py +5 -0
- chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
- chipgraph/adapters/runtime/claude_code/agents.py +158 -0
- chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
- chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
- chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
- chipgraph/adapters/runtime/claude_code/loop.py +342 -0
- chipgraph/adapters/runtime/claude_code/previous.py +419 -0
- chipgraph/adapters/runtime/claude_code/review.py +492 -0
- chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
- chipgraph/adapters/runtime/claude_code/service.py +2285 -0
- chipgraph/adapters/tool/__init__.py +7 -0
- chipgraph/adapters/tool/cmd.py +229 -0
- chipgraph/adapters/tool/edalize.py +773 -0
- chipgraph/adapters/tool/filelist.py +259 -0
- chipgraph/adapters/tool/pyslang.py +485 -0
- chipgraph/adapters/vcs/__init__.py +1 -0
- chipgraph/adapters/vcs/git.py +521 -0
- chipgraph/app/__init__.py +10 -0
- chipgraph/app/baseline.py +339 -0
- chipgraph/app/build.py +332 -0
- chipgraph/app/checks.py +202 -0
- chipgraph/app/context.py +191 -0
- chipgraph/app/errors.py +11 -0
- chipgraph/app/executors.py +79 -0
- chipgraph/app/findings.py +203 -0
- chipgraph/app/ingest.py +523 -0
- chipgraph/checks/__init__.py +52 -0
- chipgraph/checks/_cdc_analyze.py +272 -0
- chipgraph/checks/_cdc_elaborate.py +455 -0
- chipgraph/checks/_common.py +268 -0
- chipgraph/checks/_cross.py +96 -0
- chipgraph/checks/_model.py +64 -0
- chipgraph/checks/_naming_pyslang.py +235 -0
- chipgraph/checks/_naming_rules.py +127 -0
- chipgraph/checks/_xref_syntax.py +260 -0
- chipgraph/checks/cdc_struct.py +235 -0
- chipgraph/checks/connect.py +353 -0
- chipgraph/checks/cross_chip.py +454 -0
- chipgraph/checks/duplicate.py +153 -0
- chipgraph/checks/filelist.py +323 -0
- chipgraph/checks/format.py +268 -0
- chipgraph/checks/generated.py +151 -0
- chipgraph/checks/hardcode.py +233 -0
- chipgraph/checks/layout.py +155 -0
- chipgraph/checks/naming.py +311 -0
- chipgraph/checks/plan_check.py +901 -0
- chipgraph/checks/ports_diff.py +203 -0
- chipgraph/checks/spec_schema.py +277 -0
- chipgraph/checks/style.py +910 -0
- chipgraph/checks/tb_static.py +599 -0
- chipgraph/checks/trace.py +224 -0
- chipgraph/checks/vendor_guard.py +369 -0
- chipgraph/checks/wrap_check.py +486 -0
- chipgraph/cli/__init__.py +1688 -0
- chipgraph/cli/next_steps.py +137 -0
- chipgraph/core/__init__.py +1 -0
- chipgraph/core/config/__init__.py +93 -0
- chipgraph/core/config/defaults.py +14 -0
- chipgraph/core/config/errors.py +32 -0
- chipgraph/core/config/loader.py +766 -0
- chipgraph/core/config/models.py +845 -0
- chipgraph/core/config/templates.py +340 -0
- chipgraph/core/contracts/__init__.py +75 -0
- chipgraph/core/contracts/_paths.py +26 -0
- chipgraph/core/contracts/agent.py +49 -0
- chipgraph/core/contracts/artifact.py +68 -0
- chipgraph/core/contracts/check.py +76 -0
- chipgraph/core/contracts/event.py +70 -0
- chipgraph/core/contracts/export.py +128 -0
- chipgraph/core/contracts/finding.py +171 -0
- chipgraph/core/contracts/gate.py +50 -0
- chipgraph/core/contracts/rule.py +177 -0
- chipgraph/core/contracts/types.py +45 -0
- chipgraph/core/engine/__init__.py +70 -0
- chipgraph/core/engine/agent_rule.py +1362 -0
- chipgraph/core/engine/baseline.py +283 -0
- chipgraph/core/engine/decide.py +462 -0
- chipgraph/core/engine/fanout.py +1358 -0
- chipgraph/core/engine/gate.py +170 -0
- chipgraph/core/engine/graph.py +647 -0
- chipgraph/core/engine/records.py +60 -0
- chipgraph/core/engine/rules.py +92 -0
- chipgraph/core/engine/scheduler.py +637 -0
- chipgraph/core/engine/select.py +1239 -0
- chipgraph/core/engine/targets.py +342 -0
- chipgraph/core/model/__init__.py +98 -0
- chipgraph/core/model/entities.py +244 -0
- chipgraph/core/model/export.py +16 -0
- chipgraph/core/model/ingest.py +693 -0
- chipgraph/core/model/json_value.py +4 -0
- chipgraph/core/model/keys.py +95 -0
- chipgraph/core/model/model.py +153 -0
- chipgraph/core/model/provenance.py +27 -0
- chipgraph/core/model/query.py +583 -0
- chipgraph/core/model/registry.py +80 -0
- chipgraph/core/model/relations.py +61 -0
- chipgraph/core/model/store.py +279 -0
- chipgraph/core/plugin_api/__init__.py +81 -0
- chipgraph/core/plugin_api/local.py +167 -0
- chipgraph/core/plugin_api/pack.py +159 -0
- chipgraph/core/plugin_api/protocols.py +177 -0
- chipgraph/core/plugin_api/registry.py +111 -0
- chipgraph/core/plugin_api/types.py +94 -0
- chipgraph/core/runtime/__init__.py +41 -0
- chipgraph/core/runtime/executor.py +79 -0
- chipgraph/core/runtime/queue.py +565 -0
- chipgraph/core/runtime/roles/__init__.py +91 -0
- chipgraph/core/runtime/roles/data/author.md +41 -0
- chipgraph/core/runtime/roles/data/critic.md +56 -0
- chipgraph/core/runtime/roles/data/planner.md +38 -0
- chipgraph/core/runtime/roles/data/researcher.md +37 -0
- chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
- chipgraph/core/runtime/roles/data/tb-author.md +48 -0
- chipgraph/core/runtime/roles/data/triage.md +34 -0
- chipgraph/core/runtime/roles/policy.py +299 -0
- chipgraph/core/runtime/roles/registry.py +52 -0
- chipgraph/core/runtime/roles/review.py +275 -0
- chipgraph/core/runtime/roles/schema.py +82 -0
- chipgraph/core/runtime/roles/skills.py +134 -0
- chipgraph/core/runtime/roles/spec.py +232 -0
- chipgraph/core/state/__init__.py +1 -0
- chipgraph/core/state/artifacts.py +156 -0
- chipgraph/core/state/backend.py +117 -0
- chipgraph/core/state/findings.py +151 -0
- chipgraph/core/state/handoff.py +411 -0
- chipgraph/core/state/idempotency.py +59 -0
- chipgraph/core/state/journal.py +238 -0
- chipgraph/core/state/layout.py +82 -0
- chipgraph/core/state/lock.py +195 -0
- chipgraph/core/state/trace.py +225 -0
- chipgraph/learn/__init__.py +96 -0
- chipgraph/learn/draft.py +187 -0
- chipgraph/learn/infer.py +580 -0
- chipgraph/learn/models.py +145 -0
- chipgraph/learn/try_run.py +228 -0
- chipgraph/mcp/__init__.py +12 -0
- chipgraph/mcp/model_tools.py +167 -0
- chipgraph/mcp/server.py +787 -0
- chipgraph/orgs/qnsc/naming-v1.yml +113 -0
- chipgraph/orgs/qnsc/profile.yml +5 -0
- chipgraph/packs/__init__.py +8 -0
- chipgraph/packs/assist/__init__.py +16 -0
- chipgraph/packs/assist/ask/__init__.py +58 -0
- chipgraph/packs/assist/ask/_db.py +57 -0
- chipgraph/packs/assist/ask/_project.py +230 -0
- chipgraph/packs/assist/ask/answer.py +266 -0
- chipgraph/packs/assist/ask/check.py +182 -0
- chipgraph/packs/assist/ask/contract.py +115 -0
- chipgraph/packs/assist/ask/documents.py +157 -0
- chipgraph/packs/assist/ask/fts.py +80 -0
- chipgraph/packs/assist/ask/retrieve.py +331 -0
- chipgraph/packs/assist/audit/__init__.py +383 -0
- chipgraph/packs/assist/pack.yml +13 -0
- chipgraph/packs/assist/triage/__init__.py +64 -0
- chipgraph/packs/assist/triage/contract.py +94 -0
- chipgraph/packs/assist/triage/parse.py +371 -0
- chipgraph/packs/assist/triage/question.py +384 -0
- chipgraph/packs/assist/triage/report.py +160 -0
- chipgraph/packs/assist/triage/rules.py +447 -0
- chipgraph/packs/assist/triage/run.py +224 -0
- chipgraph/packs/digital_rtl/__init__.py +19 -0
- chipgraph/packs/digital_rtl/layout.py +144 -0
- chipgraph/packs/digital_rtl/pack.yml +25 -0
- chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
- chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
- chipgraph/packs/digital_rtl/plan/context.py +82 -0
- chipgraph/packs/digital_rtl/plan/expand.py +94 -0
- chipgraph/packs/digital_rtl/plan/model.py +150 -0
- chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
- chipgraph/packs/digital_rtl/plan/show.py +176 -0
- chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
- chipgraph/packs/digital_rtl/pr/body.py +165 -0
- chipgraph/packs/digital_rtl/pr/gate.py +110 -0
- chipgraph/packs/digital_rtl/pr/open.py +397 -0
- chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
- chipgraph/packs/digital_rtl/rtl/context.py +272 -0
- chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
- chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
- chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
- chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
- chipgraph/packs/digital_rtl/rules/review.yml +31 -0
- chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
- chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
- chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
- chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
- chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
- chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
- chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
- chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
- chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
- chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
- chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
- chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
- chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
- chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
- chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
- chipgraph/packs/dv/__init__.py +15 -0
- chipgraph/packs/dv/declaration.py +392 -0
- chipgraph/packs/dv/interface.py +596 -0
- chipgraph/packs/dv/pack.yml +14 -0
- chipgraph/packs/dv/rules/tb_module.yml +42 -0
- chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
- chipgraph/packs/spec_core/__init__.py +9 -0
- chipgraph/packs/spec_core/extract/__init__.py +12 -0
- chipgraph/packs/spec_core/extract/_markdown.py +371 -0
- chipgraph/packs/spec_core/extract/_text.py +116 -0
- chipgraph/packs/spec_core/extract/mas.py +975 -0
- chipgraph/packs/spec_core/gen/__init__.py +11 -0
- chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
- chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
- chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
- chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
- chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
- chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
- chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
- chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
- chipgraph/packs/spec_core/pack.yml +6 -0
- chipgraph/py.typed +0 -0
- chipgraph-0.1.0.dist-info/METADATA +53 -0
- chipgraph-0.1.0.dist-info/RECORD +252 -0
- chipgraph-0.1.0.dist-info/WHEEL +4 -0
- chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
- chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,1688 @@
|
|
|
1
|
+
"""chipgraph CLI (task M0-12): commands that run a project's build graph.
|
|
2
|
+
|
|
3
|
+
A command is small on purpose: it parses options, builds an `AppContext` (and, when
|
|
4
|
+
needed, a `Scheduler` via `chipgraph.app.build.make_scheduler`), and prints the result.
|
|
5
|
+
All the wiring lives in `chipgraph.app` (DESIGN.md 3.3, 10.1, 10.4).
|
|
6
|
+
|
|
7
|
+
Exit codes: 0 ok; 1 a check/run failed or is waiting; 2 usage/config error.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import functools
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from collections import Counter
|
|
20
|
+
from collections.abc import Callable, Mapping
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Annotated, Literal
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from chipgraph import __version__
|
|
28
|
+
from chipgraph.app import findings as findings_app
|
|
29
|
+
from chipgraph.app.baseline import (
|
|
30
|
+
current_branch,
|
|
31
|
+
main_branch,
|
|
32
|
+
plan_project_baseline,
|
|
33
|
+
run_baseline,
|
|
34
|
+
)
|
|
35
|
+
from chipgraph.app.build import make_scheduler, project_graph
|
|
36
|
+
from chipgraph.app.checks import ProfileCheckRunner
|
|
37
|
+
from chipgraph.app.context import AppContext, default_identity, find_repo_root
|
|
38
|
+
from chipgraph.app.errors import AppError
|
|
39
|
+
from chipgraph.core.config.errors import ConfigError
|
|
40
|
+
from chipgraph.core.config.loader import ConfigIssue
|
|
41
|
+
from chipgraph.core.contracts import ArtifactRef, CheckResult, Finding, RuleInstance
|
|
42
|
+
from chipgraph.core.engine import gate as gate_mod
|
|
43
|
+
from chipgraph.core.engine.graph import BuildGraph, GraphError
|
|
44
|
+
from chipgraph.core.engine.scheduler import RunSummary
|
|
45
|
+
from chipgraph.core.plugin_api.local import LocalPluginRecord
|
|
46
|
+
from chipgraph.core.state import journal as journal_mod
|
|
47
|
+
from chipgraph.core.state import trace as trace_mod
|
|
48
|
+
from chipgraph.core.state.findings import FindingStore, effective_status, waiver_gate_id
|
|
49
|
+
from chipgraph.core.state.layout import StateLayout
|
|
50
|
+
from chipgraph.core.state.trace import Tracer
|
|
51
|
+
|
|
52
|
+
TraceExporter = Literal["none", "console", "otlp"]
|
|
53
|
+
|
|
54
|
+
app = typer.Typer(no_args_is_help=True)
|
|
55
|
+
config_app = typer.Typer(no_args_is_help=True, help="Inspect the resolved profile.")
|
|
56
|
+
app.add_typer(config_app, name="config")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CliState:
|
|
60
|
+
"""The global options every command reads: `--profile`, `--json`, `-C/--dir`, `--trace`."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
profile_path: Path | None,
|
|
66
|
+
json_output: bool,
|
|
67
|
+
start: Path,
|
|
68
|
+
trace: TraceExporter,
|
|
69
|
+
) -> None:
|
|
70
|
+
self.profile_path = profile_path
|
|
71
|
+
self.json_output = json_output
|
|
72
|
+
self.start = start
|
|
73
|
+
self.trace = trace
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_ctx(state: CliState) -> AppContext:
|
|
77
|
+
return AppContext.load(state.start, profile_path=state.profile_path)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _handle_errors[F: Callable[..., None]](func: F) -> F:
|
|
81
|
+
"""Catch `AppError`/`ConfigError`, print the message, and exit 2."""
|
|
82
|
+
|
|
83
|
+
@functools.wraps(func)
|
|
84
|
+
def wrapper(*args: object, **kwargs: object) -> None:
|
|
85
|
+
try:
|
|
86
|
+
func(*args, **kwargs)
|
|
87
|
+
except (AppError, ConfigError) as exc:
|
|
88
|
+
typer.echo(str(exc), err=True)
|
|
89
|
+
raise typer.Exit(code=2) from exc
|
|
90
|
+
|
|
91
|
+
return wrapper # type: ignore[return-value]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _version_callback(value: bool) -> None:
|
|
95
|
+
if value:
|
|
96
|
+
typer.echo(f"chipgraph {__version__}")
|
|
97
|
+
raise typer.Exit(code=0)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _default_trace() -> TraceExporter:
|
|
101
|
+
"""`$CHIPGRAPH_TRACE`, if it names a known exporter, else `"none"`."""
|
|
102
|
+
value = os.environ.get("CHIPGRAPH_TRACE", "none").strip().lower()
|
|
103
|
+
if value in ("none", "console", "otlp"):
|
|
104
|
+
return value # type: ignore[return-value]
|
|
105
|
+
return "none"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.callback(invoke_without_command=True)
|
|
109
|
+
def main(
|
|
110
|
+
ctx: typer.Context,
|
|
111
|
+
version: Annotated[
|
|
112
|
+
bool,
|
|
113
|
+
typer.Option(
|
|
114
|
+
"--version",
|
|
115
|
+
callback=_version_callback,
|
|
116
|
+
is_eager=True,
|
|
117
|
+
help="Show the chipgraph version and exit.",
|
|
118
|
+
),
|
|
119
|
+
] = False,
|
|
120
|
+
profile: Annotated[
|
|
121
|
+
Path | None,
|
|
122
|
+
typer.Option("--profile", help="Use this profile file instead of discovering one."),
|
|
123
|
+
] = None,
|
|
124
|
+
json_output: Annotated[
|
|
125
|
+
bool, typer.Option("--json", help="Print machine-readable JSON instead of text.")
|
|
126
|
+
] = False,
|
|
127
|
+
directory: Annotated[
|
|
128
|
+
Path | None,
|
|
129
|
+
typer.Option("-C", "--dir", help="Start looking for a profile from this directory."),
|
|
130
|
+
] = None,
|
|
131
|
+
trace: Annotated[
|
|
132
|
+
TraceExporter | None,
|
|
133
|
+
typer.Option(
|
|
134
|
+
"--trace",
|
|
135
|
+
help="Export OpenTelemetry spans for build/resume ('none'|'console'|'otlp'); "
|
|
136
|
+
"default from $CHIPGRAPH_TRACE, else 'none'.",
|
|
137
|
+
),
|
|
138
|
+
] = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""chipgraph: an AI agent system for chip (IC) design, built on a build graph."""
|
|
141
|
+
start = directory if directory is not None else Path.cwd()
|
|
142
|
+
ctx.obj = CliState(
|
|
143
|
+
profile_path=profile,
|
|
144
|
+
json_output=json_output,
|
|
145
|
+
start=start,
|
|
146
|
+
trace=trace if trace is not None else _default_trace(),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --- init --------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _init_content(project: str, preset: str | None) -> str:
|
|
154
|
+
lines = [
|
|
155
|
+
"# chipgraph project profile (see DESIGN.md 8.3-8.6)",
|
|
156
|
+
"# generated by `chipgraph init`; edit freely.",
|
|
157
|
+
f"project: {project}",
|
|
158
|
+
]
|
|
159
|
+
if preset:
|
|
160
|
+
lines.append(f'extends: ["preset:{preset}"]')
|
|
161
|
+
lines += [
|
|
162
|
+
"packs: []",
|
|
163
|
+
"adapters: {}",
|
|
164
|
+
"# adapters:",
|
|
165
|
+
"# lint:",
|
|
166
|
+
"# use: cmd",
|
|
167
|
+
'# cmd: "make lint BLOCK={block}"',
|
|
168
|
+
"# parser: verilator",
|
|
169
|
+
"blocks: {}",
|
|
170
|
+
"",
|
|
171
|
+
]
|
|
172
|
+
return "\n".join(lines) + "\n"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command()
|
|
176
|
+
@_handle_errors
|
|
177
|
+
def init(
|
|
178
|
+
ctx: typer.Context,
|
|
179
|
+
project: Annotated[str | None, typer.Option("--project", help="Project name.")] = None,
|
|
180
|
+
preset: Annotated[
|
|
181
|
+
str | None, typer.Option("--preset", help="Extend an org/preset profile.")
|
|
182
|
+
] = None,
|
|
183
|
+
force: Annotated[
|
|
184
|
+
bool, typer.Option("--force", help="Overwrite an existing .chipgraph.yml.")
|
|
185
|
+
] = False,
|
|
186
|
+
from_learn: Annotated[
|
|
187
|
+
bool,
|
|
188
|
+
typer.Option(
|
|
189
|
+
"--from-learn",
|
|
190
|
+
help="Infer the profile from this repo (like `chipgraph learn`) instead of a stub.",
|
|
191
|
+
),
|
|
192
|
+
] = False,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Write a `.chipgraph.yml` at the repo root (a stub, or inferred with `--from-learn`)."""
|
|
195
|
+
state: CliState = ctx.obj
|
|
196
|
+
root = find_repo_root(state.start)
|
|
197
|
+
path = root / ".chipgraph.yml"
|
|
198
|
+
if path.is_file() and not force:
|
|
199
|
+
typer.echo(f"{path} already exists; pass --force to overwrite", err=True)
|
|
200
|
+
raise typer.Exit(code=2)
|
|
201
|
+
|
|
202
|
+
if from_learn:
|
|
203
|
+
from chipgraph.learn import InitFromLearnError, init_from_learn
|
|
204
|
+
|
|
205
|
+
if path.is_file() and force:
|
|
206
|
+
path.unlink()
|
|
207
|
+
try:
|
|
208
|
+
profile_path, naming_path = init_from_learn(root)
|
|
209
|
+
except InitFromLearnError as exc:
|
|
210
|
+
typer.echo(str(exc), err=True)
|
|
211
|
+
raise typer.Exit(code=2) from exc
|
|
212
|
+
typer.echo(f"wrote {profile_path} (inferred from {root})")
|
|
213
|
+
if naming_path is not None:
|
|
214
|
+
typer.echo(f"wrote {naming_path}")
|
|
215
|
+
typer.echo("review it, then:")
|
|
216
|
+
typer.echo(" chipgraph config check # validate the profile")
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
name = project or root.name
|
|
220
|
+
path.write_text(_init_content(name, preset), encoding="utf-8")
|
|
221
|
+
typer.echo(f"wrote {path}")
|
|
222
|
+
typer.echo("next steps:")
|
|
223
|
+
typer.echo(" chipgraph config check # validate the profile")
|
|
224
|
+
typer.echo(" chipgraph doctor # check tools are on PATH")
|
|
225
|
+
typer.echo(" chipgraph learn # fill this in from an existing repo (task M1-21)")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# --- learn / try --------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _learn_json(result: object) -> str:
|
|
232
|
+
from chipgraph.learn import LearnResult
|
|
233
|
+
|
|
234
|
+
assert isinstance(result, LearnResult)
|
|
235
|
+
return json.dumps(result.model_dump(mode="json"), indent=2)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _print_learn_text(result: object) -> None:
|
|
239
|
+
from chipgraph.learn import LearnResult
|
|
240
|
+
|
|
241
|
+
assert isinstance(result, LearnResult)
|
|
242
|
+
typer.echo(f"# learned {result.project} from {result.root}")
|
|
243
|
+
typer.echo(f"# blocks: {', '.join(result.blocks) or '(none, single block)'}")
|
|
244
|
+
typer.echo("")
|
|
245
|
+
typer.echo("layout (kind: template coverage):")
|
|
246
|
+
for lrule in result.layout:
|
|
247
|
+
typer.echo(f" {lrule.kind:9} {lrule.template:34} {lrule.coverage.percent:3}%")
|
|
248
|
+
typer.echo("naming (kind: pattern coverage emitted):")
|
|
249
|
+
for nrule in result.naming:
|
|
250
|
+
mark = "rule" if nrule.emitted else "obs "
|
|
251
|
+
typer.echo(
|
|
252
|
+
f" {mark} {nrule.kind:11} {nrule.pattern:30} {nrule.coverage.percent:3}% "
|
|
253
|
+
f"({nrule.coverage.matched}/{nrule.coverage.total})"
|
|
254
|
+
)
|
|
255
|
+
if result.observations:
|
|
256
|
+
typer.echo("observations:")
|
|
257
|
+
for obs in result.observations:
|
|
258
|
+
typer.echo(f" {obs.topic:9} {obs.summary}")
|
|
259
|
+
if result.vendor_paths:
|
|
260
|
+
typer.echo("vendor paths (checks off, learned):")
|
|
261
|
+
for glob in result.vendor_paths:
|
|
262
|
+
typer.echo(f" {glob}")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.command()
|
|
266
|
+
@_handle_errors
|
|
267
|
+
def learn(
|
|
268
|
+
ctx: typer.Context,
|
|
269
|
+
path: Annotated[
|
|
270
|
+
Path | None, typer.Argument(help="Repo to learn (default: the current directory).")
|
|
271
|
+
] = None,
|
|
272
|
+
out: Annotated[
|
|
273
|
+
Path | None,
|
|
274
|
+
typer.Option("--out", help="Write chipgraph.draft.yml (+ naming rules) into this dir."),
|
|
275
|
+
] = None,
|
|
276
|
+
threshold: Annotated[
|
|
277
|
+
float,
|
|
278
|
+
typer.Option("--threshold", min=0.0, max=1.0, help="Min coverage to emit a naming rule."),
|
|
279
|
+
] = 0.9,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Infer a draft profile from an existing repo, with per-rule coverage (DESIGN 8.6 V1)."""
|
|
282
|
+
from chipgraph.learn import dump_profile_yaml, write_draft
|
|
283
|
+
from chipgraph.learn import learn as run_learn
|
|
284
|
+
from chipgraph.learn.draft import DRAFT_NAMING_RULES_NAME, build_naming_rules
|
|
285
|
+
|
|
286
|
+
state: CliState = ctx.obj
|
|
287
|
+
root = (path if path is not None else state.start).resolve()
|
|
288
|
+
if not root.is_dir():
|
|
289
|
+
typer.echo(f"not a directory: {root}", err=True)
|
|
290
|
+
raise typer.Exit(code=2)
|
|
291
|
+
|
|
292
|
+
result = run_learn(root, threshold=threshold)
|
|
293
|
+
|
|
294
|
+
if state.json_output:
|
|
295
|
+
typer.echo(_learn_json(result))
|
|
296
|
+
else:
|
|
297
|
+
_print_learn_text(result)
|
|
298
|
+
naming_ref = DRAFT_NAMING_RULES_NAME if build_naming_rules(result) is not None else None
|
|
299
|
+
typer.echo("")
|
|
300
|
+
typer.echo("draft profile:")
|
|
301
|
+
typer.echo(dump_profile_yaml(result, naming_rules_ref=naming_ref))
|
|
302
|
+
|
|
303
|
+
if out is not None:
|
|
304
|
+
profile_path, naming_path = write_draft(result, out)
|
|
305
|
+
typer.echo(f"wrote {profile_path}")
|
|
306
|
+
if naming_path is not None:
|
|
307
|
+
typer.echo(f"wrote {naming_path}")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@app.command("try")
|
|
311
|
+
@_handle_errors
|
|
312
|
+
def try_cmd(
|
|
313
|
+
ctx: typer.Context,
|
|
314
|
+
path: Annotated[
|
|
315
|
+
Path | None, typer.Argument(help="Repo to try (default: the current directory).")
|
|
316
|
+
] = None,
|
|
317
|
+
profile: Annotated[
|
|
318
|
+
Path | None,
|
|
319
|
+
typer.Option("--profile", help="Use this draft profile (from `learn --out`) instead."),
|
|
320
|
+
] = None,
|
|
321
|
+
threshold: Annotated[
|
|
322
|
+
float,
|
|
323
|
+
typer.Option("--threshold", min=0.0, max=1.0, help="Min coverage to emit a naming rule."),
|
|
324
|
+
] = 0.9,
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Run chipgraph read-only against a repo (ingest/check/audit), writing nothing into it."""
|
|
327
|
+
from chipgraph.learn.try_run import try_run
|
|
328
|
+
|
|
329
|
+
state: CliState = ctx.obj
|
|
330
|
+
root = (path if path is not None else state.start).resolve()
|
|
331
|
+
if not root.is_dir():
|
|
332
|
+
typer.echo(f"not a directory: {root}", err=True)
|
|
333
|
+
raise typer.Exit(code=2)
|
|
334
|
+
|
|
335
|
+
report = try_run(root, profile_path=profile, threshold=threshold)
|
|
336
|
+
|
|
337
|
+
if state.json_output:
|
|
338
|
+
payload = {
|
|
339
|
+
"project": report.result.project,
|
|
340
|
+
"ingest_ok": report.ingest_ok,
|
|
341
|
+
"ingest": report.ingest_summary,
|
|
342
|
+
"audit_available": report.audit_available,
|
|
343
|
+
"audit": report.audit_summary,
|
|
344
|
+
"files_with_issues": report.check_files_with_issues,
|
|
345
|
+
"checks": [
|
|
346
|
+
{"check": cid, "block": blk, "status": res.status, "issues": len(res.issues)}
|
|
347
|
+
for cid, blk, res in report.checks
|
|
348
|
+
],
|
|
349
|
+
}
|
|
350
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
351
|
+
else:
|
|
352
|
+
typer.echo(f"try {report.result.project} (read-only; nothing written to the repo)")
|
|
353
|
+
typer.echo(f" ingest: {report.ingest_summary}")
|
|
354
|
+
for cid, blk, res in report.checks:
|
|
355
|
+
typer.echo(f" {res.status.upper():6} {cid} {blk or '-'} {len(res.issues)} issues")
|
|
356
|
+
typer.echo(f" {report.audit_summary}")
|
|
357
|
+
typer.echo(f"files with issues: {report.check_files_with_issues}")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# --- config show / config check -----------------------------------------------------
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@config_app.command("show")
|
|
364
|
+
@_handle_errors
|
|
365
|
+
def config_show(
|
|
366
|
+
ctx: typer.Context,
|
|
367
|
+
explain: Annotated[
|
|
368
|
+
bool, typer.Option("--explain", help="One line per leaf key, with its source.")
|
|
369
|
+
] = False,
|
|
370
|
+
) -> None:
|
|
371
|
+
"""Print the merged profile."""
|
|
372
|
+
state: CliState = ctx.obj
|
|
373
|
+
app_ctx = _load_ctx(state)
|
|
374
|
+
resolved = app_ctx.require_profile()
|
|
375
|
+
if explain:
|
|
376
|
+
for key, value, source in resolved.explain():
|
|
377
|
+
typer.echo(f"{key} = {value!r} ({source.kind}:{source.location})")
|
|
378
|
+
return
|
|
379
|
+
typer.echo(yaml.safe_dump(resolved.profile.model_dump(mode="json"), sort_keys=True))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@config_app.command("check")
|
|
383
|
+
@_handle_errors
|
|
384
|
+
def config_check(ctx: typer.Context) -> None:
|
|
385
|
+
"""Print consistency issues in the resolved profile."""
|
|
386
|
+
state: CliState = ctx.obj
|
|
387
|
+
app_ctx = _load_ctx(state)
|
|
388
|
+
resolved = app_ctx.require_profile()
|
|
389
|
+
# Drop the generic per-plugin info line from `check()`: the richer lines built from
|
|
390
|
+
# what actually loaded (below) replace it, keeping the "runs code" warning wording.
|
|
391
|
+
issues = [i for i in resolved.check() if not i.key.startswith("plugins[")]
|
|
392
|
+
plugins = [_plugin_issue(index, rec) for index, rec in enumerate(app_ctx.local_plugins)]
|
|
393
|
+
|
|
394
|
+
if state.json_output:
|
|
395
|
+
payload = [
|
|
396
|
+
{"severity": i.severity, "key": i.key, "message": i.message}
|
|
397
|
+
for i in (*issues, *plugins)
|
|
398
|
+
]
|
|
399
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
400
|
+
else:
|
|
401
|
+
if not issues and not plugins:
|
|
402
|
+
typer.echo("no issues")
|
|
403
|
+
for issue in (*issues, *plugins):
|
|
404
|
+
typer.echo(f"{issue.severity:7} {issue.key}: {issue.message}")
|
|
405
|
+
if any(issue.severity == "error" for issue in (*issues, *plugins)):
|
|
406
|
+
raise typer.Exit(code=1)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _plugin_issue(index: int, record: LocalPluginRecord) -> ConfigIssue:
|
|
410
|
+
"""One `config check` line per local plugin: loaded (with hash + registrations) or not."""
|
|
411
|
+
key = f"plugins[{index}]"
|
|
412
|
+
if not record.loaded:
|
|
413
|
+
return ConfigIssue(
|
|
414
|
+
severity="warning",
|
|
415
|
+
key=key,
|
|
416
|
+
message=f"{record.entry}: not loaded: {record.disabled_reason}",
|
|
417
|
+
)
|
|
418
|
+
registered = ", ".join(f"{kind}/{name}" for kind, name in record.registered) or "nothing"
|
|
419
|
+
return ConfigIssue(
|
|
420
|
+
severity="info",
|
|
421
|
+
key=key,
|
|
422
|
+
message=(
|
|
423
|
+
f"{record.path} (sha256 {(record.sha256 or '')[:8]}) registered {registered} "
|
|
424
|
+
"(runs code: review it like code)"
|
|
425
|
+
),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# --- check -------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _fake_instance(check_id: str, block: str | None) -> RuleInstance:
|
|
433
|
+
"""A throwaway `RuleInstance` carrying just the params `ProfileCheckRunner` reads."""
|
|
434
|
+
params = {"block": block} if block is not None else {}
|
|
435
|
+
rule_id = "chipgraph/check"
|
|
436
|
+
safe = "".join(c if c.isalnum() else "_" for c in check_id)
|
|
437
|
+
output_path = f".chipgraph/tmp/check-{safe}-{block or 'all'}.json"
|
|
438
|
+
return RuleInstance(
|
|
439
|
+
rule_id=rule_id,
|
|
440
|
+
params=params,
|
|
441
|
+
outputs=(ArtifactRef(kind="report", path=output_path),),
|
|
442
|
+
instance_id=RuleInstance.make_id(rule_id, params),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
async def _run_checks(
|
|
447
|
+
runner: ProfileCheckRunner, combos: list[tuple[str, str | None]]
|
|
448
|
+
) -> list[tuple[str, str | None, CheckResult]]:
|
|
449
|
+
results: list[tuple[str, str | None, CheckResult]] = []
|
|
450
|
+
for check_id, block in combos:
|
|
451
|
+
instance = _fake_instance(check_id, block)
|
|
452
|
+
result = await runner.run(check_id, instance)
|
|
453
|
+
results.append((check_id, block, result))
|
|
454
|
+
return results
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@app.command()
|
|
458
|
+
@_handle_errors
|
|
459
|
+
def check(
|
|
460
|
+
ctx: typer.Context,
|
|
461
|
+
block: Annotated[
|
|
462
|
+
list[str], typer.Option("--block", help="Run only for this block (repeatable).")
|
|
463
|
+
] = [], # noqa: B006
|
|
464
|
+
only: Annotated[
|
|
465
|
+
list[str], typer.Option("--only", help="Run only this check id (repeatable).")
|
|
466
|
+
] = [], # noqa: B006
|
|
467
|
+
) -> None:
|
|
468
|
+
"""Run checks directly against the current working tree, without the build graph."""
|
|
469
|
+
state: CliState = ctx.obj
|
|
470
|
+
app_ctx = _load_ctx(state)
|
|
471
|
+
resolved = app_ctx.require_profile()
|
|
472
|
+
|
|
473
|
+
check_ids = list(only) if only else sorted(resolved.profile.adapters)
|
|
474
|
+
raw_blocks = list(block) if block else sorted(resolved.profile.blocks)
|
|
475
|
+
blocks: list[str | None] = list(raw_blocks) if raw_blocks else [None]
|
|
476
|
+
|
|
477
|
+
runner = ProfileCheckRunner(app_ctx)
|
|
478
|
+
# A chip-wide check (per_block = False) runs once for the whole project, unless the
|
|
479
|
+
# user scoped the run with --block; running it per block would repeat its issues and
|
|
480
|
+
# drop those that belong to no block.
|
|
481
|
+
combos = [
|
|
482
|
+
(cid, b)
|
|
483
|
+
for cid in check_ids
|
|
484
|
+
for b in (blocks if block or runner.runs_per_block(cid) else [None])
|
|
485
|
+
]
|
|
486
|
+
results = asyncio.run(_run_checks(runner, combos))
|
|
487
|
+
|
|
488
|
+
if state.json_output:
|
|
489
|
+
typer.echo(json.dumps([r.model_dump(mode="json") for _, _, r in results], indent=2))
|
|
490
|
+
else:
|
|
491
|
+
for check_id, blk, result in results:
|
|
492
|
+
status = result.status.upper()
|
|
493
|
+
typer.echo(f"{status:6} {check_id} {blk or '-'} {len(result.issues)} issues")
|
|
494
|
+
for issue in result.issues:
|
|
495
|
+
loc = f"{issue.file or '-'}:{issue.line or '-'}"
|
|
496
|
+
typer.echo(f" {loc} [{issue.rule}] {issue.msg}")
|
|
497
|
+
|
|
498
|
+
if any(not r.ok for _, _, r in results):
|
|
499
|
+
raise typer.Exit(code=1)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# --- audit --------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _print_audit_text(report: object) -> None:
|
|
506
|
+
from chipgraph.packs.assist.audit import AuditReport
|
|
507
|
+
|
|
508
|
+
assert isinstance(report, AuditReport)
|
|
509
|
+
if report.ingest.ran:
|
|
510
|
+
by_sev = report.ingest.by_severity
|
|
511
|
+
summary = ", ".join(f"{sev}={by_sev[sev]}" for sev in sorted(by_sev)) or "none"
|
|
512
|
+
typer.echo(f"ingest issues: {summary}")
|
|
513
|
+
for message in report.ingest.errors:
|
|
514
|
+
typer.echo(f" error {message}")
|
|
515
|
+
|
|
516
|
+
typer.echo("checks:")
|
|
517
|
+
for check in report.checks:
|
|
518
|
+
typer.echo(
|
|
519
|
+
f" {check.status.upper():6} {check.check_id} {check.block or '-'} "
|
|
520
|
+
f"L{check.layer} {check.issues} issues"
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
if not report.layers:
|
|
524
|
+
typer.echo("findings: none")
|
|
525
|
+
for layer in report.layers:
|
|
526
|
+
sev = ", ".join(f"{s}={layer.by_severity[s]}" for s in sorted(layer.by_severity))
|
|
527
|
+
st = ", ".join(f"{s}={layer.by_status[s]}" for s in sorted(layer.by_status))
|
|
528
|
+
typer.echo(f"layer {layer.layer}: {sev} [{st}]")
|
|
529
|
+
for finding in layer.findings:
|
|
530
|
+
mark = "BLOCK" if finding.blocking else " "
|
|
531
|
+
typer.echo(
|
|
532
|
+
f" {mark} {finding.id} {finding.severity:9}{finding.status:10}"
|
|
533
|
+
f"{finding.source:16}{finding.evidence:40}{finding.claim}"
|
|
534
|
+
)
|
|
535
|
+
typer.echo(report.summary_line())
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@app.command()
|
|
539
|
+
@_handle_errors
|
|
540
|
+
def audit(
|
|
541
|
+
ctx: typer.Context,
|
|
542
|
+
block: Annotated[
|
|
543
|
+
list[str], typer.Option("--block", help="Audit only this block (repeatable).")
|
|
544
|
+
] = [], # noqa: B006
|
|
545
|
+
no_ingest: Annotated[
|
|
546
|
+
bool, typer.Option("--no-ingest", help="Skip the ingest step (reuse the model cache).")
|
|
547
|
+
] = False,
|
|
548
|
+
strict: Annotated[
|
|
549
|
+
bool, typer.Option("--strict", help="Exit 1 if any blocking finding exists.")
|
|
550
|
+
] = False,
|
|
551
|
+
) -> None:
|
|
552
|
+
"""Run every deterministic check over the whole project, grouped by layer (DESIGN 4.8)."""
|
|
553
|
+
from chipgraph.packs.assist.audit import run_audit
|
|
554
|
+
|
|
555
|
+
state: CliState = ctx.obj
|
|
556
|
+
app_ctx = _load_ctx(state)
|
|
557
|
+
app_ctx.require_profile()
|
|
558
|
+
report = run_audit(app_ctx, ingest=not no_ingest, blocks=list(block) if block else None)
|
|
559
|
+
|
|
560
|
+
if state.json_output:
|
|
561
|
+
typer.echo(json.dumps(report.model_dump(mode="json"), indent=2))
|
|
562
|
+
else:
|
|
563
|
+
_print_audit_text(report)
|
|
564
|
+
|
|
565
|
+
if strict and report.has_blocking:
|
|
566
|
+
raise typer.Exit(code=1)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# --- ingest -------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@app.command()
|
|
573
|
+
@_handle_errors
|
|
574
|
+
def ingest(
|
|
575
|
+
ctx: typer.Context,
|
|
576
|
+
strict: Annotated[
|
|
577
|
+
bool, typer.Option("--strict", help="Exit 1 if any error-severity issue was found.")
|
|
578
|
+
] = False,
|
|
579
|
+
) -> None:
|
|
580
|
+
"""Run every extractor, build the Design Model, and print stats."""
|
|
581
|
+
from chipgraph.app.ingest import run_ingest
|
|
582
|
+
|
|
583
|
+
state: CliState = ctx.obj
|
|
584
|
+
app_ctx = _load_ctx(state)
|
|
585
|
+
app_ctx.require_profile()
|
|
586
|
+
report = run_ingest(app_ctx)
|
|
587
|
+
stats = report.result.stats
|
|
588
|
+
|
|
589
|
+
if state.json_output:
|
|
590
|
+
payload = {
|
|
591
|
+
"db": str(report.db_path),
|
|
592
|
+
"build_inputs_hash": report.result.build_inputs_hash,
|
|
593
|
+
"stats": stats.model_dump(mode="json"),
|
|
594
|
+
"issues": [i.model_dump(mode="json") for i in report.issues],
|
|
595
|
+
}
|
|
596
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
597
|
+
else:
|
|
598
|
+
_print_ingest_text(stats, report)
|
|
599
|
+
|
|
600
|
+
if strict and report.has_error:
|
|
601
|
+
raise typer.Exit(code=1)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _print_ingest_text(stats: object, report: object) -> None:
|
|
605
|
+
from chipgraph.app.ingest import IngestReport
|
|
606
|
+
from chipgraph.core.model.ingest import IngestStats
|
|
607
|
+
|
|
608
|
+
assert isinstance(stats, IngestStats)
|
|
609
|
+
assert isinstance(report, IngestReport)
|
|
610
|
+
typer.echo(
|
|
611
|
+
f"inputs: {stats.inputs} entities: {stats.entities} relations: {stats.relations}"
|
|
612
|
+
)
|
|
613
|
+
if stats.entities_by_kind:
|
|
614
|
+
typer.echo("entities by kind:")
|
|
615
|
+
for kind, count in sorted(stats.entities_by_kind.items()):
|
|
616
|
+
typer.echo(f" {count:5} {kind}")
|
|
617
|
+
if stats.entities_by_block:
|
|
618
|
+
typer.echo("entities by block:")
|
|
619
|
+
for block, count in sorted(stats.entities_by_block.items()):
|
|
620
|
+
typer.echo(f" {count:5} {block or '(none)'}")
|
|
621
|
+
by_sev = stats.diagnostics_by_severity
|
|
622
|
+
if by_sev:
|
|
623
|
+
summary = ", ".join(f"{sev}={by_sev[sev]}" for sev in sorted(by_sev))
|
|
624
|
+
typer.echo(f"issues: {summary} conflicts: {stats.conflicts}")
|
|
625
|
+
else:
|
|
626
|
+
typer.echo(f"issues: none conflicts: {stats.conflicts}")
|
|
627
|
+
if report.documents is not None:
|
|
628
|
+
nda = len(report.documents.nda_skipped)
|
|
629
|
+
typer.echo(
|
|
630
|
+
f"documents indexed for /ask: {len(report.documents.indexed)} "
|
|
631
|
+
f"({report.documents.lines} lines; {nda} nda file(s) not indexed)"
|
|
632
|
+
)
|
|
633
|
+
shown = [i for i in report.issues if i.severity in ("warning", "error")][:20]
|
|
634
|
+
for issue in shown:
|
|
635
|
+
loc = f"{issue.file or '-'}:{issue.line or '-'}"
|
|
636
|
+
typer.echo(f" {issue.severity:7} [{issue.code}] {loc} {issue.message}")
|
|
637
|
+
typer.echo(f"db: {report.db_path}")
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# --- build / resume / rewind --------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _handoff_path(app_ctx: AppContext, run_id: str) -> Path:
|
|
644
|
+
return app_ctx.layout.run_dir(run_id) / "HANDOFF.md"
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _print_summary(summary: RunSummary, json_output: bool, handoff_path: Path) -> None:
|
|
648
|
+
if json_output:
|
|
649
|
+
payload = summary.model_dump(mode="json")
|
|
650
|
+
payload["handoff"] = str(handoff_path)
|
|
651
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
652
|
+
return
|
|
653
|
+
typer.echo(f"run {summary.run_id}")
|
|
654
|
+
for label, ids in (
|
|
655
|
+
("done", summary.done),
|
|
656
|
+
("skipped (fresh)", summary.skipped_fresh),
|
|
657
|
+
("failed", summary.failed),
|
|
658
|
+
("waiting on a gate", summary.waiting_gate),
|
|
659
|
+
("blocked", summary.blocked),
|
|
660
|
+
):
|
|
661
|
+
if ids:
|
|
662
|
+
typer.echo(f" {label}: {', '.join(ids)}")
|
|
663
|
+
typer.echo(str(handoff_path))
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _build_tracer(state: CliState, *, offline: bool) -> Tracer:
|
|
667
|
+
"""Build the tracer `build`/`resume` should use for this invocation."""
|
|
668
|
+
return trace_mod.configure(state.trace, offline=offline)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@app.command()
|
|
672
|
+
@_handle_errors
|
|
673
|
+
def build(
|
|
674
|
+
ctx: typer.Context,
|
|
675
|
+
target: Annotated[
|
|
676
|
+
str, typer.Argument(help="Rule id, 'id[k=v]', a named target ('rtl:timer'), or '*'.")
|
|
677
|
+
],
|
|
678
|
+
concurrency: Annotated[int, typer.Option("--concurrency", min=1)] = 4,
|
|
679
|
+
) -> None:
|
|
680
|
+
"""Build TARGET with the scheduler."""
|
|
681
|
+
state: CliState = ctx.obj
|
|
682
|
+
app_ctx = _load_ctx(state)
|
|
683
|
+
resolved = app_ctx.require_profile()
|
|
684
|
+
tracer = _build_tracer(state, offline=resolved.profile.offline)
|
|
685
|
+
scheduler = make_scheduler(app_ctx, target, concurrency=concurrency, tracer=tracer)
|
|
686
|
+
try:
|
|
687
|
+
summary = asyncio.run(scheduler.run(target))
|
|
688
|
+
finally:
|
|
689
|
+
tracer.flush()
|
|
690
|
+
_print_summary(summary, state.json_output, _handoff_path(app_ctx, summary.run_id))
|
|
691
|
+
if not summary.ok:
|
|
692
|
+
raise typer.Exit(code=1)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@app.command()
|
|
696
|
+
@_handle_errors
|
|
697
|
+
def resume(ctx: typer.Context, run_id: Annotated[str, typer.Argument()]) -> None:
|
|
698
|
+
"""Resume a run that was interrupted."""
|
|
699
|
+
state: CliState = ctx.obj
|
|
700
|
+
app_ctx = _load_ctx(state)
|
|
701
|
+
resolved = app_ctx.require_profile()
|
|
702
|
+
tracer = _build_tracer(state, offline=resolved.profile.offline)
|
|
703
|
+
scheduler = make_scheduler(app_ctx, "*", tracer=tracer)
|
|
704
|
+
if isinstance(scheduler.checks, ProfileCheckRunner):
|
|
705
|
+
scheduler.checks.run_id = run_id
|
|
706
|
+
try:
|
|
707
|
+
summary = asyncio.run(scheduler.resume(run_id))
|
|
708
|
+
finally:
|
|
709
|
+
tracer.flush()
|
|
710
|
+
_print_summary(summary, state.json_output, _handoff_path(app_ctx, summary.run_id))
|
|
711
|
+
if not summary.ok:
|
|
712
|
+
raise typer.Exit(code=1)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
@app.command()
|
|
716
|
+
@_handle_errors
|
|
717
|
+
def rewind(ctx: typer.Context, instance_id: Annotated[str, typer.Argument()]) -> None:
|
|
718
|
+
"""Delete a rule instance's record (and everything downstream), so it rebuilds."""
|
|
719
|
+
state: CliState = ctx.obj
|
|
720
|
+
app_ctx = _load_ctx(state)
|
|
721
|
+
app_ctx.require_profile()
|
|
722
|
+
scheduler = make_scheduler(app_ctx, "*")
|
|
723
|
+
ids = sorted(scheduler.rewind(instance_id))
|
|
724
|
+
if state.json_output:
|
|
725
|
+
typer.echo(json.dumps(ids))
|
|
726
|
+
else:
|
|
727
|
+
for iid in ids:
|
|
728
|
+
typer.echo(f"rewound {iid}")
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# --- status --------------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _latest_run_id(layout: StateLayout) -> str | None:
|
|
735
|
+
if not layout.runs_dir.is_dir():
|
|
736
|
+
return None
|
|
737
|
+
ids = sorted(p.name for p in layout.runs_dir.iterdir() if p.is_dir())
|
|
738
|
+
return ids[-1] if ids else None
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
@app.command()
|
|
742
|
+
@_handle_errors
|
|
743
|
+
def status(ctx: typer.Context, run_id: Annotated[str | None, typer.Argument()] = None) -> None:
|
|
744
|
+
"""Show the status of the latest run, or a given one."""
|
|
745
|
+
state: CliState = ctx.obj
|
|
746
|
+
app_ctx = _load_ctx(state)
|
|
747
|
+
layout = app_ctx.layout
|
|
748
|
+
rid = run_id or _latest_run_id(layout)
|
|
749
|
+
if rid is None:
|
|
750
|
+
typer.echo("no runs found")
|
|
751
|
+
return
|
|
752
|
+
|
|
753
|
+
journal_read = journal_mod.read(layout.journal(rid))
|
|
754
|
+
run_state = journal_mod.replay(journal_read.events)
|
|
755
|
+
gate_of: dict[str, str] = {}
|
|
756
|
+
for event in journal_read.events:
|
|
757
|
+
if event.type == "gate_wait" and event.rule_instance is not None:
|
|
758
|
+
gate_of[event.rule_instance] = str(event.payload.get("gate", ""))
|
|
759
|
+
waiting = {
|
|
760
|
+
iid: gate_of.get(iid, "") for iid, st in run_state.rules.items() if st == "waiting_gate"
|
|
761
|
+
}
|
|
762
|
+
counts = Counter(run_state.rules.values())
|
|
763
|
+
|
|
764
|
+
if state.json_output:
|
|
765
|
+
typer.echo(
|
|
766
|
+
json.dumps(
|
|
767
|
+
{
|
|
768
|
+
"run_id": rid,
|
|
769
|
+
"started": run_state.started,
|
|
770
|
+
"stopped": run_state.stopped,
|
|
771
|
+
"rules": run_state.rules,
|
|
772
|
+
"counts": dict(counts),
|
|
773
|
+
"waiting_gates": waiting,
|
|
774
|
+
"chain": _chain_rows(app_ctx, rid, run_state.rules),
|
|
775
|
+
},
|
|
776
|
+
indent=2,
|
|
777
|
+
)
|
|
778
|
+
)
|
|
779
|
+
return
|
|
780
|
+
|
|
781
|
+
typer.echo(f"run {rid} started={run_state.started} stopped={run_state.stopped}")
|
|
782
|
+
for iid, st in sorted(run_state.rules.items()):
|
|
783
|
+
typer.echo(f" {st:13} {iid}")
|
|
784
|
+
typer.echo("counts: " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))
|
|
785
|
+
if waiting:
|
|
786
|
+
typer.echo("waiting on gates:")
|
|
787
|
+
for iid, gate_id in sorted(waiting.items()):
|
|
788
|
+
typer.echo(f" {iid} -> {gate_id}")
|
|
789
|
+
chain = _target_chain(app_ctx, rid, run_state.rules)
|
|
790
|
+
if chain:
|
|
791
|
+
typer.echo(f"chain of {chain[0]}:")
|
|
792
|
+
typer.echo(chain[1])
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _chain_rows(app_ctx: AppContext, rid: str, states: Mapping[str, str]) -> list[dict[str, str]]:
|
|
796
|
+
"""The instances of the run's named target, in build order, with their state.
|
|
797
|
+
|
|
798
|
+
Empty when the run's target is not a named target (`rtl:<block>`), or the graph
|
|
799
|
+
cannot be built now.
|
|
800
|
+
"""
|
|
801
|
+
if app_ctx.resolved is None:
|
|
802
|
+
return []
|
|
803
|
+
try:
|
|
804
|
+
target = journal_mod.read_manifest(app_ctx.layout, rid).target
|
|
805
|
+
graph = project_graph(app_ctx)
|
|
806
|
+
if graph.targets is None or not graph.targets.is_target(target):
|
|
807
|
+
return []
|
|
808
|
+
selected = graph.select(target)
|
|
809
|
+
except (AppError, GraphError, OSError, ValueError):
|
|
810
|
+
return []
|
|
811
|
+
rows: list[dict[str, str]] = []
|
|
812
|
+
for iid in graph.topo_order():
|
|
813
|
+
if iid in selected:
|
|
814
|
+
rows.append({"instance": iid, "state": states.get(iid, "not run")})
|
|
815
|
+
return rows
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _target_chain(
|
|
819
|
+
app_ctx: AppContext, rid: str, states: Mapping[str, str]
|
|
820
|
+
) -> tuple[str, str] | None:
|
|
821
|
+
rows = _chain_rows(app_ctx, rid, states)
|
|
822
|
+
if not rows:
|
|
823
|
+
return None
|
|
824
|
+
target = journal_mod.read_manifest(app_ctx.layout, rid).target
|
|
825
|
+
lines = [f" {i + 1:2}. {r['state']:13} {r['instance']}" for i, r in enumerate(rows)]
|
|
826
|
+
return target, "\n".join(lines)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
# --- approve -------------------------------------------------------------------------
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
@app.command()
|
|
833
|
+
@_handle_errors
|
|
834
|
+
def approve(
|
|
835
|
+
ctx: typer.Context,
|
|
836
|
+
gate_id: Annotated[str, typer.Argument()],
|
|
837
|
+
instance: Annotated[
|
|
838
|
+
str | None,
|
|
839
|
+
typer.Option(
|
|
840
|
+
"--instance",
|
|
841
|
+
help="The rule instance id; optional when exactly one instance has the gate.",
|
|
842
|
+
),
|
|
843
|
+
] = None,
|
|
844
|
+
reject: Annotated[bool, typer.Option("--reject", help="Reject instead of approve.")] = False,
|
|
845
|
+
by: Annotated[str | None, typer.Option("--by")] = None,
|
|
846
|
+
note: Annotated[str, typer.Option("--note")] = "",
|
|
847
|
+
) -> None:
|
|
848
|
+
"""Record an approve/reject decision for a gate over a rule instance's inputs."""
|
|
849
|
+
state: CliState = ctx.obj
|
|
850
|
+
app_ctx = _load_ctx(state)
|
|
851
|
+
app_ctx.require_profile()
|
|
852
|
+
scheduler = make_scheduler(app_ctx, "*")
|
|
853
|
+
if instance is None:
|
|
854
|
+
instance = _gated_instance(scheduler.graph, gate_id)
|
|
855
|
+
rule_instance = scheduler.graph.instances.get(instance)
|
|
856
|
+
if rule_instance is None:
|
|
857
|
+
raise AppError(
|
|
858
|
+
f"no such rule instance {instance!r}; known instances: "
|
|
859
|
+
f"{sorted(scheduler.graph.instances)}"
|
|
860
|
+
)
|
|
861
|
+
decision: Literal["approve", "reject"] = "reject" if reject else "approve"
|
|
862
|
+
who = by or default_identity()
|
|
863
|
+
try:
|
|
864
|
+
approval = gate_mod.approve(
|
|
865
|
+
app_ctx.review,
|
|
866
|
+
app_ctx.store,
|
|
867
|
+
gate_id,
|
|
868
|
+
rule_instance,
|
|
869
|
+
by=who,
|
|
870
|
+
decision=decision,
|
|
871
|
+
note=note,
|
|
872
|
+
)
|
|
873
|
+
except gate_mod.GateError as exc:
|
|
874
|
+
raise AppError(str(exc)) from exc
|
|
875
|
+
if state.json_output:
|
|
876
|
+
typer.echo(approval.model_dump_json())
|
|
877
|
+
else:
|
|
878
|
+
typer.echo(f"{decision} recorded for gate {gate_id!r} by {who!r}")
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _gated_instance(graph: BuildGraph, gate_id: str) -> str:
|
|
882
|
+
"""The one instance whose rule's gate is `gate_id`; an `AppError` if not exactly one."""
|
|
883
|
+
found = sorted(
|
|
884
|
+
iid
|
|
885
|
+
for iid, inst in graph.instances.items()
|
|
886
|
+
if (rule := graph.rules[inst.rule_id]).gate
|
|
887
|
+
and _format_gate_id(rule.gate, inst.params) == gate_id
|
|
888
|
+
)
|
|
889
|
+
if len(found) != 1:
|
|
890
|
+
known = sorted(
|
|
891
|
+
{
|
|
892
|
+
_format_gate_id(rule.gate, inst.params)
|
|
893
|
+
for inst in graph.instances.values()
|
|
894
|
+
if (rule := graph.rules[inst.rule_id]).gate
|
|
895
|
+
}
|
|
896
|
+
)
|
|
897
|
+
what = "no instance has" if not found else f"{len(found)} instances have"
|
|
898
|
+
raise AppError(
|
|
899
|
+
f"{what} gate {gate_id!r}: pass --instance; gates: {', '.join(known) or 'none'}"
|
|
900
|
+
)
|
|
901
|
+
return found[0]
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _format_gate_id(template: str, params: dict[str, str]) -> str:
|
|
905
|
+
try:
|
|
906
|
+
return gate_mod.gate_id_for(template, params)
|
|
907
|
+
except gate_mod.GateError:
|
|
908
|
+
return template
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
# --- plan, rtl: the RTL target of a block --------------------------------------------
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _plan_show(state: CliState, block: str) -> None:
|
|
915
|
+
from chipgraph.packs.digital_rtl.plan.show import plan_summary, render_text
|
|
916
|
+
|
|
917
|
+
app_ctx = _load_ctx(state)
|
|
918
|
+
summary = plan_summary(app_ctx, block)
|
|
919
|
+
if state.json_output:
|
|
920
|
+
typer.echo(json.dumps(summary, indent=2))
|
|
921
|
+
else:
|
|
922
|
+
typer.echo(render_text(summary), nl=False)
|
|
923
|
+
if not summary["exists"] or not summary["ok"]:
|
|
924
|
+
raise typer.Exit(code=1)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _build_target(state: CliState, target: str, concurrency: int) -> None:
|
|
928
|
+
"""Build a named target, print the run and what is next; exit like `build`."""
|
|
929
|
+
from chipgraph.cli.next_steps import next_steps, write_chain
|
|
930
|
+
|
|
931
|
+
app_ctx = _load_ctx(state)
|
|
932
|
+
resolved = app_ctx.require_profile()
|
|
933
|
+
tracer = _build_tracer(state, offline=resolved.profile.offline)
|
|
934
|
+
scheduler = make_scheduler(app_ctx, target, concurrency=concurrency, tracer=tracer)
|
|
935
|
+
try:
|
|
936
|
+
summary = asyncio.run(scheduler.run(target))
|
|
937
|
+
finally:
|
|
938
|
+
tracer.flush()
|
|
939
|
+
steps = next_steps(app_ctx, scheduler.graph, summary)
|
|
940
|
+
handoff = _handoff_path(app_ctx, summary.run_id)
|
|
941
|
+
steps["chain"] = [
|
|
942
|
+
{"instance": iid, "state": st}
|
|
943
|
+
for iid, st in write_chain(handoff, scheduler.graph, target, summary)
|
|
944
|
+
]
|
|
945
|
+
if state.json_output:
|
|
946
|
+
payload = summary.model_dump(mode="json")
|
|
947
|
+
payload["handoff"] = str(handoff)
|
|
948
|
+
payload["target"] = target
|
|
949
|
+
payload["next"] = steps
|
|
950
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
951
|
+
else:
|
|
952
|
+
_print_summary(summary, False, handoff)
|
|
953
|
+
typer.echo("next:")
|
|
954
|
+
for step in steps["lines"]:
|
|
955
|
+
typer.echo(f" {step}")
|
|
956
|
+
if not summary.ok:
|
|
957
|
+
raise typer.Exit(code=1)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
@app.command("plan")
|
|
961
|
+
@_handle_errors
|
|
962
|
+
def plan_cmd(
|
|
963
|
+
ctx: typer.Context,
|
|
964
|
+
words: Annotated[
|
|
965
|
+
list[str],
|
|
966
|
+
typer.Argument(help="BLOCK: build its plan up to the gate; 'show BLOCK': read it."),
|
|
967
|
+
],
|
|
968
|
+
concurrency: Annotated[int, typer.Option("--concurrency", min=1)] = 4,
|
|
969
|
+
) -> None:
|
|
970
|
+
"""Build a block's plan up to and including its gate `plan:<block>`, or show it.
|
|
971
|
+
|
|
972
|
+
`chipgraph plan <block>` builds target `plan:<block>` (the planner writes the plan,
|
|
973
|
+
`plan_check` checks it, the build waits on the gate) and prints what is next: the
|
|
974
|
+
gate to approve, or the tasks waiting for `/chipgraph:run`. Exit 0 when the gate is
|
|
975
|
+
approved, 1 while something waits or failed, 2 on a usage or config error.
|
|
976
|
+
|
|
977
|
+
`chipgraph plan show <block>` prints the plan for its approver: modules, REQ
|
|
978
|
+
coverage, write sets, questions; exit 1 when there is none yet or it fails
|
|
979
|
+
`plan_check`.
|
|
980
|
+
"""
|
|
981
|
+
state: CliState = ctx.obj
|
|
982
|
+
if len(words) == 2 and words[0] == "show":
|
|
983
|
+
_plan_show(state, words[1])
|
|
984
|
+
return
|
|
985
|
+
if len(words) != 1:
|
|
986
|
+
raise AppError("usage: chipgraph plan <block> | chipgraph plan show <block>")
|
|
987
|
+
_build_target(state, f"plan:{words[0]}", concurrency)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
@app.command("rtl")
|
|
991
|
+
@_handle_errors
|
|
992
|
+
def rtl_cmd(
|
|
993
|
+
ctx: typer.Context,
|
|
994
|
+
block: Annotated[str, typer.Argument(help="The block.")],
|
|
995
|
+
concurrency: Annotated[int, typer.Option("--concurrency", min=1)] = 4,
|
|
996
|
+
) -> None:
|
|
997
|
+
"""Build target `rtl:<block>`: plan, gate, RTL per module, testbench, review, PR, gate.
|
|
998
|
+
|
|
999
|
+
Prints what is next: the gate to approve (`plan:<block>`, then `pr:<block>` and the
|
|
1000
|
+
PR's URL), or the agent tasks waiting for `/chipgraph:run`. Exit 0 when the PR is
|
|
1001
|
+
approved, 1 while something waits or failed, 2 on a usage or config error.
|
|
1002
|
+
"""
|
|
1003
|
+
_build_target(ctx.obj, f"rtl:{block}", concurrency)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
# --- baseline ------------------------------------------------------------------------
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _baseline_plan_json(plan: object) -> dict[str, object]:
|
|
1010
|
+
from chipgraph.core.engine.baseline import BaselineArtifact, BaselineGate, BaselinePlan
|
|
1011
|
+
|
|
1012
|
+
assert isinstance(plan, BaselinePlan)
|
|
1013
|
+
|
|
1014
|
+
def _artifact(a: BaselineArtifact) -> dict[str, object]:
|
|
1015
|
+
commit = (
|
|
1016
|
+
{
|
|
1017
|
+
"short_sha": a.last_commit.short_sha,
|
|
1018
|
+
"date": a.last_commit.date,
|
|
1019
|
+
"subject": a.last_commit.subject,
|
|
1020
|
+
"is_merge": a.last_commit.is_merge,
|
|
1021
|
+
}
|
|
1022
|
+
if a.last_commit is not None
|
|
1023
|
+
else None
|
|
1024
|
+
)
|
|
1025
|
+
return {
|
|
1026
|
+
"path": a.path,
|
|
1027
|
+
"sha256": a.sha256,
|
|
1028
|
+
"short_sha256": a.short_sha256,
|
|
1029
|
+
"status": a.status,
|
|
1030
|
+
"baselineable": a.baselineable,
|
|
1031
|
+
"last_commit": commit,
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
def _gate(g: BaselineGate) -> dict[str, object]:
|
|
1035
|
+
return {
|
|
1036
|
+
"gate_id": g.gate_id,
|
|
1037
|
+
"instance_id": g.instance_id,
|
|
1038
|
+
"already_decided": g.already_decided,
|
|
1039
|
+
"will_baseline": g.will_baseline,
|
|
1040
|
+
"artifacts": [_artifact(a) for a in g.artifacts],
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return {
|
|
1044
|
+
"gates": [_gate(g) for g in plan.gates],
|
|
1045
|
+
"gates_to_baseline": [g.gate_id for g in plan.gates_to_baseline],
|
|
1046
|
+
"skipped_gates": [g.gate_id for g in plan.skipped_gates],
|
|
1047
|
+
"dirty_artifacts": [_artifact(a) for a in plan.dirty_artifacts],
|
|
1048
|
+
"open_findings": plan.open_findings,
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _print_baseline_text(plan: object) -> None:
|
|
1053
|
+
from chipgraph.core.engine.baseline import BaselinePlan
|
|
1054
|
+
|
|
1055
|
+
assert isinstance(plan, BaselinePlan)
|
|
1056
|
+
if not plan.gates:
|
|
1057
|
+
typer.echo("no gates found: nothing to baseline")
|
|
1058
|
+
for gate in plan.gates:
|
|
1059
|
+
state = "decided (skipped)" if gate.already_decided else "waiting"
|
|
1060
|
+
typer.echo(f"gate {gate.gate_id} [{gate.instance_id}] {state}")
|
|
1061
|
+
for artifact in gate.artifacts:
|
|
1062
|
+
commit = artifact.last_commit
|
|
1063
|
+
where = (
|
|
1064
|
+
f"{commit.short_sha} {commit.date} {commit.subject}"
|
|
1065
|
+
if commit is not None
|
|
1066
|
+
else "(no commit)"
|
|
1067
|
+
)
|
|
1068
|
+
typer.echo(f" {artifact.status:9} {artifact.short_sha256} {artifact.path} {where}")
|
|
1069
|
+
if plan.dirty_artifacts:
|
|
1070
|
+
typer.echo("not baselined (modified or untracked in the working tree):")
|
|
1071
|
+
for artifact in plan.dirty_artifacts:
|
|
1072
|
+
typer.echo(f" {artifact.status:9} {artifact.path}")
|
|
1073
|
+
typer.echo(f"open findings: {plan.open_findings}")
|
|
1074
|
+
to_do = plan.gates_to_baseline
|
|
1075
|
+
if to_do:
|
|
1076
|
+
typer.echo(f"would baseline {len(to_do)} gate(s): {', '.join(g.gate_id for g in to_do)}")
|
|
1077
|
+
else:
|
|
1078
|
+
typer.echo("would baseline 0 gates")
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
@app.command("baseline")
|
|
1082
|
+
@_handle_errors
|
|
1083
|
+
def baseline_cmd(
|
|
1084
|
+
ctx: typer.Context,
|
|
1085
|
+
confirm: Annotated[
|
|
1086
|
+
bool, typer.Option("--confirm", help="Record the baseline decisions (default: dry run).")
|
|
1087
|
+
] = False,
|
|
1088
|
+
by: Annotated[str | None, typer.Option("--by")] = None,
|
|
1089
|
+
note: Annotated[str, typer.Option("--note")] = "",
|
|
1090
|
+
allow_branch: Annotated[
|
|
1091
|
+
bool,
|
|
1092
|
+
typer.Option("--allow-branch", help="Allow running on a branch other than main."),
|
|
1093
|
+
] = False,
|
|
1094
|
+
) -> None:
|
|
1095
|
+
"""List the artifacts on the main branch and, with --confirm, baseline them (DESIGN 6.4)."""
|
|
1096
|
+
state: CliState = ctx.obj
|
|
1097
|
+
app_ctx = _load_ctx(state)
|
|
1098
|
+
app_ctx.require_profile()
|
|
1099
|
+
|
|
1100
|
+
branch = current_branch(app_ctx.root)
|
|
1101
|
+
main = main_branch(app_ctx.root)
|
|
1102
|
+
if not allow_branch and branch is not None and branch != main:
|
|
1103
|
+
typer.echo(
|
|
1104
|
+
f"refusing to baseline on branch {branch!r}: not the main branch {main!r} "
|
|
1105
|
+
"(pass --allow-branch to override)",
|
|
1106
|
+
err=True,
|
|
1107
|
+
)
|
|
1108
|
+
raise typer.Exit(code=1)
|
|
1109
|
+
|
|
1110
|
+
plan, refs_by_path = plan_project_baseline(app_ctx)
|
|
1111
|
+
|
|
1112
|
+
if not confirm:
|
|
1113
|
+
if state.json_output:
|
|
1114
|
+
typer.echo(json.dumps(_baseline_plan_json(plan), indent=2))
|
|
1115
|
+
else:
|
|
1116
|
+
_print_baseline_text(plan)
|
|
1117
|
+
return
|
|
1118
|
+
|
|
1119
|
+
who = by or default_identity()
|
|
1120
|
+
outcome = run_baseline(app_ctx, plan, refs_by_path, by=who, note=note)
|
|
1121
|
+
if state.json_output:
|
|
1122
|
+
typer.echo(
|
|
1123
|
+
json.dumps(
|
|
1124
|
+
{
|
|
1125
|
+
"recorded_gates": [a.gate_id for a in outcome.approvals],
|
|
1126
|
+
"findings_baselined": list(outcome.findings_baselined),
|
|
1127
|
+
},
|
|
1128
|
+
indent=2,
|
|
1129
|
+
)
|
|
1130
|
+
)
|
|
1131
|
+
else:
|
|
1132
|
+
if outcome.approvals:
|
|
1133
|
+
typer.echo(f"recorded baseline for {len(outcome.approvals)} gate(s):")
|
|
1134
|
+
for approval in outcome.approvals:
|
|
1135
|
+
typer.echo(f" {approval.gate_id}")
|
|
1136
|
+
else:
|
|
1137
|
+
typer.echo("recorded baseline for 0 gates (nothing new)")
|
|
1138
|
+
if outcome.findings_baselined:
|
|
1139
|
+
typer.echo(f"baselined {len(outcome.findings_baselined)} open finding(s)")
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
# --- doctor ----------------------------------------------------------------------
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _writable(path: Path) -> bool:
|
|
1146
|
+
try:
|
|
1147
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
1148
|
+
probe = path / ".chipgraph-doctor-write-test"
|
|
1149
|
+
probe.write_text("ok", encoding="utf-8")
|
|
1150
|
+
probe.unlink()
|
|
1151
|
+
except OSError:
|
|
1152
|
+
return False
|
|
1153
|
+
return True
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _first_word(cmd: object) -> str | None:
|
|
1157
|
+
if isinstance(cmd, str):
|
|
1158
|
+
parts = cmd.split()
|
|
1159
|
+
return parts[0] if parts else None
|
|
1160
|
+
if isinstance(cmd, list) and cmd:
|
|
1161
|
+
return str(cmd[0])
|
|
1162
|
+
return None
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
_GUARD_MIN_PYTHON = (3, 9)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _guard_python() -> tuple[bool, str]:
|
|
1169
|
+
"""Whether the plugin's write guard can start: a `python3` >= 3.9 on PATH.
|
|
1170
|
+
|
|
1171
|
+
Claude Code runs the guard as `python3 guard.py`. Without it the hook cannot start and
|
|
1172
|
+
Claude Code lets the tool call through, so the write guard would be off without any
|
|
1173
|
+
warning (`submit` still rejects changes outside a task's outputs afterwards).
|
|
1174
|
+
"""
|
|
1175
|
+
found = shutil.which("python3")
|
|
1176
|
+
if found is None:
|
|
1177
|
+
return False, "not found on PATH: the plugin's write guard cannot run"
|
|
1178
|
+
try:
|
|
1179
|
+
out = subprocess.run(
|
|
1180
|
+
[found, "-c", "import sys; print('%d.%d' % sys.version_info[:2])"],
|
|
1181
|
+
capture_output=True,
|
|
1182
|
+
text=True,
|
|
1183
|
+
timeout=10,
|
|
1184
|
+
check=True,
|
|
1185
|
+
).stdout.strip()
|
|
1186
|
+
major, minor = (int(x) for x in out.split("."))
|
|
1187
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
1188
|
+
return False, f"{found} does not run: the plugin's write guard cannot run"
|
|
1189
|
+
if (major, minor) < _GUARD_MIN_PYTHON:
|
|
1190
|
+
return False, f"{found} is {out}; the write guard needs 3.9 or newer"
|
|
1191
|
+
return True, f"{found} ({out})"
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
@app.command()
|
|
1195
|
+
@_handle_errors
|
|
1196
|
+
def doctor(ctx: typer.Context) -> None:
|
|
1197
|
+
"""Report whether the environment is set up to run chipgraph."""
|
|
1198
|
+
state: CliState = ctx.obj
|
|
1199
|
+
lines: list[tuple[str, bool, str]] = []
|
|
1200
|
+
|
|
1201
|
+
py_ok = sys.version_info >= (3, 13)
|
|
1202
|
+
lines.append(("python", py_ok, sys.version.split()[0]))
|
|
1203
|
+
|
|
1204
|
+
git_path = shutil.which("git")
|
|
1205
|
+
lines.append(("git", git_path is not None, git_path or "not found on PATH"))
|
|
1206
|
+
|
|
1207
|
+
app_ctx: AppContext | None
|
|
1208
|
+
try:
|
|
1209
|
+
app_ctx = _load_ctx(state)
|
|
1210
|
+
except (AppError, ConfigError) as exc:
|
|
1211
|
+
app_ctx = None
|
|
1212
|
+
lines.append(("profile", False, str(exc)))
|
|
1213
|
+
else:
|
|
1214
|
+
if app_ctx.profile is not None:
|
|
1215
|
+
lines.append(("profile", True, str(app_ctx.root / ".chipgraph.yml")))
|
|
1216
|
+
else:
|
|
1217
|
+
lines.append(("profile", False, "no .chipgraph.yml found"))
|
|
1218
|
+
|
|
1219
|
+
runtime = app_ctx.profile.runtime if app_ctx is not None and app_ctx.profile else None
|
|
1220
|
+
if runtime in (None, "claude-code"):
|
|
1221
|
+
lines.append(("python3 for the write guard", *_guard_python()))
|
|
1222
|
+
|
|
1223
|
+
if app_ctx is not None and app_ctx.profile is not None:
|
|
1224
|
+
state_dir = app_ctx.layout.state_dir
|
|
1225
|
+
lines.append(("state dir writable", _writable(state_dir), str(state_dir)))
|
|
1226
|
+
for check_id, cfg in sorted(app_ctx.profile.adapters.items()):
|
|
1227
|
+
if cfg.use != "cmd":
|
|
1228
|
+
continue
|
|
1229
|
+
first = _first_word(cfg.model_dump().get("cmd"))
|
|
1230
|
+
found = shutil.which(first) if first else None
|
|
1231
|
+
detail = f"{first!r} -> {found}" if first else "no 'cmd' configured"
|
|
1232
|
+
lines.append((f"tool for {check_id}", found is not None, detail))
|
|
1233
|
+
|
|
1234
|
+
ok = all(passed for _, passed, _ in lines)
|
|
1235
|
+
if state.json_output:
|
|
1236
|
+
typer.echo(json.dumps([{"check": n, "ok": p, "detail": d} for n, p, d in lines], indent=2))
|
|
1237
|
+
else:
|
|
1238
|
+
for name, passed, detail in lines:
|
|
1239
|
+
mark = "ok" if passed else "MISSING"
|
|
1240
|
+
typer.echo(f"{mark:7} {name}: {detail}")
|
|
1241
|
+
if not ok:
|
|
1242
|
+
raise typer.Exit(code=1)
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
# --- findings / waive ---------------------------------------------------------------
|
|
1246
|
+
|
|
1247
|
+
_FINDING_STATUS_CHOICES = ("open", "waived", "fixed", "all")
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _finding_first_evidence(finding: Finding) -> str:
|
|
1251
|
+
ev = finding.evidence[0]
|
|
1252
|
+
if ev.file is not None:
|
|
1253
|
+
return f"{ev.file}:{ev.line}" if ev.line is not None else ev.file
|
|
1254
|
+
return f"model:{ev.model_key}"
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _findings_with_effective_status(
|
|
1258
|
+
app_ctx: AppContext, *, status: str, layer: int | None
|
|
1259
|
+
) -> list[Finding]:
|
|
1260
|
+
"""Every stored finding matching `layer`, with `.status` set to its *effective*
|
|
1261
|
+
status (resolving waiver currency against the finding's current artifact hashes),
|
|
1262
|
+
filtered by `status` unless it is `"all"`.
|
|
1263
|
+
"""
|
|
1264
|
+
store = FindingStore(app_ctx.layout)
|
|
1265
|
+
rows: list[Finding] = []
|
|
1266
|
+
for finding in store.list(layer=layer):
|
|
1267
|
+
waivers = app_ctx.review.approvals(waiver_gate_id(finding.id))
|
|
1268
|
+
current_hashes = findings_app.finding_current_hashes(app_ctx.store, finding, waivers)
|
|
1269
|
+
eff = effective_status(finding, waivers, current_hashes)
|
|
1270
|
+
if status != "all" and eff != status:
|
|
1271
|
+
continue
|
|
1272
|
+
display = finding if eff == finding.status else finding.model_copy(update={"status": eff})
|
|
1273
|
+
rows.append(display)
|
|
1274
|
+
return rows
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
@app.command("findings")
|
|
1278
|
+
@_handle_errors
|
|
1279
|
+
def findings_cmd(
|
|
1280
|
+
ctx: typer.Context,
|
|
1281
|
+
status: Annotated[
|
|
1282
|
+
str, typer.Option("--status", help="open|waived|fixed|all (default: open).")
|
|
1283
|
+
] = "open",
|
|
1284
|
+
layer: Annotated[
|
|
1285
|
+
int | None, typer.Option("--layer", min=1, max=5, help="Only this DESIGN.md layer.")
|
|
1286
|
+
] = None,
|
|
1287
|
+
) -> None:
|
|
1288
|
+
"""List recorded findings, with their effective status (DESIGN.md 4.8)."""
|
|
1289
|
+
state: CliState = ctx.obj
|
|
1290
|
+
if status not in _FINDING_STATUS_CHOICES:
|
|
1291
|
+
raise AppError(
|
|
1292
|
+
f"--status must be one of {'|'.join(_FINDING_STATUS_CHOICES)}, got {status!r}"
|
|
1293
|
+
)
|
|
1294
|
+
app_ctx = _load_ctx(state)
|
|
1295
|
+
rows = _findings_with_effective_status(app_ctx, status=status, layer=layer)
|
|
1296
|
+
|
|
1297
|
+
if state.json_output:
|
|
1298
|
+
typer.echo(json.dumps([f.model_dump(mode="json") for f in rows], indent=2))
|
|
1299
|
+
return
|
|
1300
|
+
if not rows:
|
|
1301
|
+
typer.echo("no findings")
|
|
1302
|
+
return
|
|
1303
|
+
for finding in rows:
|
|
1304
|
+
typer.echo(
|
|
1305
|
+
f"{finding.id} L{finding.layer} {finding.severity:9}{finding.source:16}"
|
|
1306
|
+
f"{_finding_first_evidence(finding):40}{finding.status:7}{finding.claim}"
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
@app.command("waive")
|
|
1311
|
+
@_handle_errors
|
|
1312
|
+
def waive_cmd(
|
|
1313
|
+
ctx: typer.Context,
|
|
1314
|
+
finding_id: Annotated[str, typer.Argument(help="A finding id, e.g. 'F-1a2b3c4d'.")],
|
|
1315
|
+
reason: Annotated[str, typer.Option("--reason", help="Why this finding is waived.")],
|
|
1316
|
+
by: Annotated[str | None, typer.Option("--by")] = None,
|
|
1317
|
+
bind: Annotated[
|
|
1318
|
+
list[str],
|
|
1319
|
+
typer.Option(
|
|
1320
|
+
"--bind",
|
|
1321
|
+
help=(
|
|
1322
|
+
"Also bind the waiver to this repo file (repeatable; may not exist yet). "
|
|
1323
|
+
"Needed for a finding with no file; the waiver ends when a bound file "
|
|
1324
|
+
"appears or changes."
|
|
1325
|
+
),
|
|
1326
|
+
),
|
|
1327
|
+
] = [], # noqa: B006
|
|
1328
|
+
) -> None:
|
|
1329
|
+
"""Waive a finding, binding the waiver to its artifacts' current hashes."""
|
|
1330
|
+
state: CliState = ctx.obj
|
|
1331
|
+
app_ctx = _load_ctx(state)
|
|
1332
|
+
store = FindingStore(app_ctx.layout)
|
|
1333
|
+
finding = store.get_by_id(finding_id)
|
|
1334
|
+
if finding is None:
|
|
1335
|
+
typer.echo(f"no such finding: {finding_id!r}", err=True)
|
|
1336
|
+
raise typer.Exit(code=2)
|
|
1337
|
+
|
|
1338
|
+
who = by or default_identity()
|
|
1339
|
+
current_hashes = app_ctx.store.current_hashes(finding.artifacts)
|
|
1340
|
+
current_hashes.update(findings_app.bound_hashes(app_ctx.store, bind))
|
|
1341
|
+
try:
|
|
1342
|
+
waived = findings_app.waive(
|
|
1343
|
+
finding,
|
|
1344
|
+
by=who,
|
|
1345
|
+
reason=reason,
|
|
1346
|
+
store=store,
|
|
1347
|
+
review=app_ctx.review,
|
|
1348
|
+
current_hashes=current_hashes,
|
|
1349
|
+
)
|
|
1350
|
+
except ValueError as exc:
|
|
1351
|
+
raise AppError(str(exc)) from exc
|
|
1352
|
+
|
|
1353
|
+
if state.json_output:
|
|
1354
|
+
typer.echo(waived.model_dump_json())
|
|
1355
|
+
else:
|
|
1356
|
+
typer.echo(f"waived {waived.id} by {who!r}: {reason}")
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
# --- agents-md ----------------------------------------------------------------------
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
@app.command("agents-md")
|
|
1363
|
+
@_handle_errors
|
|
1364
|
+
def agents_md_cmd(
|
|
1365
|
+
ctx: typer.Context,
|
|
1366
|
+
write: Annotated[
|
|
1367
|
+
bool, typer.Option("--write", help="Write the file (only if its content changes).")
|
|
1368
|
+
] = False,
|
|
1369
|
+
check_only: Annotated[
|
|
1370
|
+
bool,
|
|
1371
|
+
typer.Option(
|
|
1372
|
+
"--check", help="Exit 1 if the file on disk is not what --write would write (CI)."
|
|
1373
|
+
),
|
|
1374
|
+
] = False,
|
|
1375
|
+
path: Annotated[
|
|
1376
|
+
Path,
|
|
1377
|
+
typer.Option("--path", help="The file to manage, relative to the project root."),
|
|
1378
|
+
] = Path("AGENTS.md"),
|
|
1379
|
+
) -> None:
|
|
1380
|
+
"""Generate chipgraph's part of AGENTS.md from the profile and org rules (optional, no AI).
|
|
1381
|
+
|
|
1382
|
+
Only the text between `<!-- chipgraph:begin -->` and `<!-- chipgraph:end -->` is
|
|
1383
|
+
chipgraph's; the rest of the file is the team's and is kept byte for byte. A file
|
|
1384
|
+
without the markers gets the block appended. By default the full resulting file is
|
|
1385
|
+
printed and nothing is written. Opening a PR is out of scope: review and commit the
|
|
1386
|
+
file yourself.
|
|
1387
|
+
"""
|
|
1388
|
+
from chipgraph.core.config import load as load_config
|
|
1389
|
+
from chipgraph.packs.spec_core.gen.agents_md import AgentsMdError, agents_md_content
|
|
1390
|
+
|
|
1391
|
+
state: CliState = ctx.obj
|
|
1392
|
+
if write and check_only:
|
|
1393
|
+
typer.echo("--write and --check cannot be used together", err=True)
|
|
1394
|
+
raise typer.Exit(code=2)
|
|
1395
|
+
resolved = load_config(state.start, profile_path=state.profile_path)
|
|
1396
|
+
if resolved is None:
|
|
1397
|
+
raise AppError("no .chipgraph.yml: only read-only commands work; run `chipgraph init`")
|
|
1398
|
+
root = find_repo_root(state.start)
|
|
1399
|
+
target = path if path.is_absolute() else root / path
|
|
1400
|
+
|
|
1401
|
+
try:
|
|
1402
|
+
existing = target.read_bytes().decode("utf-8") if target.is_file() else None
|
|
1403
|
+
content = agents_md_content(existing, resolved, root)
|
|
1404
|
+
except (AgentsMdError, OSError, UnicodeDecodeError) as exc:
|
|
1405
|
+
typer.echo(f"{path}: {exc}", err=True)
|
|
1406
|
+
raise typer.Exit(code=2) from exc
|
|
1407
|
+
|
|
1408
|
+
if check_only:
|
|
1409
|
+
if content != existing:
|
|
1410
|
+
typer.echo(f"{path} is out of date; run `chipgraph agents-md --write`", err=True)
|
|
1411
|
+
raise typer.Exit(code=1)
|
|
1412
|
+
typer.echo(f"{path} is up to date")
|
|
1413
|
+
elif write:
|
|
1414
|
+
if content == existing:
|
|
1415
|
+
typer.echo(f"{path} is up to date")
|
|
1416
|
+
return
|
|
1417
|
+
try:
|
|
1418
|
+
target.write_bytes(content.encode("utf-8"))
|
|
1419
|
+
except OSError as exc:
|
|
1420
|
+
typer.echo(f"{path}: {exc}", err=True)
|
|
1421
|
+
raise typer.Exit(code=2) from exc
|
|
1422
|
+
typer.echo(f"wrote {path}")
|
|
1423
|
+
else:
|
|
1424
|
+
typer.echo(content, nl=False)
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
# --- ask ---------------------------------------------------------------------------
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
@app.command("ask")
|
|
1431
|
+
@_handle_errors
|
|
1432
|
+
def ask_cmd(
|
|
1433
|
+
ctx: typer.Context,
|
|
1434
|
+
question: Annotated[str, typer.Argument(help="The question, in quotes.")],
|
|
1435
|
+
limit: Annotated[
|
|
1436
|
+
int, typer.Option("--limit", min=1, max=50, help="At most this many sources.")
|
|
1437
|
+
] = 12,
|
|
1438
|
+
provider: Annotated[
|
|
1439
|
+
str | None,
|
|
1440
|
+
typer.Option("--provider", help="The models.providers entry to use (if several)."),
|
|
1441
|
+
] = None,
|
|
1442
|
+
) -> None:
|
|
1443
|
+
"""Answer a question from the Design Model and the project's documents, with citations.
|
|
1444
|
+
|
|
1445
|
+
Every citation is checked (an indexed `path:line` or a `model:<key>`); an answer that
|
|
1446
|
+
cannot be verified is not shown. Uses the profile's `models.providers` (API runtime);
|
|
1447
|
+
with none configured it prints the sources: in Claude Code, use `/chipgraph:ask`.
|
|
1448
|
+
Exit 1 when the model's answer was rejected.
|
|
1449
|
+
"""
|
|
1450
|
+
from chipgraph.packs.assist.ask import run_ask
|
|
1451
|
+
|
|
1452
|
+
state: CliState = ctx.obj
|
|
1453
|
+
app_ctx = _load_ctx(state)
|
|
1454
|
+
app_ctx.require_profile()
|
|
1455
|
+
result = run_ask(app_ctx, question, limit=limit, provider_name=provider)
|
|
1456
|
+
|
|
1457
|
+
if state.json_output:
|
|
1458
|
+
typer.echo(json.dumps(result.model_dump(mode="json"), indent=2))
|
|
1459
|
+
elif result.status == "no_provider":
|
|
1460
|
+
sources = result.context.sources
|
|
1461
|
+
typer.echo(f"sources for: {question}" if sources else "no source matches the question")
|
|
1462
|
+
for source in sources:
|
|
1463
|
+
typer.echo(f" {source.citation} {source.text}")
|
|
1464
|
+
typer.echo(
|
|
1465
|
+
"no model provider is configured (models.providers); in Claude Code, run "
|
|
1466
|
+
f'/chipgraph:ask "{question}" for a checked answer'
|
|
1467
|
+
)
|
|
1468
|
+
elif result.answer is not None:
|
|
1469
|
+
typer.echo(result.answer.answer)
|
|
1470
|
+
checks = {c.normalized: c for c in (result.check.citations if result.check else ())}
|
|
1471
|
+
if result.answer.citations:
|
|
1472
|
+
typer.echo("sources:")
|
|
1473
|
+
for citation in result.answer.citations:
|
|
1474
|
+
cited = checks.get(citation)
|
|
1475
|
+
snippet = cited.text.splitlines()[0] if cited and cited.text else ""
|
|
1476
|
+
typer.echo(f" {citation} {snippet}")
|
|
1477
|
+
if result.status == "rejected" and result.check is not None:
|
|
1478
|
+
typer.echo("the model's answer was rejected:", err=True)
|
|
1479
|
+
for reason in result.check.reasons:
|
|
1480
|
+
typer.echo(f" {reason}", err=True)
|
|
1481
|
+
|
|
1482
|
+
if result.status == "rejected":
|
|
1483
|
+
raise typer.Exit(code=1)
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
# --- triage ------------------------------------------------------------------------
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
@app.command("triage")
|
|
1490
|
+
@_handle_errors
|
|
1491
|
+
def triage_cmd(
|
|
1492
|
+
ctx: typer.Context,
|
|
1493
|
+
log: Annotated[
|
|
1494
|
+
str, typer.Argument(help="The failing log file, or - to read it from stdin.")
|
|
1495
|
+
] = "-",
|
|
1496
|
+
check: Annotated[
|
|
1497
|
+
str | None, typer.Option("--check", help="The check id that produced the log.")
|
|
1498
|
+
] = None,
|
|
1499
|
+
json_flag: Annotated[bool, typer.Option("--json", help="Print the report as JSON.")] = False,
|
|
1500
|
+
) -> None:
|
|
1501
|
+
"""Classify a failing lint/sim/check log as infra, rtl, tb or spec, and say what to do.
|
|
1502
|
+
|
|
1503
|
+
Deterministic rules answer first; otherwise decide() asks a model: the API runtime uses
|
|
1504
|
+
`models.providers`; runtime claude-code queues the question for Claude Code (run
|
|
1505
|
+
`/chipgraph:triage LOG` there). A model's label is advice and never blocks.
|
|
1506
|
+
Exit 1 when no label was decided.
|
|
1507
|
+
"""
|
|
1508
|
+
from chipgraph.packs.assist.triage import run_triage
|
|
1509
|
+
|
|
1510
|
+
state: CliState = ctx.obj
|
|
1511
|
+
app_ctx = _load_ctx(state)
|
|
1512
|
+
app_ctx.require_profile()
|
|
1513
|
+
if log == "-":
|
|
1514
|
+
text, source = sys.stdin.read(), None
|
|
1515
|
+
else:
|
|
1516
|
+
path = Path(log)
|
|
1517
|
+
try:
|
|
1518
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1519
|
+
except OSError as exc:
|
|
1520
|
+
raise AppError(f"cannot read the log {log}: {exc}") from exc
|
|
1521
|
+
source = log
|
|
1522
|
+
resolved_log = path.resolve()
|
|
1523
|
+
if resolved_log.is_relative_to(app_ctx.root.resolve()):
|
|
1524
|
+
source = resolved_log.relative_to(app_ctx.root.resolve()).as_posix()
|
|
1525
|
+
report = run_triage(app_ctx, text, source=source, check_id=check)
|
|
1526
|
+
|
|
1527
|
+
if json_flag or state.json_output:
|
|
1528
|
+
typer.echo(json.dumps(report.model_dump(mode="json"), indent=2))
|
|
1529
|
+
elif report.status == "decided":
|
|
1530
|
+
how = f"rule {report.rule}" if report.backend == "rule" else f"{report.backend} model"
|
|
1531
|
+
low = ", low confidence: advice only" if report.low_confidence else ""
|
|
1532
|
+
typer.echo(f"label: {report.label} ({how}, confidence {report.confidence:.2f}{low})")
|
|
1533
|
+
typer.echo(f"summary: {report.summary}")
|
|
1534
|
+
typer.echo(f"suggestion: {report.suggestion}")
|
|
1535
|
+
if report.evidence:
|
|
1536
|
+
typer.echo("evidence:")
|
|
1537
|
+
for item in report.evidence:
|
|
1538
|
+
typer.echo(f" {item}")
|
|
1539
|
+
else:
|
|
1540
|
+
typer.echo(f"summary: {report.summary}")
|
|
1541
|
+
typer.echo(report.message, err=True)
|
|
1542
|
+
|
|
1543
|
+
if report.status != "decided":
|
|
1544
|
+
raise typer.Exit(code=1)
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
# --- eval --------------------------------------------------------------------------
|
|
1548
|
+
|
|
1549
|
+
_EVAL_PACKAGE = "chipgraph_evals"
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def _evals_harness() -> object:
|
|
1553
|
+
"""`evals/harness/` of this chipgraph checkout, loaded as the package `chipgraph_evals`.
|
|
1554
|
+
|
|
1555
|
+
The evals are not part of the installed package; they are looked up next to the
|
|
1556
|
+
source tree (an editable install), then upward from the current directory.
|
|
1557
|
+
"""
|
|
1558
|
+
import importlib.util
|
|
1559
|
+
|
|
1560
|
+
if _EVAL_PACKAGE in sys.modules:
|
|
1561
|
+
return sys.modules[_EVAL_PACKAGE]
|
|
1562
|
+
checkout = Path(__file__).resolve().parents[3]
|
|
1563
|
+
for root in (checkout, Path.cwd().resolve(), *Path.cwd().resolve().parents):
|
|
1564
|
+
harness = root / "evals" / "harness"
|
|
1565
|
+
if (harness / "__init__.py").is_file():
|
|
1566
|
+
break
|
|
1567
|
+
else:
|
|
1568
|
+
raise AppError("chipgraph eval runs in a chipgraph checkout: evals/harness/ not found")
|
|
1569
|
+
spec = importlib.util.spec_from_file_location(
|
|
1570
|
+
_EVAL_PACKAGE, harness / "__init__.py", submodule_search_locations=[str(harness)]
|
|
1571
|
+
)
|
|
1572
|
+
assert spec is not None and spec.loader is not None
|
|
1573
|
+
module = importlib.util.module_from_spec(spec)
|
|
1574
|
+
sys.modules[_EVAL_PACKAGE] = module
|
|
1575
|
+
try:
|
|
1576
|
+
spec.loader.exec_module(module)
|
|
1577
|
+
except BaseException:
|
|
1578
|
+
del sys.modules[_EVAL_PACKAGE]
|
|
1579
|
+
raise
|
|
1580
|
+
return module
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
@app.command("eval")
|
|
1584
|
+
@_handle_errors
|
|
1585
|
+
def eval_cmd(
|
|
1586
|
+
ctx: typer.Context,
|
|
1587
|
+
suite: Annotated[
|
|
1588
|
+
str,
|
|
1589
|
+
typer.Argument(
|
|
1590
|
+
help=(
|
|
1591
|
+
"ask, triage, triage-holdout, triage-holdout2, review, review-holdout2, rtl or tb."
|
|
1592
|
+
)
|
|
1593
|
+
),
|
|
1594
|
+
],
|
|
1595
|
+
runtime: Annotated[
|
|
1596
|
+
Literal["fake", "claude-code"],
|
|
1597
|
+
typer.Option("--runtime", help="fake: scripted, no model (CI); claude-code: real."),
|
|
1598
|
+
] = "fake",
|
|
1599
|
+
main_model: Annotated[
|
|
1600
|
+
str, typer.Option("--main-model", help="claude-code: the main session's model.")
|
|
1601
|
+
] = "sonnet",
|
|
1602
|
+
small_model: Annotated[
|
|
1603
|
+
str, typer.Option("--small-model", help="claude-code triage: the small decider tier.")
|
|
1604
|
+
] = "haiku",
|
|
1605
|
+
large_model: Annotated[
|
|
1606
|
+
str,
|
|
1607
|
+
typer.Option(
|
|
1608
|
+
"--large-model",
|
|
1609
|
+
help="claude-code: the large tier (triage decider, review critic, rtl/tb escalation).",
|
|
1610
|
+
),
|
|
1611
|
+
] = "opus",
|
|
1612
|
+
budget_usd: Annotated[
|
|
1613
|
+
float | None,
|
|
1614
|
+
typer.Option(
|
|
1615
|
+
"--budget-usd",
|
|
1616
|
+
min=0.01,
|
|
1617
|
+
help="claude-code: stop once the suite cost reaches this (default 3; rtl, tb: 10).",
|
|
1618
|
+
),
|
|
1619
|
+
] = None,
|
|
1620
|
+
time_limit: Annotated[
|
|
1621
|
+
int, typer.Option("--time-limit", min=1, help="claude-code: suite time limit, seconds.")
|
|
1622
|
+
] = 3600,
|
|
1623
|
+
out: Annotated[
|
|
1624
|
+
Path | None,
|
|
1625
|
+
typer.Option("--out", help="Report directory (default: a new temporary directory)."),
|
|
1626
|
+
] = None,
|
|
1627
|
+
only: Annotated[
|
|
1628
|
+
str | None, typer.Option("--only", help="Only these ids, comma-separated.")
|
|
1629
|
+
] = None,
|
|
1630
|
+
) -> None:
|
|
1631
|
+
"""Run an eval suite (Inspect AI) and grade it with its deterministic grader.
|
|
1632
|
+
|
|
1633
|
+
Writes the Inspect log, answers.jsonl and summary.json/summary.md to --out. The fake
|
|
1634
|
+
runtime calls no model. claude-code runs one headless `claude -p` per sample on a
|
|
1635
|
+
tinysoc copy with the dev plugin, under --budget-usd and --time-limit; auth is
|
|
1636
|
+
CLAUDE_CODE_OAUTH_TOKEN when set, else the logged-in Claude Code account.
|
|
1637
|
+
Exit 0 pass (or no data yet), 1 fail, 2 error.
|
|
1638
|
+
"""
|
|
1639
|
+
import tempfile
|
|
1640
|
+
from typing import Any
|
|
1641
|
+
|
|
1642
|
+
try:
|
|
1643
|
+
import inspect_ai # noqa: F401
|
|
1644
|
+
except ImportError as exc:
|
|
1645
|
+
raise AppError(
|
|
1646
|
+
"chipgraph eval needs Inspect AI: run `uv sync` (dev group) or `uv sync --extra evals`"
|
|
1647
|
+
) from exc
|
|
1648
|
+
harness: Any = _evals_harness()
|
|
1649
|
+
state: CliState = ctx.obj
|
|
1650
|
+
out_dir = out if out is not None else Path(tempfile.mkdtemp(prefix=f"cg-eval-{suite}-"))
|
|
1651
|
+
options = harness.EvalOptions(
|
|
1652
|
+
runtime=runtime,
|
|
1653
|
+
main_model=main_model,
|
|
1654
|
+
small_model=small_model,
|
|
1655
|
+
large_model=large_model,
|
|
1656
|
+
budget_usd=budget_usd,
|
|
1657
|
+
time_limit_s=time_limit,
|
|
1658
|
+
only=tuple(i for i in (only or "").replace(",", " ").split() if i),
|
|
1659
|
+
)
|
|
1660
|
+
try:
|
|
1661
|
+
result = harness.run_suite(
|
|
1662
|
+
suite, out_dir, options, progress=lambda line: typer.echo(line, err=True)
|
|
1663
|
+
)
|
|
1664
|
+
except harness.EvalError as exc:
|
|
1665
|
+
raise AppError(str(exc)) from exc
|
|
1666
|
+
if state.json_output:
|
|
1667
|
+
typer.echo(json.dumps(result.summary, indent=2))
|
|
1668
|
+
else:
|
|
1669
|
+
typer.echo((out_dir / "summary.md").read_text(encoding="utf-8").rstrip())
|
|
1670
|
+
typer.echo(f"report: {out_dir}")
|
|
1671
|
+
if result.exit_code:
|
|
1672
|
+
raise typer.Exit(code=result.exit_code)
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
# --- mcp ---------------------------------------------------------------------------
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
@app.command("mcp")
|
|
1679
|
+
@_handle_errors
|
|
1680
|
+
def mcp_cmd(ctx: typer.Context) -> None:
|
|
1681
|
+
"""Start the MCP server over stdio, for Claude Code or another MCP client."""
|
|
1682
|
+
from chipgraph.mcp import run_stdio
|
|
1683
|
+
|
|
1684
|
+
state: CliState = ctx.obj
|
|
1685
|
+
asyncio.run(run_stdio(state.start, profile_path=state.profile_path))
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
__all__ = ["app"]
|