sourcecode 3.2.0__py3-none-any.whl → 3.2.1__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.
sourcecode/__init__.py CHANGED
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "3.2.0"
7
+ __version__ = "3.2.1"
sourcecode/chain_rules.py CHANGED
@@ -274,16 +274,57 @@ def rule_matches(rule: AccessRule, method: str, path: str) -> bool:
274
274
  return any(ant_matches(pattern, path) for pattern in rule.patterns)
275
275
 
276
276
 
277
- def first_matching_rule(
278
- rules: "list[AccessRule]", method: str, path: str
279
- ) -> "Optional[AccessRule]":
280
- """The rule Spring would apply: the first declared one that matches."""
277
+ #: Matchers that see the DispatcherServlet path. An ant matcher matches the URL
278
+ #: the filter chain receives — servlet path included, servlet CONTEXT path never,
279
+ #: because the container has already stripped it. An mvc matcher matches the path
280
+ #: relative to the servlet mapping, so the servlet path is not part of it.
281
+ #: `requestMatchers` is either one depending on the Spring Security version and
282
+ #: what is on the classpath — undecidable from source, so both readings are tried
283
+ #: and the pattern is honoured if it covers the request under either.
284
+ _SERVLET_PATH_MATCHERS = ("antMatchers", "regexMatchers", "requestMatchers")
285
+ _SERVLET_RELATIVE_MATCHERS = ("mvcMatchers", "requestMatchers")
286
+
287
+
288
+ def candidate_paths(rule: AccessRule, path: str, servlet_prefix: str = "") -> "tuple[str, ...]":
289
+ """The path(s) `rule`'s matcher compares against a mapping-relative `path`.
290
+
291
+ Without a declared servlet path the two readings coincide and this is just
292
+ `(path,)` — which is every repository that never set `spring.mvc.servlet.path`.
293
+ """
294
+ if not servlet_prefix or rule.is_any_request:
295
+ return (path,)
296
+ prefixed = f"{servlet_prefix.rstrip('/')}/{path.lstrip('/')}".rstrip("/") or "/"
297
+ out: list[str] = []
298
+ if rule.matcher in _SERVLET_PATH_MATCHERS:
299
+ out.append(prefixed)
300
+ if rule.matcher in _SERVLET_RELATIVE_MATCHERS or not out:
301
+ out.append(path)
302
+ return tuple(dict.fromkeys(out))
303
+
304
+
305
+ def first_match(
306
+ rules: "list[AccessRule]", method: str, path: str, servlet_prefix: str = ""
307
+ ) -> "tuple[Optional[AccessRule], str]":
308
+ """`(rule Spring would apply, the path it matched)` — first declared wins.
309
+
310
+ The matched path is returned because with a servlet path declared it is not
311
+ the mapping-relative path the endpoint is keyed by, and a reader checking the
312
+ claim against the source line needs to see the URL the rule actually covers.
313
+ """
281
314
  for rule in rules:
282
315
  if rule.paths_unknown:
283
316
  # A rule whose paths could not be read may cover this request, and
284
317
  # everything after it is only reachable if it does not. Stopping here
285
318
  # is what keeps a later `permitAll` from being reported as the answer.
286
- return rule
287
- if rule_matches(rule, method, path):
288
- return rule
289
- return None
319
+ return rule, path
320
+ for candidate in candidate_paths(rule, path, servlet_prefix):
321
+ if rule_matches(rule, method, candidate):
322
+ return rule, candidate
323
+ return None, path
324
+
325
+
326
+ def first_matching_rule(
327
+ rules: "list[AccessRule]", method: str, path: str, servlet_prefix: str = ""
328
+ ) -> "Optional[AccessRule]":
329
+ """The rule Spring would apply: the first declared one that matches."""
330
+ return first_match(rules, method, path, servlet_prefix)[0]
sourcecode/cli.py CHANGED
@@ -170,10 +170,28 @@ Cache warms on first scan; later calls reuse pre-built context instead of rescan
170
170
  Scan and warm time scale with repo size — small repos in seconds, large repos (thousands
171
171
  of files) in minutes. Semantic analysis itself is sub-second; repo indexing dominates.
172
172
 
