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.
Files changed (178) hide show
  1. dos/__init__.py +261 -0
  2. dos/_bin/dos-hook.exe +0 -0
  3. dos/_filelock.py +255 -0
  4. dos/_job_policy.py +97 -0
  5. dos/_tree.py +145 -0
  6. dos/admission.py +433 -0
  7. dos/answer_shape.py +299 -0
  8. dos/arbiter.py +859 -0
  9. dos/archive_lock.py +266 -0
  10. dos/arg_provenance.py +814 -0
  11. dos/attest.py +472 -0
  12. dos/breaker.py +311 -0
  13. dos/churn.py +226 -0
  14. dos/claim_extract.py +229 -0
  15. dos/claim_ttl.py +150 -0
  16. dos/cli.py +8721 -0
  17. dos/commit_audit.py +666 -0
  18. dos/completion.py +466 -0
  19. dos/concurrency_class.py +154 -0
  20. dos/config.py +1380 -0
  21. dos/config_lint.py +464 -0
  22. dos/cooldown.py +390 -0
  23. dos/coverage.py +387 -0
  24. dos/dangling_intent.py +287 -0
  25. dos/data_class.py +397 -0
  26. dos/decisions.py +1274 -0
  27. dos/decisions_tui.py +251 -0
  28. dos/dispatch_top.py +740 -0
  29. dos/dispatch_top_tui.py +116 -0
  30. dos/drivers/__init__.py +40 -0
  31. dos/drivers/ci_status.py +630 -0
  32. dos/drivers/citation_resolve.py +703 -0
  33. dos/drivers/decision_stop.py +98 -0
  34. dos/drivers/export_file.py +173 -0
  35. dos/drivers/export_otlp.py +275 -0
  36. dos/drivers/export_statsd.py +242 -0
  37. dos/drivers/hook_dialects.py +391 -0
  38. dos/drivers/job.py +47 -0
  39. dos/drivers/llm_judge.py +360 -0
  40. dos/drivers/memory_recall.py +1231 -0
  41. dos/drivers/notify_slack.py +373 -0
  42. dos/drivers/notify_webhook.py +251 -0
  43. dos/drivers/operator_judge.py +114 -0
  44. dos/drivers/os_acceptance.py +228 -0
  45. dos/drivers/paste_log.py +132 -0
  46. dos/drivers/plan_scope.py +133 -0
  47. dos/drivers/self_improve.py +375 -0
  48. dos/drivers/similarity_judge.py +249 -0
  49. dos/drivers/state_diff.py +274 -0
  50. dos/drivers/supervisor.py +347 -0
  51. dos/drivers/watchdog.py +363 -0
  52. dos/drivers/workshop.py +160 -0
  53. dos/durable_schema.py +344 -0
  54. dos/effect_witness.py +393 -0
  55. dos/efficiency.py +318 -0
  56. dos/enforce.py +414 -0
  57. dos/enumerate.py +776 -0
  58. dos/env_print.py +378 -0
  59. dos/event_severity.py +258 -0
  60. dos/evidence.py +692 -0
  61. dos/exec_capability.py +256 -0
  62. dos/export_cursor.py +143 -0
  63. dos/exporter.py +320 -0
  64. dos/firing_label.py +353 -0
  65. dos/fleet_roll.py +226 -0
  66. dos/gate_classify.py +827 -0
  67. dos/gh4_coverage.py +179 -0
  68. dos/git_delta.py +122 -0
  69. dos/guard.py +215 -0
  70. dos/health.py +552 -0
  71. dos/help_summary.py +519 -0
  72. dos/home.py +934 -0
  73. dos/hook_binary.py +194 -0
  74. dos/hook_dialect.py +271 -0
  75. dos/hook_exit.py +191 -0
  76. dos/hook_install.py +437 -0
  77. dos/id_alloc.py +304 -0
  78. dos/improve.py +499 -0
  79. dos/intent_ledger.py +635 -0
  80. dos/interpret.py +176 -0
  81. dos/intervention.py +769 -0
  82. dos/intervention_eval.py +371 -0
  83. dos/journal_delta.py +308 -0
  84. dos/judge_eval.py +328 -0
  85. dos/judges.py +366 -0
  86. dos/lane_infer.py +127 -0
  87. dos/lane_journal.py +1001 -0
  88. dos/lane_lease.py +952 -0
  89. dos/lane_overlap.py +228 -0
  90. dos/lease_health.py +282 -0
  91. dos/lifecycle.py +211 -0
  92. dos/liveness.py +352 -0
  93. dos/lock_modes.py +185 -0
  94. dos/log_source.py +395 -0
  95. dos/loop_decide.py +1746 -0
  96. dos/marker_gate.py +254 -0
  97. dos/marker_sensor.py +396 -0
  98. dos/noop_streak.py +280 -0
  99. dos/notify.py +479 -0
  100. dos/observe.py +175 -0
  101. dos/oracle.py +1661 -0
  102. dos/overlap_eval.py +214 -0
  103. dos/overlap_policy.py +342 -0
  104. dos/packet_sidecar.py +267 -0
  105. dos/phase_shipped.py +1985 -0
  106. dos/pick_priority.py +225 -0
  107. dos/pickable.py +369 -0
  108. dos/picker_oracle.py +1037 -0
  109. dos/plan_board.py +513 -0
  110. dos/plan_board_tui.py +113 -0
  111. dos/plan_source.py +455 -0
  112. dos/posttool_sensor.py +528 -0
  113. dos/precursor_gate.py +499 -0
  114. dos/precursor_gate_eval.py +239 -0
  115. dos/preflight.py +825 -0
  116. dos/pretool_sensor.py +490 -0
  117. dos/proc_delta.py +181 -0
  118. dos/productivity.py +296 -0
  119. dos/provider_limit.py +242 -0
  120. dos/py.typed +4 -0
  121. dos/reason_morphology.py +299 -0
  122. dos/reasons.py +449 -0
  123. dos/reconcile.py +173 -0
  124. dos/recurring_wedge.py +206 -0
  125. dos/render.py +393 -0
  126. dos/result_state.py +468 -0
  127. dos/resume.py +578 -0
  128. dos/resume_evidence.py +293 -0
  129. dos/retention.py +344 -0
  130. dos/reward.py +372 -0
  131. dos/rewind.py +587 -0
  132. dos/rewind_evidence.py +168 -0
  133. dos/rewind_tokens.py +252 -0
  134. dos/run_id.py +342 -0
  135. dos/scope.py +520 -0
  136. dos/scope_source.py +382 -0
  137. dos/scout.py +982 -0
  138. dos/self_modify.py +209 -0
  139. dos/sibling_scan.py +569 -0
  140. dos/skills/EXAMPLES.md +584 -0
  141. dos/skills/dos-class-cycle/SKILL.md +107 -0
  142. dos/skills/dos-dispatch/SKILL.md +177 -0
  143. dos/skills/dos-dispatch-loop/SKILL.md +254 -0
  144. dos/skills/dos-goal-gate/SKILL.md +269 -0
  145. dos/skills/dos-next-up/SKILL.md +231 -0
  146. dos/skills/dos-promote/SKILL.md +114 -0
  147. dos/skills/dos-replan/SKILL.md +159 -0
  148. dos/skills/dos-replan-loop/SKILL.md +114 -0
  149. dos/skills/dos-self-improve/SKILL.md +213 -0
  150. dos/skills/dos-supervise-loop/SKILL.md +180 -0
  151. dos/skills/dos-unstick/SKILL.md +108 -0
  152. dos/skills/dos-witness-claim/SKILL.md +251 -0
  153. dos/stamp.py +1002 -0
  154. dos/state_health.py +387 -0
  155. dos/status.py +114 -0
  156. dos/stop_policy.py +334 -0
  157. dos/supervise.py +1014 -0
  158. dos/testwitness.py +392 -0
  159. dos/timeline.py +1027 -0
  160. dos/tokens.py +485 -0
  161. dos/tool_stream.py +393 -0
  162. dos/tool_stream_eval.py +226 -0
  163. dos/trace.py +524 -0
  164. dos/verdict.py +140 -0
  165. dos/verdict_cli.py +189 -0
  166. dos/verdict_journal.py +497 -0
  167. dos/verdict_rollup.py +217 -0
  168. dos/verdicts.py +181 -0
  169. dos/wedge_reason.py +282 -0
  170. dos_kernel-0.22.0.dist-info/METADATA +859 -0
  171. dos_kernel-0.22.0.dist-info/RECORD +178 -0
  172. dos_kernel-0.22.0.dist-info/WHEEL +5 -0
  173. dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
  174. dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
  175. dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
  176. dos_mcp/__init__.py +52 -0
  177. dos_mcp/py.typed +2 -0
  178. 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)