sourcecode 3.2.2__py3-none-any.whl → 3.4.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.
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.2"
7
+ __version__ = "3.4.0"
sourcecode/cli.py CHANGED
@@ -6106,9 +6106,14 @@ def verify_edit_cmd(
6106
6106
 
6107
6107
  Deterministic, in-loop verdict over the diff of the working tree against HEAD:
6108
6108
  public contract, @Transactional proxy boundary, security surface, bean wiring,
6109
- custom domain rules, and blast radius. Built for the agent write-loop (MCP) and
6110
- git hooks. Only regressions the edit INTRODUCED (vs HEAD) block — pre-existing
6111
- debt never fails an unrelated commit.
6109
+ custom domain rules, build/configuration changes, and blast radius. Built for the
6110
+ agent write-loop (MCP) and git hooks. Only regressions the edit INTRODUCED (vs
6111
+ HEAD) block — pre-existing debt never fails an unrelated commit.
6112
+
6113
+ Build and configuration files (pom.xml, build.gradle, application*.yml, web.xml,
6114
+ container descriptors, Spring XML) are reported under `changed_build_files` and
6115
+ make the verdict UNVERIFIED, never `pass`: their effect is not decidable from a
6116
+ source diff, and a compiler-release bump moves every file in the repo at once.
6112
6117
 
6113
6118
  Custom rules: drop a `.ask/verify-rules.json` at the repo root to enforce your own
6114
6119
  invariants (e.g. "no @RestController injects a *DaoJpa", "every /payroll endpoint
@@ -8105,47 +8110,44 @@ def auth_status_cmd() -> None:
8105
8110
  """Show current authentication and plan status."""
8106
8111
  import json as _json
8107
8112
  try:
8108
- from sourcecode.license import (
8109
- _PRO_UNLOCK_ALL as _unlock,
8110
- _license_data as _ld,
8111
- is_pro as _ip,
8112
- )
8113
+ from sourcecode.license import _license_data as _ld, entitlement as _entitlement
8114
+ _ent = _entitlement()
8113
8115
  except Exception:
8114
8116
  _ld = None
8115
- _ip = False
8116
- _unlock = False
8117
-
8118
- # While the early-adoption unlock is on, every Pro command runs. Reporting
8119
- # `pro: false` here contradicted the --help header and the commands' own
8120
- # behaviour: the status has to describe the gating that is actually in force,
8121
- # not the credential that happens to be absent.
8122
- _gating = {
8123
- "pro_gating": "disabled" if _unlock else "enabled",
8124
- "pro_effective": bool(_ip or _unlock),
8125
- }
8126
- if _unlock:
8127
- _gating["pro_reason"] = "early-adoption unlock: Pro commands run without a license"
8128
-
8129
- if not _ld:
8130
- out: dict = {
8131
- "status": "unauthenticated",
8132
- "pro": bool(_unlock),
8133
- **_gating,
8117
+ _ent = {
8118
+ "entitlement": "free", "source": "free_tier", "authenticated": False,
8119
+ "paywall_active": True, "when_it_changes": "", "free_repo_java_file_limit": None,
8134
8120
  }
8135
- sys.stdout.write(_json.dumps(out, ensure_ascii=False) + "\n")
8136
- sys.stdout.flush()
8137
- return
8138
8121
 
8139
- out = {
8140
- "status": "authenticated",
8141
- "auth_method": _ld.get("auth_method", "license_key"),
8142
- "email": _ld.get("email", ""),
8143
- "plan": _ld.get("plan", "unknown"),
8144
- "plan_status": _ld.get("status", "unknown"),
8145
- "pro": _ip,
8146
- "validated_at": _ld.get("validated_at") or _ld.get("activated_at") or "",
8147
- **_gating,
8122
+ # P-2: one authority (`license.entitlement`) answers "what runs here, and why".
8123
+ # The legacy keys below are *derived* from it rather than computed a second
8124
+ # time — that is what stops `{"status": "unauthenticated", "pro": true}` from
8125
+ # reappearing. They are deprecated and will be removed in a future major.
8126
+ _pro = _ent["entitlement"] == "pro"
8127
+ _legacy = {
8128
+ "pro": _pro,
8129
+ "pro_effective": _pro,
8130
+ "pro_gating": "enabled" if _ent["paywall_active"] else "disabled",
8131
+ }
8132
+ if _ent["source"] == "early_adoption_unlock":
8133
+ _legacy["pro_reason"] = "early-adoption unlock: Pro commands run without a license"
8134
+
8135
+ out: dict = {
8136
+ "status": "authenticated" if _ent["authenticated"] else "unauthenticated",
8137
+ **_ent,
8138
+ **_legacy,
8139
+ "deprecated_fields": ["pro", "pro_effective", "pro_gating", "pro_reason"],
8148
8140
  }
8141
+ if _ld:
8142
+ out.update({
8143
+ "auth_method": _ld.get("auth_method", "license_key"),
8144
+ "email": _ld.get("email", ""),
8145
+ "plan": _ld.get("plan", "unknown"),
8146
+ # The authority reads a missing status as active, so reporting
8147
+ # "unknown" here would contradict the entitlement in the same object.
8148
+ "plan_status": _ld.get("status", "active"),
8149
+ "validated_at": _ld.get("validated_at") or _ld.get("activated_at") or "",
8150
+ })
8149
8151
  sys.stdout.write(_json.dumps(out, indent=2, ensure_ascii=False) + "\n")
8150
8152
  sys.stdout.flush()
8151
8153
 
sourcecode/license.py CHANGED
@@ -376,16 +376,78 @@ def _maybe_revalidate() -> None:
376
376
  pass
377
377
 
378
378
 
379
- def _init() -> None:
380
- global _license_data, is_pro
381
- _license_data = _load_license_file()
382
- is_pro = (
379
+ def entitlement() -> dict:
380
+ """The one answer to "what runs here today, and why" (P-2).
381
+
382
+ `auth status` used to publish four fields about this — `status`, `pro`,
383
+ `pro_gating`, `pro_effective`, plus a `pro_reason` sentence — and they read as
384
+ a contradiction: `{"status": "unauthenticated", "pro": true}`. Each field was
385
+ true on its own axis; together they told a buyer nothing about what they have,
386
+ why they have it, or what changes when it ends. That is the ADR-0008 pattern
387
+ exactly, on the commercial surface instead of an analysis one.
388
+
389
+ Every published entitlement field is derived from this dict and nowhere else.
390
+
391
+ Keys:
392
+ entitlement "pro" | "free" — what actually runs right now
393
+ source why: "license_key" | "early_adoption_unlock" | "free_tier"
394
+ authenticated whether a credential is on this machine (an independent
395
+ fact: today one can be entitled to Pro without one)
396
+ paywall_active whether the paywall is enforcing anything at all
397
+ when_it_changes what a user loses when `source` stops applying, in the
398
+ terms the gate actually uses — never a price
399
+ free_repo_java_file_limit
400
+ the number the size gate compares against
401
+ """
402
+ authenticated = _license_data is not None
403
+ licensed = bool(
383
404
  _license_data is not None
384
405
  and _license_data.get("plan") == "pro"
385
406
  and _license_data.get("status", "active") != "inactive"
386
407
  )
387
- if _PRO_UNLOCK_ALL:
388
- is_pro = True # TEMPORARY: early-adoption Pro unlock (see _PRO_UNLOCK_ALL)
408
+ if licensed:
409
+ source = "license_key"
410
+ elif _PRO_UNLOCK_ALL:
411
+ source = "early_adoption_unlock"
412
+ else:
413
+ source = "free_tier"
414
+
415
+ if source == "license_key":
416
+ when = "Your licence entitles you to Pro; nothing changes while it stays active."
417
+ elif source == "early_adoption_unlock":
418
+ when = (
419
+ "Pro runs without a licence during early adoption. When that ends, every command "
420
+ f"still runs at full output on repositories up to {_FREE_REPO_JAVA_FILE_LIMIT} Java "
421
+ "source files; above that, the heavy-analysis commands ask for Pro. Capability is "
422
+ "never what is gated — size, automation and team are."
423
+ )
424
+ else:
425
+ when = (
426
+ f"Every command runs at full output on repositories up to {_FREE_REPO_JAVA_FILE_LIMIT} "
427
+ "Java source files. Above that, the heavy-analysis commands require Pro."
428
+ )
429
+
430
+ return {
431
+ "entitlement": "pro" if (licensed or _PRO_UNLOCK_ALL) else "free",
432
+ "source": source,
433
+ "authenticated": authenticated,
434
+ "paywall_active": not _PRO_UNLOCK_ALL,
435
+ "when_it_changes": when,
436
+ "free_repo_java_file_limit": _FREE_REPO_JAVA_FILE_LIMIT,
437
+ }
438
+
439
+
440
+ def _init() -> None:
441
+ """Load the credential and derive the entitlement from the one authority.
442
+
443
+ `is_pro` is what every gate reads (`can_use`, `require_feature`), and it is
444
+ computed here from `entitlement()` rather than beside it — a second copy of
445
+ this rule is how `auth status` came to publish a state the commands
446
+ contradicted (P-2).
447
+ """
448
+ global _license_data, is_pro
449
+ _license_data = _load_license_file()
450
+ is_pro = entitlement()["entitlement"] == "pro"
389
451
 
390
452
 
391
453
  _init()
@@ -765,6 +765,15 @@ _BOOT3_MIGRATION_TARGETS: frozenset[str] = frozenset(
765
765
  # reported as a separate hygiene metric, excluded from JDK-modernization scoring.
766
766
  _BEST_PRACTICE_TARGETS: frozenset[str] = frozenset({"java_8_best_practice"})
767
767
 
768
+ #: What a file costs when every finding in it has an OpenRewrite recipe, as a
769
+ #: fraction of the hand-edit rate (C1-11). The recipe does not remove the work —
770
+ #: it turns editing into running one recipe and reviewing the diff.
771
+ #:
772
+ #: This is an **assumption, not a measurement**, and it is published as a field
773
+ #: (`effort_breakdown.mechanical_review_factor`) with that label so a reader can
774
+ #: substitute their own team's number instead of inheriting ours silently.
775
+ _MECHANICAL_REVIEW_FACTOR: float = 0.2
776
+
768
777
  # BUG #3: the migration dimensions that feed the readiness_score aggregate, in
769
778
  # order. jdk_modernization is deliberately NOT here — it is orthogonal upkeep debt
770
779
  # reported on its own axis, never folded into the migration headline.
@@ -1625,12 +1634,45 @@ class MigrationReport:
1625
1634
 
1626
1635
  # BUG #5: effort over MAIN findings only — N/A axes (Hibernate-6 phantom,
1627
1636
  # test fixtures) no longer pad the estimate.
1628
- _file_effort = (
1629
- len(critical_files) * 0.5
1630
- + len(high_files) * 0.25
1631
- + len(medium_files) * 0.1
1632
- + len(low_files) * 0.05
1633
- )
1637
+ #
1638
+ # C1-11 (field eval #4): the estimate used to bill every file at the
1639
+ # hand-edit rate while the report itself shipped an OpenRewrite recipe for
1640
+ # most of the findings in it — on openmrs, 296 of 310 findings carry
1641
+ # `auto_fix_available: true` and all 310 were costed as hand work. A number
1642
+ # that contradicts a field in the same payload is the ADR-0008 defect, so
1643
+ # the estimate now reads that field.
1644
+ #
1645
+ # The unit is the file, because the weights below are per file: a file is
1646
+ # mechanical only when EVERY main finding in it carries a recipe. One
1647
+ # unautomatable finding puts a human in the file, and the rest of that
1648
+ # file's work comes with them.
1649
+ _findings_by_file: "dict[str, list[MigrationFinding]]" = {}
1650
+ for f in main_findings:
1651
+ _findings_by_file.setdefault(f.source_file, []).append(f)
1652
+ _mechanical_files = {
1653
+ path for path, items in _findings_by_file.items()
1654
+ if items and all(bool(i.openrewrite_recipe) for i in items)
1655
+ }
1656
+
1657
+ def _weighted(files: "set[str]") -> float:
1658
+ return (
1659
+ len(critical_files & files) * 0.5
1660
+ + len(high_files & files) * 0.25
1661
+ + len(medium_files & files) * 0.1
1662
+ + len(low_files & files) * 0.05
1663
+ )
1664
+
1665
+ _all_effort_files = critical_files | high_files | medium_files | low_files
1666
+ _manual_effort = _weighted(_all_effort_files - _mechanical_files)
1667
+ # A recipe does not remove the work, it changes what the work is: run the
1668
+ # recipe once, then review the diff. Reviewing is charged at a fraction of
1669
+ # editing. The fraction is an assumption, not a measurement, so it is
1670
+ # published as a field rather than buried in this expression.
1671
+ _mechanical_raw = _weighted(_all_effort_files & _mechanical_files)
1672
+ _mechanical_effort = _mechanical_raw * _MECHANICAL_REVIEW_FACTOR
1673
+ _file_effort = _manual_effort + _mechanical_effort
1674
+ _auto_fixable = sum(1 for f in main_findings if f.openrewrite_recipe)
1675
+ _recipes = sorted({f.openrewrite_recipe for f in main_findings if f.openrewrite_recipe})
1634
1676
  # BUG #1 (v1.70.0): when the Hibernate 5→6 axis APPLIES, fold its measured
1635
1677
  # rewrite effort (risk_matrix → total_effort_range_days) into the headline
1636
1678
  # estimate. Previously a Hibernate-5 project whose ${hibernateVersion} was
@@ -1646,6 +1688,33 @@ class MigrationReport:
1646
1688
  self.estimated_effort_days = round(_file_effort + _hib_effort, 1)
1647
1689
  self.effort_breakdown = {
1648
1690
  "findings_effort_days": round(_file_effort, 1),
1691
+ # C1-11: the same total, split by what the work actually is.
1692
+ "mechanical_days": round(_mechanical_effort, 1),
1693
+ "manual_days": round(_manual_effort, 1),
1694
+ # Not estimated rather than estimated at zero: the re-test scope is a
1695
+ # different measurement (which endpoints the changed files reach), and
1696
+ # `migrate-check --blast-radius` is what produces it. I-3: a fact that
1697
+ # cannot be decided says so.
1698
+ "validation_days": None,
1699
+ "validation_days_note": (
1700
+ "Not estimated here — the re-test scope is the endpoints the changed "
1701
+ "files reach. Run `migrate-check --blast-radius` for that ordering."
1702
+ ),
1703
+ "mechanical_basis": (
1704
+ f"{_auto_fixable} of {len(main_findings)} findings carry an OpenRewrite "
1705
+ f"recipe; {len(_mechanical_files & _all_effort_files)} of "
1706
+ f"{len(_all_effort_files)} affected files have a recipe for *every* "
1707
+ f"finding in them and are costed at {_MECHANICAL_REVIEW_FACTOR:g}× the "
1708
+ "hand-edit rate (run the recipe, review the diff). A file with one "
1709
+ "unautomatable finding is costed manually in full — a human is in that "
1710
+ "file either way."
1711
+ ),
1712
+ "mechanical_review_factor": _MECHANICAL_REVIEW_FACTOR,
1713
+ "mechanical_review_factor_basis": "assumption, not a measurement",
1714
+ "auto_fixable_findings": _auto_fixable,
1715
+ "auto_fixable_files": len(_mechanical_files & _all_effort_files),
1716
+ "affected_files": len(_all_effort_files),
1717
+ "recipes": _recipes,
1649
1718
  "hibernate_rewrite_effort_days": round(_hib_effort, 1),
1650
1719
  "hibernate_rewrite_range": (
1651
1720
  hib.total_effort_range_days if (_hibernate_applies and hib is not None) else None
@@ -1809,7 +1878,20 @@ class MigrationReport:
1809
1878
  f"(critical: {main_crit}, high: {main_high})"
1810
1879
  + (f" [+{nb} in test/generated, non-blocking]" if nb else ""),
1811
1880
  f"Affected files: {self.summary.get('affected_files', 0)}",
1812
- f"Estimated effort: {self.estimated_effort_days}d",
1881
+ f"Estimated effort: {self.estimated_effort_days}d"
1882
+ + (
1883
+ f" ({self.effort_breakdown.get('manual_days', 0)}d manual + "
1884
+ f"{self.effort_breakdown.get('mechanical_days', 0)}d mechanical"
1885
+ + (f" + {self.effort_breakdown.get('hibernate_rewrite_effort_days')}d hibernate"
1886
+ if self.effort_breakdown.get("hibernate_rewrite_effort_days") else "")
1887
+ + "; validation not estimated)"
1888
+ if self.effort_breakdown else ""
1889
+ ),
1890
+ (
1891
+ f" {self.effort_breakdown.get('auto_fixable_findings', 0)} findings carry an "
1892
+ f"OpenRewrite recipe — see effort_breakdown.recipes"
1893
+ if self.effort_breakdown.get("auto_fixable_findings") else ""
1894
+ ),
1813
1895
  "",
1814
1896
  ]
1815
1897
 
sourcecode/verify_edit.py CHANGED
@@ -19,6 +19,7 @@ No public command and no verdict layer yet (those are Fase V1). What it must pro
19
19
  """
20
20
  from __future__ import annotations
21
21
 
22
+ import re
22
23
  import shutil
23
24
  import subprocess
24
25
  import tempfile
@@ -48,6 +49,10 @@ class HeadVsWorking:
48
49
  head_sha: str
49
50
  head_cache_hit: bool
50
51
  timings_ms: dict[str, float] = field(default_factory=dict)
52
+ #: Changed files that are not code but decide what the code compiles to and how
53
+ #: it is wired (C3-14). Kept apart from ``changed_files`` because the CIR diff
54
+ #: cannot reason about them — they make the verdict *unverified*, not *broken*.
55
+ changed_build_files: tuple[str, ...] = ()
51
56
 
52
57
 
53
58
  # Git env vars that REDIRECT git at where it operates. When verify-edit runs INSIDE a
@@ -98,8 +103,8 @@ def _head_sha(root: Path) -> str:
98
103
  return ""
99
104
 
100
105
 
101
- def _changed_java_files(root: Path) -> tuple[str, ...]:
102
- """`.java` paths that differ from HEAD: tracked diff ∪ untracked adds.
106
+ def _changed_paths(root: Path) -> tuple[str, ...]:
107
+ """Every path that differs from HEAD: tracked diff ∪ untracked adds.
103
108
 
104
109
  Deletions are kept (the path is gone but the contract change is real). Pure
105
110
  git; never reads file contents.
@@ -114,7 +119,53 @@ def _changed_java_files(root: Path) -> tuple[str, ...]:
114
119
  files.update(line.strip() for line in ru.stdout.splitlines() if line.strip())
115
120
  except Exception:
116
121
  pass
117
- return tuple(sorted(f for f in files if f.endswith(".java")))
122
+ return tuple(sorted(files))
123
+
124
+
125
+ def _changed_java_files(root: Path) -> tuple[str, ...]:
126
+ """The `.java` subset of `_changed_paths` — what the CIR diff can reason about."""
127
+ return tuple(f for f in _changed_paths(root) if f.endswith(".java"))
128
+
129
+
130
+ #: Files that change what the code *means* without being code: the build, the
131
+ #: container contract, the Spring wiring, the configuration a profile resolves.
132
+ #:
133
+ #: C3-14 (field eval #4): an edit to any of these was invisible to `verify-edit`,
134
+ #: which answered `changed_files: []` and `verdict: pass` — a confident green over
135
+ #: a change that can move every source file in the repository at once. Matched on
136
+ #: the file name, never on content, so the check costs nothing.
137
+ _BUILD_FILE_NAMES: frozenset[str] = frozenset({
138
+ "pom.xml", "build.gradle", "build.gradle.kts",
139
+ "settings.gradle", "settings.gradle.kts", "gradle.properties",
140
+ "web.xml", "context.xml", "jboss-web.xml", "beans.xml", "persistence.xml",
141
+ })
142
+ #: Descriptor families matched by prefix + `.xml`: the container contract and the
143
+ #: classic Spring XML wiring.
144
+ _XML_DESCRIPTOR_PREFIXES: tuple[str, ...] = ("weblogic", "jboss-", "applicationcontext")
145
+ #: Config file families that a profile resolves against. Deliberately narrow —
146
+ #: `messages.properties` is text for humans, not wiring.
147
+ _CONFIG_PREFIXES: tuple[str, ...] = ("application", "bootstrap")
148
+ _CONFIG_SUFFIXES: tuple[str, ...] = (".properties", ".yml", ".yaml")
149
+
150
+
151
+ def _is_build_config_path(rel_path: str) -> bool:
152
+ """Admission rule, kept narrow on purpose: every admitted file degrades a verdict
153
+ to unverified, and a loose rule turns the gate into noise people learn to ignore."""
154
+ name = rel_path.rsplit("/", 1)[-1].lower()
155
+ if name in _BUILD_FILE_NAMES:
156
+ return True
157
+ if name.endswith(".xml") and (
158
+ name.endswith("-context.xml") or name.startswith(_XML_DESCRIPTOR_PREFIXES)
159
+ ):
160
+ return True
161
+ if name.endswith(_CONFIG_SUFFIXES) and name.startswith(_CONFIG_PREFIXES):
162
+ return True
163
+ return False
164
+
165
+
166
+ def _changed_build_files(root: Path) -> tuple[str, ...]:
167
+ """Changed non-Java files that can alter compilation or runtime wiring."""
168
+ return tuple(f for f in _changed_paths(root) if _is_build_config_path(f))
118
169
 
119
170
 
120
171
  # ── CIR construction ───────────────────────────────────────────────────────────
@@ -213,7 +264,9 @@ def head_vs_working(path: Path) -> HeadVsWorking:
213
264
  timings: dict[str, float] = {}
214
265
 
215
266
  t0 = time.perf_counter()
216
- changed = _changed_java_files(root)
267
+ all_changed = _changed_paths(root)
268
+ changed = tuple(f for f in all_changed if f.endswith(".java"))
269
+ changed_build = tuple(f for f in all_changed if _is_build_config_path(f))
217
270
  timings["changed_files_ms"] = (time.perf_counter() - t0) * 1000.0
218
271
 
219
272
  t1 = time.perf_counter()
@@ -231,6 +284,7 @@ def head_vs_working(path: Path) -> HeadVsWorking:
231
284
  head_sha=head_sha,
232
285
  head_cache_hit=hit,
233
286
  timings_ms=timings,
287
+ changed_build_files=changed_build,
234
288
  )
235
289
 
236
290
 
@@ -252,7 +306,7 @@ VERDICT_SCHEMA = "verify-edit-v1"
252
306
  # no-op (clean) when no rules file exists, so adding it never blocks a repo that has none.
253
307
  _BLOCKING_AXES = (
254
308
  "contract_broken", "tx_boundary_changed", "security_delta", "new_orphan_bean",
255
- "custom_rule_violated",
309
+ "custom_rule_violated", "build_config_changed",
256
310
  )
257
311
 
258
312
 
@@ -264,6 +318,7 @@ class AxisResult:
264
318
  changed: Optional[bool]
265
319
  detail: str = ""
266
320
  items: tuple[dict, ...] = ()
321
+ data: dict = field(default_factory=dict)
267
322
 
268
323
  def to_dict(self) -> dict:
269
324
  d: dict = {"changed": self.changed}
@@ -271,6 +326,7 @@ class AxisResult:
271
326
  d["detail"] = self.detail
272
327
  if self.items:
273
328
  d["items"] = list(self.items)
329
+ d.update(self.data)
274
330
  return d
275
331
 
276
332
 
@@ -279,6 +335,7 @@ class VerifyVerdict:
279
335
  verdict: str # "pass" | "break" | "unverified"
280
336
  axes: dict[str, AxisResult]
281
337
  changed_files: tuple[str, ...]
338
+ changed_build_files: tuple[str, ...]
282
339
  blast_radius: dict
283
340
  reasons: tuple[str, ...]
284
341
  exit_code: int
@@ -290,6 +347,7 @@ class VerifyVerdict:
290
347
  "verdict": self.verdict,
291
348
  "head_sha": self.head_sha,
292
349
  "changed_files": list(self.changed_files),
350
+ "changed_build_files": list(self.changed_build_files),
293
351
  "axes": {name: ax.to_dict() for name, ax in self.axes.items()},
294
352
  "blast_radius": self.blast_radius,
295
353
  "reasons": list(self.reasons),
@@ -451,9 +509,132 @@ def _custom_rules_axis(cir_head: Any, cir_working: Any, root: Path) -> AxisResul
451
509
  return AxisResult(False)
452
510
 
453
511
 
512
+ # ── build configuration (C3-14) ────────────────────────────────────────────────
513
+ _COMPILER_PROPS: tuple[str, ...] = (
514
+ "java.version", "maven.compiler.release", "maven.compiler.source",
515
+ "maven.compiler.target",
516
+ )
517
+ _MAVEN_DEP_RE: "re.Pattern[str]" = re.compile(
518
+ r"<dependency>(.*?)</dependency>", re.DOTALL | re.IGNORECASE
519
+ )
520
+ _MAVEN_FIELD_RE: "re.Pattern[str]" = re.compile(
521
+ r"<(groupId|artifactId|version)>\s*([^<]+?)\s*</\1>", re.IGNORECASE
522
+ )
523
+
524
+
525
+ def _file_at_head(root: Path, rel_path: str) -> Optional[str]:
526
+ """The HEAD content of a path, or None when it did not exist there."""
527
+ try:
528
+ r = _run_git(root, "show", f"HEAD:{rel_path}")
529
+ return r.stdout if r.returncode == 0 else None
530
+ except Exception:
531
+ return None
532
+
533
+
534
+ def _maven_property(text: str, name: str) -> Optional[str]:
535
+ m = re.search(rf"<{re.escape(name)}>\s*([^<]+?)\s*</{re.escape(name)}>", text, re.IGNORECASE)
536
+ return m.group(1) if m else None
537
+
538
+
539
+ def _maven_dependencies(text: str) -> "dict[str, str]":
540
+ """`groupId:artifactId` → version (or "" when managed elsewhere). A diff input,
541
+ not an inventory: this answers *what changed in this file*, which is why it does
542
+ not go through the declared-coordinates authority (that one answers a different
543
+ question — which client libraries the build declares, allowlisted by kind)."""
544
+ out: dict[str, str] = {}
545
+ for block in _MAVEN_DEP_RE.findall(text or ""):
546
+ fields = {k.lower(): v for k, v in _MAVEN_FIELD_RE.findall(block)}
547
+ gid, aid = fields.get("groupid"), fields.get("artifactid")
548
+ if gid and aid:
549
+ out[f"{gid}:{aid}"] = fields.get("version", "")
550
+ return out
551
+
552
+
553
+ def _build_config_axis(root: Path, changed_build_files: tuple[str, ...]) -> AxisResult:
554
+ """C3-14: a changed build/config file is a change `verify-edit` cannot decide.
555
+
556
+ `changed: None` on purpose. The CIR diff proves nothing about a `pom.xml` edit —
557
+ it is neither a proven regression (`True`) nor computed-clean (`False`) — so the
558
+ axis degrades to *unverified* and the verdict follows the machinery that already
559
+ exists for "could not compute". Answering `pass` was the defect: a compiler-release
560
+ bump moves every source file in the repository at once, and the gate said nothing
561
+ changed.
562
+ """
563
+ if not changed_build_files:
564
+ return AxisResult(False, "no build or configuration file changed")
565
+
566
+ compiler: dict = {}
567
+ dependency_delta: list[dict] = []
568
+ for rel in changed_build_files:
569
+ if not rel.endswith("pom.xml"):
570
+ continue
571
+ before = _file_at_head(root, rel)
572
+ try:
573
+ after = (root / rel).read_text(encoding="utf-8", errors="replace")
574
+ except OSError:
575
+ after = None
576
+ if before is None or after is None:
577
+ continue
578
+ for prop in _COMPILER_PROPS:
579
+ was, now = _maven_property(before, prop), _maven_property(after, prop)
580
+ if was != now:
581
+ compiler[prop] = {"from": was, "to": now, "file": rel}
582
+ deps_before, deps_after = _maven_dependencies(before), _maven_dependencies(after)
583
+ for key in sorted(set(deps_before) | set(deps_after)):
584
+ if deps_before.get(key) == deps_after.get(key):
585
+ continue
586
+ dependency_delta.append({
587
+ "coordinate": key,
588
+ "from": deps_before.get(key),
589
+ "to": deps_after.get(key),
590
+ "file": rel,
591
+ })
592
+
593
+ detail = f"{len(changed_build_files)} build/configuration file(s) changed; "
594
+ if compiler:
595
+ first = next(iter(compiler.values()))
596
+ detail += f"compiler release {first['from']} → {first['to']}; "
597
+ if dependency_delta:
598
+ detail += f"{len(dependency_delta)} dependency change(s); "
599
+ detail += "effect on runtime behaviour is not decidable from the source diff"
600
+
601
+ return AxisResult(
602
+ None,
603
+ detail,
604
+ items=tuple({"file": f} for f in changed_build_files[:20]),
605
+ data={
606
+ "files": list(changed_build_files),
607
+ "effective_compiler_release": compiler or None,
608
+ "dependency_delta": dependency_delta or None,
609
+ "blast_note": (
610
+ "A build or configuration change applies to the whole compilation "
611
+ "unit, not to a file: its reach is every source file in scope, and "
612
+ "`verify-edit` does not model it. Verdict degrades to unverified "
613
+ "(exit 2) rather than passing."
614
+ ),
615
+ },
616
+ )
617
+
618
+
454
619
  # ── blast radius (reach over the changed files; informational, not a diff) ──────
455
- def _blast_radius(cir_working: Any, changed_files: tuple[str, ...]) -> dict:
620
+ def _blast_radius(
621
+ cir_working: Any,
622
+ changed_files: tuple[str, ...],
623
+ changed_build_files: tuple[str, ...] = (),
624
+ ) -> dict:
456
625
  if not changed_files:
626
+ # C3-14: "no changed files" with high confidence was a lie when the build
627
+ # changed — the reach of that edit is the whole tree, and it is unmeasured.
628
+ if changed_build_files:
629
+ return {
630
+ "affected_entry_points": None,
631
+ "confidence": "unknown",
632
+ "note": (
633
+ f"no changed .java files, but {len(changed_build_files)} build/"
634
+ "configuration file(s) changed — reach is the whole compilation "
635
+ "unit and is not modelled here"
636
+ ),
637
+ }
457
638
  return {"affected_entry_points": 0, "confidence": "high", "note": "no changed files"}
458
639
  try:
459
640
  from sourcecode.context_graph import ContextGraph
@@ -487,8 +668,9 @@ def verdict(hvw: HeadVsWorking, root: Path) -> VerifyVerdict:
487
668
  "security_delta": _security_axis(hvw.cir_head, hvw.cir_working, root),
488
669
  "new_orphan_bean": _orphan_bean_axis(hvw.cir_head, hvw.cir_working),
489
670
  "custom_rule_violated": _custom_rules_axis(hvw.cir_head, hvw.cir_working, root),
671
+ "build_config_changed": _build_config_axis(root, hvw.changed_build_files),
490
672
  }
491
- blast = _blast_radius(hvw.cir_working, hvw.changed_files)
673
+ blast = _blast_radius(hvw.cir_working, hvw.changed_files, hvw.changed_build_files)
492
674
 
493
675
  reasons: list[str] = []
494
676
  broke = False
@@ -513,6 +695,7 @@ def verdict(hvw: HeadVsWorking, root: Path) -> VerifyVerdict:
513
695
  verdict=verdict_str,
514
696
  axes=axes,
515
697
  changed_files=hvw.changed_files,
698
+ changed_build_files=hvw.changed_build_files,
516
699
  blast_radius=blast,
517
700
  reasons=tuple(reasons),
518
701
  exit_code=code,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 3.2.2
3
+ Version: 3.4.0
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -283,10 +283,13 @@ Honest limits worth knowing before you rely on it:
283
283
  > full Pro entitlements — no size gate, no key. The tiers below describe the model the
284
284
  > paywall will return to later.
285
285
  >
286
- > **What that means concretely.** `ask auth status` reports `"status": "unauthenticated"`
287
- > together with `"pro": true`, `"pro_reason": "early-adoption unlock"` that combination is
288
- > expected, not a bug: you are unauthenticated *and* unlocked. When the unlock ends, gating
289
- > returns **by repo size and automation, never by command**: `posture`, `endpoints`,
286
+ > **What that means concretely.** Ask the product: `ask auth status` answers in one block —
287
+ > `entitlement` (what runs today), `source` (*why* — a licence, the unlock, or the free tier),
288
+ > `authenticated` (whether a credential exists, which is a separate fact) and
289
+ > `when_it_changes` (what you lose when that source stops applying). A fresh install reads
290
+ > `entitlement: pro`, `source: early_adoption_unlock`, `authenticated: false` — unauthenticated
291
+ > *and* entitled, stated as two facts instead of one contradiction. When the unlock ends,
292
+ > gating returns **by repo size and automation, never by command**: `posture`, `endpoints`,
290
293
  > `spring-audit` and `migrate-check` stay in the base tier at full output. Nothing you can run
291
294
  > today becomes a paid-only command tomorrow.
292
295
 
@@ -1,4 +1,4 @@
1
- sourcecode/__init__.py,sha256=U82Z_Ox36oAqoNY_hG72f5nWmFp6G5hZFIJetg0zXsI,308
1
+ sourcecode/__init__.py,sha256=BJYzzs2-t47FGMhqR8simvGdbEj3lwxxqRiN1D-DvaM,308
2
2
  sourcecode/adaptive_scanner.py,sha256=yJBKjNpkY6bpueYJ2YnRezen3sYZDecEt7WaaNWdqug,9466
3
3
  sourcecode/archetype.py,sha256=qWMCyL2UzSGXJhCIZj38JjS7wI15EE3eILsWiaoCZXQ,35779
4
4
  sourcecode/architectural_baseline.py,sha256=7QzJri4pbL3nzAn9gNutZW6mZR8HHD6H2C2H1EEwYPE,17904
@@ -15,7 +15,7 @@ sourcecode/chain_rules.py,sha256=Bi6UHfgd-GxWswmnHRcPz5jdbAuqka3Zkz_P-MTvqhw,127
15
15
  sourcecode/change_plan.py,sha256=MNgNyu4zrLGvBBaXPwyCh-T2ZGaUQX3Hm4W87uuhZ3w,7750
16
16
  sourcecode/cir_graphs.py,sha256=9G0HHj1kw2325IDyzo2OpX73BNswEckecf4MZUXB4JM,12078
17
17
  sourcecode/classifier.py,sha256=JBzPwSSrDG-tUHAbcKB678HRbjLpD-ohzbzzO62mgpo,20114
18
- sourcecode/cli.py,sha256=kXMCuDtQtL5Px8cHHsSPwgfB9S31mxoGJk84nOQ3vHA,411359
18
+ sourcecode/cli.py,sha256=roM8n8UVzNYbDEhDHYB8DqzKXmGr8U1yWuwrTUhex-U,412075
19
19
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
20
20
  sourcecode/compare.py,sha256=jdePg0dNCxFpysiTGMsROL5XABLjy_zVUgVeySvdb0o,7514
21
21
  sourcecode/confidence_analyzer.py,sha256=_nvtwO6RM3k0JMuH5m7DsBxOD0PdxGFS4Bb1fTQ536E,21946
@@ -51,10 +51,10 @@ sourcecode/graph_evidence.py,sha256=rENNsYRZeNstX_ExNCLlbHJAruFQwxo5d00x6wO3xwI,
51
51
  sourcecode/hibernate_strat.py,sha256=5GmHiB865HxKdvaxeFV07ojglUgxyOP41AY2xiHfV30,61700
52
52
  sourcecode/integration_coordinates.py,sha256=7vGFxN4tCn_KTisYFJuixa8XkyHBoasEFB3fqM62OBI,7407
53
53
  sourcecode/jdk_exports.py,sha256=fCrlwNAXUT9gge_joq6kMnY3zJxYB2pxqy-0w3o3MJI,874
54
- sourcecode/license.py,sha256=OwLh4x1gU_iGL69zFKAgsnwHO2yx9jBsCOK_WK7CUUw,24907
54
+ sourcecode/license.py,sha256=vvgE1vBUWh8ozWI7USYuteh6noxntnjuzDQIPF-l4rA,27795
55
55
  sourcecode/mcp_nudge.py,sha256=lKemOqK_wny2u7Ymcr2Idi5Kx8pXY02jCi-_nJYLGMg,2992
56
56
  sourcecode/metrics_analyzer.py,sha256=gLoRWKygF18jLgwsqmGXSWopw-f5iO1VbM7Jjdif6ag,22809
57
- sourcecode/migrate_check.py,sha256=TJvaShbglENOr8ikvCodnlXQy54TtT05N2ZVe02b3lc,110421
57
+ sourcecode/migrate_check.py,sha256=bI2ZseCM5xlrXMvZTKtpPcfkK5EbUGHHhYNUo5l2voc,115427
58
58
  sourcecode/migration_blast.py,sha256=5qUsUi4n0cwBKltdJYyvyfhBkGENAYjB-d_7LZtnVks,9282
59
59
  sourcecode/openapi_surface.py,sha256=BTt0K-woZbkbWTN77IkqeBm_Okag9owR0848fmot8sk,16207
60
60
  sourcecode/output_budget.py,sha256=Js9yUlfQtPhqBl9R6wn_9UHVjjJc3GtLcqyfjf5t50Q,9869
@@ -102,7 +102,7 @@ sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
102
102
  sourcecode/type_usage_surface.py,sha256=51IrKRQoIoRnlsiDjHnqpJBn2rc6E59aRhgS0HTzAF0,4428
103
103
  sourcecode/validation_inference.py,sha256=yd2TSuh-hhdF0vdBB63ffkbPn1vEUp896K2tAKkNogM,19508
104
104
  sourcecode/validation_surface.py,sha256=jYL-hkDjaaRKkAt7ZUcbxm3Dydl8o6KVRhTkuPXKPVc,31460
105
- sourcecode/verify_edit.py,sha256=LFk5CRste11GwNUTUrTGxoQpmQhy4Qoqd7NdhGq1weA,26190
105
+ sourcecode/verify_edit.py,sha256=ZakJc7Bnh7e6dX3XtS3uDWaButLMCQ22MTkPP4wYyeg,34523
106
106
  sourcecode/verify_repo.py,sha256=XpyofGahvs9y7uJEy_axqEQdXpmR41WsWw7GJqs8vQ4,9440
107
107
  sourcecode/verify_rules.py,sha256=vX0YADEI78J8-tjRTtRhe2pZP0x7K_qvS1EypuqD3KQ,18011
108
108
  sourcecode/version_check.py,sha256=CHp6ZxTIfo8kyHPCBgJA1uFC0xQCoXMuuOfrW8QTL8o,4942
@@ -165,8 +165,8 @@ sourcecode/telemetry/consent.py,sha256=pQdl-QeLl6Gcibn0eWHSKZrm-HYSsjpVqOnjrgFp8
165
165
  sourcecode/telemetry/events.py,sha256=4_yeO58U-Cwc1Qb27VB0_EjhmroY0k91n3_VGxeALB8,2776
166
166
  sourcecode/telemetry/filters.py,sha256=RzxauTz8HliO4BllQnXEXc7zTeqdCZi5MgqGEDuW7OQ,6570
167
167
  sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
168
- sourcecode-3.2.2.dist-info/METADATA,sha256=jVS3W1ZhWUoj34fE8GVEeg4w8CmEPhtAxCpuJ-qIEV4,19406
169
- sourcecode-3.2.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
170
- sourcecode-3.2.2.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
171
- sourcecode-3.2.2.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
172
- sourcecode-3.2.2.dist-info/RECORD,,
168
+ sourcecode-3.4.0.dist-info/METADATA,sha256=Jya4xGSyUpcnOk7Oqip8LL0NO-6LzNxaJXTWKq8Hq4A,19684
169
+ sourcecode-3.4.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
170
+ sourcecode-3.4.0.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
171
+ sourcecode-3.4.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
172
+ sourcecode-3.4.0.dist-info/RECORD,,