173
- [bold]Primary usage:[/bold]
174
- ask --compact high-signal summary (~2,500–4,000 tokens)
175
- ask --compact --git-context include git hotspots and uncommitted files
176
- ask --agent full structured JSON for AI agents
173
+ [bold]Start here — Java/Spring analysis:[/bold]
174
+ posture . --diff dev:prod [dim]# effective access, two profile sets (exp.)[/dim]
175
+ endpoints . [dim]# endpoints + effective path + policy[/dim]
176
+ spring-audit . [dim]# TX anomalies + security surface[/dim]
177
+ migrate-check . --compact [dim]# Boot 2→3: located blockers + effort[/dim]
178
+
179
+ [bold]Agent context:[/bold]
180
+ ask --compact [dim]# high-signal summary (~2,500–4,000 tokens)[/dim]
181
+ ask --compact --git-context [dim]# + git hotspots and uncommitted files[/dim]
182
+ ask --agent [dim]# full structured JSON for AI agents[/dim]
183
+
184
+ [bold]Change and risk:[/bold]
185
+ impact-chain <Class> . [dim]# blast radius w/ TX + security per hop[/dim]
186
+ impact <Class> . [dim]# reverse deps → endpoints reached[/dim]
187
+ pr-impact . --since main [dim]# same, scoped to a diff; gating codes[/dim]
188
+ verify . [dim]# contract gate, baseline-relative[/dim]
189
+ verify-edit . [dim]# did working-tree edits change behaviour?[/dim]
190
+ [dim]modernize · explain <Class> · validation · export · repo-ir[/dim]
191
+
192
+ [dim]Spring commands take the INTERFACE, not the Impl — callers inject it.[/dim]
193
+
194
+ [dim]Every command is grouped in the panels below; full reference in docs/USER_GUIDE.md[/dim]
177
195
 
178
196
  [bold]Auth commands:[/bold]
179
197
  auth status [dim]# show current plan and auth state[/dim]
@@ -181,14 +199,17 @@ of files) in minutes. Semantic analysis itself is sub-second; repo indexing domi
181
199
 
182
200
  [bold]Cache commands:[/bold]
183
201
  cache status [dim]# cache size, hit keys, last-warmed timestamp[/dim]
184
- cache warm [dim]# pre-build cache ahead of an agent session[/dim]
202
+ cache warm [dim]# pre-build structural layers + compact view
203
+ # (--agent warms the agent view too; --full,
204
+ # --env-map and raised --depth are NOT warmed)[/dim]
185
205
  cache clear [dim]# clear all cached results for this repo[/dim]
186
206
 
187
207
  [bold]Examples:[/bold]
208
+ ask posture . --diff default:prod -o posture.json
209
+ ask endpoints . -f json | jq '.endpoints | map(select(.method=="POST"))'
210
+ ask spring-audit . --min-severity high -o audit.json
188
211
  ask my-project --compact
189
212
  ask . --compact --git-context --copy
190
- ask . --changed-only --git-context
191
- ask prepare-context onboard my-project
192
213
  ask prepare-context delta . --since main
193
214
 
194
215
  [bold]Subcommands:[/bold]
