oxn 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.
- oxn/__init__.py +10 -0
- oxn/__main__.py +16 -0
- oxn/_app.py +470 -0
- oxn/calibration.py +603 -0
- oxn/calls.py +282 -0
- oxn/check.py +770 -0
- oxn/cli.py +256 -0
- oxn/config.py +403 -0
- oxn/context/__init__.py +15 -0
- oxn/context/bm25.py +133 -0
- oxn/context/bundle.py +352 -0
- oxn/context/decisions.py +46 -0
- oxn/context/project.py +34 -0
- oxn/doctor.py +94 -0
- oxn/graph/__init__.py +1 -0
- oxn/graph/algos.py +380 -0
- oxn/graph/architecture.py +509 -0
- oxn/graph/asp.py +113 -0
- oxn/graph/builder.py +313 -0
- oxn/graph/contracts.py +357 -0
- oxn/graph/depgraph.py +283 -0
- oxn/graph/imports.py +470 -0
- oxn/graph/indexer.py +433 -0
- oxn/graph/manifests.py +393 -0
- oxn/graph/model.py +219 -0
- oxn/graph/resolve.py +461 -0
- oxn/graph/resolve_rust.py +162 -0
- oxn/graph/rings.py +72 -0
- oxn/graph/rows.py +114 -0
- oxn/graph/sources.py +165 -0
- oxn/graph/store.py +596 -0
- oxn/health.py +205 -0
- oxn/init.py +518 -0
- oxn/languages.py +68 -0
- oxn/llm.py +386 -0
- oxn/metrics/__init__.py +26 -0
- oxn/metrics/callgraph.py +240 -0
- oxn/metrics/cognitive.py +549 -0
- oxn/metrics/cohesion.py +242 -0
- oxn/metrics/coupling.py +385 -0
- oxn/metrics/cyclomatic.py +113 -0
- oxn/metrics/engine.py +304 -0
- oxn/metrics/halstead.py +129 -0
- oxn/metrics/profile.py +197 -0
- oxn/metrics/shredding.py +173 -0
- oxn/metrics/size.py +250 -0
- oxn/profiles/__init__.py +59 -0
- oxn/profiles/base.py +535 -0
- oxn/profiles/go.py +206 -0
- oxn/profiles/java.py +214 -0
- oxn/profiles/python.py +238 -0
- oxn/profiles/rust.py +186 -0
- oxn/profiles/spec.py +305 -0
- oxn/profiles/typescript.py +298 -0
- oxn/py.typed +0 -0
- oxn/render.py +613 -0
- oxn/report.py +602 -0
- oxn/resolve/__init__.py +25 -0
- oxn/resolve/importers.py +201 -0
- oxn/resolve/measure.py +424 -0
- oxn/resolve/members.py +544 -0
- oxn/resolve/project.py +156 -0
- oxn/resolve/scopes.py +641 -0
- oxn/resolve/symbols.py +366 -0
- oxn/retry.py +231 -0
- oxn/review.py +475 -0
- oxn/rules/__init__.py +0 -0
- oxn/rules/adr.py +249 -0
- oxn/rules/builtin.py +239 -0
- oxn/rules/engine.py +299 -0
- oxn/rules/facts.py +262 -0
- oxn/rules/model.py +152 -0
- oxn/scip/__init__.py +29 -0
- oxn/scip/aliases.py +183 -0
- oxn/scip/index.py +247 -0
- oxn/scip/ingest.py +208 -0
- oxn/scip/join.py +401 -0
- oxn/scip/runner.py +399 -0
- oxn/scip/wire.py +99 -0
- oxn/server.py +434 -0
- oxn/thresholds.py +186 -0
- oxn/vcs/__init__.py +33 -0
- oxn/vcs/analysis.py +249 -0
- oxn/vcs/log.py +291 -0
- oxn/volume/__init__.py +24 -0
- oxn/volume/clones.py +360 -0
- oxn/volume/erosion.py +96 -0
- oxn/volume/scan.py +169 -0
- oxn/volume/tokens.py +107 -0
- oxn/writers.py +281 -0
- oxn-0.1.0.dist-info/METADATA +498 -0
- oxn-0.1.0.dist-info/RECORD +95 -0
- oxn-0.1.0.dist-info/WHEEL +4 -0
- oxn-0.1.0.dist-info/entry_points.txt +2 -0
- oxn-0.1.0.dist-info/licenses/LICENSE +21 -0
oxn/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""OXN -- a local architecture and quality gatekeeper for LLM coding agents.
|
|
2
|
+
|
|
3
|
+
This module is imported by every entry point, including the PostToolUse hook that runs
|
|
4
|
+
after every agent edit. Keep it free of imports: see ADR-0002 for the latency budget
|
|
5
|
+
(p95 <= 200 ms per file, of which a bare interpreter already costs ~15 ms).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
oxn/__main__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Run OXN as a module: ``python -m oxn``.
|
|
2
|
+
|
|
3
|
+
Equivalent to the ``oxn`` console script, and useful wherever that script is not on
|
|
4
|
+
``PATH`` -- an unactivated virtualenv, a hook whose environment you do not control, ``uv
|
|
5
|
+
run``, or a CI step that only knows an interpreter path.
|
|
6
|
+
|
|
7
|
+
Kept deliberately thin. The PostToolUse hook may invoke OXN this way, so this module must
|
|
8
|
+
respect the same rule as :mod:`oxn.cli`: **nothing heavy at import time.**
|
|
9
|
+
``tests/test_import_guard.py`` enforces it for both entry points.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from oxn.cli import main
|
|
15
|
+
|
|
16
|
+
main()
|
oxn/_app.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""The human-facing typer application.
|
|
2
|
+
|
|
3
|
+
Imported lazily by :func:`oxn.cli.main`, never by the hook fast path. Anything that is
|
|
4
|
+
pleasant for a person and expensive for a machine belongs here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from oxn import __version__
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="oxn",
|
|
18
|
+
help="Local architecture and quality gatekeeper for LLM coding agents.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
add_completion=False,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _console() -> Console:
|
|
29
|
+
from rich.console import Console
|
|
30
|
+
|
|
31
|
+
return Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def version() -> None:
|
|
36
|
+
"""Print the OXN version."""
|
|
37
|
+
_console().print(f"oxn {__version__}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def check(
|
|
42
|
+
paths: list[str] = typer.Argument(None, help="Files or directories to check."),
|
|
43
|
+
json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
44
|
+
deep: bool = typer.Option(
|
|
45
|
+
False, "--deep", help="Also check repository-scoped contracts. Too slow for a hook."
|
|
46
|
+
),
|
|
47
|
+
no_baseline: bool = typer.Option(
|
|
48
|
+
False, "--no-baseline", help="Ignore .oxn/baseline.json and report every violation."
|
|
49
|
+
),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Check files against the project's architectural and quality invariants.
|
|
52
|
+
|
|
53
|
+
The ``--json`` form is dispatched by :mod:`oxn.cli` before typer is ever imported, so
|
|
54
|
+
this function is only reached on the human path.
|
|
55
|
+
"""
|
|
56
|
+
from oxn.check import render, run_check
|
|
57
|
+
from oxn.config import ConfigError
|
|
58
|
+
|
|
59
|
+
console = _console()
|
|
60
|
+
try:
|
|
61
|
+
report = run_check(paths or ["."], deep=deep, use_baseline=not no_baseline)
|
|
62
|
+
except ConfigError as error:
|
|
63
|
+
console.print(f"[red]oxn.yaml[/red]: {error}")
|
|
64
|
+
raise typer.Exit(1) from error
|
|
65
|
+
|
|
66
|
+
if json_output:
|
|
67
|
+
import json
|
|
68
|
+
|
|
69
|
+
console.print_json(json.dumps(report.as_dict()))
|
|
70
|
+
else:
|
|
71
|
+
render(report, console)
|
|
72
|
+
raise typer.Exit(report.exit_code)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command()
|
|
76
|
+
def baseline(
|
|
77
|
+
paths: list[str] = typer.Argument(None, help="Files or directories to record."),
|
|
78
|
+
deep: bool = typer.Option(False, "--deep", help="Include repository-scoped contracts."),
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Record today's violations as accepted debt, so only *new* ones fail.
|
|
81
|
+
|
|
82
|
+
The ratchet, not an amnesty: a recorded violation stops failing the build, but the same
|
|
83
|
+
violation getting worse starts failing it again, and anything new fails immediately.
|
|
84
|
+
This is what makes `oxn check` adoptable on a repository that predates it.
|
|
85
|
+
"""
|
|
86
|
+
from oxn.check import run_check, write_baseline
|
|
87
|
+
from oxn.config import Config, ConfigError
|
|
88
|
+
|
|
89
|
+
console = _console()
|
|
90
|
+
try:
|
|
91
|
+
settings = Config.load()
|
|
92
|
+
report = run_check(paths or ["."], config=settings, deep=deep, use_baseline=False)
|
|
93
|
+
except ConfigError as error:
|
|
94
|
+
console.print(f"[red]oxn.yaml[/red]: {error}")
|
|
95
|
+
raise typer.Exit(1) from error
|
|
96
|
+
|
|
97
|
+
if report.errors:
|
|
98
|
+
for path, problem in report.errors.items():
|
|
99
|
+
console.print(f"[red]error[/red] {path}: {problem}")
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
|
|
102
|
+
recorded = write_baseline(report, settings.baseline_path)
|
|
103
|
+
where = settings.baseline_path.relative_to(settings.root)
|
|
104
|
+
console.print(f"recorded [bold]{recorded}[/bold] violation(s) as accepted debt in {where}")
|
|
105
|
+
console.print("[dim]new violations, and these getting worse, will still fail[/dim]")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def init(
|
|
110
|
+
no_hook: bool = typer.Option(
|
|
111
|
+
False, "--no-hook", help="Write config and docs but do not install the hook."
|
|
112
|
+
),
|
|
113
|
+
no_mcp: bool = typer.Option(
|
|
114
|
+
False, "--no-mcp", help="Do not wire the MCP server into .mcp.json."
|
|
115
|
+
),
|
|
116
|
+
github: bool = typer.Option(
|
|
117
|
+
False, "--github", help="Also write the `@oxn` pull-request review workflow."
|
|
118
|
+
),
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Wire OXN into this repository. Additive and idempotent -- run it twice safely.
|
|
121
|
+
|
|
122
|
+
Nothing is overwritten. `oxn.yaml` is written only if absent, the `CLAUDE.md` section
|
|
123
|
+
lives between markers, and existing `.claude/settings.json` and `.mcp.json` files are
|
|
124
|
+
merged rather than replaced.
|
|
125
|
+
"""
|
|
126
|
+
from oxn.init import run_init
|
|
127
|
+
|
|
128
|
+
console = _console()
|
|
129
|
+
report = run_init(with_hook=not no_hook, with_mcp=not no_mcp, with_github=github)
|
|
130
|
+
for label, paths in (
|
|
131
|
+
("created", report.created),
|
|
132
|
+
("updated", report.updated),
|
|
133
|
+
("unchanged", report.unchanged),
|
|
134
|
+
):
|
|
135
|
+
for path in paths:
|
|
136
|
+
colour = {"created": "green", "updated": "cyan"}.get(label, "dim")
|
|
137
|
+
console.print(f"[{colour}]{label:9}[/{colour}] {path}")
|
|
138
|
+
for note in report.notes:
|
|
139
|
+
console.print(f"[yellow]note[/yellow] {note}")
|
|
140
|
+
console.print(
|
|
141
|
+
"\n[dim]next: `oxn check` to see where you stand, `oxn baseline` to accept it[/dim]"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command()
|
|
146
|
+
def calibration(
|
|
147
|
+
json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
148
|
+
) -> None:
|
|
149
|
+
"""List every tunable threshold with the evidence behind it.
|
|
150
|
+
|
|
151
|
+
A number with no stated provenance is folklore, and folklore is what makes developers
|
|
152
|
+
distrust a gate. This is also the surface P10's calibration work optimises over.
|
|
153
|
+
"""
|
|
154
|
+
from oxn.calibration import gate_status, summary
|
|
155
|
+
from oxn.calibration import parameters as _parameters
|
|
156
|
+
from oxn.config import Config
|
|
157
|
+
|
|
158
|
+
console = _console()
|
|
159
|
+
report = summary()
|
|
160
|
+
active = gate_status(Config.load(Path.cwd()))
|
|
161
|
+
report["disabled"] = sorted(name for name, on in active.items() if not on)
|
|
162
|
+
if json_output:
|
|
163
|
+
import json
|
|
164
|
+
|
|
165
|
+
console.print_json(json.dumps(report))
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
for parameter in _parameters():
|
|
169
|
+
marker = (
|
|
170
|
+
"[yellow]provisional[/yellow]" if parameter.is_provisional else "[green]fitted[/green]"
|
|
171
|
+
)
|
|
172
|
+
state = "" if active.get(parameter.name, True) else " [red]off in this project[/red]"
|
|
173
|
+
console.print(
|
|
174
|
+
f"[bold]{parameter.name}[/bold] = {parameter.value:g} "
|
|
175
|
+
f"{marker} {parameter.evidence.value} n={parameter.observations}{state}"
|
|
176
|
+
)
|
|
177
|
+
console.print(f" [dim]{parameter.provenance}[/dim]")
|
|
178
|
+
if parameter.fit_when:
|
|
179
|
+
console.print(f" [dim]fit when: {parameter.fit_when}[/dim]")
|
|
180
|
+
console.print(f"\n[dim]{report['provisional']}/{report['total']} provisional[/dim]")
|
|
181
|
+
console.print(f"[dim]{report['note']}[/dim]")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command()
|
|
185
|
+
def parse(
|
|
186
|
+
paths: list[str] = typer.Argument(None, help="Files or directories to parse."),
|
|
187
|
+
json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
188
|
+
force: bool = typer.Option(False, "--force", help="Ignore the cache and re-parse."),
|
|
189
|
+
stats: bool = typer.Option(False, "--stats", help="Show cache totals instead of entities."),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Parse sources into the code graph and report what was built.
|
|
192
|
+
|
|
193
|
+
A debugging surface for the parse layer: it shows the containment skeleton the metric
|
|
194
|
+
engine will read, and whether a run hit the cache.
|
|
195
|
+
"""
|
|
196
|
+
from oxn.render import Output
|
|
197
|
+
from oxn.report import run_parse
|
|
198
|
+
|
|
199
|
+
run_parse(
|
|
200
|
+
paths or ["."],
|
|
201
|
+
Output(console=None if json_output else _console()),
|
|
202
|
+
force=force,
|
|
203
|
+
stats_only=stats,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@app.command()
|
|
208
|
+
def metrics(
|
|
209
|
+
paths: list[str] = typer.Argument(None, help="Files or directories to measure."),
|
|
210
|
+
json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
211
|
+
sort_by: str = typer.Option("cognitive_complexity", "--sort-by", help="Metric to rank by."),
|
|
212
|
+
limit: int = typer.Option(20, "--limit", help="How many entities to show."),
|
|
213
|
+
explain: bool = typer.Option(
|
|
214
|
+
False, "--explain", help="Show the increment trail for the worst offender."
|
|
215
|
+
),
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Rank code by a Tier-1 metric.
|
|
218
|
+
|
|
219
|
+
Every point of a cognitive-complexity score can be traced to a line and a reason:
|
|
220
|
+
``oxn metrics --explain`` prints that trail.
|
|
221
|
+
"""
|
|
222
|
+
from oxn.render import Output
|
|
223
|
+
from oxn.report import run_metrics
|
|
224
|
+
|
|
225
|
+
run_metrics(
|
|
226
|
+
paths or ["."],
|
|
227
|
+
Output(console=None if json_output else _console()),
|
|
228
|
+
sort_by=sort_by,
|
|
229
|
+
limit=limit,
|
|
230
|
+
explain=explain,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.command()
|
|
235
|
+
def volume(
|
|
236
|
+
paths: list[str] = typer.Argument(None, help="Files or directories to analyse."),
|
|
237
|
+
json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
238
|
+
no_history: bool = typer.Option(False, "--no-history", help="Skip git history."),
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Report duplication, verbosity, structural erosion and hotspots.
|
|
241
|
+
|
|
242
|
+
These are the signals agent-written code degrades: volume correlates with
|
|
243
|
+
architectural decay where prompt specificity does not.
|
|
244
|
+
"""
|
|
245
|
+
from oxn.render import Output
|
|
246
|
+
from oxn.report import run_volume
|
|
247
|
+
|
|
248
|
+
run_volume(
|
|
249
|
+
paths or ["."],
|
|
250
|
+
Output(console=None if json_output else _console()),
|
|
251
|
+
include_history=not no_history,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.command()
|
|
256
|
+
def arch(
|
|
257
|
+
paths: list[str] = typer.Argument(None, help="Files or directories to analyse."),
|
|
258
|
+
json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
259
|
+
granularity: str = typer.Option(
|
|
260
|
+
"directory", "--by", help="Component granularity: directory | file | top."
|
|
261
|
+
),
|
|
262
|
+
show_unresolved: bool = typer.Option(
|
|
263
|
+
False, "--unresolved", help="List imports that could not be resolved."
|
|
264
|
+
),
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Report the dependency graph: cycles, Martin metrics, Lakos levels, smells.
|
|
267
|
+
|
|
268
|
+
Report-path only. Building the graph compares every file against every other, so it
|
|
269
|
+
never runs on the hook's per-edit budget.
|
|
270
|
+
"""
|
|
271
|
+
from oxn.render import Output
|
|
272
|
+
from oxn.report import run_arch
|
|
273
|
+
|
|
274
|
+
run_arch(
|
|
275
|
+
paths or ["."],
|
|
276
|
+
Output(console=None if json_output else _console()),
|
|
277
|
+
granularity=granularity,
|
|
278
|
+
show_unresolved=show_unresolved,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@app.command()
|
|
283
|
+
def index(
|
|
284
|
+
paths: list[str] = typer.Argument(None, help="Directory to index."),
|
|
285
|
+
language: str = typer.Option("python", "--language", help="Language to index."),
|
|
286
|
+
json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
287
|
+
reuse: str = typer.Option(
|
|
288
|
+
"", "--index-file", help="Ingest an existing .scip file instead of generating one."
|
|
289
|
+
),
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Build a SCIP index and merge compiler-grade symbols into the graph.
|
|
292
|
+
|
|
293
|
+
This is resolution rung L2 (ADR-0002): it makes coupling and call-graph metrics exact
|
|
294
|
+
rather than heuristic. It needs a per-language indexer, which OXN detects but never
|
|
295
|
+
installs -- run `oxn doctor` to see what is available.
|
|
296
|
+
"""
|
|
297
|
+
from oxn.render import Output
|
|
298
|
+
from oxn.report import run_index
|
|
299
|
+
|
|
300
|
+
run_index(
|
|
301
|
+
paths or ["."],
|
|
302
|
+
Output(console=None if json_output else _console()),
|
|
303
|
+
language=language,
|
|
304
|
+
index_file=reuse or None,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@app.command()
|
|
309
|
+
def calls(
|
|
310
|
+
paths: list[str] = typer.Argument(None, help="Files or directories to analyse."),
|
|
311
|
+
json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
312
|
+
limit: int = typer.Option(20, "--limit", help="How many rows to show."),
|
|
313
|
+
) -> None:
|
|
314
|
+
"""Fan-in, fan-out, recursion and dead-code candidates over the call graph.
|
|
315
|
+
|
|
316
|
+
Needs L2. OXN recovers call edges from a SCIP index and never invents them from names,
|
|
317
|
+
so on a tree that has not been indexed this says it has no call graph rather than
|
|
318
|
+
reporting an empty one -- see `oxn index`.
|
|
319
|
+
"""
|
|
320
|
+
from oxn.calls import run_calls
|
|
321
|
+
from oxn.render import Output
|
|
322
|
+
|
|
323
|
+
run_calls(
|
|
324
|
+
paths or ["."],
|
|
325
|
+
Output(console=None if json_output else _console()),
|
|
326
|
+
limit=limit,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@app.command()
|
|
331
|
+
def review(
|
|
332
|
+
base: str = typer.Option("origin/main", "--base", help="Ref the pull request targets."),
|
|
333
|
+
head: str = typer.Option("HEAD", "--head", help="Ref under review."),
|
|
334
|
+
json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
335
|
+
write: str = typer.Option(
|
|
336
|
+
"", "--write", help="Backend that turns the measurement into prose: `ollama`."
|
|
337
|
+
),
|
|
338
|
+
model: str = typer.Option("", "--model", help="Model for `--write`."),
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Measure what a pull request changed, and optionally write the comment.
|
|
341
|
+
|
|
342
|
+
Without `--write` this prints the measurement and nothing else, which is the half OXN is
|
|
343
|
+
sure about. With it, a model turns the measurement into review prose -- and any comment
|
|
344
|
+
stating a number the measurement does not contain is **refused rather than posted**.
|
|
345
|
+
|
|
346
|
+
This never gates. The CI job running `oxn check` already does, and two gates disagreeing
|
|
347
|
+
about one pull request is worse than one gate.
|
|
348
|
+
"""
|
|
349
|
+
from oxn.render import Output
|
|
350
|
+
from oxn.report import run_review_report
|
|
351
|
+
|
|
352
|
+
run_review_report(
|
|
353
|
+
base,
|
|
354
|
+
head,
|
|
355
|
+
Output(console=None if json_output else _console()),
|
|
356
|
+
write=write,
|
|
357
|
+
model=model,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@app.command()
|
|
362
|
+
def classes(
|
|
363
|
+
paths: list[str] = typer.Argument(None, help="Files or directories to analyse."),
|
|
364
|
+
json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
365
|
+
sort_by: str = typer.Option(
|
|
366
|
+
"lcom_star", "--sort-by", help="lcom_star | lcom4 | wmc | cbo | rfc"
|
|
367
|
+
),
|
|
368
|
+
limit: int = typer.Option(20, "--limit", help="How many classes to show."),
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Report class cohesion (LCOM) and coupling (the CK suite).
|
|
371
|
+
|
|
372
|
+
These are the metrics that detect the "Modular Mirage" -- file-level modularity with no
|
|
373
|
+
semantic cohesion -- which is the documented signature of agent-written code.
|
|
374
|
+
"""
|
|
375
|
+
from oxn.render import Output
|
|
376
|
+
from oxn.report import run_classes
|
|
377
|
+
|
|
378
|
+
run_classes(
|
|
379
|
+
paths or ["."],
|
|
380
|
+
Output(console=None if json_output else _console()),
|
|
381
|
+
sort_by=sort_by,
|
|
382
|
+
limit=limit,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@app.command()
|
|
387
|
+
def health(
|
|
388
|
+
paths: list[str] = typer.Argument(None, help="Files or directories to report on."),
|
|
389
|
+
json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
390
|
+
limit: int = typer.Option(10, "--limit", help="Entities to name per profile."),
|
|
391
|
+
) -> None:
|
|
392
|
+
"""Risk profiles: how much of the code is over budget, and which code.
|
|
393
|
+
|
|
394
|
+
The informational output of `docs/metrics.md` section 10.1 -- it never blocks, and it
|
|
395
|
+
reports no single score. Every share names the entities that carry it, because a number
|
|
396
|
+
on its own is not something an agent or a person can act on.
|
|
397
|
+
"""
|
|
398
|
+
from oxn.health import run_health
|
|
399
|
+
from oxn.render import Output
|
|
400
|
+
|
|
401
|
+
run_health(
|
|
402
|
+
paths or ["."],
|
|
403
|
+
Output(console=None if json_output else _console()),
|
|
404
|
+
limit=limit,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@app.command()
|
|
409
|
+
def doctor() -> None:
|
|
410
|
+
"""Report the environment OXN found: grammars, toolchains, and indexers."""
|
|
411
|
+
from oxn.doctor import run_doctor
|
|
412
|
+
|
|
413
|
+
run_doctor(_console())
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@app.command()
|
|
417
|
+
def context(
|
|
418
|
+
task: str = typer.Argument(..., help="What you are about to do, in your own words."),
|
|
419
|
+
files: list[str] = typer.Option(
|
|
420
|
+
None, "--file", "-f", help="Files the task is about. Scope beats wording (ADR-0006)."
|
|
421
|
+
),
|
|
422
|
+
limit: int = typer.Option(0, "--limit", help="Constraints to show; 0 uses the budget."),
|
|
423
|
+
json_output: bool = typer.Option(False, "--json", help="The bundle as typed JSON."),
|
|
424
|
+
) -> None:
|
|
425
|
+
"""Show the constraints that govern a task -- ranked, capped, and never a gate.
|
|
426
|
+
|
|
427
|
+
This is the human view of what P9's MCP `get_architectural_context` will serve. It
|
|
428
|
+
cannot pass or fail anything: ADR-0006 keeps retrieval out of the gate entirely.
|
|
429
|
+
"""
|
|
430
|
+
from oxn import thresholds
|
|
431
|
+
from oxn.config import Config
|
|
432
|
+
from oxn.context.bundle import build_bundle
|
|
433
|
+
from oxn.context.project import load_project
|
|
434
|
+
|
|
435
|
+
bundle = build_bundle(
|
|
436
|
+
load_project(Config.load()),
|
|
437
|
+
task=task,
|
|
438
|
+
targets=files or (),
|
|
439
|
+
limit=limit or thresholds.MAX_BUNDLE_CONSTRAINTS,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
console = _console()
|
|
443
|
+
if json_output:
|
|
444
|
+
console.print_json(bundle.model_dump_json())
|
|
445
|
+
return
|
|
446
|
+
for constraint in bundle.constraints:
|
|
447
|
+
mark = "gated" if constraint.enforced else "prose"
|
|
448
|
+
console.print(f"[bold]{constraint.statement}[/bold]")
|
|
449
|
+
console.print(f" {mark} · {constraint.source} · governs {constraint.governs} file(s)")
|
|
450
|
+
console.print(f"\n{bundle.omitted} more not shown. {bundle.note}")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@app.command()
|
|
454
|
+
def serve(
|
|
455
|
+
root: str = typer.Option(
|
|
456
|
+
"", "--root", help="The repository to serve. Defaults to $OXN_ROOT, else discovery."
|
|
457
|
+
),
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Speak MCP over stdio, so an agent can ask before it edits.
|
|
460
|
+
|
|
461
|
+
The server is also OXN's warm process (ADR-0004): it is alive for the whole session, so
|
|
462
|
+
"current when the agent asks" costs no daemon. It informs and never enforces -- the gate
|
|
463
|
+
is the `PostToolUse` hook, and `check_code` here reaches the same verdict without being
|
|
464
|
+
able to stop anything.
|
|
465
|
+
|
|
466
|
+
Wire it with `claude mcp add oxn -- oxn serve`. Nothing is printed: stdout is the wire.
|
|
467
|
+
"""
|
|
468
|
+
from oxn.server import serve as run_server
|
|
469
|
+
|
|
470
|
+
run_server(root=root or None)
|