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,340 @@
|
|
|
1
|
+
"""Block-by-block Jinja2 template overrides (DESIGN 8.6 V2, docs/IMPLEMENTATION_PLAN.md M1-22).
|
|
2
|
+
|
|
3
|
+
A `TemplateSet` renders named templates (e.g. `rtl_module.sv.j2`) built from an ordered
|
|
4
|
+
stack of layers, most specific first. DESIGN.md's lookup order for a template is::
|
|
5
|
+
|
|
6
|
+
block -> project -> organisation -> pack
|
|
7
|
+
|
|
8
|
+
("block" here is the project's own per-IP-block layer, DESIGN 8.6's `blocks:` section;
|
|
9
|
+
"pack" is the lowest, always-present fallback.) Each layer may contribute, for a given
|
|
10
|
+
template name:
|
|
11
|
+
|
|
12
|
+
* a **whole-file** template: a file named exactly like the template, in that layer's
|
|
13
|
+
``directory``, whose top level has real content (not just `{% block %}` tags). The most
|
|
14
|
+
specific layer that has a whole file for a name is the base of the render.
|
|
15
|
+
* a **block-only fragment** that overrides individual `{% block %}` regions without
|
|
16
|
+
touching the rest of the file. Two conventions produce a fragment, and both may be used
|
|
17
|
+
together:
|
|
18
|
+
|
|
19
|
+
1. **Same file name, in a more specific layer's directory**, containing *only*
|
|
20
|
+
`{% block ... %}...{% endblock %}` definitions (no other top-level output). Detected
|
|
21
|
+
automatically by parsing the file: if every top-level node is a block definition or
|
|
22
|
+
whitespace, it is a fragment, not a whole-file replacement.
|
|
23
|
+
2. **`templates.override` mapping** (`ResolvedProfile.for_block(...).profile.templates
|
|
24
|
+
.override`, or any other `Mapping[str, str]` a caller assembles the same way): a
|
|
25
|
+
block name (not necessarily the template's file name) to a file path containing that
|
|
26
|
+
one block's `{% block <name> %}...{% endblock %}` definition. This is what DESIGN.md's
|
|
27
|
+
example ``templates: override: { rtl_header: templates/header.j2 }`` means: the key is
|
|
28
|
+
the *block name* `rtl_header`, not a template file name, so it applies to any template
|
|
29
|
+
in the chain that defines a block called `rtl_header`.
|
|
30
|
+
|
|
31
|
+
Both conventions are implemented with real Jinja2 inheritance: each more specific layer's
|
|
32
|
+
contribution is compiled as a virtual template `layer:<n>/<name>` that `{% extends %}` the
|
|
33
|
+
next less specific layer's virtual template. A block a more specific layer does not touch
|
|
34
|
+
is therefore untouched in the rendered output -- it simply falls through Jinja's own block
|
|
35
|
+
resolution. This is also why overriding one block never changes any other block's source
|
|
36
|
+
or rendering: each block's *content* lives in exactly one layer's fragment/whole file.
|
|
37
|
+
|
|
38
|
+
Gap vs. `core/config/models.py`: `BlockOverride` (the project's own `blocks:` per-IP-block
|
|
39
|
+
section) has no `templates` field, so a project cannot currently express a block-scoped
|
|
40
|
+
template override through the resolved `Profile` alone -- only a project-wide
|
|
41
|
+
`templates.override`. `TemplateSet` accepts a "block" layer as plain data (an optional
|
|
42
|
+
directory and/or override mapping) so a caller can still supply one (e.g. read from
|
|
43
|
+
`.chipgraph/blocks/<block>/templates/`, by convention, until `models.py` grows a field);
|
|
44
|
+
this module does not itself decide where a per-block layer's directory or overrides come
|
|
45
|
+
from.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
from jinja2 import BaseLoader, Environment, StrictUndefined, nodes
|
|
55
|
+
from jinja2.exceptions import TemplateNotFound as Jinja2TemplateNotFound
|
|
56
|
+
from jinja2.exceptions import UndefinedError
|
|
57
|
+
from jinja2.sandbox import SandboxedEnvironment
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"BlockSource",
|
|
61
|
+
"TemplateLayer",
|
|
62
|
+
"TemplateNotFoundError",
|
|
63
|
+
"TemplateRenderError",
|
|
64
|
+
"TemplateSet",
|
|
65
|
+
"TemplateSetError",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
_VIRTUAL_PREFIX = "layer:"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TemplateSetError(Exception):
|
|
72
|
+
"""Base error for `TemplateSet` problems."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TemplateNotFoundError(TemplateSetError):
|
|
76
|
+
"""No layer contributes anything (whole file or fragment) for a template name."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, name: str) -> None:
|
|
79
|
+
super().__init__(f"template {name!r} not found in any layer")
|
|
80
|
+
self.name = name
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TemplateRenderError(TemplateSetError):
|
|
84
|
+
"""Rendering failed; names the template and the layer chain that produced it."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, name: str, layers: Sequence[str], cause: Exception) -> None:
|
|
87
|
+
chain = " -> ".join(layers) if layers else "(no layer)"
|
|
88
|
+
super().__init__(
|
|
89
|
+
f"error rendering template {name!r} (layer chain, least to most specific: "
|
|
90
|
+
f"{chain}): {cause}"
|
|
91
|
+
)
|
|
92
|
+
self.name = name
|
|
93
|
+
self.layers = tuple(layers)
|
|
94
|
+
self.__cause__ = cause
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class TemplateLayer:
|
|
99
|
+
"""One layer in the lookup stack: a directory of whole/fragment files, and/or an
|
|
100
|
+
explicit block-name-to-path override mapping (DESIGN 8.6 V2's `templates.override`).
|
|
101
|
+
|
|
102
|
+
`name` is a short label used in `explain()` output and error messages, e.g. "block",
|
|
103
|
+
"project", "org", "pack".
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
name: str
|
|
107
|
+
directory: Path | None = None
|
|
108
|
+
override: Mapping[str, str] = field(default_factory=dict)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class BlockSource:
|
|
113
|
+
"""Which layer (and file, if any) a rendered block's content came from."""
|
|
114
|
+
|
|
115
|
+
block: str
|
|
116
|
+
layer: str
|
|
117
|
+
path: Path | None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class _Contribution:
|
|
122
|
+
"""One layer's compiled contribution to a template's inheritance chain."""
|
|
123
|
+
|
|
124
|
+
layer_name: str
|
|
125
|
+
source: str
|
|
126
|
+
is_root: bool # True: a whole-file base/replacement (does not extend the chain so far)
|
|
127
|
+
block_paths: dict[str, Path | None]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _read(path: str | Path) -> str:
|
|
131
|
+
p = Path(path)
|
|
132
|
+
try:
|
|
133
|
+
return p.read_text(encoding="utf-8")
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
raise TemplateSetError(f"cannot read template file {p}: {exc}") from exc
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _is_pure_fragment(source: str, parser_env: Environment, origin: str) -> bool:
|
|
139
|
+
"""True if `source`'s top level has only `{% block %}` tags and/or whitespace text."""
|
|
140
|
+
try:
|
|
141
|
+
parsed = parser_env.parse(source)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
raise TemplateSetError(f"cannot parse template fragment {origin}: {exc}") from exc
|
|
144
|
+
for top in parsed.body:
|
|
145
|
+
if isinstance(top, nodes.Block):
|
|
146
|
+
continue
|
|
147
|
+
if isinstance(top, nodes.Output) and all(
|
|
148
|
+
isinstance(child, nodes.TemplateData) and child.data.strip() == ""
|
|
149
|
+
for child in top.nodes
|
|
150
|
+
):
|
|
151
|
+
continue
|
|
152
|
+
return False
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _block_names(source: str, parser_env: Environment, origin: str) -> list[str]:
|
|
157
|
+
try:
|
|
158
|
+
parsed = parser_env.parse(source)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
raise TemplateSetError(f"cannot parse template fragment {origin}: {exc}") from exc
|
|
161
|
+
return [b.name for b in parsed.find_all(nodes.Block)]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class _LayeredLoader(BaseLoader):
|
|
165
|
+
"""Serves the virtual `layer:<n>/<name>` sources a `TemplateSet` compiles."""
|
|
166
|
+
|
|
167
|
+
def __init__(self) -> None:
|
|
168
|
+
self._sources: dict[str, str] = {}
|
|
169
|
+
|
|
170
|
+
def add(self, virtual_name: str, source: str) -> None:
|
|
171
|
+
self._sources[virtual_name] = source
|
|
172
|
+
|
|
173
|
+
def get_source(
|
|
174
|
+
self, environment: Environment, template: str
|
|
175
|
+
) -> tuple[str, str | None, Callable[[], bool] | None]:
|
|
176
|
+
try:
|
|
177
|
+
source = self._sources[template]
|
|
178
|
+
except KeyError as exc:
|
|
179
|
+
raise Jinja2TemplateNotFound(template) from exc
|
|
180
|
+
return source, None, lambda: True
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class TemplateSet:
|
|
184
|
+
"""Renders templates built from ordered layers (most specific first)."""
|
|
185
|
+
|
|
186
|
+
def __init__(self, layers: Sequence[TemplateLayer]) -> None:
|
|
187
|
+
self._layers: tuple[TemplateLayer, ...] = tuple(layers)
|
|
188
|
+
self._loader = _LayeredLoader()
|
|
189
|
+
self._env = SandboxedEnvironment(
|
|
190
|
+
loader=self._loader,
|
|
191
|
+
undefined=StrictUndefined,
|
|
192
|
+
autoescape=False,
|
|
193
|
+
keep_trailing_newline=True,
|
|
194
|
+
)
|
|
195
|
+
# A plain (non-sandboxed) environment used only to parse candidate sources when
|
|
196
|
+
# deciding whether they are whole files or block-only fragments, and to list the
|
|
197
|
+
# block names they define. No template of ours is *rendered* through it.
|
|
198
|
+
self._parser_env = Environment()
|
|
199
|
+
self._chains: dict[str, tuple[list[str], dict[str, BlockSource]]] = {}
|
|
200
|
+
|
|
201
|
+
# -- chain construction --------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def _gather_layer_candidates(
|
|
204
|
+
self, layer: TemplateLayer, name: str
|
|
205
|
+
) -> tuple[str | None, Path | None, dict[str, str]]:
|
|
206
|
+
"""Return (whole_or_fragment_source_for_name, its_path, {block_name: path}) for
|
|
207
|
+
one layer's directory file plus any `override` entries keyed by `name` itself."""
|
|
208
|
+
whole_source: str | None = None
|
|
209
|
+
whole_path: Path | None = None
|
|
210
|
+
|
|
211
|
+
if name in layer.override:
|
|
212
|
+
whole_path = Path(layer.override[name])
|
|
213
|
+
whole_source = _read(whole_path)
|
|
214
|
+
elif layer.directory is not None:
|
|
215
|
+
candidate = layer.directory / name
|
|
216
|
+
if candidate.is_file():
|
|
217
|
+
whole_path = candidate
|
|
218
|
+
whole_source = _read(candidate)
|
|
219
|
+
|
|
220
|
+
block_overrides = {
|
|
221
|
+
block_name: path for block_name, path in layer.override.items() if block_name != name
|
|
222
|
+
}
|
|
223
|
+
return whole_source, whole_path, block_overrides
|
|
224
|
+
|
|
225
|
+
def _build_contribution(self, layer: TemplateLayer, name: str) -> _Contribution | None:
|
|
226
|
+
whole_source, whole_path, block_override_paths = self._gather_layer_candidates(layer, name)
|
|
227
|
+
|
|
228
|
+
pieces: list[str] = []
|
|
229
|
+
block_paths: dict[str, Path | None] = {}
|
|
230
|
+
is_root = False
|
|
231
|
+
|
|
232
|
+
if whole_source is not None:
|
|
233
|
+
origin = str(whole_path)
|
|
234
|
+
if _is_pure_fragment(whole_source, self._parser_env, origin):
|
|
235
|
+
pieces.append(whole_source)
|
|
236
|
+
for block_name in _block_names(whole_source, self._parser_env, origin):
|
|
237
|
+
block_paths[block_name] = whole_path
|
|
238
|
+
else:
|
|
239
|
+
# A whole (non-fragment) file: it is the base, replacing anything less
|
|
240
|
+
# specific accumulated so far.
|
|
241
|
+
pieces = [whole_source]
|
|
242
|
+
for block_name in _block_names(whole_source, self._parser_env, origin):
|
|
243
|
+
block_paths[block_name] = whole_path
|
|
244
|
+
is_root = True
|
|
245
|
+
|
|
246
|
+
for block_name, path_str in block_override_paths.items():
|
|
247
|
+
path = Path(path_str)
|
|
248
|
+
source = _read(path)
|
|
249
|
+
origin = str(path)
|
|
250
|
+
if not _is_pure_fragment(source, self._parser_env, origin):
|
|
251
|
+
raise TemplateSetError(
|
|
252
|
+
f"templates.override[{block_name!r}] = {origin!r} must contain only "
|
|
253
|
+
"{% block %} definitions, not whole-template content"
|
|
254
|
+
)
|
|
255
|
+
pieces.append(source)
|
|
256
|
+
for found in _block_names(source, self._parser_env, origin):
|
|
257
|
+
block_paths[found] = path
|
|
258
|
+
|
|
259
|
+
if not pieces:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
return _Contribution(
|
|
263
|
+
layer_name=layer.name,
|
|
264
|
+
source="\n".join(pieces),
|
|
265
|
+
is_root=is_root,
|
|
266
|
+
block_paths=block_paths,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def _build_chain(self, name: str) -> tuple[list[str], dict[str, BlockSource]]:
|
|
270
|
+
cached = self._chains.get(name)
|
|
271
|
+
if cached is not None:
|
|
272
|
+
return cached
|
|
273
|
+
|
|
274
|
+
# Layers are stored most-specific-first; process least-specific-first so each
|
|
275
|
+
# more specific contribution can `{% extends %}` the previous virtual template.
|
|
276
|
+
layers_asc = list(reversed(self._layers))
|
|
277
|
+
|
|
278
|
+
parent_virtual: str | None = None
|
|
279
|
+
chain_layer_names: list[str] = []
|
|
280
|
+
block_sources: dict[str, BlockSource] = {}
|
|
281
|
+
index = 0
|
|
282
|
+
|
|
283
|
+
for layer in layers_asc:
|
|
284
|
+
contribution = self._build_contribution(layer, name)
|
|
285
|
+
if contribution is None:
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
if contribution.is_root:
|
|
289
|
+
# Resets the chain: this whole file replaces anything accumulated so far.
|
|
290
|
+
source = contribution.source
|
|
291
|
+
parent_virtual = None
|
|
292
|
+
chain_layer_names = []
|
|
293
|
+
else:
|
|
294
|
+
if parent_virtual is None:
|
|
295
|
+
raise TemplateSetError(
|
|
296
|
+
f"layer {layer.name!r} overrides block(s) "
|
|
297
|
+
f"{sorted(contribution.block_paths)} of template {name!r}, but no "
|
|
298
|
+
"less specific layer provides a whole (base) file for it"
|
|
299
|
+
)
|
|
300
|
+
source = f'{{% extends "{parent_virtual}" %}}\n{contribution.source}'
|
|
301
|
+
|
|
302
|
+
virtual_name = f"{_VIRTUAL_PREFIX}{index}/{name}"
|
|
303
|
+
self._loader.add(virtual_name, source)
|
|
304
|
+
index += 1
|
|
305
|
+
parent_virtual = virtual_name
|
|
306
|
+
chain_layer_names.append(layer.name)
|
|
307
|
+
|
|
308
|
+
for block_name, path in contribution.block_paths.items():
|
|
309
|
+
block_sources[block_name] = BlockSource(
|
|
310
|
+
block=block_name, layer=layer.name, path=path
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if parent_virtual is None:
|
|
314
|
+
raise TemplateNotFoundError(name)
|
|
315
|
+
|
|
316
|
+
# Publish the final, most specific virtual template under the public name too.
|
|
317
|
+
self._loader.add(name, f'{{% extends "{parent_virtual}" %}}\n')
|
|
318
|
+
|
|
319
|
+
result = (chain_layer_names, block_sources)
|
|
320
|
+
self._chains[name] = result
|
|
321
|
+
return result
|
|
322
|
+
|
|
323
|
+
# -- public API ------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
def render(self, name: str, context: Mapping[str, object] | None = None) -> str:
|
|
326
|
+
"""Render template `name` against `context`. Deterministic, no implicit state."""
|
|
327
|
+
chain_layers, _ = self._build_chain(name)
|
|
328
|
+
template = self._env.get_template(name)
|
|
329
|
+
try:
|
|
330
|
+
return template.render(dict(context) if context is not None else {})
|
|
331
|
+
except UndefinedError as exc:
|
|
332
|
+
raise TemplateRenderError(name, chain_layers, exc) from exc
|
|
333
|
+
|
|
334
|
+
def explain(self, name: str) -> list[tuple[str, str, Path | None]]:
|
|
335
|
+
"""Which layer (and file, if any) each block of `name` was resolved from.
|
|
336
|
+
|
|
337
|
+
Returns a list of `(block_name, layer_name, path)`, sorted by block name.
|
|
338
|
+
"""
|
|
339
|
+
_, block_sources = self._build_chain(name)
|
|
340
|
+
return sorted((b.block, b.layer, b.path) for b in block_sources.values())
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Core contracts: the pydantic models every other part of chipgraph builds against.
|
|
2
|
+
|
|
3
|
+
`chipgraph.core.contracts` knows nothing about chips or tools: no project, bus, PDK or
|
|
4
|
+
tool names appear here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from chipgraph.core.contracts.agent import AgentResult, Decision
|
|
8
|
+
from chipgraph.core.contracts.artifact import Artifact, ArtifactRef, ProducedBy
|
|
9
|
+
from chipgraph.core.contracts.check import CheckResult, CheckSpec, Issue
|
|
10
|
+
from chipgraph.core.contracts.event import Event, EventType, RunManifest
|
|
11
|
+
from chipgraph.core.contracts.finding import Evidence, Finding, FindingSeverity, FindingStatus
|
|
12
|
+
from chipgraph.core.contracts.gate import Approval, GateSpec
|
|
13
|
+
from chipgraph.core.contracts.rule import Budget, InputSpec, RuleInstance, RuleSpec, RunSpec
|
|
14
|
+
from chipgraph.core.contracts.types import (
|
|
15
|
+
ArtifactKind,
|
|
16
|
+
CheckStatus,
|
|
17
|
+
DataLabel,
|
|
18
|
+
FailureLabel,
|
|
19
|
+
ModelTier,
|
|
20
|
+
RuleId,
|
|
21
|
+
RuleKind,
|
|
22
|
+
Severity,
|
|
23
|
+
Sha256,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AgentResult",
|
|
28
|
+
"Approval",
|
|
29
|
+
"Artifact",
|
|
30
|
+
"ArtifactKind",
|
|
31
|
+
"ArtifactRef",
|
|
32
|
+
"Budget",
|
|
33
|
+
"CheckResult",
|
|
34
|
+
"CheckSpec",
|
|
35
|
+
"CheckStatus",
|
|
36
|
+
"DataLabel",
|
|
37
|
+
"Decision",
|
|
38
|
+
"Event",
|
|
39
|
+
"EventType",
|
|
40
|
+
"Evidence",
|
|
41
|
+
"FailureLabel",
|
|
42
|
+
"Finding",
|
|
43
|
+
"FindingSeverity",
|
|
44
|
+
"FindingStatus",
|
|
45
|
+
"GateSpec",
|
|
46
|
+
"InputSpec",
|
|
47
|
+
"Issue",
|
|
48
|
+
"ModelTier",
|
|
49
|
+
"ProducedBy",
|
|
50
|
+
"RuleId",
|
|
51
|
+
"RuleInstance",
|
|
52
|
+
"RuleKind",
|
|
53
|
+
"RuleSpec",
|
|
54
|
+
"RunManifest",
|
|
55
|
+
"RunSpec",
|
|
56
|
+
"Severity",
|
|
57
|
+
"Sha256",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
TOP_LEVEL_MODELS = (
|
|
61
|
+
ArtifactRef,
|
|
62
|
+
Artifact,
|
|
63
|
+
RuleSpec,
|
|
64
|
+
RuleInstance,
|
|
65
|
+
CheckSpec,
|
|
66
|
+
CheckResult,
|
|
67
|
+
GateSpec,
|
|
68
|
+
Approval,
|
|
69
|
+
Event,
|
|
70
|
+
RunManifest,
|
|
71
|
+
AgentResult,
|
|
72
|
+
Decision,
|
|
73
|
+
Finding,
|
|
74
|
+
)
|
|
75
|
+
"""Top-level models: each gets its own file in `schemas/` (see export.py)."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Shared path validation for artifact and rule-output paths.
|
|
2
|
+
|
|
3
|
+
Not part of the public API of this package (no leading re-export in ``__init__``).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def validate_relative_path(path: str, *, field_name: str = "path") -> str:
|
|
10
|
+
"""Validate a POSIX path relative to a repo root; return it unchanged if valid.
|
|
11
|
+
|
|
12
|
+
The path must not be empty, must not start with ``/`` (absolute), must not
|
|
13
|
+
contain backslashes, and no segment may be ``.`` or ``..``. Placeholders such
|
|
14
|
+
as ``{block}`` are allowed inside segments (used by rule output templates).
|
|
15
|
+
"""
|
|
16
|
+
if path == "":
|
|
17
|
+
raise ValueError(f"{field_name} must not be empty")
|
|
18
|
+
if path.startswith("/"):
|
|
19
|
+
raise ValueError(f"{field_name} must be relative, got absolute path {path!r}")
|
|
20
|
+
if "\\" in path:
|
|
21
|
+
raise ValueError(f"{field_name} must be a POSIX path, got {path!r}")
|
|
22
|
+
segments = path.split("/")
|
|
23
|
+
for segment in segments:
|
|
24
|
+
if segment in ("", ".", ".."):
|
|
25
|
+
raise ValueError(f"{field_name} must not contain '.', '..' or empty segments: {path!r}")
|
|
26
|
+
return path
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Agent results and decisions: the output of AI agent rules and their sub-decisions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal, Self
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AgentResult(BaseModel):
|
|
11
|
+
"""The outcome of running an agent rule."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
14
|
+
|
|
15
|
+
schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
|
|
16
|
+
status: Literal["done", "failed", "needs_human", "budget_exhausted"] = Field(
|
|
17
|
+
description="The final status of the agent run."
|
|
18
|
+
)
|
|
19
|
+
files_written: tuple[str, ...] = Field(
|
|
20
|
+
default=(), description="Paths of files the agent wrote."
|
|
21
|
+
)
|
|
22
|
+
assumptions: tuple[str, ...] = Field(
|
|
23
|
+
default=(), description="Assumptions the agent made while working."
|
|
24
|
+
)
|
|
25
|
+
open_questions: tuple[str, ...] = Field(
|
|
26
|
+
default=(), description="Questions the agent could not resolve on its own."
|
|
27
|
+
)
|
|
28
|
+
tokens: int | None = Field(default=None, ge=0, description="Tokens consumed by the agent run.")
|
|
29
|
+
cost: float | None = Field(default=None, ge=0, description="Cost of the agent run, in USD.")
|
|
30
|
+
|
|
31
|
+
@model_validator(mode="after")
|
|
32
|
+
def _check_needs_human_has_question(self) -> Self:
|
|
33
|
+
if self.status == "needs_human" and len(self.open_questions) < 1:
|
|
34
|
+
raise ValueError("status='needs_human' requires at least one open question")
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Decision(BaseModel):
|
|
39
|
+
"""A single resolved decision, made by a rule, a small model, or a large model."""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
42
|
+
|
|
43
|
+
schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
|
|
44
|
+
question_id: str = Field(description="The id of the question this decision answers.")
|
|
45
|
+
value: str | int | float | bool = Field(description="The decided value.")
|
|
46
|
+
confidence: float = Field(ge=0, le=1, description="Confidence in this decision, from 0 to 1.")
|
|
47
|
+
backend: Literal["rule", "small", "large"] = Field(
|
|
48
|
+
description="What made this decision: a rule, a small model, or a large model."
|
|
49
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Artifacts: the nodes of the build graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal, Self
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
8
|
+
|
|
9
|
+
from chipgraph.core.contracts._paths import validate_relative_path
|
|
10
|
+
from chipgraph.core.contracts.types import ArtifactKind, DataLabel, RuleId, Sha256
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ArtifactRef(BaseModel):
|
|
14
|
+
"""A reference to an artifact, identifying it without describing its content."""
|
|
15
|
+
|
|
16
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
17
|
+
|
|
18
|
+
schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
|
|
19
|
+
repo: str = Field(
|
|
20
|
+
default=".", description="The workspace member (repo) id this artifact belongs to."
|
|
21
|
+
)
|
|
22
|
+
kind: ArtifactKind = Field(description="The kind of artifact this is, e.g. rtl, spec, report.")
|
|
23
|
+
path: str | None = Field(
|
|
24
|
+
default=None,
|
|
25
|
+
description="POSIX path to the artifact, relative to the repo root, when it is file-based.",
|
|
26
|
+
)
|
|
27
|
+
model_key: str | None = Field(
|
|
28
|
+
default=None,
|
|
29
|
+
description="Key into the Design Model when this artifact is model-based, not a file.",
|
|
30
|
+
)
|
|
31
|
+
label: DataLabel = Field(
|
|
32
|
+
default="internal", description="Data sensitivity label: public, internal, or nda."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@model_validator(mode="after")
|
|
36
|
+
def _check_exactly_one_locator(self) -> Self:
|
|
37
|
+
if (self.path is None) == (self.model_key is None):
|
|
38
|
+
raise ValueError("ArtifactRef needs exactly one of 'path' or 'model_key' to be set")
|
|
39
|
+
if self.path is not None:
|
|
40
|
+
validate_relative_path(self.path, field_name="path")
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ProducedBy(BaseModel):
|
|
45
|
+
"""Records which rule instance and run produced an artifact."""
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
48
|
+
|
|
49
|
+
rule_id: RuleId = Field(description="The id of the rule that produced this artifact.")
|
|
50
|
+
run_id: str = Field(description="The id of the run in which this artifact was produced.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Artifact(BaseModel):
|
|
54
|
+
"""A concrete artifact: a reference plus its content hash and provenance."""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
57
|
+
|
|
58
|
+
schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
|
|
59
|
+
ref: ArtifactRef = Field(description="The reference identifying this artifact.")
|
|
60
|
+
content_hash: Sha256 = Field(description="SHA-256 hash of the artifact's content.")
|
|
61
|
+
produced_by: ProducedBy | None = Field(
|
|
62
|
+
default=None,
|
|
63
|
+
description="Which rule and run produced this; None if human-written or pre-existing.",
|
|
64
|
+
)
|
|
65
|
+
inputs_hash: Sha256 | None = Field(
|
|
66
|
+
default=None,
|
|
67
|
+
description="Hash of the inputs this was produced from; None when not produced by a rule.",
|
|
68
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Checks: deterministic verification of artifacts, and their specs and results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal, Self
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
8
|
+
|
|
9
|
+
from chipgraph.core.contracts.types import CheckStatus, Severity
|
|
10
|
+
|
|
11
|
+
_LOG_TAIL_MAX_CHARS = 4000
|
|
12
|
+
_TRUNCATION_MARKER = "…"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CheckSpec(BaseModel):
|
|
16
|
+
"""The specification of a check: which adapter runs it and with what arguments."""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
19
|
+
|
|
20
|
+
schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
|
|
21
|
+
id: str = Field(description="The id of this check.")
|
|
22
|
+
capability: str = Field(description="The capability this check exercises, e.g. lint, sim.")
|
|
23
|
+
adapter: str = Field(description="The adapter id that implements this check.")
|
|
24
|
+
args: dict[str, Any] = Field(default={}, description="Arguments passed to the adapter.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Issue(BaseModel):
|
|
28
|
+
"""A single issue found by a check, such as a lint violation."""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
31
|
+
|
|
32
|
+
file: str | None = Field(default=None, description="The file this issue was found in, if any.")
|
|
33
|
+
line: int | None = Field(default=None, ge=1, description="The 1-based line number, if any.")
|
|
34
|
+
rule: str = Field(default="", description="The specific rule within the check that fired.")
|
|
35
|
+
severity: Severity = Field(default="error", description="How serious this issue is.")
|
|
36
|
+
msg: str = Field(description="A human-readable message describing the issue.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CheckResult(BaseModel):
|
|
40
|
+
"""The outcome of running a check."""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
43
|
+
|
|
44
|
+
schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
|
|
45
|
+
check_id: str = Field(description="The id of the check that was run.")
|
|
46
|
+
status: CheckStatus = Field(description="The overall outcome of the check.")
|
|
47
|
+
issues: tuple[Issue, ...] = Field(
|
|
48
|
+
default=(), description="Issues found while running the check."
|
|
49
|
+
)
|
|
50
|
+
log_tail: str = Field(
|
|
51
|
+
default="", description="The tail of the check's log output, truncated to 4000 characters."
|
|
52
|
+
)
|
|
53
|
+
duration_s: float = Field(ge=0, description="How long the check took to run, in seconds.")
|
|
54
|
+
idempotency_key: str = Field(
|
|
55
|
+
description="A key identifying repeat runs of the same check invocation."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def ok(self) -> bool:
|
|
60
|
+
"""Whether this result counts as acceptable: pass or skipped."""
|
|
61
|
+
return self.status in ("pass", "skipped")
|
|
62
|
+
|
|
63
|
+
@model_validator(mode="after")
|
|
64
|
+
def _truncate_log_tail(self) -> Self:
|
|
65
|
+
if len(self.log_tail) > _LOG_TAIL_MAX_CHARS:
|
|
66
|
+
keep = _LOG_TAIL_MAX_CHARS - len(_TRUNCATION_MARKER)
|
|
67
|
+
object.__setattr__(self, "log_tail", _TRUNCATION_MARKER + self.log_tail[-keep:])
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
@model_validator(mode="after")
|
|
71
|
+
def _check_pass_has_no_errors(self) -> Self:
|
|
72
|
+
if self.status == "pass" and any(issue.severity == "error" for issue in self.issues):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"CheckResult with status='pass' must not have any issue of severity 'error'"
|
|
75
|
+
)
|
|
76
|
+
return self
|