@@ -4332,6 +4353,10 @@ def endpoints_cmd(
4332
4353
  if controller:
4333
4354
  _ctrl_lower = controller.lower()
4334
4355
  endpoints_list = [e for e in endpoints_list if _ctrl_lower in e.get("controller", "").lower()]
4356
+ # A filter changes the population a count is about; `--limit` does not — it
4357
+ # cuts the rendering. Measuring `total` after the cut published the display
4358
+ # size as the measurement (ADR-0008 R5), so the two are separated here.
4359
+ _selected = endpoints_list
4335
4360
  if limit is not None and limit > 0:
4336
4361
  endpoints_list = endpoints_list[:limit]
4337
4362
  if path_prefix or controller or limit is not None:
@@ -4341,11 +4366,12 @@ def endpoints_cmd(
4341
4366
  _no_sec_before = data.get("no_security_signal")
4342
4367
  _undoc_before = data.get("undocumented")
4343
4368
  _no_sec_after = sum(
4344
- 1 for e in endpoints_list
4369
+ 1 for e in _selected
4345
4370
  if e.get("security", {}).get("policy") == "none_detected"
4346
4371
  )
4347
4372
  data["endpoints"] = endpoints_list
4348
- data["total"] = len(endpoints_list)
4373
+ data["total"] = len(_selected)
4374
+ data["shown"] = len(endpoints_list)
4349
4375
  data["no_security_signal"] = _no_sec_after
4350
4376
  data["undocumented"] = _no_sec_after
4351
4377
  data["_filter"] = {
@@ -4355,6 +4381,10 @@ def endpoints_cmd(
4355
4381
  "total_before_filter": _total_before,
4356
4382
  "no_security_signal_before_filter": _no_sec_before,
4357
4383
  "undocumented_before_filter": _undoc_before,
4384
+ "note": (
4385
+ "`total` counts the endpoints the filters selected; `shown` is how "
4386
+ "many of them this document lists, which `--limit` cuts."
4387
+ ),
4358
4388
  }
4359
4389
 
4360
4390
  if by_controller:
@@ -5818,10 +5848,14 @@ def spring_audit_cmd(
5818
5848
  SEC-003 @Transactional on @Controller/@RestController (TX in wrong layer)
5819
5849
 
5820
5850
  \b
5821
- CI/CD usage:
5851
+ CI/CD usage — this gate is ABSOLUTE (any finding fails, including debt that
5852
+ was already there). For a baseline-relative gate that fails only on findings
5853
+ a change INTRODUCES, use `ask verify --fail-on new` (or `ask pr-impact
5854
+ --fail-on`); `ask baseline capture/diff` tracks the debt over time.
5822
5855
  ask spring-audit . --ci # exit 1 on any finding
5823
5856
  ask spring-audit . --ci --min-severity high # exit 1 only on high/critical
5824
5857
  ask spring-audit . --ci --format github-comment # Markdown PR comment + exit 1
5858
+ ask verify . --fail-on new # exit 1 only on NEW findings
5825
5859
 
5826
5860
  \b
5827
5861
  Examples:
@@ -7330,138 +7364,24 @@ def fix_bug_cmd(
7330
7364
  )
7331
7365
 
7332
7366
 
7333
- # Method signatures that are almost always FRAMEWORK ENTRY POINTS, not dead code —
7334
- # invoked by a dispatcher (reflection / XML / SPI), invisible to a static Java
7335
- # call-graph. Generalizes beyond any one framework.
7336
- _DYNAMIC_ENTRY_SIGNATURE_RE = __import__("re").compile(
7337
- r"\(\s*DispatchContext\b" # OFBiz Service Engine service
7338
- r"|HttpServletRequest\s+\w+\s*,\s*HttpServletResponse" # OFBiz event / servlet handler
7339
- r"|@(?:Scheduled|PostConstruct|PreDestroy|EventListener|Bean|"
7340
- r"RequestMapping|GetMapping|PostMapping|Path|GET|POST|Provider|"
7341
- r"ApplicationScoped|Singleton)\b", # annotation-dispatched entry
7342
- )
7343
- # Config file extensions where a framework wires classes by name (XML/props/yaml).
7344
- _CONFIG_REF_EXTS: frozenset = frozenset({".xml", ".properties", ".yml", ".yaml", ".groovy"})
7345
- _CONFIG_SCAN_MAX_FILES: int = 12000
7346
- _CONFIG_SCAN_MAX_BYTES: int = 256 * 1024
7347
-
7348
-
7349
- def _partition_static_unreferenced(nodes: list[dict], root: Path) -> tuple[list[dict], list[dict]]:
7350
- """Split zero-degree classes into (truly_unreferenced, framework_dispatched).
7351
-
7352
- A class with no static callers is NOT necessarily dead: frameworks invoke
7353
- classes via reflection, XML/SPI config, or annotations that a static call-graph
7354
- cannot see (e.g. Apache OFBiz Service Engine services, JAX-RS resources,
7355
- ServiceLoader providers, scheduled beans). We exclude a candidate when EITHER:
7356
- 1. its source declares a dynamic-entry method signature, OR
7357
- 2. its simple name / FQN is referenced from a non-Java config file.
7358
- Whatever survives is reported as *statically_unreferenced* — never a confident
7359
- "dead zone".
7360
- """
7361
- import os
7362
- if not nodes:
7363
- return [], []
7364
- by_simple: dict[str, list[dict]] = {}
7365
- for n in nodes:
7366
- simple = (n.get("fqn") or "").rsplit(".", 1)[-1]
7367
- if simple:
7368
- by_simple.setdefault(simple, []).append(n)
7369
-
7370
- dispatched_fqns: set[str] = set()
7371
-
7372
- # 1. Source-signature allowlist (bounded — candidate set is small).
7373
- for n in nodes:
7374
- src = n.get("source_file")
7375
- if not src:
7376
- continue
7377
- try:
7378
- txt = (root / src).read_text(encoding="utf-8", errors="replace")
7379
- except OSError:
7380
- continue
7381
- if _DYNAMIC_ENTRY_SIGNATURE_RE.search(txt):
7382
- dispatched_fqns.add(n["fqn"])
7383
-
7384
- # 2. Config-reference scan — find candidate names wired from XML/props/yaml.
7385
- unresolved_simple = {s for s, ns in by_simple.items()
7386
- if any(x["fqn"] not in dispatched_fqns for x in ns)}
7387
- if unresolved_simple:
7388
- files_scanned = 0
7389
- for dirpath, dirnames, filenames in os.walk(root):
7390
- dirnames[:] = [d for d in dirnames
7391
- if d not in {".git", "build", "out", "target", "node_modules", ".gradle"}]
7392
- for fname in filenames:
7393
- ext = os.path.splitext(fname)[1].lower()
7394
- if ext not in _CONFIG_REF_EXTS:
7395
- continue
7396
- if files_scanned >= _CONFIG_SCAN_MAX_FILES or not unresolved_simple:
7397
- break
7398
- fpath = os.path.join(dirpath, fname)
7399
- try:
7400
- with open(fpath, "r", encoding="utf-8", errors="replace") as fh:
7401
- text = fh.read(_CONFIG_SCAN_MAX_BYTES)
7402
- except OSError:
7403
- continue
7404
- files_scanned += 1
7405
- for simple in list(unresolved_simple):
7406
- if simple in text:
7407
- for x in by_simple.get(simple, []):
7408
- dispatched_fqns.add(x["fqn"])
7409
- unresolved_simple.discard(simple)
7410
- if files_scanned >= _CONFIG_SCAN_MAX_FILES or not unresolved_simple:
7411
- break
7367
+ def _partition_static_unreferenced(
7368
+ nodes: list[dict], root: Path
7369
+ ) -> tuple[list[dict], list[dict]]:
7370
+ """Split callerless types into (no dispatch signal found, framework-dispatched).
7412
7371
 
7413
- # 3. Nested-type qualified-reference scan (SC-2 residual). The static call-graph
7414
- # does not emit an edge for a nested-type member access in ordinary method-body
7415
- # code e.g. `PropertyType.AdminGroupPresentation.NAME` credits the enclosing
7416
- # type, not the nested holder — so a referenced nested class can look
7417
- # zero-degree. When a candidate is a nested type (its second-to-last FQN segment
7418
- # is an UpperCamel enclosing type) and its qualified `Outer.Nested` form appears
7419
- # in some OTHER Java source, it is statically referenced: neither dead nor
7420
- # framework-dispatched. Structural, name-agnostic (pattern derived from the
7421
- # candidate's own FQN).
7422
- import re as _re_nested
7423
- statically_referenced: set[str] = set()
7424
- nested_patterns: dict[str, "tuple"] = {}
7425
- for n in nodes:
7426
- if n["fqn"] in dispatched_fqns:
7427
- continue
7428
- parts = (n.get("fqn") or "").split(".")
7429
- if len(parts) >= 2 and parts[-2][:1].isupper():
7430
- outer, nested = parts[-2], parts[-1]
7431
- nested_patterns[n["fqn"]] = (
7432
- _re_nested.compile(r"\b" + _re_nested.escape(outer) + r"\." + _re_nested.escape(nested) + r"\b"),
7433
- n.get("source_file") or "",
7434
- )
7435
- if nested_patterns:
7436
- files_scanned = 0
7437
- for dirpath, dirnames, filenames in os.walk(root):
7438
- dirnames[:] = [d for d in dirnames
7439
- if d not in {".git", "build", "out", "target", "node_modules", ".gradle"}]
7440
- for fname in filenames:
7441
- if not fname.endswith(".java"):
7442
- continue
7443
- if files_scanned >= _CONFIG_SCAN_MAX_FILES or len(statically_referenced) == len(nested_patterns):
7444
- break
7445
- fpath = os.path.join(dirpath, fname)
7446
- rel = os.path.relpath(fpath, root)
7447
- try:
7448
- with open(fpath, "r", encoding="utf-8", errors="replace") as fh:
7449
- text = fh.read(_CONFIG_SCAN_MAX_BYTES)
7450
- except OSError:
7451
- continue
7452
- files_scanned += 1
7453
- for fqn, (pat, own) in nested_patterns.items():
7454
- if fqn in statically_referenced or rel == own:
7455
- continue
7456
- if pat.search(text):
7457
- statically_referenced.add(fqn)
7458
- if files_scanned >= _CONFIG_SCAN_MAX_FILES or len(statically_referenced) == len(nested_patterns):
7459
- break
7372
+ Kept as the shape `modernize` renders; the fact itself is derived once, in
7373
+ `reference_facts` the authority that also states the third answer this pair
7374
+ cannot express, `unknown`.
7375
+ """
7376
+ from sourcecode.reference_facts import (
7377
+ NO_STATIC_CALLERS, UNKNOWN_DISPATCH, analyze_type_references,
7378
+ )
7460
7379
 
7461
- unreferenced = [n for n in nodes
7462
- if n["fqn"] not in dispatched_fqns and n["fqn"] not in statically_referenced]
7463
- dispatched = [n for n in nodes if n["fqn"] in dispatched_fqns]
7464
- return unreferenced, dispatched
7380
+ facts = analyze_type_references(nodes, root)
7381
+ by_fqn = {str(n.get("fqn")): n for n in nodes}
7382
+ def _nodes_of(status: str) -> list[dict]:
7383
+ return [by_fqn[e.fqn] for e in facts.of_status(status) if e.fqn in by_fqn]
7384
+ return _nodes_of(NO_STATIC_CALLERS), _nodes_of(UNKNOWN_DISPATCH)
7465
7385
 
7466
7386
 
7467
7387
  @app.command("modernize")
@@ -7479,7 +7399,7 @@ def modernize_cmd(
7479
7399
  help="Copy output to clipboard after a successful run.",
7480
7400
  ),
7481
7401
  ) -> None:
7482
- """[Pro*] Modernization planning: coupling, dead zones, risky modules, refactor candidates.
7402
+ """[Pro*] Modernization planning: coupling, callerless types, risky modules, refactor candidates.
7483
7403
 
7484
7404
  Note: [Pro*] label is reserved for a future licensing gate. This command currently
7485
7405
  runs without authentication. Behavior may change in a future version.
@@ -7489,7 +7409,7 @@ def modernize_cmd(
7489
7409
 
7490
7410
  Analyzes the repo for:
7491
7411
  - High-coupling modules (high in-degree + out-degree nodes)
7492
- - Dead zones (isolated symbols with no callers)
7412
+ - Types with no static caller, split from those a framework may dispatch (unknown)
7493
7413
  - Risk hotspots (high fan-in + security annotations + transaction boundaries)
7494
7414
  - Cross-module dependency tangles
7495
7415
  - Subsystem summary with member counts
@@ -7559,27 +7479,29 @@ def modernize_cmd(
7559
7479
  if _src and _src in _file_churn:
7560
7480
  _fqn_churn[_n["fqn"]] = _file_churn[_src]
7561
7481
 
7562
- # High-coupling nodes: high in_degree (many dependents = risky to change)
7563
- coupling_nodes = sorted(
7482
+ # High-coupling nodes: high in_degree (many dependents = risky to change).
7483
+ # The measurement is the full set; the list below is the display cut, and the
7484
+ # summary counts the first, never the second (ADR-0008 R5).
7485
+ _coupling_all = sorted(
7564
7486
  [n for n in graph_nodes if n.get("in_degree", 0) >= 3],
7565
7487
  key=lambda n: (-n.get("in_degree", 0), n.get("fqn", "")),
7566
- )[:20]
7567
-
7568
- # Statically-unreferenced zones: classes with zero in-degree AND zero out-degree
7569
- # in the Java call-graph. These are NOT necessarily dead — framework dispatch
7570
- # (reflection / XML / SPI / annotations) is invisible to a static graph — so we
7571
- # partition out framework-dispatched entry points before reporting, and never
7572
- # call the survivors "dead". (Defect 5: OFBiz Service-Engine services and event
7573
- # handlers were false-positive "dead zones".)
7574
- _zero_degree = sorted(
7575
- [n for n in graph_nodes
7576
- if n.get("in_degree", 0) == 0 and n.get("out_degree", 0) == 0
7577
- and n.get("type") in ("class", "interface")],
7578
- key=lambda n: n.get("fqn", ""),
7579
7488
  )
7580
- dead_zones, framework_dispatched = _partition_static_unreferenced(_zero_degree, root)
7581
- dead_zones = dead_zones[:20]
7582
- framework_dispatched = framework_dispatched[:20]
7489
+ coupling_nodes = _coupling_all[:20]
7490
+
7491
+ # Which types nothing calls — one authority (`reference_facts`), three answers.
7492
+ # A static call-graph cannot see reflection, XML/SPI wiring or annotation
7493
+ # dispatch, so "no caller found" and "not called" are different statements: the
7494
+ # first is published here, the second never is. (M7 seam 6 / defect C3-8: this
7495
+ # surface examined only types with no edges in EITHER direction while calling
7496
+ # the result "zero static callers", and published `0` where the answer was
7497
+ # unknown.)
7498
+ from sourcecode.reference_facts import (
7499
+ NO_STATIC_CALLERS as _NO_CALLERS,
7500
+ UNKNOWN_DISPATCH as _UNKNOWN_DISPATCH,
7501
+ analyze_type_references as _analyze_type_references,
7502
+ )
7503
+
7504
+ _reference_facts = _analyze_type_references(graph_nodes, root)
7583
7505
 
7584
7506
  # Hotspot candidates: high in-degree service/repository/controller nodes,
7585
7507
  # ranked by composite score (in_degree × 2 + git_churn) for volatility signal.
@@ -7682,11 +7604,17 @@ def modernize_cmd(
7682
7604
  _cross_module_tangles = _cross_module_tangles[:15]
7683
7605
 
7684
7606
  _summary = {
7685
- "total_classes": len([n for n in graph_nodes if n.get("type") in ("class", "interface")]),
7607
+ # Same population the reference partition is measured over, so the three
7608
+ # statuses below sum to exactly this number.
7609
+ "total_classes": _reference_facts.classes_examined,
7686
7610
  "total_subsystems": len(subsystems),
7687
- "high_coupling_nodes": len(coupling_nodes),
7688
- "statically_unreferenced": len(dead_zones),
7689
- "framework_dispatched": len(framework_dispatched),
7611
+ "high_coupling_nodes": len(_coupling_all),
7612
+ "high_coupling_nodes_shown": len(coupling_nodes),
7613
+ # From the measurement, not from the lists below — those are truncated for
7614
+ # display, and a count taken off them is a different number (ADR-0008 R5).
7615
+ "statically_unreferenced": _reference_facts.count(_NO_CALLERS),
7616
+ "framework_dispatched": _reference_facts.count(_UNKNOWN_DISPATCH),
7617
+ "reference_status": _reference_facts.to_dict(),
7690
7618
  }
7691
7619
  # BUG #6 (v1.68.0): `member_count` counts ALL graph members in the subsystem —
7692
7620
  # classes, methods AND fields — so it runs ~5x higher than the class count and
@@ -7725,7 +7653,7 @@ def modernize_cmd(
7725
7653
  "tier_note": (
7726
7654
  "This repository exceeds the free-tier size limit. "
7727
7655
  "Upgrade to Pro for full analysis on enterprise-scale monoliths: "
7728
- "dead zones, dependency tangles, refactor candidates ranked by git "
7656
+ "callerless types, dependency tangles, refactor candidates ranked by git "
7729
7657
  "churn, and complete coupling graphs."
7730
7658
  ),
7731
7659
  "summary": _summary,
@@ -7753,19 +7681,36 @@ def modernize_cmd(
7753
7681
  # three share one reconciliation note so they can never contradict.
7754
7682
  "high_coupling_nodes_note": CALLER_METRIC_RECONCILIATION,
7755
7683
  "statically_unreferenced": [
7756
- {"fqn": n["fqn"], "type": n.get("type", ""), "role": n.get("role", "other")}
7757
- for n in dead_zones
7684
+ {
7685
+ "fqn": e.fqn, "type": e.type, "role": e.role,
7686
+ # Why this type is in this list, so the claim can be checked
7687
+ # rather than trusted.
7688
+ "basis": e.basis,
7689
+ }
7690
+ for e in _reference_facts.of_status(_NO_CALLERS)[:20]
7758
7691
  ],
7759
7692
  "statically_unreferenced_note": (
7760
- "Zero static callers in the Java call-graph. NOT confirmed dead: verify "
7761
- "no framework dispatch (reflection, XML/SPI config, annotations) before "
7762
- "removing. Classes detected as framework-dispatched are listed separately "
7763
- "under framework_dispatched and excluded from this list."
7693
+ "No incoming edge in the Java call-graph and no framework-dispatch "
7694
+ "signal found. This is absence of evidence, NOT confirmed dead code: "
7695
+ "a static graph cannot see reflection, XML/SPI wiring or annotation "
7696
+ "dispatch, and this scan reads only the repository's own sources and "
7697
+ "configuration. Types that DO carry a dispatch signal are reported "
7698
+ "separately as framework_dispatched — status "
7699
+ "`unknown_framework_dispatch`, because whether they run is not "
7700
+ "decidable here. Counts live in summary.reference_status; this list "
7701
+ f"shows at most 20 of {_reference_facts.count(_NO_CALLERS)}."
7764
7702
  ),
7765
7703
  "framework_dispatched": [
7766
- {"fqn": n["fqn"], "type": n.get("type", ""), "role": n.get("role", "other")}
7767
- for n in framework_dispatched
7704
+ {"fqn": e.fqn, "type": e.type, "role": e.role, "basis": e.basis}
7705
+ for e in _reference_facts.of_status(_UNKNOWN_DISPATCH)[:20]
7768
7706
  ],
7707
+ "framework_dispatched_note": (
7708
+ "No static caller, but a framework may invoke them (published "
7709
+ "annotation, entry-point signature, or the name wired from a "
7710
+ "configuration file). Status is `unknown`, never 'alive' and never "
7711
+ "'dead'. This list shows at most 20 of "
7712
+ f"{_reference_facts.count(_UNKNOWN_DISPATCH)}."
7713
+ ),
7769
7714
  "subsystem_summary": _subsystem_summary,
7770
7715
  "subsystem_summary_note": _subsystem_summary_note,
7771
7716
  "cross_module_tangles": _cross_module_tangles,
@@ -7787,8 +7732,10 @@ def modernize_cmd(
7787
7732
  if hotspots else
7788
7733
  "high_coupling_nodes shows the most-referenced classes — start there. "
7789
7734
  )
7790
- + "statically_unreferenced lists classes with no Java callers review "
7791
- + "for framework dispatch (XML/reflection/SPI) before removing. "
7735
+ + "statically_unreferenced lists types with no Java caller and no "
7736
+ + "dispatch signal found a starting point for review, not a "
7737
+ + "removal list; confirm at runtime before deleting. "
7738
+ + _reference_facts.statement + ". "
7792
7739
  + "In cross_module_tangles, coupling_type=cyclic entries are the "
7793
7740
  + "real tangles to decompose first; directional entries are normal "
7794
7741
  + "layering ranked by coupling strength."
@@ -8138,6 +8085,9 @@ def version_cmd() -> None:
8138
8085
  # contract does.
8139
8086
  "envelope_version": ENVELOPE_VERSION,
8140
8087
  "published_schemas": available_schemas(),
8088
+ # Same list `ask schema` prints, so the two surfaces cannot disagree
8089
+ # about what this release publishes.
8090
+ "registries": ["facts-v1"],
8141
8091
  }, ensure_ascii=False))
8142
8092
 
8143
8093
 
@@ -8162,13 +8112,33 @@ def schema_cmd(
8162
8112
  Examples:
8163
8113
  ask schema # list published schemas
8164
8114
  ask schema envelope-v1 # print the response-envelope schema
8115
+ ask schema facts-v1 # print the fact registry (one fact, one authority)
8165
8116
  """
8166
8117
  from sourcecode.envelope import available_schemas, load_schema
8118
+ from sourcecode.facts import load_registry
8167
8119
 
8120
+ # The fact registry is a contract too: which facts have a single authority,
8121
+ # who derives each, and which modules may emit it (ADR-0008 R11). Published
8122
+ # through the command that already answers "what shape does this release
8123
+ # produce?" rather than a command of its own.
8168
8124
  names = available_schemas()
8125
+ if name == "facts-v1":
8126
+ _emit_command_output(
8127
+ json.dumps(load_registry(), indent=2, ensure_ascii=False),
8128
+ output_path,
8129
+ False,
8130
+ stamp_envelope=False,
8131
+ )
8132
+ return
8169
8133
  if name is None:
8170
8134
  _emit_command_output(
8171
- json.dumps({"schemas": names}, indent=2, ensure_ascii=False),
8135
+ # Schemas describe output shapes; the fact registry describes which
8136
+ # answers have a single authority. Both are contracts this release
8137
+ # carries, listed apart because they are not the same kind of thing.
8138
+ json.dumps(
8139
+ {"schemas": names, "registries": ["facts-v1"]},
8140
+ indent=2, ensure_ascii=False,
8141
+ ),
8172
8142
  output_path,
8173
8143
  False,
8174
8144
  )
@@ -9516,6 +9486,78 @@ def _stderr_is_interactive() -> bool:
9516
9486
  return False
9517
9487
 
9518
9488
 
9489
+ #: How `--help` groups and orders the commands — one authority for it, applied
9490
+ #: below to the registered commands themselves.
9491
+ #:
9492
+ #: `--help` is the first thing a new user reads, and it was ordered by the file's
9493
+ #: registration order: a reader met `prepare-context` and `repo-ir` before
9494
+ #: `posture` or `endpoints`, so the surface introduced itself with its weakest
9495
+ #: 20 %. Field evaluation #3 scored discoverability 3/10 and found `endpoints` —
9496
+ #: the command it valued most — by accident, inside another command's JSON.
9497
+ #:
9498
+ #: Panels are ordered by what a reader should meet first, and inside a panel by
9499
+ #: what answers the most common question. Nothing is hidden: every command is
9500
+ #: still listed, and the battery fails if one is missing from this table.
9501
+ HELP_PANELS: "tuple[tuple[str, tuple[str, ...]], ...]" = (
9502
+ ("Java/Spring analysis — start here", (
9503
+ "posture", "endpoints", "spring-audit", "migrate-check",
9504
+ )),
9505
+ ("Change and risk", (
9506
+ "impact-chain", "impact", "pr-impact", "verify", "verify-edit",
9507
+ "review-pr", "plan", "compare", "delta", "contract-diff", "fix-bug",
9508
+ "rename-class",
9509
+ )),
9510
+ ("Context for AI agents", (
9511
+ "prepare-context", "onboard", "explain", "export", "repo-ir",
9512
+ "validation", "modernize", "chunk-file", "cold-start",
9513
+ )),
9514
+ # Inside a panel, Typer renders plain commands before command groups, so the
9515
+ # groups (`cache`, `auth`, …) are listed last here to keep this table and the
9516
+ # rendered help in the same order.
9517
+ ("Setup and inspection", (
9518
+ "activate", "config", "schema", "version",
9519
+ "cache", "auth", "mcp", "telemetry", "baseline",
9520
+ )),
9521
+ ("Experimental — shape may change", (
9522
+ "archetype", "retrieve",
9523
+ )),
9524
+ )
9525
+
9526
+ #: Where a command lands when it is not in the table. Visible on purpose: an
9527
+ #: unlisted command must look unfinished, not disappear.
9528
+ _UNPANELLED = "Other commands"
9529
+
9530
+
9531
+ def _apply_help_panels() -> None:
9532
+ """Group and order the registered commands per `HELP_PANELS`.
9533
+
9534
+ Typer renders panels in the order it first meets them and commands in
9535
+ registration order, so the grouping is applied to the registered objects
9536
+ rather than to thirty decorator call sites — one place to read, one place a
9537
+ new command has to be added to.
9538
+ """
9539
+ rank: dict[str, tuple[int, int]] = {}
9540
+ panel_of: dict[str, str] = {}
9541
+ for panel_index, (panel, names) in enumerate(HELP_PANELS):
9542
+ for name_index, name in enumerate(names):
9543
+ rank[name] = (panel_index, name_index)
9544
+ panel_of[name] = panel
9545
+
9546
+ def _sort_key(entry: Any) -> "tuple[int, int, str]":
9547
+ name = str(getattr(entry, "name", "") or "")
9548
+ position = rank.get(name, (len(HELP_PANELS), 0))
9549
+ return (position[0], position[1], name)
9550
+
9551
+ for registry in (app.registered_commands, app.registered_groups):
9552
+ for entry in registry:
9553
+ name = str(getattr(entry, "name", "") or "")
9554
+ entry.rich_help_panel = panel_of.get(name, _UNPANELLED)
9555
+ registry.sort(key=_sort_key)
9556
+
9557
+
9558
+ _apply_help_panels()
9559
+
9560
+
9519
9561
  def _force_utf8_streams() -> None:
9520
9562
  """Force UTF-8 on stdout AND stderr so Unicode characters (em-dash, arrows, box
9521
9563
  drawing) survive on Windows where the default console codec is cp1252 (BUG-1).
@@ -315,12 +315,16 @@ class ConfidenceAnalyzer:
315
315
  _test_exclude_tokens = frozenset({"test", "tests", "spec", "specs", "it", "testing"})
316
316
  _tests_deliberately_excluded = bool(_extra_exc & _test_exclude_tokens)
317
317
 
318
+ # One authority for which files are tests (ADR-0008 R1): the substring
319
+ # rule this replaces counted production classes living in a package
320
+ # named `test` as tests, so a repository with none reported "2 test
321
+ # files" while `has_tests` in the same document said the same wrong
322
+ # thing from a second derivation.
323
+ from sourcecode.test_sources import analyze_test_sources
324
+
318
325
  _java_all = [p for p in sm.file_paths if p.endswith(".java")]
319
- _java_tests = [
320
- p for p in _java_all
321
- if "/test/" in p.replace("\\", "/") or "/tests/" in p.replace("\\", "/")
322
- or Path(p).stem.endswith(("Test", "Tests", "IT", "Spec"))
323
- ]
326
+ _java_test_facts = analyze_test_sources(_java_all, extensions=(".java",))
327
+ _java_tests = list(_java_test_facts.test_files)
324
328
  _java_prod = [p for p in _java_all if p not in set(_java_tests)]
325
329
  if _java_prod and len(_java_prod) >= 10:
326
330
  _ratio = len(_java_tests) / len(_java_prod)
@@ -341,7 +345,7 @@ class ConfidenceAnalyzer:
341
345
  reason=(
342
346
  f"Backend test coverage critical: {len(_java_tests)} test files "
343
347
  f"for {len(_java_prod)} Java files "
344
- f"({_ratio:.1%})"
348
+ f"({_ratio:.1%}) — {_java_test_facts.basis}"
345
349
  ),
346
350
  impact="high",
347
351
  ))