dos-kernel 0.22.0__py3-none-win_amd64.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.
- dos/__init__.py +261 -0
- dos/_bin/dos-hook.exe +0 -0
- dos/_filelock.py +255 -0
- dos/_job_policy.py +97 -0
- dos/_tree.py +145 -0
- dos/admission.py +433 -0
- dos/answer_shape.py +299 -0
- dos/arbiter.py +859 -0
- dos/archive_lock.py +266 -0
- dos/arg_provenance.py +814 -0
- dos/attest.py +472 -0
- dos/breaker.py +311 -0
- dos/churn.py +226 -0
- dos/claim_extract.py +229 -0
- dos/claim_ttl.py +150 -0
- dos/cli.py +8721 -0
- dos/commit_audit.py +666 -0
- dos/completion.py +466 -0
- dos/concurrency_class.py +154 -0
- dos/config.py +1380 -0
- dos/config_lint.py +464 -0
- dos/cooldown.py +390 -0
- dos/coverage.py +387 -0
- dos/dangling_intent.py +287 -0
- dos/data_class.py +397 -0
- dos/decisions.py +1274 -0
- dos/decisions_tui.py +251 -0
- dos/dispatch_top.py +740 -0
- dos/dispatch_top_tui.py +116 -0
- dos/drivers/__init__.py +40 -0
- dos/drivers/ci_status.py +630 -0
- dos/drivers/citation_resolve.py +703 -0
- dos/drivers/decision_stop.py +98 -0
- dos/drivers/export_file.py +173 -0
- dos/drivers/export_otlp.py +275 -0
- dos/drivers/export_statsd.py +242 -0
- dos/drivers/hook_dialects.py +391 -0
- dos/drivers/job.py +47 -0
- dos/drivers/llm_judge.py +360 -0
- dos/drivers/memory_recall.py +1231 -0
- dos/drivers/notify_slack.py +373 -0
- dos/drivers/notify_webhook.py +251 -0
- dos/drivers/operator_judge.py +114 -0
- dos/drivers/os_acceptance.py +228 -0
- dos/drivers/paste_log.py +132 -0
- dos/drivers/plan_scope.py +133 -0
- dos/drivers/self_improve.py +375 -0
- dos/drivers/similarity_judge.py +249 -0
- dos/drivers/state_diff.py +274 -0
- dos/drivers/supervisor.py +347 -0
- dos/drivers/watchdog.py +363 -0
- dos/drivers/workshop.py +160 -0
- dos/durable_schema.py +344 -0
- dos/effect_witness.py +393 -0
- dos/efficiency.py +318 -0
- dos/enforce.py +414 -0
- dos/enumerate.py +776 -0
- dos/env_print.py +378 -0
- dos/event_severity.py +258 -0
- dos/evidence.py +692 -0
- dos/exec_capability.py +256 -0
- dos/export_cursor.py +143 -0
- dos/exporter.py +320 -0
- dos/firing_label.py +353 -0
- dos/fleet_roll.py +226 -0
- dos/gate_classify.py +827 -0
- dos/gh4_coverage.py +179 -0
- dos/git_delta.py +122 -0
- dos/guard.py +215 -0
- dos/health.py +552 -0
- dos/help_summary.py +519 -0
- dos/home.py +934 -0
- dos/hook_binary.py +194 -0
- dos/hook_dialect.py +271 -0
- dos/hook_exit.py +191 -0
- dos/hook_install.py +437 -0
- dos/id_alloc.py +304 -0
- dos/improve.py +499 -0
- dos/intent_ledger.py +635 -0
- dos/interpret.py +176 -0
- dos/intervention.py +769 -0
- dos/intervention_eval.py +371 -0
- dos/journal_delta.py +308 -0
- dos/judge_eval.py +328 -0
- dos/judges.py +366 -0
- dos/lane_infer.py +127 -0
- dos/lane_journal.py +1001 -0
- dos/lane_lease.py +952 -0
- dos/lane_overlap.py +228 -0
- dos/lease_health.py +282 -0
- dos/lifecycle.py +211 -0
- dos/liveness.py +352 -0
- dos/lock_modes.py +185 -0
- dos/log_source.py +395 -0
- dos/loop_decide.py +1746 -0
- dos/marker_gate.py +254 -0
- dos/marker_sensor.py +396 -0
- dos/noop_streak.py +280 -0
- dos/notify.py +479 -0
- dos/observe.py +175 -0
- dos/oracle.py +1661 -0
- dos/overlap_eval.py +214 -0
- dos/overlap_policy.py +342 -0
- dos/packet_sidecar.py +267 -0
- dos/phase_shipped.py +1985 -0
- dos/pick_priority.py +225 -0
- dos/pickable.py +369 -0
- dos/picker_oracle.py +1037 -0
- dos/plan_board.py +513 -0
- dos/plan_board_tui.py +113 -0
- dos/plan_source.py +455 -0
- dos/posttool_sensor.py +528 -0
- dos/precursor_gate.py +499 -0
- dos/precursor_gate_eval.py +239 -0
- dos/preflight.py +825 -0
- dos/pretool_sensor.py +490 -0
- dos/proc_delta.py +181 -0
- dos/productivity.py +296 -0
- dos/provider_limit.py +242 -0
- dos/py.typed +4 -0
- dos/reason_morphology.py +299 -0
- dos/reasons.py +449 -0
- dos/reconcile.py +173 -0
- dos/recurring_wedge.py +206 -0
- dos/render.py +393 -0
- dos/result_state.py +468 -0
- dos/resume.py +578 -0
- dos/resume_evidence.py +293 -0
- dos/retention.py +344 -0
- dos/reward.py +372 -0
- dos/rewind.py +587 -0
- dos/rewind_evidence.py +168 -0
- dos/rewind_tokens.py +252 -0
- dos/run_id.py +342 -0
- dos/scope.py +520 -0
- dos/scope_source.py +382 -0
- dos/scout.py +982 -0
- dos/self_modify.py +209 -0
- dos/sibling_scan.py +569 -0
- dos/skills/EXAMPLES.md +584 -0
- dos/skills/dos-class-cycle/SKILL.md +107 -0
- dos/skills/dos-dispatch/SKILL.md +177 -0
- dos/skills/dos-dispatch-loop/SKILL.md +254 -0
- dos/skills/dos-goal-gate/SKILL.md +269 -0
- dos/skills/dos-next-up/SKILL.md +231 -0
- dos/skills/dos-promote/SKILL.md +114 -0
- dos/skills/dos-replan/SKILL.md +159 -0
- dos/skills/dos-replan-loop/SKILL.md +114 -0
- dos/skills/dos-self-improve/SKILL.md +213 -0
- dos/skills/dos-supervise-loop/SKILL.md +180 -0
- dos/skills/dos-unstick/SKILL.md +108 -0
- dos/skills/dos-witness-claim/SKILL.md +251 -0
- dos/stamp.py +1002 -0
- dos/state_health.py +387 -0
- dos/status.py +114 -0
- dos/stop_policy.py +334 -0
- dos/supervise.py +1014 -0
- dos/testwitness.py +392 -0
- dos/timeline.py +1027 -0
- dos/tokens.py +485 -0
- dos/tool_stream.py +393 -0
- dos/tool_stream_eval.py +226 -0
- dos/trace.py +524 -0
- dos/verdict.py +140 -0
- dos/verdict_cli.py +189 -0
- dos/verdict_journal.py +497 -0
- dos/verdict_rollup.py +217 -0
- dos/verdicts.py +181 -0
- dos/wedge_reason.py +282 -0
- dos_kernel-0.22.0.dist-info/METADATA +859 -0
- dos_kernel-0.22.0.dist-info/RECORD +178 -0
- dos_kernel-0.22.0.dist-info/WHEEL +5 -0
- dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
- dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
- dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
- dos_mcp/__init__.py +52 -0
- dos_mcp/py.typed +2 -0
- dos_mcp/server.py +779 -0
dos/config_lint.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""CFL — the config-integrity linter: *dead policy as a verdict.*
|
|
2
|
+
|
|
3
|
+
docs/227 — **G1 from the docs/189 Claude Code audit.** Claude Code ships
|
|
4
|
+
`shadowedRuleDetection.ts` (`detectUnreachableRules`): a static check that finds a
|
|
5
|
+
permission *allow* rule made **unreachable** — dead code — by a more general
|
|
6
|
+
deny/ask rule that precedes it. This module is that idea aimed at *DOS's own*
|
|
7
|
+
registries: a workspace declares its policy as data (the lane taxonomy + the reason
|
|
8
|
+
vocabulary in `dos.toml`), and data can be **internally inconsistent** in ways that
|
|
9
|
+
are *structurally detectable* without running anything — a lane that can never be
|
|
10
|
+
arbitrated, a lane that is both "runs in parallel" and "runs alone," a reference to
|
|
11
|
+
a lane that was never declared, a lane whose region is wholly swallowed by another.
|
|
12
|
+
Each is **dead policy**: a declaration that looks active but can never fire.
|
|
13
|
+
|
|
14
|
+
This is a kernel verdict, not a CLI helper, for the same reason every other verdict
|
|
15
|
+
is — it is **byte-clean by construction**: its only input is the config the
|
|
16
|
+
operator authored (`LaneTaxonomy` + `ReasonRegistry`), never an agent's narration
|
|
17
|
+
and never the live world. It is a pure function — registry-in, findings-out — with
|
|
18
|
+
no I/O, no clock, no plan, the `liveness.classify` / `overlap_eval` shape:
|
|
19
|
+
|
|
20
|
+
liveness.classify (ProgressEvidence, policy) -> LivenessVerdict
|
|
21
|
+
productivity.classify (WorkHistory, policy) -> ProductivityVerdict
|
|
22
|
+
config_lint.lint (LaneTaxonomy, ReasonRegistry) -> tuple[Finding, ...]
|
|
23
|
+
^ THIS module
|
|
24
|
+
|
|
25
|
+
It also **consolidates logic that drifted into the CLI shell**. The treeless-lane
|
|
26
|
+
check lived inline in `cli.py::_treeless_lane_findings` (policy logic in a layer
|
|
27
|
+
that is supposed to carry none); the overlapping-concurrent-lane check is mirrored
|
|
28
|
+
in `supervise.overlapping_concurrent_lanes` (re-derived here from the same
|
|
29
|
+
`_tree.lane_trees_disjoint` definition, so the two cannot drift). The four checks
|
|
30
|
+
with no prior home — contradictory concurrent∩exclusive, dangling autopick/alias
|
|
31
|
+
targets, and the true strict-subset *shadow* — are new.
|
|
32
|
+
|
|
33
|
+
**Advisory** (the docs/99 floor): the linter REPORTS findings; it never rewrites
|
|
34
|
+
`dos.toml` and never refuses a lease. It is a `dos doctor --check` integrity rail
|
|
35
|
+
and a `dos lint` CI gate — the operator decides what a finding *means*.
|
|
36
|
+
|
|
37
|
+
**Shadow vs. overlap — the subtle distinction (docs/227 §3).** `_tree`'s existing
|
|
38
|
+
`lane_trees_disjoint` returns False for *any* prefix collision; it cannot tell
|
|
39
|
+
"A's region is wholly inside B's" (A is dead) from "A and B incidentally share a
|
|
40
|
+
file" (both live, order-sensitive). Those are different findings with different
|
|
41
|
+
fixes, so this module adds a **strict-subset** test (`_region_swallowed_by`): A is
|
|
42
|
+
swallowed by B iff every normalized prefix in A collides with some prefix in B and
|
|
43
|
+
the reverse is not also true (the symmetric case is identical regions — reported as
|
|
44
|
+
overlap, since neither is "the smaller one"). SHADOW means *remove the dead lane*;
|
|
45
|
+
OVERLAP means *disjoin the trees or mark one exclusive*. A pair is reported by
|
|
46
|
+
exactly one of the two, never both.
|
|
47
|
+
|
|
48
|
+
No host names (Law 1): the linter reads the generic taxonomy FIELDS
|
|
49
|
+
(`concurrent`/`exclusive`/`autopick`/`trees`/`aliases`) and never a lane NAME like
|
|
50
|
+
`apply` or `src`. Pure stdlib + the `_tree` leaf — a true leaf importing no layer
|
|
51
|
+
above it.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import enum
|
|
57
|
+
from dataclasses import dataclass
|
|
58
|
+
from typing import Iterable, Sequence
|
|
59
|
+
|
|
60
|
+
from dos._tree import norm_tree_prefix as _norm_tree_prefix
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LintKind(str, enum.Enum):
|
|
64
|
+
"""The closed set of config-integrity findings — `str`-valued so a finding
|
|
65
|
+
round-trips through JSON / a CLI line without a lookup table (the
|
|
66
|
+
`liveness.Liveness` / `gate_classify.Verdict` discipline)."""
|
|
67
|
+
|
|
68
|
+
# -- lane taxonomy --------------------------------------------------------
|
|
69
|
+
LANE_WITHOUT_TREE = "LANE_WITHOUT_TREE"
|
|
70
|
+
LANE_BOTH_CONCURRENT_AND_EXCLUSIVE = "LANE_BOTH_CONCURRENT_AND_EXCLUSIVE"
|
|
71
|
+
AUTOPICK_LANE_UNDECLARED = "AUTOPICK_LANE_UNDECLARED"
|
|
72
|
+
ALIAS_TARGET_UNDECLARED = "ALIAS_TARGET_UNDECLARED"
|
|
73
|
+
LANE_REGION_SHADOWED = "LANE_REGION_SHADOWED"
|
|
74
|
+
CONCURRENT_LANES_OVERLAP = "CONCURRENT_LANES_OVERLAP"
|
|
75
|
+
# -- reason registry ------------------------------------------------------
|
|
76
|
+
REASON_SEE_ALSO_DANGLES = "REASON_SEE_ALSO_DANGLES"
|
|
77
|
+
|
|
78
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
79
|
+
return self.value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Severity(str, enum.Enum):
|
|
83
|
+
"""How bad a finding is — ordered, so a consumer can gate on it.
|
|
84
|
+
|
|
85
|
+
error — the config is BROKEN (a lane that cannot be arbitrated, a
|
|
86
|
+
contradiction). A CI gate should fail.
|
|
87
|
+
warn — the config is SUSPECT (a dangling reference, a dead/shadowed lane,
|
|
88
|
+
an order-sensitive roster). Surfaced; a host may treat as advisory.
|
|
89
|
+
info — a COSMETIC nit (a dead doc link). Surfaced, never gates.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
ERROR = "error"
|
|
93
|
+
WARN = "warn"
|
|
94
|
+
INFO = "info"
|
|
95
|
+
|
|
96
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
97
|
+
return self.value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# The sort rank for severities (error first — the most actionable on top). Kept as
|
|
101
|
+
# a module constant rather than relying on enum definition order so the ordering is
|
|
102
|
+
# explicit and a reorder of the enum can't silently change the report order.
|
|
103
|
+
_SEVERITY_RANK: dict[Severity, int] = {
|
|
104
|
+
Severity.ERROR: 0,
|
|
105
|
+
Severity.WARN: 1,
|
|
106
|
+
Severity.INFO: 2,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Finding:
|
|
112
|
+
"""One config-integrity finding, as data — a typed record a consumer filters
|
|
113
|
+
on, never prose it must re-parse (the `InterventionDecision` / `OverlapDecision`
|
|
114
|
+
discipline).
|
|
115
|
+
|
|
116
|
+
Fields:
|
|
117
|
+
kind — the closed `LintKind` this finding is.
|
|
118
|
+
severity — error / warn / info (§4).
|
|
119
|
+
subject — the lane / reason / alias keyword the finding is ABOUT (so a
|
|
120
|
+
consumer can group/dedupe by subject).
|
|
121
|
+
detail — one line: what is wrong.
|
|
122
|
+
fix — one line: how to fix it (co-located with the finding, the
|
|
123
|
+
`ReasonSpec.fix` "remedy beside the symbol" rule).
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
kind: LintKind
|
|
127
|
+
severity: Severity
|
|
128
|
+
subject: str
|
|
129
|
+
detail: str
|
|
130
|
+
fix: str
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def is_error(self) -> bool:
|
|
134
|
+
return self.severity is Severity.ERROR
|
|
135
|
+
|
|
136
|
+
def line(self) -> str:
|
|
137
|
+
"""Render to one operator-facing line (the form `dos doctor --check`
|
|
138
|
+
appends to its findings list). `[severity] subject: detail — fix`."""
|
|
139
|
+
return f"[{self.severity}] {self.kind} {self.subject!r}: {self.detail} — {self.fix}"
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> dict:
|
|
142
|
+
"""JSON-friendly projection (for `dos lint --json` / `doctor --json`)."""
|
|
143
|
+
return {
|
|
144
|
+
"kind": self.kind.value,
|
|
145
|
+
"severity": self.severity.value,
|
|
146
|
+
"subject": self.subject,
|
|
147
|
+
"detail": self.detail,
|
|
148
|
+
"fix": self.fix,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# The prefix algebra for the shadow / overlap distinction. Reuses
|
|
154
|
+
# `_tree.norm_tree_prefix` (the ONE normalization — case-folded, glob-truncated)
|
|
155
|
+
# so this module collides identically to `lane_overlap` / the self-modify guard
|
|
156
|
+
# and cannot drift. We do NOT reuse `_tree.lane_trees_disjoint` for the subset
|
|
157
|
+
# test because that function answers only the boolean "do they collide at all?",
|
|
158
|
+
# which conflates strict-subset (shadow) with incidental-intersection (overlap).
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _norm_prefixes(tree: Sequence[str]) -> list[str]:
|
|
163
|
+
"""The normalized, non-empty-source prefixes of a lane tree. An entry that is
|
|
164
|
+
the empty string is dropped (no region); a leading-glob entry (`**/*`) keeps
|
|
165
|
+
its EMPTY prefix `""`, the universal prefix that collides with everything —
|
|
166
|
+
`_tree`'s documented rule, preserved here."""
|
|
167
|
+
return [_norm_tree_prefix(p) for p in tree if p]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _prefix_collides(prefix: str, others: Sequence[str]) -> bool:
|
|
171
|
+
"""True iff `prefix` collides with at least one of `others` — SYMMETRIC (one is
|
|
172
|
+
a prefix of the other, the `_tree.prefixes_collide` rule). This answers "do the
|
|
173
|
+
regions intersect?", NOT "is one contained in the other" — see
|
|
174
|
+
`_prefix_within` for the directional containment test."""
|
|
175
|
+
for o in others:
|
|
176
|
+
if prefix.startswith(o) or o.startswith(prefix):
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _prefix_within(prefix: str, outers: Sequence[str]) -> bool:
|
|
182
|
+
"""True iff `prefix` names a region AT OR BELOW some prefix in `outers` —
|
|
183
|
+
DIRECTIONAL (``prefix.startswith(outer)`` only, never the reverse).
|
|
184
|
+
|
|
185
|
+
This is the load-bearing asymmetry the shadow test needs (docs/227 §3). "Lane
|
|
186
|
+
A's region is contained in lane B's" means every A-prefix sits *inside* a
|
|
187
|
+
B-prefix — i.e. the A-prefix is the more-specific one (``src/api/`` is within
|
|
188
|
+
``src/``). The symmetric collision test (`_prefix_collides`) cannot tell that
|
|
189
|
+
from the reverse (B inside A), which is exactly why the first draft mis-reported
|
|
190
|
+
a strict subset as an incidental overlap. The empty/universal outer prefix
|
|
191
|
+
``""`` contains everything (``x.startswith("")`` is always True), so a lane is
|
|
192
|
+
"within" a whole-repo lane — correct, it is swallowed by it.
|
|
193
|
+
"""
|
|
194
|
+
for o in outers:
|
|
195
|
+
if prefix.startswith(o):
|
|
196
|
+
return True
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _region_within(a: Sequence[str], b: Sequence[str]) -> bool:
|
|
201
|
+
"""True iff EVERY normalized prefix in `a` sits at-or-below SOME prefix in `b`
|
|
202
|
+
— "a's region is contained in b's region" (directional, via `_prefix_within`).
|
|
203
|
+
|
|
204
|
+
An empty `a`/`b` → False (the caller guards treeless lanes upstream — a
|
|
205
|
+
treeless concurrent lane is its own LANE_WITHOUT_TREE error, never a shadow —
|
|
206
|
+
so this is only reached for lanes that have a tree).
|
|
207
|
+
"""
|
|
208
|
+
na = _norm_prefixes(a)
|
|
209
|
+
nb = _norm_prefixes(b)
|
|
210
|
+
if not na or not nb:
|
|
211
|
+
return False
|
|
212
|
+
return all(_prefix_within(p, nb) for p in na)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _region_swallowed_by(a: Sequence[str], b: Sequence[str]) -> bool:
|
|
216
|
+
"""True iff lane A's region is a STRICT subset of lane B's region (docs/227 §3).
|
|
217
|
+
|
|
218
|
+
A ⊂ B iff A's region is contained in B AND B's is not also contained in A
|
|
219
|
+
(the reverse containment failing is what makes it *strict* — equal regions, where
|
|
220
|
+
both directions hold, are NOT a shadow; they are reported as overlap since neither
|
|
221
|
+
lane is "the smaller one"). So `["src/api/**"]` is swallowed by `["src/**"]`, but
|
|
222
|
+
two lanes with the identical tree are not (mutual containment → not strict).
|
|
223
|
+
"""
|
|
224
|
+
a_in_b = _region_within(a, b)
|
|
225
|
+
if not a_in_b:
|
|
226
|
+
return False
|
|
227
|
+
b_in_a = _region_within(b, a)
|
|
228
|
+
return not b_in_a # strict: A ⊆ B but B ⊄ A
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _regions_collide(a: Sequence[str], b: Sequence[str]) -> bool:
|
|
232
|
+
"""True iff any prefix of A collides with any prefix of B (the symmetric
|
|
233
|
+
'do these regions intersect at all?' — same answer as
|
|
234
|
+
`_tree.lane_trees_disjoint` negated, recomputed here over already-normalized
|
|
235
|
+
prefixes so the overlap finding and the shadow finding share one prefix pass)."""
|
|
236
|
+
na = _norm_prefixes(a)
|
|
237
|
+
nb = _norm_prefixes(b)
|
|
238
|
+
if not na or not nb:
|
|
239
|
+
return False
|
|
240
|
+
for pa in na:
|
|
241
|
+
if _prefix_collides(pa, nb):
|
|
242
|
+
return True
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# The lane-taxonomy lint.
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def lint_lanes(taxonomy) -> tuple[Finding, ...]:
|
|
252
|
+
"""Every lane-taxonomy integrity finding, unsorted (the caller sorts).
|
|
253
|
+
|
|
254
|
+
`taxonomy` is duck-typed on the `config.LaneTaxonomy` surface
|
|
255
|
+
(`.concurrent`/`.exclusive`/`.autopick` tuples, `.trees` dict, `.aliases`
|
|
256
|
+
dict, `.tree_for(lane)`) so this leaf imports no layer above it — the
|
|
257
|
+
dependency-arrow rule (it never imports `config`, which imports the seam-data
|
|
258
|
+
modules; it reads the shape it is handed). Pure: no I/O.
|
|
259
|
+
"""
|
|
260
|
+
findings: list[Finding] = []
|
|
261
|
+
|
|
262
|
+
concurrent = tuple(taxonomy.concurrent)
|
|
263
|
+
exclusive = tuple(taxonomy.exclusive)
|
|
264
|
+
autopick = tuple(taxonomy.autopick)
|
|
265
|
+
aliases = dict(taxonomy.aliases)
|
|
266
|
+
|
|
267
|
+
declared = set(concurrent) | set(exclusive)
|
|
268
|
+
|
|
269
|
+
# 1. LANE_WITHOUT_TREE — a concurrent/autopick lane with no tree. Exclusive
|
|
270
|
+
# lanes are EXEMPT (the arbiter admits them on liveness alone, never a tree —
|
|
271
|
+
# the bug `_treeless_lane_findings` already learned: a treeless `global` is
|
|
272
|
+
# correct). One finding per offending lane, naming which role(s) referenced it.
|
|
273
|
+
treeless_roles: dict[str, list[str]] = {}
|
|
274
|
+
for role, members in (("concurrent", concurrent), ("autopick", autopick)):
|
|
275
|
+
for lane in members:
|
|
276
|
+
if not taxonomy.tree_for(lane):
|
|
277
|
+
treeless_roles.setdefault(lane, []).append(role)
|
|
278
|
+
for lane in sorted(treeless_roles):
|
|
279
|
+
where = "/".join(treeless_roles[lane])
|
|
280
|
+
findings.append(Finding(
|
|
281
|
+
kind=LintKind.LANE_WITHOUT_TREE,
|
|
282
|
+
severity=Severity.ERROR,
|
|
283
|
+
subject=lane,
|
|
284
|
+
detail=(f"declared in [lanes].{where} but has no tree in [lanes.trees] "
|
|
285
|
+
f"— it can't be arbitrated (the disjointness algebra has nothing "
|
|
286
|
+
f"to compare)"),
|
|
287
|
+
fix="declare its tree in [lanes.trees], or drop the lane",
|
|
288
|
+
))
|
|
289
|
+
|
|
290
|
+
# 2. LANE_BOTH_CONCURRENT_AND_EXCLUSIVE — a contradiction.
|
|
291
|
+
both = sorted(set(concurrent) & set(exclusive))
|
|
292
|
+
for lane in both:
|
|
293
|
+
findings.append(Finding(
|
|
294
|
+
kind=LintKind.LANE_BOTH_CONCURRENT_AND_EXCLUSIVE,
|
|
295
|
+
severity=Severity.ERROR,
|
|
296
|
+
subject=lane,
|
|
297
|
+
detail=("declared in BOTH [lanes].concurrent and [lanes].exclusive — "
|
|
298
|
+
"contradictory (concurrent runs in parallel iff disjoint; "
|
|
299
|
+
"exclusive runs alone)"),
|
|
300
|
+
fix="keep the lane in exactly one of concurrent / exclusive",
|
|
301
|
+
))
|
|
302
|
+
|
|
303
|
+
# 3. AUTOPICK_LANE_UNDECLARED — an autopick walk entry that is neither
|
|
304
|
+
# concurrent nor exclusive (a dangling reference — usually a typo).
|
|
305
|
+
for lane in autopick:
|
|
306
|
+
if lane not in declared:
|
|
307
|
+
findings.append(Finding(
|
|
308
|
+
kind=LintKind.AUTOPICK_LANE_UNDECLARED,
|
|
309
|
+
severity=Severity.WARN,
|
|
310
|
+
subject=lane,
|
|
311
|
+
detail=("is in the [lanes].autopick walk order but is declared in "
|
|
312
|
+
"neither [lanes].concurrent nor [lanes].exclusive — the walk "
|
|
313
|
+
"silently skips it"),
|
|
314
|
+
fix=("add it to [lanes].concurrent (or [lanes].exclusive), or remove "
|
|
315
|
+
"it from autopick"),
|
|
316
|
+
))
|
|
317
|
+
|
|
318
|
+
# 4. ALIAS_TARGET_UNDECLARED — an alias pointing at an undeclared lane.
|
|
319
|
+
# Subject is the keyword (what the operator typed); detail names the target.
|
|
320
|
+
for keyword in sorted(aliases):
|
|
321
|
+
target = aliases[keyword]
|
|
322
|
+
if target not in declared:
|
|
323
|
+
findings.append(Finding(
|
|
324
|
+
kind=LintKind.ALIAS_TARGET_UNDECLARED,
|
|
325
|
+
severity=Severity.WARN,
|
|
326
|
+
subject=keyword,
|
|
327
|
+
detail=(f"[lanes.aliases].{keyword} routes to lane {target!r}, which "
|
|
328
|
+
f"is declared in neither concurrent nor exclusive — the alias "
|
|
329
|
+
f"resolves to nothing (or an UNKNOWN_LANE refuse at request "
|
|
330
|
+
f"time)"),
|
|
331
|
+
fix=f"declare lane {target!r}, or fix the alias target",
|
|
332
|
+
))
|
|
333
|
+
|
|
334
|
+
# 5 + 6. Shadow (strict subset → dead) vs. overlap (incidental intersection →
|
|
335
|
+
# order-sensitive), over the CONCURRENT lanes that HAVE a tree (a
|
|
336
|
+
# treeless lane is already a LANE_WITHOUT_TREE error; we don't double-
|
|
337
|
+
# report it as shadowed/overlapping). A pair is reported by exactly one
|
|
338
|
+
# of the two findings.
|
|
339
|
+
sized = [(lane, tuple(taxonomy.tree_for(lane)))
|
|
340
|
+
for lane in concurrent if taxonomy.tree_for(lane)]
|
|
341
|
+
# Stable order: name-sorted, so the report and fixtures are deterministic.
|
|
342
|
+
sized.sort(key=lambda t: t[0])
|
|
343
|
+
for i in range(len(sized)):
|
|
344
|
+
for j in range(i + 1, len(sized)):
|
|
345
|
+
a_name, a_tree = sized[i]
|
|
346
|
+
b_name, b_tree = sized[j]
|
|
347
|
+
# Shadow first — it is the more specific (and more actionable) verdict.
|
|
348
|
+
# Either direction can be the swallowed one; name the dead lane as the
|
|
349
|
+
# subject and the swallowing lane in the detail.
|
|
350
|
+
if _region_swallowed_by(a_tree, b_tree):
|
|
351
|
+
dead, broad = a_name, b_name
|
|
352
|
+
findings.append(_shadow_finding(dead, broad))
|
|
353
|
+
elif _region_swallowed_by(b_tree, a_tree):
|
|
354
|
+
dead, broad = b_name, a_name
|
|
355
|
+
findings.append(_shadow_finding(dead, broad))
|
|
356
|
+
elif _regions_collide(a_tree, b_tree):
|
|
357
|
+
# Incidental intersection (neither swallows the other) → overlap.
|
|
358
|
+
findings.append(Finding(
|
|
359
|
+
kind=LintKind.CONCURRENT_LANES_OVERLAP,
|
|
360
|
+
severity=Severity.WARN,
|
|
361
|
+
subject=f"{a_name}+{b_name}",
|
|
362
|
+
detail=(f"concurrent lanes {a_name!r} and {b_name!r} have "
|
|
363
|
+
f"OVERLAPPING regions — only one can hold a worker at a "
|
|
364
|
+
f"time, so the supervisor's spawn order decides which"),
|
|
365
|
+
fix=("declare disjoint trees in [lanes.trees], or mark one "
|
|
366
|
+
"[lanes].exclusive, to make the roster order-insensitive"),
|
|
367
|
+
))
|
|
368
|
+
|
|
369
|
+
return tuple(findings)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _shadow_finding(dead: str, broad: str) -> Finding:
|
|
373
|
+
"""The LANE_REGION_SHADOWED finding for `dead` ⊂ `broad` (factored so both
|
|
374
|
+
direction branches build it identically)."""
|
|
375
|
+
return Finding(
|
|
376
|
+
kind=LintKind.LANE_REGION_SHADOWED,
|
|
377
|
+
severity=Severity.WARN,
|
|
378
|
+
subject=dead,
|
|
379
|
+
detail=(f"concurrent lane {dead!r}'s region is wholly inside concurrent lane "
|
|
380
|
+
f"{broad!r}'s — {dead!r} can never be picked independently (any "
|
|
381
|
+
f"request for it collides with {broad!r}), so it is dead"),
|
|
382
|
+
fix=(f"remove lane {dead!r}, or carve {broad!r} so it no longer contains "
|
|
383
|
+
f"{dead!r}"),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
# The reason-registry lint. (Most reason integrity is enforced at construction —
|
|
389
|
+
# `ReasonRegistry.__post_init__` rejects duplicate tokens, `ReasonSpec.__post_init__`
|
|
390
|
+
# rejects an unknown category — so the linter adds only the cross-reference check
|
|
391
|
+
# the constructor can't do without knowing the lane set.)
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def lint_reasons(registry, *, known_lanes: Iterable[str]) -> tuple[Finding, ...]:
|
|
396
|
+
"""The reason-registry integrity findings, unsorted (the caller sorts).
|
|
397
|
+
|
|
398
|
+
`registry` is duck-typed on the `reasons.ReasonRegistry` surface (`.specs`
|
|
399
|
+
iterable of objects with `.token` + `.see_also`). `known_lanes` is the set of
|
|
400
|
+
declared lane names a `see_also` of the form `lane <name>` is checked against.
|
|
401
|
+
Pure: no I/O.
|
|
402
|
+
|
|
403
|
+
The only check: REASON_SEE_ALSO_DANGLES — a `see_also` pointer `lane <name>`
|
|
404
|
+
whose `<name>` is not a declared lane (a dead man-page cross-ref). Conservative
|
|
405
|
+
— only the `lane <name>` shape is modeled; other `see_also` targets (oracles,
|
|
406
|
+
meta keys) are free-form prose the linter does not judge.
|
|
407
|
+
"""
|
|
408
|
+
findings: list[Finding] = []
|
|
409
|
+
lanes = set(known_lanes)
|
|
410
|
+
for spec in getattr(registry, "specs", ()): # tolerate an empty/absent registry
|
|
411
|
+
for ref in getattr(spec, "see_also", ()):
|
|
412
|
+
ref = str(ref).strip()
|
|
413
|
+
if not ref.lower().startswith("lane "):
|
|
414
|
+
continue
|
|
415
|
+
target = ref[len("lane "):].strip()
|
|
416
|
+
# A `lane <name>` ref may itself be a placeholder like `<holder>` (the
|
|
417
|
+
# LANE_LEASE_HELD reason's templated pointer); skip angle-bracketed
|
|
418
|
+
# placeholders — they are documentation, not a concrete lane name.
|
|
419
|
+
if not target or (target.startswith("<") and target.endswith(">")):
|
|
420
|
+
continue
|
|
421
|
+
if target not in lanes:
|
|
422
|
+
findings.append(Finding(
|
|
423
|
+
kind=LintKind.REASON_SEE_ALSO_DANGLES,
|
|
424
|
+
severity=Severity.INFO,
|
|
425
|
+
subject=str(spec.token),
|
|
426
|
+
detail=(f"see_also points at {ref!r} but lane {target!r} is not "
|
|
427
|
+
f"declared — the man-page cross-ref dead-ends"),
|
|
428
|
+
fix=f"declare lane {target!r}, or fix the see_also pointer",
|
|
429
|
+
))
|
|
430
|
+
return tuple(findings)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# ---------------------------------------------------------------------------
|
|
434
|
+
# The top-level lint — both registries, sorted.
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _sort_key(f: Finding) -> tuple[int, str, str]:
|
|
439
|
+
"""error → warn → info, then by kind, then by subject — a stable, deterministic
|
|
440
|
+
report order (the `overlap_eval` / `lane_overlap` ordering discipline)."""
|
|
441
|
+
return (_SEVERITY_RANK[f.severity], f.kind.value, f.subject)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def lint(taxonomy, registry=None) -> tuple[Finding, ...]:
|
|
445
|
+
"""Every config-integrity finding across the lane taxonomy and (optionally) the
|
|
446
|
+
reason registry, sorted error → warn → info.
|
|
447
|
+
|
|
448
|
+
`registry` is optional: a caller that only has a taxonomy (or wants only the
|
|
449
|
+
lane rail) passes `None` and gets the lane findings alone. When a registry is
|
|
450
|
+
given, its `see_also` pointers are checked against the taxonomy's declared lanes
|
|
451
|
+
(so the cross-reference check sees both halves). Pure: no I/O.
|
|
452
|
+
"""
|
|
453
|
+
findings: list[Finding] = list(lint_lanes(taxonomy))
|
|
454
|
+
if registry is not None:
|
|
455
|
+
known = set(taxonomy.concurrent) | set(taxonomy.exclusive)
|
|
456
|
+
findings.extend(lint_reasons(registry, known_lanes=known))
|
|
457
|
+
return tuple(sorted(findings, key=_sort_key))
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def has_error(findings: Iterable[Finding]) -> bool:
|
|
461
|
+
"""True iff any finding is an ERROR — the gate a strict CI check keys on (a
|
|
462
|
+
`dos lint --strict` could fail on this alone, while the default fails on any
|
|
463
|
+
finding, matching today's `doctor --check`)."""
|
|
464
|
+
return any(f.severity is Severity.ERROR for f in findings)
|