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,766 @@
|
|
|
1
|
+
"""Find, read, merge and resolve the layered configuration (DESIGN 8.3, 8.4, 8.6, 12.6).
|
|
2
|
+
|
|
3
|
+
Layer order, later overrides earlier::
|
|
4
|
+
|
|
5
|
+
tool defaults -> each `extends` entry, in order (recursively, ancestors first)
|
|
6
|
+
-> project `.chipgraph.yml`
|
|
7
|
+
|
|
8
|
+
The personal user layer (`~/.config/chipgraph/user.yml`) is resolved separately and
|
|
9
|
+
applied only to its own keys (autonomy, notify, answer_language, prefer_models, editor);
|
|
10
|
+
it never merges into the project's artifact conventions.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import fnmatch
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from importlib import resources
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Literal, Protocol, cast
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
from pydantic import ValidationError
|
|
27
|
+
|
|
28
|
+
from chipgraph.core.config.defaults import DEFAULT_PROFILE
|
|
29
|
+
from chipgraph.core.config.errors import ConfigError
|
|
30
|
+
from chipgraph.core.config.models import Level, PathRule, Profile, UserConfig
|
|
31
|
+
|
|
32
|
+
SourceKind = Literal["tool", "org", "preset", "path", "git", "project", "user"]
|
|
33
|
+
|
|
34
|
+
_LEVEL_ORDER: dict[Level, int] = {"L1": 1, "L2": 2, "L3": 3, "L4": 4, "L5": 5}
|
|
35
|
+
|
|
36
|
+
_UNSET_LOCATION = "(unset, model default)"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SourceFetcher(Protocol):
|
|
40
|
+
"""Fetches a git-hosted rule repository, pinned to a ref, to a local directory."""
|
|
41
|
+
|
|
42
|
+
def fetch(self, url: str, ref: str) -> tuple[Path, str]:
|
|
43
|
+
"""Return (local directory containing the fetched profile, resolved commit)."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class SourceRef:
|
|
49
|
+
"""Where one merged value came from: which layer, which file/URL, which version."""
|
|
50
|
+
|
|
51
|
+
kind: SourceKind
|
|
52
|
+
location: str
|
|
53
|
+
version: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ConfigIssue:
|
|
58
|
+
"""One consistency problem found by `ResolvedProfile.check()`."""
|
|
59
|
+
|
|
60
|
+
severity: Literal["error", "warning", "info"]
|
|
61
|
+
key: str
|
|
62
|
+
message: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class _Layer:
|
|
67
|
+
source: SourceRef
|
|
68
|
+
data: dict[str, Any]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------------------------------
|
|
72
|
+
# YAML reading
|
|
73
|
+
# --------------------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _read_yaml_with_hash(path: Path) -> tuple[Any, str]:
|
|
77
|
+
try:
|
|
78
|
+
text = path.read_text(encoding="utf-8")
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
raise ConfigError(f"cannot read file: {exc}", file=path) from exc
|
|
81
|
+
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
82
|
+
try:
|
|
83
|
+
return yaml.safe_load(text), digest
|
|
84
|
+
except yaml.YAMLError as exc:
|
|
85
|
+
mark = getattr(exc, "problem_mark", None)
|
|
86
|
+
line = (mark.line + 1) if mark is not None else None
|
|
87
|
+
raise ConfigError(f"invalid YAML: {exc}", file=path, line=line) from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _sha256_json(data: Any) -> str:
|
|
91
|
+
canonical = json.dumps(data, sort_keys=True, separators=(",", ":"), default=str)
|
|
92
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --------------------------------------------------------------------------------------
|
|
96
|
+
# find_profile
|
|
97
|
+
# --------------------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def find_profile(start: Path) -> Path | None:
|
|
101
|
+
"""Walk up from `start` to the nearest directory with `.chipgraph.yml`.
|
|
102
|
+
|
|
103
|
+
Stops (inclusive) at the first directory containing `.git`, or at the filesystem root.
|
|
104
|
+
"""
|
|
105
|
+
current = start.resolve()
|
|
106
|
+
if current.is_file():
|
|
107
|
+
current = current.parent
|
|
108
|
+
while True:
|
|
109
|
+
candidate = current / ".chipgraph.yml"
|
|
110
|
+
if candidate.is_file():
|
|
111
|
+
return candidate
|
|
112
|
+
if (current / ".git").exists():
|
|
113
|
+
return None
|
|
114
|
+
parent = current.parent
|
|
115
|
+
if parent == current:
|
|
116
|
+
return None
|
|
117
|
+
current = parent
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --------------------------------------------------------------------------------------
|
|
121
|
+
# extends resolution
|
|
122
|
+
# --------------------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _split_extends_entry(entry: str) -> tuple[Literal["org", "preset", "path", "git"], str]:
|
|
126
|
+
if entry.startswith("git+"):
|
|
127
|
+
return "git", entry[len("git+") :]
|
|
128
|
+
if ":" not in entry:
|
|
129
|
+
raise ConfigError(
|
|
130
|
+
f"invalid extends entry {entry!r}: expected 'org:'/'preset:'/'path:'/'git+'"
|
|
131
|
+
)
|
|
132
|
+
kind, rest = entry.split(":", 1)
|
|
133
|
+
if kind in ("org", "preset", "path"):
|
|
134
|
+
return cast(Literal["org", "preset", "path"], kind), rest
|
|
135
|
+
raise ConfigError(f"invalid extends entry {entry!r}: unknown kind {kind!r}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _load_layer_chain(
|
|
139
|
+
file_path: Path,
|
|
140
|
+
kind: SourceKind,
|
|
141
|
+
location: str,
|
|
142
|
+
data_dir: Path | None,
|
|
143
|
+
fetcher: SourceFetcher | None,
|
|
144
|
+
visited: frozenset[str],
|
|
145
|
+
*,
|
|
146
|
+
version: str | None = None,
|
|
147
|
+
) -> list[_Layer]:
|
|
148
|
+
"""Load one profile file, recursing into its own `extends` first (ancestors first)."""
|
|
149
|
+
key = str(file_path)
|
|
150
|
+
if key in visited:
|
|
151
|
+
raise ConfigError(f"cycle in 'extends': {key} is already being loaded", file=file_path)
|
|
152
|
+
visited = visited | {key}
|
|
153
|
+
|
|
154
|
+
raw, file_hash = _read_yaml_with_hash(file_path)
|
|
155
|
+
if raw is None:
|
|
156
|
+
raw = {}
|
|
157
|
+
if not isinstance(raw, dict):
|
|
158
|
+
raise ConfigError("profile must be a YAML mapping", file=file_path)
|
|
159
|
+
|
|
160
|
+
source = SourceRef(
|
|
161
|
+
kind=kind, location=location, version=version if version is not None else file_hash
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
layers: list[_Layer] = []
|
|
165
|
+
for entry in raw.get("extends", []) or []:
|
|
166
|
+
layers.extend(_resolve_extends(entry, file_path.parent, data_dir, fetcher, visited))
|
|
167
|
+
layers.append(_Layer(source=source, data=raw))
|
|
168
|
+
return layers
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _resolve_extends(
|
|
172
|
+
entry: str,
|
|
173
|
+
referencing_dir: Path,
|
|
174
|
+
data_dir: Path | None,
|
|
175
|
+
fetcher: SourceFetcher | None,
|
|
176
|
+
visited: frozenset[str],
|
|
177
|
+
) -> list[_Layer]:
|
|
178
|
+
kind, rest = _split_extends_entry(entry)
|
|
179
|
+
|
|
180
|
+
if kind == "path":
|
|
181
|
+
target = (referencing_dir / rest).resolve()
|
|
182
|
+
if not target.is_file():
|
|
183
|
+
raise ConfigError(f"extends entry {entry!r}: no file at {target}")
|
|
184
|
+
return _load_layer_chain(target, "path", str(target), data_dir, fetcher, visited)
|
|
185
|
+
|
|
186
|
+
if kind == "org" or kind == "preset":
|
|
187
|
+
if data_dir is None:
|
|
188
|
+
raise ConfigError(
|
|
189
|
+
f"extends entry {entry!r} needs a data_dir with an '{kind}s/' directory; "
|
|
190
|
+
"none was found or given"
|
|
191
|
+
)
|
|
192
|
+
target = data_dir / f"{kind}s" / rest / "profile.yml"
|
|
193
|
+
if not target.is_file():
|
|
194
|
+
raise ConfigError(f"extends entry {entry!r}: no profile.yml at {target}")
|
|
195
|
+
return _load_layer_chain(target, kind, entry, data_dir, fetcher, visited)
|
|
196
|
+
|
|
197
|
+
if kind == "git":
|
|
198
|
+
if "@" not in rest:
|
|
199
|
+
raise ConfigError(f"extends entry {entry!r}: pin a tag or commit ('@<ref>')")
|
|
200
|
+
url, ref = rest.rsplit("@", 1)
|
|
201
|
+
if fetcher is None:
|
|
202
|
+
raise ConfigError(
|
|
203
|
+
f"extends entry {entry!r} needs a git fetcher for {url!r}, none given"
|
|
204
|
+
)
|
|
205
|
+
local_dir, commit = fetcher.fetch(url, ref)
|
|
206
|
+
target = local_dir / "profile.yml"
|
|
207
|
+
if not target.is_file():
|
|
208
|
+
raise ConfigError(f"extends entry {entry!r}: no profile.yml in {local_dir}")
|
|
209
|
+
return _load_layer_chain(target, "git", url, data_dir, fetcher, visited, version=commit)
|
|
210
|
+
|
|
211
|
+
raise ConfigError(f"invalid extends entry {entry!r}") # pragma: no cover - unreachable
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --------------------------------------------------------------------------------------
|
|
215
|
+
# merge with provenance
|
|
216
|
+
# --------------------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _merge(
|
|
220
|
+
base: Mapping[str, Any],
|
|
221
|
+
overlay: Mapping[str, Any],
|
|
222
|
+
source: SourceRef,
|
|
223
|
+
provenance: dict[str, SourceRef],
|
|
224
|
+
prefix: str = "",
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
"""Recursively merge `overlay` over `base`; mappings merge, everything else replaces."""
|
|
227
|
+
result = dict(base)
|
|
228
|
+
for raw_key, value in overlay.items():
|
|
229
|
+
key = str(raw_key)
|
|
230
|
+
path = f"{prefix}.{key}" if prefix else key
|
|
231
|
+
if isinstance(value, Mapping):
|
|
232
|
+
base_value = base.get(key)
|
|
233
|
+
base_sub = base_value if isinstance(base_value, Mapping) else {}
|
|
234
|
+
result[key] = _merge(base_sub, value, source, provenance, path)
|
|
235
|
+
else:
|
|
236
|
+
result[key] = value
|
|
237
|
+
provenance[path] = source
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _record_leaves(
|
|
242
|
+
data: Any, source: SourceRef, provenance: dict[str, SourceRef], prefix: str
|
|
243
|
+
) -> None:
|
|
244
|
+
if isinstance(data, Mapping):
|
|
245
|
+
for raw_key, value in data.items():
|
|
246
|
+
path = f"{prefix}.{raw_key}" if prefix else str(raw_key)
|
|
247
|
+
_record_leaves(value, source, provenance, path)
|
|
248
|
+
else:
|
|
249
|
+
provenance[prefix] = source
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _collect_leaves(
|
|
253
|
+
data: Any,
|
|
254
|
+
prefix: str,
|
|
255
|
+
provenance: Mapping[str, SourceRef],
|
|
256
|
+
out: list[tuple[str, object, SourceRef]],
|
|
257
|
+
fallback_kind: SourceKind,
|
|
258
|
+
) -> None:
|
|
259
|
+
if isinstance(data, Mapping):
|
|
260
|
+
for raw_key, value in data.items():
|
|
261
|
+
path = f"{prefix}.{raw_key}" if prefix else str(raw_key)
|
|
262
|
+
_collect_leaves(value, path, provenance, out, fallback_kind)
|
|
263
|
+
return
|
|
264
|
+
source = provenance.get(prefix)
|
|
265
|
+
if source is None:
|
|
266
|
+
source = SourceRef(kind=fallback_kind, location=_UNSET_LOCATION, version="")
|
|
267
|
+
if isinstance(data, list):
|
|
268
|
+
data = tuple(data)
|
|
269
|
+
out.append((prefix, data, source))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _loc_to_key(loc: tuple[Any, ...]) -> str:
|
|
273
|
+
return ".".join(str(part) for part in loc)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _find_provenance(provenance: Mapping[str, SourceRef], key: str) -> SourceRef | None:
|
|
277
|
+
if key in provenance:
|
|
278
|
+
return provenance[key]
|
|
279
|
+
# fall back to the closest ancestor or descendant prefix we do have provenance for
|
|
280
|
+
for candidate in sorted(provenance, key=len, reverse=True):
|
|
281
|
+
if key.startswith(candidate + ".") or candidate.startswith(key + "."):
|
|
282
|
+
return provenance[candidate]
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _translate_validation_error(
|
|
287
|
+
exc: ValidationError, provenance: Mapping[str, SourceRef], fallback_file: str
|
|
288
|
+
) -> ConfigError:
|
|
289
|
+
first = exc.errors()[0]
|
|
290
|
+
key = _loc_to_key(first["loc"])
|
|
291
|
+
source = _find_provenance(provenance, key) if key else None
|
|
292
|
+
file = source.location if source is not None else fallback_file
|
|
293
|
+
return ConfigError(str(first["msg"]), file=file, key=key or None)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# --------------------------------------------------------------------------------------
|
|
297
|
+
# defaults for data_dir / user config path
|
|
298
|
+
# --------------------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def builtin_data_dir() -> Path | None:
|
|
302
|
+
"""The directory holding the `orgs/` and `presets/` shipped with chipgraph (D36).
|
|
303
|
+
|
|
304
|
+
It is the `chipgraph` package directory, found through `importlib.resources` so it is
|
|
305
|
+
the same from a checkout and from a wheel. `None` if the package is not on a real
|
|
306
|
+
filesystem or ships neither `orgs/` nor `presets/`.
|
|
307
|
+
"""
|
|
308
|
+
root = resources.files("chipgraph")
|
|
309
|
+
if not isinstance(root, Path):
|
|
310
|
+
return None
|
|
311
|
+
if (root / "orgs").is_dir() or (root / "presets").is_dir():
|
|
312
|
+
return root
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _default_data_dir() -> Path | None:
|
|
317
|
+
return builtin_data_dir()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def resolve_data_ref(ref: str, base_dir: Path, data_dir: Path | None = None) -> Path:
|
|
321
|
+
"""Resolve a data file reference from a profile to an existing file.
|
|
322
|
+
|
|
323
|
+
- `org:<org>/<file>` and `preset:<preset>/<file>` resolve under `data_dir/orgs/` and
|
|
324
|
+
`data_dir/presets/` (default: `builtin_data_dir()`), e.g. `org:qnsc/naming-v1.yml`.
|
|
325
|
+
- `path:<rel>` or a plain relative path resolves against `base_dir`; an absolute path
|
|
326
|
+
is used as is.
|
|
327
|
+
|
|
328
|
+
Raises `ConfigError` if the reference is malformed or the file does not exist.
|
|
329
|
+
"""
|
|
330
|
+
kind, sep, rest = ref.partition(":")
|
|
331
|
+
if sep and kind in ("org", "preset"):
|
|
332
|
+
root = data_dir if data_dir is not None else builtin_data_dir()
|
|
333
|
+
if root is None:
|
|
334
|
+
raise ConfigError(f"reference {ref!r} needs a data_dir with '{kind}s/'; none found")
|
|
335
|
+
if "/" not in rest or rest.startswith("/") or ".." in Path(rest).parts:
|
|
336
|
+
raise ConfigError(f"invalid reference {ref!r}: expected '{kind}:<name>/<file>'")
|
|
337
|
+
target = root / f"{kind}s" / rest
|
|
338
|
+
else:
|
|
339
|
+
rel = rest if sep and kind == "path" else ref
|
|
340
|
+
target = base_dir / rel
|
|
341
|
+
if not target.is_file():
|
|
342
|
+
raise ConfigError(f"reference {ref!r}: no file at {target}")
|
|
343
|
+
return target
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _default_user_config_path() -> Path:
|
|
347
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
348
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
349
|
+
return base / "chipgraph" / "user.yml"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# --------------------------------------------------------------------------------------
|
|
353
|
+
# ResolvedProfile
|
|
354
|
+
# --------------------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@dataclass
|
|
358
|
+
class ResolvedProfile:
|
|
359
|
+
"""The fully merged project profile, plus the personal layer and full provenance."""
|
|
360
|
+
|
|
361
|
+
profile: Profile
|
|
362
|
+
user: UserConfig
|
|
363
|
+
sources: tuple[SourceRef, ...]
|
|
364
|
+
profile_hash: str
|
|
365
|
+
provenance: dict[str, SourceRef] = field(default_factory=dict)
|
|
366
|
+
|
|
367
|
+
def explain(self) -> list[tuple[str, object, SourceRef]]:
|
|
368
|
+
"""Every leaf value (dotted key, value, source), sorted by key.
|
|
369
|
+
|
|
370
|
+
Backs `chipgraph config show --explain`. User-layer leaves are prefixed `user.`.
|
|
371
|
+
"""
|
|
372
|
+
leaves: list[tuple[str, object, SourceRef]] = []
|
|
373
|
+
_collect_leaves(self.profile.model_dump(mode="python"), "", self.provenance, leaves, "tool")
|
|
374
|
+
_collect_leaves(
|
|
375
|
+
self.user.model_dump(mode="python"), "user", self.provenance, leaves, "user"
|
|
376
|
+
)
|
|
377
|
+
leaves.sort(key=lambda item: item[0])
|
|
378
|
+
return leaves
|
|
379
|
+
|
|
380
|
+
def check(self) -> list[ConfigIssue]:
|
|
381
|
+
"""Consistency problems: user overreach, duplicate layout templates, wrappers, plan
|
|
382
|
+
limits, the style section, local plugins."""
|
|
383
|
+
issues: list[ConfigIssue] = []
|
|
384
|
+
for area, user_level in self.user.autonomy.items():
|
|
385
|
+
project_level = self.profile.autonomy.get(area)
|
|
386
|
+
if project_level is not None and _LEVEL_ORDER[user_level] > _LEVEL_ORDER[project_level]:
|
|
387
|
+
issues.append(
|
|
388
|
+
ConfigIssue(
|
|
389
|
+
severity="error",
|
|
390
|
+
key=f"autonomy.{area}",
|
|
391
|
+
message="the user layer may lower autonomy, not raise it",
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
allowed_models = set(self.profile.models.tiers.values()) | set(
|
|
396
|
+
self.profile.models.alt.values()
|
|
397
|
+
)
|
|
398
|
+
for tier, model in self.user.prefer_models.items():
|
|
399
|
+
if model not in allowed_models:
|
|
400
|
+
issues.append(
|
|
401
|
+
ConfigIssue(
|
|
402
|
+
severity="error",
|
|
403
|
+
key=f"user.prefer_models.{tier}",
|
|
404
|
+
message=f"model {model!r} is not in this project's models.tiers/alt",
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
seen: dict[str, str] = {}
|
|
409
|
+
for name, template in self.profile.layout.items():
|
|
410
|
+
if not isinstance(template, str):
|
|
411
|
+
continue
|
|
412
|
+
other = next((k for k, v in seen.items() if v == template), None)
|
|
413
|
+
if other is not None:
|
|
414
|
+
issues.append(
|
|
415
|
+
ConfigIssue(
|
|
416
|
+
severity="error",
|
|
417
|
+
key=f"layout.{name}",
|
|
418
|
+
message=f"layout template is identical to 'layout.{other}'",
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
seen[name] = template
|
|
422
|
+
|
|
423
|
+
instance_owner: dict[str, str] = {}
|
|
424
|
+
for block_name in sorted(self.profile.blocks):
|
|
425
|
+
override = self.profile.blocks[block_name]
|
|
426
|
+
for instance in override.instances:
|
|
427
|
+
other = instance_owner.get(instance)
|
|
428
|
+
if other is not None:
|
|
429
|
+
issues.append(
|
|
430
|
+
ConfigIssue(
|
|
431
|
+
severity="error",
|
|
432
|
+
key=f"blocks.{block_name}.instances",
|
|
433
|
+
message=(
|
|
434
|
+
f"instance {instance!r} is already an instance of block {other!r}"
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
else:
|
|
439
|
+
instance_owner[instance] = block_name
|
|
440
|
+
|
|
441
|
+
issues.extend(_wrapper_issues(self.profile))
|
|
442
|
+
|
|
443
|
+
plan = self.profile.plan
|
|
444
|
+
if plan.max_total_tries < plan.max_modules:
|
|
445
|
+
issues.append(
|
|
446
|
+
ConfigIssue(
|
|
447
|
+
severity="warning",
|
|
448
|
+
key="plan.max_total_tries",
|
|
449
|
+
message=(
|
|
450
|
+
f"{plan.max_total_tries} is below plan.max_modules "
|
|
451
|
+
f"({plan.max_modules}): every module needs at least one try, so a "
|
|
452
|
+
f"plan of {plan.max_modules} modules can never pass"
|
|
453
|
+
),
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
if plan.max_depth > plan.max_modules:
|
|
457
|
+
issues.append(
|
|
458
|
+
ConfigIssue(
|
|
459
|
+
severity="info",
|
|
460
|
+
key="plan.max_depth",
|
|
461
|
+
message=(
|
|
462
|
+
f"{plan.max_depth} is above plan.max_modules ({plan.max_modules}): "
|
|
463
|
+
"a dependency chain cannot be longer than the number of modules"
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
max_parallel = self.profile.fanout.max_parallel
|
|
468
|
+
cpus = os.cpu_count()
|
|
469
|
+
if max_parallel is not None and cpus is not None and max_parallel > cpus:
|
|
470
|
+
issues.append(
|
|
471
|
+
ConfigIssue(
|
|
472
|
+
severity="warning",
|
|
473
|
+
key="fanout.max_parallel",
|
|
474
|
+
message=(
|
|
475
|
+
f"{max_parallel} branches at once is more than this machine's {cpus} CPUs"
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
style = self.profile.style
|
|
481
|
+
if style.enforce and not style.exemplars:
|
|
482
|
+
issues.append(
|
|
483
|
+
ConfigIssue(
|
|
484
|
+
severity="warning",
|
|
485
|
+
key="style.enforce",
|
|
486
|
+
message=(
|
|
487
|
+
"style.enforce is set but style.exemplars is empty: the style check "
|
|
488
|
+
"has nothing to measure a file against, so nothing is enforced"
|
|
489
|
+
),
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
repeated = sorted({p for p in style.exemplars if style.exemplars.count(p) > 1})
|
|
493
|
+
if repeated:
|
|
494
|
+
issues.append(
|
|
495
|
+
ConfigIssue(
|
|
496
|
+
severity="warning",
|
|
497
|
+
key="style.exemplars",
|
|
498
|
+
message=f"listed more than once: {', '.join(repeated)}",
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
if len(set(style.enforce)) != len(style.enforce):
|
|
502
|
+
issues.append(
|
|
503
|
+
ConfigIssue(
|
|
504
|
+
severity="info", key="style.enforce", message="a style key is listed twice"
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
issues.extend(_select_issues(self.profile))
|
|
508
|
+
|
|
509
|
+
for index, plugin in enumerate(self.profile.plugins):
|
|
510
|
+
issues.append(
|
|
511
|
+
ConfigIssue(
|
|
512
|
+
severity="info",
|
|
513
|
+
key=f"plugins[{index}]",
|
|
514
|
+
message=f"local plugin {plugin!r} runs code: review it like code",
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
return issues
|
|
519
|
+
|
|
520
|
+
def effective_autonomy(self, area: str) -> Level:
|
|
521
|
+
"""The effective autonomy for `area`: min(project, user), user only ever lowers it."""
|
|
522
|
+
project_level: Level = self.profile.autonomy.get(area, "L3")
|
|
523
|
+
user_level = self.user.autonomy.get(area)
|
|
524
|
+
if user_level is None:
|
|
525
|
+
return project_level
|
|
526
|
+
return (
|
|
527
|
+
user_level if _LEVEL_ORDER[user_level] < _LEVEL_ORDER[project_level] else project_level
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def for_block(self, name: str) -> Profile:
|
|
531
|
+
"""The profile with `blocks[name]` merged over it, if any.
|
|
532
|
+
|
|
533
|
+
Only the fields the block sets are merged. `instances` (D38) and `wrappers` are
|
|
534
|
+
block-level facts, not `Profile` fields, so they are dropped from the overlay here;
|
|
535
|
+
they are read directly from `profile.blocks` (ingest, the wrapper rules).
|
|
536
|
+
"""
|
|
537
|
+
override = self.profile.blocks.get(name)
|
|
538
|
+
if override is None:
|
|
539
|
+
return self.profile
|
|
540
|
+
base = self.profile.model_dump(mode="python")
|
|
541
|
+
# Only what the block sets: an unset nested field (say `spec.requirements.infer`)
|
|
542
|
+
# must not reset the project's value to the model default.
|
|
543
|
+
overlay = override.model_dump(mode="python", exclude_unset=True)
|
|
544
|
+
overlay.pop("instances", None)
|
|
545
|
+
overlay.pop("wrappers", None)
|
|
546
|
+
dummy_source = SourceRef(kind="project", location="", version="")
|
|
547
|
+
merged = _merge(base, overlay, dummy_source, {})
|
|
548
|
+
return Profile.model_validate(merged)
|
|
549
|
+
|
|
550
|
+
def for_path(self, rel_path: str) -> PathRule:
|
|
551
|
+
"""Combine every `paths` glob matching `rel_path`; more specific (longer) wins."""
|
|
552
|
+
matches = [
|
|
553
|
+
(pattern, rule)
|
|
554
|
+
for pattern, rule in self.profile.paths.items()
|
|
555
|
+
if fnmatch.fnmatchcase(rel_path, pattern)
|
|
556
|
+
]
|
|
557
|
+
matches.sort(key=lambda pair: len(pair[0]))
|
|
558
|
+
|
|
559
|
+
checks: dict[str, Literal["on", "off"]] = {}
|
|
560
|
+
write: Literal["allow", "deny"] = "allow"
|
|
561
|
+
reason: str | None = None
|
|
562
|
+
for _, rule in matches:
|
|
563
|
+
checks.update(rule.checks)
|
|
564
|
+
write = rule.write
|
|
565
|
+
if rule.reason is not None:
|
|
566
|
+
reason = rule.reason
|
|
567
|
+
return PathRule(checks=checks, write=write, reason=reason)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _wrapper_issues(profile: Profile) -> list[ConfigIssue]:
|
|
571
|
+
"""`config check` notes on `blocks.<b>.wrappers`: a wrapper name is unique in the
|
|
572
|
+
project and is no IP's module name; an exemplar is a wrapper source, not an IP file."""
|
|
573
|
+
issues: list[ConfigIssue] = []
|
|
574
|
+
owner: dict[str, str] = {}
|
|
575
|
+
vendor_modules: dict[str, str] = {}
|
|
576
|
+
vendor_files: set[str] = set()
|
|
577
|
+
for block in sorted(profile.blocks):
|
|
578
|
+
for wrapper in profile.blocks[block].wrappers:
|
|
579
|
+
vendor_modules.setdefault(wrapper.vendor_module, block)
|
|
580
|
+
vendor_files.update(wrapper.vendor_files)
|
|
581
|
+
for block in sorted(profile.blocks):
|
|
582
|
+
for index, wrapper in enumerate(profile.blocks[block].wrappers):
|
|
583
|
+
key = f"blocks.{block}.wrappers[{index}]"
|
|
584
|
+
other = owner.setdefault(wrapper.name, block)
|
|
585
|
+
if other != block:
|
|
586
|
+
issues.append(
|
|
587
|
+
ConfigIssue(
|
|
588
|
+
severity="error",
|
|
589
|
+
key=key,
|
|
590
|
+
message=f"wrapper {wrapper.name!r} is already a wrapper of block {other!r}",
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
if wrapper.name in vendor_modules:
|
|
594
|
+
issues.append(
|
|
595
|
+
ConfigIssue(
|
|
596
|
+
severity="error",
|
|
597
|
+
key=key,
|
|
598
|
+
message=(
|
|
599
|
+
f"wrapper {wrapper.name!r} has the name of an IP module that a "
|
|
600
|
+
f"wrapper of block {vendor_modules[wrapper.name]!r} instantiates"
|
|
601
|
+
),
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
if wrapper.exemplar is not None and wrapper.exemplar in vendor_files:
|
|
605
|
+
issues.append(
|
|
606
|
+
ConfigIssue(
|
|
607
|
+
severity="warning",
|
|
608
|
+
key=f"{key}.exemplar",
|
|
609
|
+
message=(
|
|
610
|
+
f"{wrapper.exemplar} is an IP file; an exemplar is another "
|
|
611
|
+
"wrapper's source, whose structure the new wrapper imitates"
|
|
612
|
+
),
|
|
613
|
+
)
|
|
614
|
+
)
|
|
615
|
+
return issues
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _select_issues(profile: Profile) -> list[ConfigIssue]:
|
|
619
|
+
"""`config check` notes on the `select:` section (best-of-N, F6)."""
|
|
620
|
+
select = profile.select
|
|
621
|
+
issues: list[ConfigIssue] = []
|
|
622
|
+
if select.n > select.max_n:
|
|
623
|
+
issues.append(
|
|
624
|
+
ConfigIssue(
|
|
625
|
+
severity="warning",
|
|
626
|
+
key="select.n",
|
|
627
|
+
message=f"{select.n} candidates is above select.max_n ({select.max_n}): "
|
|
628
|
+
f"capped at {select.max_n}",
|
|
629
|
+
)
|
|
630
|
+
)
|
|
631
|
+
for rule_id in sorted(select.rules):
|
|
632
|
+
n = select.rules[rule_id]
|
|
633
|
+
if n > select.max_n:
|
|
634
|
+
issues.append(
|
|
635
|
+
ConfigIssue(
|
|
636
|
+
severity="warning",
|
|
637
|
+
key=f"select.rules.{rule_id}",
|
|
638
|
+
message=f"{n} candidates is above select.max_n ({select.max_n}): "
|
|
639
|
+
f"capped at {select.max_n}",
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
widest = max([select.effective_n(), *(select.effective_n(n) for n in select.rules.values())])
|
|
643
|
+
if widest >= 2 and select.budget_factor >= widest:
|
|
644
|
+
issues.append(
|
|
645
|
+
ConfigIssue(
|
|
646
|
+
severity="warning",
|
|
647
|
+
key="select.budget_factor",
|
|
648
|
+
message=(
|
|
649
|
+
f"{select.budget_factor} x one run's budget gives each of {widest} "
|
|
650
|
+
"candidates a full run: best-of-N then costs N runs; the default is 1.5"
|
|
651
|
+
),
|
|
652
|
+
)
|
|
653
|
+
)
|
|
654
|
+
if select.budget_factor < 1:
|
|
655
|
+
issues.append(
|
|
656
|
+
ConfigIssue(
|
|
657
|
+
severity="info",
|
|
658
|
+
key="select.budget_factor",
|
|
659
|
+
message=(
|
|
660
|
+
f"{select.budget_factor} is below 1: the candidates together get less "
|
|
661
|
+
"than one plain run's budget"
|
|
662
|
+
),
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
return issues
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
# --------------------------------------------------------------------------------------
|
|
669
|
+
# load()
|
|
670
|
+
# --------------------------------------------------------------------------------------
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _enforce_tighten_only_policy(layers: list[_Layer]) -> None:
|
|
674
|
+
"""`policy.local_plugins` may only tighten across layers.
|
|
675
|
+
|
|
676
|
+
Any layer may set it to ``deny``; once a layer has denied, no later layer may set it
|
|
677
|
+
back to ``allow``. Raises `ConfigError` naming the offending file and the layer that
|
|
678
|
+
denied. Layers that do not mention the key are ignored.
|
|
679
|
+
"""
|
|
680
|
+
denied_by: SourceRef | None = None
|
|
681
|
+
for layer in layers:
|
|
682
|
+
policy = layer.data.get("policy")
|
|
683
|
+
if not isinstance(policy, Mapping) or "local_plugins" not in policy:
|
|
684
|
+
continue
|
|
685
|
+
value = policy["local_plugins"]
|
|
686
|
+
if value == "deny":
|
|
687
|
+
denied_by = layer.source
|
|
688
|
+
elif value == "allow" and denied_by is not None:
|
|
689
|
+
raise ConfigError(
|
|
690
|
+
"policy.local_plugins is tighten-only: cannot set 'allow' after "
|
|
691
|
+
f"{denied_by.location!r} ({denied_by.kind}) set 'deny'",
|
|
692
|
+
file=layer.source.location,
|
|
693
|
+
key="policy.local_plugins",
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def load(
|
|
698
|
+
root: Path,
|
|
699
|
+
*,
|
|
700
|
+
profile_path: Path | None = None,
|
|
701
|
+
user_config: Path | None = None,
|
|
702
|
+
data_dir: Path | None = None,
|
|
703
|
+
fetcher: SourceFetcher | None = None,
|
|
704
|
+
) -> ResolvedProfile | None:
|
|
705
|
+
"""Find, read and merge every configuration layer under `root`.
|
|
706
|
+
|
|
707
|
+
Returns `None` when no project profile exists (callers then allow only read-only
|
|
708
|
+
commands, DESIGN 8.4/6.1).
|
|
709
|
+
"""
|
|
710
|
+
project_file = profile_path.resolve() if profile_path is not None else find_profile(root)
|
|
711
|
+
if project_file is None:
|
|
712
|
+
return None
|
|
713
|
+
|
|
714
|
+
if data_dir is None:
|
|
715
|
+
data_dir = _default_data_dir()
|
|
716
|
+
|
|
717
|
+
tool_source = SourceRef(
|
|
718
|
+
kind="tool",
|
|
719
|
+
location="chipgraph.core.config.defaults",
|
|
720
|
+
version=_sha256_json(DEFAULT_PROFILE),
|
|
721
|
+
)
|
|
722
|
+
tool_layer = _Layer(source=tool_source, data=DEFAULT_PROFILE)
|
|
723
|
+
|
|
724
|
+
project_layers = _load_layer_chain(
|
|
725
|
+
project_file, "project", str(project_file), data_dir, fetcher, frozenset()
|
|
726
|
+
)
|
|
727
|
+
layers = [tool_layer, *project_layers]
|
|
728
|
+
|
|
729
|
+
_enforce_tighten_only_policy(layers)
|
|
730
|
+
|
|
731
|
+
merged: dict[str, Any] = {}
|
|
732
|
+
provenance: dict[str, SourceRef] = {}
|
|
733
|
+
for layer in layers:
|
|
734
|
+
merged = _merge(merged, layer.data, layer.source, provenance)
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
profile = Profile.model_validate(merged)
|
|
738
|
+
except ValidationError as exc:
|
|
739
|
+
raise _translate_validation_error(exc, provenance, str(project_file)) from exc
|
|
740
|
+
|
|
741
|
+
user_path = user_config if user_config is not None else _default_user_config_path()
|
|
742
|
+
user_raw: dict[str, Any] = {}
|
|
743
|
+
user_source = SourceRef(kind="user", location=str(user_path), version="")
|
|
744
|
+
if user_path.is_file():
|
|
745
|
+
loaded, user_hash = _read_yaml_with_hash(user_path)
|
|
746
|
+
user_raw = loaded or {}
|
|
747
|
+
if not isinstance(user_raw, dict):
|
|
748
|
+
raise ConfigError("profile must be a YAML mapping", file=user_path)
|
|
749
|
+
user_source = SourceRef(kind="user", location=str(user_path), version=user_hash)
|
|
750
|
+
|
|
751
|
+
try:
|
|
752
|
+
user = UserConfig.model_validate(user_raw)
|
|
753
|
+
except ValidationError as exc:
|
|
754
|
+
first = exc.errors()[0]
|
|
755
|
+
key = _loc_to_key(first["loc"])
|
|
756
|
+
raise ConfigError(str(first["msg"]), file=str(user_path), key=key or None) from exc
|
|
757
|
+
|
|
758
|
+
_record_leaves(user_raw, user_source, provenance, "user")
|
|
759
|
+
|
|
760
|
+
return ResolvedProfile(
|
|
761
|
+
profile=profile,
|
|
762
|
+
user=user,
|
|
763
|
+
sources=(*(layer.source for layer in layers), user_source),
|
|
764
|
+
profile_hash=_sha256_json(profile.model_dump(mode="json")),
|
|
765
|
+
provenance=provenance,
|
|
766
|
+
)
|