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 +1 -1
- sourcecode/chain_rules.py +49 -8
- sourcecode/cli.py +218 -176
- sourcecode/confidence_analyzer.py +10 -6
- sourcecode/deployment_prefix.py +85 -0
- sourcecode/facts/__init__.py +71 -0
- sourcecode/facts/registry.json +158 -0
- sourcecode/mcp/registry.py +1 -1
- sourcecode/mcp/server.py +1 -1
- sourcecode/metrics_analyzer.py +8 -5
- sourcecode/posture.py +78 -6
- sourcecode/prepare_context.py +44 -19
- sourcecode/reference_facts.py +307 -0
- sourcecode/serializer.py +36 -9
- sourcecode/spring_profiles.py +93 -13
- sourcecode/test_sources.py +178 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.1.dist-info}/METADATA +100 -10
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.1.dist-info}/RECORD +21 -17
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.1.dist-info}/WHEEL +0 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.1.dist-info}/entry_points.txt +0 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.1.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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]
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
7334
|
-
|
|
7335
|
-
|
|
7336
|
-
|
|
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
|
-
|
|
7414
|
-
|
|
7415
|
-
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
7419
|
-
|
|
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
|
-
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
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,
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
7581
|
-
|
|
7582
|
-
|
|
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
|
-
|
|
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(
|
|
7688
|
-
"
|
|
7689
|
-
|
|
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
|
-
"
|
|
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
|
-
{
|
|
7757
|
-
|
|
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
|
-
"
|
|
7761
|
-
"
|
|
7762
|
-
"
|
|
7763
|
-
"
|
|
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":
|
|
7767
|
-
for
|
|
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
|
|
7791
|
-
+ "
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
))
|