Audit an app for how its code lines up with its OpenSpec specs. Writes {app}/openspec/coverage-report.md plus coverage-report.json (parseable sidecar). Buckets every code unit into one of six categories. Read-only — this skill never touches code.
The scanner's job is just to produce the plan. The actual ghost-change creation happens in the annotate / reverse-spec skills.
4.5. Large-app guard
Count the total PHP files enumerated in Step 4. If > 500, stop and ask via AskUserQuestion before proceeding:
Do not skip this check — silently attempting a 700-file classification in one context window is the failure mode that produced the large-app-asks-confirmation eval zero.
-
Classify every method — two passes
Pass A — bucket everything except private helpers. Walk each method linearly:
-
Already annotated (@spec openspec/changes/ present in docblock) → record as annotated. Note which task(s) it points at. Skip bucketing.
-
Framework plumbing — empty constructors, __call/__get/__set, listener handle() bodies that only dispatch to a service, single-line controllers that just call a service method with no logic → record as plumbing. Plumbing never carries @spec.
-
Bucket 1 — match to a REQ — score every REQ against method name + docblock + file path + class name. Top match ≥ 0.85 → Bucket 1. 0.70–0.85 → Bucket 1 with NEEDS-REVIEW flag. Below 0.70 → fall through.
-
Bucket 2a — existing capability, no REQ — file path clearly belongs to a capability (e.g. lib/Controller/MeetingController.php → meeting-management) but no REQ matched, OR the match confidence fell below 0.70. Cluster by the owning capability.
Large-cluster sanity check: before emitting a Bucket 2a cluster with > 50 methods, read 5–10 random method names from the cluster and compare them against the owning capability's REQ scenario text. If the method names do not match the capability's domain vocabulary, the cluster is likely a scanner misclassification (wrong capability keyword matched by directory name). Emit a warning:
⚠️ Bucket 2a cluster {capability} has {N} methods but sampled method names ({method1}, {method2}, ...) do not match this capability's REQ scenarios. Likely misclassification — verify before running /opsx-reverse-spec.
-
Bucket 2b — no capability owner — no capability matches the file path. Cluster by a human-readable label derived from the directory (e.g. lib/Service/Integration/* → integrations).
Namespace-word warning: after emitting Bucket 2b clusters, flag any whose label matches a filesystem namespace word: service, controller, background, job, event, tool, appinfo, middleware, exception, listener, command, repair. These labels describe component types, not observable behaviors — they are NOT usable as /opsx-reverse-spec --cluster targets without a human pre-split. Emit a warning in the report:
⚠️ Cluster {label} is a namespace word, not a behavioral name. Pre-split required before running /opsx-reverse-spec.
Pass B — resolve private helpers. After Pass A, walk every private/protected method:
- Find its caller(s) inside the same class. If all callers are in Bucket 1 pointing at the same REQ → inherit that REQ into Bucket 1.
- If callers span multiple REQs → inherit all of them (multi-REQ helper).
- If no caller is bucketed (all callers are plumbing or themselves private) → follow the call chain upward until you find a bucketed method.
- If the helper is unreachable from any bucketed caller → keep in whichever bucket Pass A assigned.
Confidence scoring is judgment. Consider: does the REQ title's verb+noun appear in the method name? Does the REQ's scenario text reference the same domain concepts (meeting, motion, vote) as the file/class? Is the file path aligned with the capability directory? Record the signal(s) used in the report so a human can audit.
-
Reverse pass — find unimplemented REQs
Build the removed-lines cache first (one-time, before the per-REQ loop):
git log --all -p -- lib/ src/ | grep "^-[^-]" > /tmp/removed-lines.txt
This scans the full git history once and collects every removed line. Expected cost: 5–15 seconds on typical repos (~5–10k commits). If it exceeds 30 seconds, abort the cache build and classify all unimplemented REQs as Bucket 3b — note "git history too large for reverse pass" in the report. Do not fall back to grepping the current codebase; that has entirely different semantics ("keyword in current code but unmatched" vs "implementation evidence in removed code").
For every unimplemented REQ (no method landed in Bucket 1):
grep -ic "<keyword1>\|<keyword2>" /tmp/removed-lines.txt
- ≥ 2 distinct keywords from the REQ appear in removed lines → Bucket 3a (implementation evidence found in git history, possibly broken or removed).
- Fewer than 2 matches → Bucket 3b (never implemented).
Don't over-invest in 3a classification — if the match is weak (single keyword, generic term like "id" or "name"), collapse to 3b with a note. Bucket 3 is surfaced for humans to triage, not auto-fixed.
-
ADR conformance sweep (Bucket 4)
Quick grep pass (non-blocking, surface only):
- Missing
@license / @copyright / @spec in file docblocks (per ADR-014 + ADR-003)
var_dump / dd( / die( / print_r( / error_log( outside tests (per hydra-gate-forbidden-patterns — use word-boundary grep; ->add( is not dd()
- Hardcoded user-facing strings not wrapped in
t( (Vue) or $this->l10n->t( (PHP)
- Direct SQL (
$this->db->query(, prepare() — should use OpenRegister per ADR-001
One finding per file, grouped by rule.
-
Honor .opsx-ignore
If {app}/.opsx-ignore exists, read glob patterns (one per line, # for comments). Drop any matching entries from buckets 1, 2a, 2b, and 4 (not 3 — REQ-level, useful regardless). Report how many entries were suppressed.
-
Write the report
Write two files.
openspec/coverage-report.json — parseable sidecar (consumed by /opsx-annotate):
{
"generated_at": "2026-04-20T14:30:00Z",
"app": "procest",
"branch": "development",
"scanner_version": "1",
"buckets": {
"annotated": [{"file": "...", "method": "...", "spec_tag": "openspec/changes/.../tasks.md#task-3"}],
"plumbing": [{"file": "...", "method": "...", "reason": "listener-dispatch-only"}],
"bucket_1": [{"file": "...", "method": "...", "capability": "meeting-management", "req_id": "REQ-001", "confidence": 0.92, "needs_review": false, "signal": "path+name+scenario noun match", "inherits_from": null}],
"bucket_2a": {
"meeting-management": [{"file": "...", "method": "...", "observed_behavior": "..."}]
},
"bucket_2b": {
"integrations": [{"file": "...", "method": "...", "observed_behavior": "..."}]
},
"bucket_3a": [{"req": "catalogs#REQ-042", "evidence": "removed-lines cache matched 3 keywords: handleCatalog, catalogId, registerCatalog"}],
"bucket_3b": [{"req": "federation#REQ-089"}],
"bucket_4": {
"missing-spec-in-file-docblock": [{"file": "lib/Service/..."}]
}
},
"ignored": 0,
"notes": ["optional free-text notes for human reviewers"]
}
openspec/coverage-report.md — human-readable companion. Structure:
# Coverage Report — {app}
Generated: YYYY-MM-DD HH:MM UTC
Branch: {branch}
Scanner: opsx-coverage-scan v1
## Summary
| Bucket | Count | Next action |
|---|---|---|
| annotated | N | — (already tagged) |
| plumbing | N | — (never tagged) |
| 1 — REQ matched | N | `/opsx-annotate {app}` |
| 2a — existing capability, no REQ | N (M clusters) | `/opsx-reverse-spec {app} --extend <cap>` |
| 2b — no capability owner | N (M clusters) | `/opsx-reverse-spec {app} --cluster <name>` |
| 3a — REQ broken (code removed) | N | Separate fix PR |
| 3b — REQ never implemented | N | Mark deferred or remove |
| 4 — ADR conformance | N findings across M rules | Follow-up issue |
## Bucket 1 — Ready to annotate (via ghost change `retrofit-{date}-annotate-{app}`)
(grouped by capability, then file)
### capability: meeting-management → task-1
| File | Method | REQ | Confidence | Signal |
|---|---|---|---|---|
| lib/Controller/MeetingController.php | index() | REQ-001 | 0.92 | path+name+scenario-noun |
## Bucket 2a — Existing capability, no REQ (reverse-spec --extend)
### cluster: meeting-management (3 methods)
- lib/Service/MeetingService.php::archive() — observed: moves meeting to archive register, writes audit entry. No REQ covers archival.
## Bucket 2b — No capability owner (reverse-spec --cluster)
### cluster: integrations (5 methods)
- ...
## Bucket 3 — Surfaced for human triage
### 3a — possibly broken
- catalogs#REQ-042 — removed-lines cache matched keywords: handleCatalog, catalogId, registerCatalog. Verify.
### 3b — never implemented
- federation#REQ-089 — no git history reference. Consider deferring.
## Bucket 4 — ADR conformance findings
### missing-spec-in-file-docblock (8 files)
- lib/Service/...
## Notes for the human reviewer
- (free-text — surprises, low-confidence calls, ambiguous REQs)
-
Print the summary and suggest next command
## Coverage Scan Complete — {app}
Buckets: annotated={N} | plumbing={N} | 1={N} | 2a={N}/{M} clusters | 2b={N}/{M} clusters | 3a={N} | 3b={N} | 4={N}
Report: {app}/openspec/coverage-report.md
JSON: {app}/openspec/coverage-report.json
Next:
1. Read the .md manually — confirm Bucket 1 matches before annotating
2. `/opsx-annotate {app}` — creates one ghost change + applies Bucket 1 annotations in one PR
3. `/opsx-reverse-spec {app} --extend <cap>` / `--cluster <name>` — one Bucket 2 entry at a time (bias toward --extend)