pyintent 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.
pyintent/_perf.py ADDED
@@ -0,0 +1,49 @@
1
+ """Performance declaration for a spec.
2
+
3
+ In v0.1 ``Perf`` is *stored only* — pyintent records the declared complexity but
4
+ does not measure or verify it. Measurement is planned for v0.2.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._errors import PyIntentSpecError
10
+
11
+ _ALLOWED = ("time", "space")
12
+
13
+
14
+ class Perf:
15
+ """Declared algorithmic complexity, e.g. ``Perf(time="O(log n)", space="O(1)")``."""
16
+
17
+ __slots__ = ("time", "space")
18
+
19
+ def __init__(self, **kwargs: str) -> None:
20
+ unknown = set(kwargs) - set(_ALLOWED)
21
+ if unknown:
22
+ raise PyIntentSpecError(
23
+ f"Perf() got unexpected keyword(s): {', '.join(sorted(unknown))}. "
24
+ f"Allowed: {', '.join(_ALLOWED)}"
25
+ )
26
+ time = kwargs.get("time")
27
+ space = kwargs.get("space")
28
+ if time is None and space is None:
29
+ raise PyIntentSpecError("Perf() requires at least one of time= or space=")
30
+ for name, val in (("time", time), ("space", space)):
31
+ if val is not None and (not isinstance(val, str) or not val.strip()):
32
+ raise PyIntentSpecError(
33
+ f"Perf {name}= must be a non-empty string, got {val!r}"
34
+ )
35
+ self.time = time
36
+ self.space = space
37
+
38
+ def __repr__(self) -> str:
39
+ parts = []
40
+ if self.time is not None:
41
+ parts.append(f"time={self.time!r}")
42
+ if self.space is not None:
43
+ parts.append(f"space={self.space!r}")
44
+ return f"Perf({', '.join(parts)})"
45
+
46
+ def __eq__(self, other: object) -> bool:
47
+ if not isinstance(other, Perf):
48
+ return NotImplemented
49
+ return self.time == other.time and self.space == other.space
pyintent/_spec.py ADDED
@@ -0,0 +1,375 @@
1
+ """The ``@spec`` decorator and its data model.
2
+
3
+ ``@spec`` attaches a :class:`PyIntentSpec` to the target as ``__pyintent_spec__``
4
+ and returns the target **unchanged** — there is zero runtime overhead. All
5
+ validation happens eagerly at decoration time so a malformed spec fails on import.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Any, Callable
14
+
15
+ from ._effects import Effect, EffectKind
16
+ from ._errors import PyIntentSpecError
17
+ from ._parser import Example, parse_example
18
+ from ._perf import Perf
19
+
20
+ SPEC_ATTR = "__pyintent_spec__"
21
+
22
+ _IMPURE_KINDS = {
23
+ EffectKind.READS,
24
+ EffectKind.WRITES,
25
+ EffectKind.NETWORK,
26
+ EffectKind.IO,
27
+ EffectKind.ASYNC,
28
+ }
29
+
30
+
31
+ class SpecLevel(Enum):
32
+ FUNCTION = "function"
33
+ METHOD = "method"
34
+ CLASSMETHOD = "classmethod"
35
+ STATICMETHOD = "staticmethod"
36
+ PROPERTY = "property"
37
+ ABSTRACT = "abstract method"
38
+ CLASS = "class"
39
+ MODULE = "module"
40
+ PACKAGE = "package"
41
+
42
+
43
+ #: Callable levels that accept call-time conditions and examples.
44
+ _CALLABLE_LEVELS = {
45
+ SpecLevel.FUNCTION,
46
+ SpecLevel.METHOD,
47
+ SpecLevel.CLASSMETHOD,
48
+ SpecLevel.STATICMETHOD,
49
+ SpecLevel.ABSTRACT,
50
+ SpecLevel.PROPERTY,
51
+ }
52
+
53
+ #: Which kwargs each level accepts (``intent`` is always required, handled apart).
54
+ _ALLOWED_FIELDS: dict[SpecLevel, set[str]] = {
55
+ SpecLevel.FUNCTION: {"where", "ensures", "effects", "perf", "ex"},
56
+ SpecLevel.METHOD: {"where", "ensures", "effects", "perf", "ex"},
57
+ SpecLevel.CLASSMETHOD: {"where", "ensures", "effects", "perf", "ex"},
58
+ SpecLevel.STATICMETHOD: {"where", "ensures", "effects", "perf", "ex"},
59
+ SpecLevel.ABSTRACT: {"where", "ensures", "effects", "perf", "ex"},
60
+ SpecLevel.PROPERTY: {"ensures", "effects", "perf", "ex"},
61
+ SpecLevel.CLASS: {"effects", "invariants"},
62
+ SpecLevel.MODULE: {"effects", "invariants"},
63
+ SpecLevel.PACKAGE: {"effects", "invariants", "modules"},
64
+ }
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Invariant:
69
+ """A class/module/package invariant.
70
+
71
+ ``is_expr`` is True when the text compiles as a Python expression (and is
72
+ therefore checkable); otherwise it is treated as natural-language docs.
73
+ """
74
+
75
+ text: str
76
+ is_expr: bool
77
+
78
+
79
+ @dataclass
80
+ class PyIntentSpec:
81
+ level: SpecLevel
82
+ intent: str
83
+ target_name: str
84
+ is_async: bool = False
85
+ where: list[str] = field(default_factory=list)
86
+ ensures: list[str] = field(default_factory=list)
87
+ effects: list[Effect] = field(default_factory=list)
88
+ perf: Perf | None = None
89
+ examples: list[Example] = field(default_factory=list)
90
+ invariants: list[Invariant] = field(default_factory=list)
91
+ modules: list[str] = field(default_factory=list)
92
+
93
+ @property
94
+ def declares_pure(self) -> bool:
95
+ return any(e.kind is EffectKind.PURE for e in self.effects)
96
+
97
+ @property
98
+ def is_verifiable_pure(self) -> bool:
99
+ """True when property-based testing is safe (no impure declared effects)."""
100
+ return not ({e.kind for e in self.effects} & _IMPURE_KINDS)
101
+
102
+ @property
103
+ def thrown_exceptions(self) -> tuple[type[BaseException], ...]:
104
+ result: list[type[BaseException]] = []
105
+ for e in self.effects:
106
+ if e.kind is EffectKind.THROWS:
107
+ result.extend(e.exceptions)
108
+ return tuple(result)
109
+
110
+
111
+ # --------------------------------------------------------------------------- #
112
+ # Validation helpers
113
+ # --------------------------------------------------------------------------- #
114
+ def _require_intent(intent: object) -> str:
115
+ if not isinstance(intent, str) or not intent.strip():
116
+ raise PyIntentSpecError("intent= is required and must be a non-empty string")
117
+ return intent
118
+
119
+
120
+ def _validate_conditions(name: str, conditions: object) -> list[str]:
121
+ if conditions is None:
122
+ return []
123
+ if not isinstance(conditions, (list, tuple)):
124
+ raise PyIntentSpecError(f"{name}= must be a list of strings")
125
+ result: list[str] = []
126
+ for c in conditions:
127
+ if not isinstance(c, str) or not c.strip():
128
+ raise PyIntentSpecError(f"each {name} entry must be a non-empty string, got {c!r}")
129
+ try:
130
+ compile(c, f"<{name}>", "eval")
131
+ except (SyntaxError, ValueError) as e:
132
+ msg = e.msg if isinstance(e, SyntaxError) else str(e)
133
+ raise PyIntentSpecError(
134
+ f"{name} condition {c!r} is not a valid Python expression: {msg}"
135
+ ) from e
136
+ result.append(c)
137
+ return result
138
+
139
+
140
+ def _validate_effects(effects: object) -> list[Effect]:
141
+ if effects is None:
142
+ return []
143
+ if not isinstance(effects, (list, tuple)):
144
+ raise PyIntentSpecError("effects= must be a list of Effect objects")
145
+ for e in effects:
146
+ if not isinstance(e, Effect):
147
+ raise PyIntentSpecError(
148
+ f"effects must be Effect objects (pure, reads(...), writes(...), "
149
+ f"network(...), io, async_, throws(...)); got {e!r}"
150
+ )
151
+ return list(effects)
152
+
153
+
154
+ def _validate_perf(perf: object) -> Perf | None:
155
+ if perf is None:
156
+ return None
157
+ if not isinstance(perf, Perf):
158
+ raise PyIntentSpecError("perf= must be a Perf object, e.g. Perf(time='O(n)')")
159
+ return perf
160
+
161
+
162
+ def _validate_examples(ex: object) -> list[Example]:
163
+ if ex is None:
164
+ return []
165
+ if not isinstance(ex, (list, tuple)):
166
+ raise PyIntentSpecError("ex= must be a list of example strings")
167
+ return [parse_example(e) for e in ex]
168
+
169
+
170
+ def parse_invariant(text: object) -> Invariant:
171
+ if not isinstance(text, str) or not text.strip():
172
+ raise PyIntentSpecError("each invariant must be a non-empty string")
173
+ try:
174
+ compile(text, "<invariant>", "eval")
175
+ is_expr = True
176
+ except SyntaxError:
177
+ is_expr = False
178
+ return Invariant(text=text, is_expr=is_expr)
179
+
180
+
181
+ def _validate_invariants(invariants: object) -> list[Invariant]:
182
+ if invariants is None:
183
+ return []
184
+ if not isinstance(invariants, (list, tuple)):
185
+ raise PyIntentSpecError("invariants= must be a list of strings")
186
+ return [parse_invariant(i) for i in invariants]
187
+
188
+
189
+ def _reject_disallowed(level: SpecLevel, provided: dict[str, Any]) -> None:
190
+ allowed = _ALLOWED_FIELDS[level]
191
+ for name, value in provided.items():
192
+ if value and name not in allowed:
193
+ allowed_list = ", ".join(sorted(allowed)) or "(none)"
194
+ raise PyIntentSpecError(
195
+ f"{name}= is not valid on a {level.value} spec. "
196
+ f"Allowed fields: intent, {allowed_list}."
197
+ )
198
+
199
+
200
+ # --------------------------------------------------------------------------- #
201
+ # Target introspection
202
+ # --------------------------------------------------------------------------- #
203
+ def _underlying(target: Any) -> Any:
204
+ if isinstance(target, (classmethod, staticmethod)):
205
+ return target.__func__
206
+ if isinstance(target, property):
207
+ return target.fget
208
+ return target
209
+
210
+
211
+ def _detect_level(target: Any) -> SpecLevel:
212
+ if isinstance(target, classmethod):
213
+ return SpecLevel.CLASSMETHOD
214
+ if isinstance(target, staticmethod):
215
+ return SpecLevel.STATICMETHOD
216
+ if isinstance(target, property):
217
+ return SpecLevel.PROPERTY
218
+ if isinstance(target, type):
219
+ return SpecLevel.CLASS
220
+ if getattr(target, "__isabstractmethod__", False):
221
+ return SpecLevel.ABSTRACT
222
+ if inspect.isfunction(target):
223
+ qualname = getattr(target, "__qualname__", "")
224
+ if "." in qualname:
225
+ parent = qualname.rsplit(".", 1)[0]
226
+ if not parent.endswith("<locals>"):
227
+ return SpecLevel.METHOD
228
+ return SpecLevel.FUNCTION
229
+ raise PyIntentSpecError(
230
+ f"@spec can only decorate functions, methods, or classes; got {target!r}"
231
+ )
232
+
233
+
234
+ def _target_name(target: Any, underlying: Any) -> str:
235
+ if isinstance(target, type):
236
+ return target.__qualname__
237
+ name = getattr(underlying, "__qualname__", None) or getattr(underlying, "__name__", None)
238
+ return name or repr(target)
239
+
240
+
241
+ def _attach(target: Any, underlying: Any, ps: PyIntentSpec) -> None:
242
+ holder = underlying if underlying is not None else target
243
+ try:
244
+ setattr(holder, SPEC_ATTR, ps)
245
+ except (AttributeError, TypeError) as e: # pragma: no cover
246
+ raise PyIntentSpecError(
247
+ f"could not attach spec to {ps.target_name}: {e}"
248
+ ) from e
249
+
250
+
251
+ # --------------------------------------------------------------------------- #
252
+ # The decorator
253
+ # --------------------------------------------------------------------------- #
254
+ def spec(
255
+ *,
256
+ intent: str,
257
+ where: list[str] | None = None,
258
+ ensures: list[str] | None = None,
259
+ effects: list[Effect] | None = None,
260
+ perf: Perf | None = None,
261
+ ex: list[str] | None = None,
262
+ invariants: list[str] | None = None,
263
+ ) -> Callable[[Any], Any]:
264
+ """Attach an intent specification to a function, method, or class.
265
+
266
+ ``@spec`` must be the **outermost** decorator. It returns the target
267
+ **unchanged** — there is zero runtime overhead. All validation happens
268
+ eagerly at decoration time so a malformed spec fails on import.
269
+
270
+ Parameters
271
+ ----------
272
+ intent:
273
+ Required. One-line description of what the target does and why.
274
+ where:
275
+ Preconditions — Python expression strings evaluated over the input
276
+ parameters. Example: ``["n >= 0", "isinstance(n, int)"]``.
277
+ ensures:
278
+ Postconditions — Python expression strings evaluated over input
279
+ parameters and ``result`` (the return value).
280
+ Example: ``["result >= 0", "result == abs(x)"]``.
281
+ effects:
282
+ Declared side-effects. Use the helpers: ``pure``, ``reads(...)``,
283
+ ``writes(...)``, ``network(...)``, ``io``, ``async_``,
284
+ ``throws(...)``. Example: ``[reads("db"), throws(NotFoundError)]``.
285
+ perf:
286
+ Advisory complexity declaration. Example: ``Perf(time="O(n log n)")``.
287
+ Recorded but not measured in v0.1.
288
+ ex:
289
+ Runnable examples in ``"(args) -> expected"`` format.
290
+ Example: ``["(1, 2) -> 3", "(0,) -> raises ValueError", "() -> _"]``.
291
+ Values are evaluated in the target module's global namespace.
292
+ invariants:
293
+ Class/module-level invariants (plain text or Python expressions).
294
+ Valid only on class, module, and package specs.
295
+
296
+ Returns
297
+ -------
298
+ Callable[[Any], Any]
299
+ A decorator that attaches the spec and returns the target unchanged.
300
+
301
+ Raises
302
+ ------
303
+ PyIntentSpecError
304
+ If ``intent`` is empty, any expression is syntactically invalid, an
305
+ unsupported field is used for the target level, or an ``ex`` entry
306
+ is malformed.
307
+
308
+ See Also
309
+ --------
310
+ ``pyintent prompt`` : print the full spec-authoring reference guide.
311
+ ``get_spec`` : retrieve a spec attached to a target.
312
+ """
313
+ intent = _require_intent(intent)
314
+
315
+ def decorate(target: Any) -> Any:
316
+ level = _detect_level(target)
317
+ underlying = _underlying(target)
318
+ if underlying is None:
319
+ raise PyIntentSpecError(
320
+ "@spec on a property requires a getter (the property has no fget)"
321
+ )
322
+
323
+ _reject_disallowed(
324
+ level,
325
+ {
326
+ "where": where,
327
+ "ensures": ensures,
328
+ "perf": perf,
329
+ "ex": ex,
330
+ "invariants": invariants,
331
+ },
332
+ )
333
+
334
+ is_async = bool(
335
+ inspect.iscoroutinefunction(underlying)
336
+ or inspect.isasyncgenfunction(underlying)
337
+ )
338
+
339
+ ps = PyIntentSpec(
340
+ level=level,
341
+ intent=intent,
342
+ target_name=_target_name(target, underlying),
343
+ is_async=is_async,
344
+ where=_validate_conditions("where", where),
345
+ ensures=_validate_conditions("ensures", ensures),
346
+ effects=_validate_effects(effects),
347
+ perf=_validate_perf(perf),
348
+ examples=_validate_examples(ex),
349
+ invariants=_validate_invariants(invariants),
350
+ )
351
+ _attach(target, underlying, ps)
352
+ return target
353
+
354
+ return decorate
355
+
356
+
357
+ def get_spec(obj: Any) -> PyIntentSpec | None:
358
+ """Return the :class:`PyIntentSpec` attached to ``obj``, or ``None``.
359
+
360
+ Works for functions, methods, classmethods, staticmethods, properties,
361
+ and classes decorated with :func:`spec`.
362
+
363
+ Parameters
364
+ ----------
365
+ obj:
366
+ Any Python object that may have a spec attached.
367
+
368
+ Returns
369
+ -------
370
+ PyIntentSpec | None
371
+ The attached spec, or ``None`` if ``obj`` was not decorated with
372
+ ``@spec``.
373
+ """
374
+ underlying = _underlying(obj)
375
+ return getattr(underlying, SPEC_ATTR, None)
pyintent/cli.py ADDED
@@ -0,0 +1,240 @@
1
+ """``pyintent`` command-line interface.
2
+
3
+ Commands
4
+ --------
5
+ * ``pyintent init`` write the agent guide into prompt files
6
+ * ``pyintent prompt`` print the agent guide to stdout
7
+ * ``pyintent check PATH`` validate spec structure (imports modules)
8
+ * ``pyintent verify PATH [--json]`` run the verifiers
9
+
10
+ Exit codes: ``0`` all good, ``1`` verification/spec failures, ``2`` usage or
11
+ load error (could not import a target, malformed spec at import time).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import click
21
+
22
+ from ._errors import PyIntentSpecError
23
+ from ._loader import import_file, iter_python_files
24
+ from ._module_spec import MODULE_ATTR
25
+ from ._spec import SpecLevel, get_spec
26
+ from . import prompt as _prompt
27
+ from .verifier import ALL_VERIFIERS, run_all
28
+ from .verifier._result import Status
29
+
30
+ _STATUS_STYLE = {
31
+ Status.PASS: ("PASS", "green"),
32
+ Status.FAIL: ("FAIL", "red"),
33
+ Status.ERROR: ("ERR ", "red"),
34
+ Status.SKIPPED: ("SKIP", "yellow"),
35
+ }
36
+
37
+
38
+ # --------------------------------------------------------------------------- #
39
+ # config
40
+ # --------------------------------------------------------------------------- #
41
+ def _load_config(start: Path) -> dict:
42
+ try:
43
+ import tomllib
44
+ except ModuleNotFoundError: # pragma: no cover - py<3.11
45
+ return {}
46
+ for directory in [start, *start.parents]:
47
+ cfg = directory / "pyproject.toml"
48
+ if cfg.is_file():
49
+ try:
50
+ data = tomllib.loads(cfg.read_text(encoding="utf-8"))
51
+ except Exception:
52
+ return {}
53
+ return data.get("tool", {}).get("pyintent", {}) or {}
54
+ return {}
55
+
56
+
57
+ def _collect_modules(path: Path):
58
+ """Import every target file under ``path``. Returns (modules, load_errors)."""
59
+ modules = []
60
+ errors: list[tuple[Path, BaseException]] = []
61
+ for f in iter_python_files(path):
62
+ try:
63
+ modules.append((f, import_file(f)))
64
+ except PyIntentSpecError as e:
65
+ errors.append((f, e))
66
+ except BaseException as e: # noqa: BLE001 - report, keep going
67
+ errors.append((f, e))
68
+ return modules, errors
69
+
70
+
71
+ # --------------------------------------------------------------------------- #
72
+ # reporting
73
+ # --------------------------------------------------------------------------- #
74
+ def _print_results(results, *, show_detail=True) -> dict[Status, int]:
75
+ counts: dict[Status, int] = {s: 0 for s in Status}
76
+ for r in results:
77
+ counts[r.status] += 1
78
+ label, color = _STATUS_STYLE[r.status]
79
+ tag = click.style(f"[{label}]", fg=color, bold=True)
80
+ loc = click.style(r.target, bold=True)
81
+ extra = f" {r.label}" if r.label else ""
82
+ summ = f" {r.summary}" if r.summary else ""
83
+ click.echo(f"{tag} {r.verifier:<10} {loc}{extra}{summ}")
84
+ if show_detail and r.status in (Status.FAIL, Status.ERROR) and r.detail:
85
+ for line in r.detail.splitlines():
86
+ click.echo(click.style(" " + line, fg="bright_black"))
87
+ return counts
88
+
89
+
90
+ def _summary_line(counts: dict[Status, int]) -> str:
91
+ parts = [
92
+ click.style(f"{counts[Status.PASS]} passed", fg="green"),
93
+ click.style(f"{counts[Status.FAIL]} failed", fg="red"),
94
+ click.style(f"{counts[Status.ERROR]} errored", fg="red"),
95
+ click.style(f"{counts[Status.SKIPPED]} skipped", fg="yellow"),
96
+ ]
97
+ return " ".join(parts)
98
+
99
+
100
+ # --------------------------------------------------------------------------- #
101
+ # commands
102
+ # --------------------------------------------------------------------------- #
103
+ @click.group()
104
+ @click.version_option(package_name="pyintent", message="%(version)s")
105
+ def main() -> None:
106
+ """pyintent — verify that implementations satisfy their @spec intent."""
107
+
108
+
109
+ @main.command()
110
+ @click.option("--root", default=".", type=click.Path(file_okay=False),
111
+ help="Project root to write prompt files into.")
112
+ def init(root: str) -> None:
113
+ """Write the pyintent authoring guide into agent prompt files."""
114
+ actions = _prompt.write_prompt_files(root)
115
+ for rel, action in actions.items():
116
+ click.echo(f" {action:<9} {rel}")
117
+ click.echo(click.style("pyintent guide written. Point your AI tools here.", fg="green"))
118
+
119
+
120
+ @main.command()
121
+ def prompt() -> None:
122
+ """Print the pyintent authoring guide to stdout."""
123
+ click.echo(_prompt.get_reference())
124
+
125
+
126
+ def _iter_specs(module):
127
+ """Yield (level, qualified_name, spec_or_None) for top-level defs in module."""
128
+ import inspect
129
+
130
+ modname = getattr(module, "__name__", "")
131
+ for name, obj in vars(module).items():
132
+ if name.startswith("__") and name.endswith("__"):
133
+ continue
134
+ if inspect.isfunction(obj) and getattr(obj, "__module__", None) == modname:
135
+ yield SpecLevel.FUNCTION, name, get_spec(obj)
136
+ elif inspect.isclass(obj) and getattr(obj, "__module__", None) == modname:
137
+ yield SpecLevel.CLASS, name, get_spec(obj)
138
+ for mname, mobj in vars(obj).items():
139
+ if mname.startswith("__") and mname.endswith("__"):
140
+ continue
141
+ inner = mobj
142
+ if isinstance(mobj, (staticmethod, classmethod)):
143
+ inner = mobj.__func__
144
+ elif isinstance(mobj, property):
145
+ inner = mobj.fget
146
+ if inner is not None and (inspect.isfunction(inner)):
147
+ yield SpecLevel.METHOD, f"{name}.{mname}", get_spec(inner)
148
+
149
+
150
+ @main.command()
151
+ @click.argument("path", type=click.Path(exists=True))
152
+ @click.option("--require-specs", "require_specs", is_flag=True, default=False,
153
+ help="Fail if public functions/methods lack a @spec.")
154
+ @click.option("--all", "require_all", is_flag=True, default=False,
155
+ help="With --require-specs, also require class and module specs.")
156
+ def check(path: str, require_specs: bool, require_all: bool) -> None:
157
+ """Validate spec structure by importing PATH (no execution)."""
158
+ target = Path(path)
159
+ cfg = _load_config(target if target.is_dir() else target.parent)
160
+ cfg_rs = cfg.get("require_specs")
161
+ if not require_specs and cfg_rs:
162
+ require_specs = True
163
+ require_all = require_all or (cfg_rs == "all" or cfg_rs is True)
164
+
165
+ level = "all" if (require_specs and require_all) else ("public" if require_specs else None)
166
+
167
+ modules, errors = _collect_modules(target)
168
+
169
+ if errors:
170
+ for f, e in errors:
171
+ kind = "spec error" if isinstance(e, PyIntentSpecError) else type(e).__name__
172
+ click.echo(click.style(f"[{kind}] {f}: {e}", fg="red"))
173
+ sys.exit(2)
174
+
175
+ spec_count = 0
176
+ missing: list[str] = []
177
+ for f, module in modules:
178
+ for lvl, qual, sp in _iter_specs(module):
179
+ if sp is not None:
180
+ spec_count += 1
181
+ elif level:
182
+ if lvl is SpecLevel.CLASS and level != "all":
183
+ continue
184
+ missing.append(f"{f}::{qual} ({lvl.value})")
185
+ if level == "all" and getattr(module, MODULE_ATTR, None) is None:
186
+ missing.append(f"{f}::<module> (module)")
187
+
188
+ click.echo(f"Imported {len(modules)} file(s); found {spec_count} spec(s).")
189
+ if missing:
190
+ click.echo(click.style(f"{len(missing)} definition(s) missing a @spec:", fg="red"))
191
+ for m in missing:
192
+ click.echo(f" - {m}")
193
+ sys.exit(1)
194
+ click.echo(click.style("All specs valid.", fg="green"))
195
+
196
+
197
+ @main.command()
198
+ @click.argument("path", type=click.Path(exists=True))
199
+ @click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON.")
200
+ @click.option("--only", type=click.Choice(ALL_VERIFIERS), multiple=True,
201
+ help="Run only these verifiers (repeatable).")
202
+ def verify(path: str, as_json: bool, only: tuple[str, ...]) -> None:
203
+ """Run pyintent verifiers over PATH and report pass/fail."""
204
+ target = Path(path)
205
+ which = set(only) if only else None
206
+
207
+ modules, errors = _collect_modules(target)
208
+ if errors:
209
+ if as_json:
210
+ click.echo(json.dumps({
211
+ "ok": False,
212
+ "load_errors": [{"file": str(f), "error": str(e)} for f, e in errors],
213
+ }, indent=2))
214
+ else:
215
+ for f, e in errors:
216
+ click.echo(click.style(f"[load error] {f}: {e}", fg="red"))
217
+ sys.exit(2)
218
+
219
+ all_results = []
220
+ for _f, module in modules:
221
+ all_results.extend(run_all(module, which))
222
+
223
+ if as_json:
224
+ payload = {
225
+ "ok": all(r.status is not Status.FAIL and r.status is not Status.ERROR
226
+ for r in all_results),
227
+ "results": [r.to_dict() for r in all_results],
228
+ }
229
+ click.echo(json.dumps(payload, indent=2))
230
+ else:
231
+ counts = _print_results(all_results)
232
+ click.echo("")
233
+ click.echo(_summary_line(counts))
234
+
235
+ failed = sum(1 for r in all_results if r.status in (Status.FAIL, Status.ERROR))
236
+ sys.exit(1 if failed else 0)
237
+
238
+
239
+ if __name__ == "__main__":
240
+ main()