sourcecode 2.6.2__tar.gz → 2.6.5__tar.gz
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-2.6.2 → sourcecode-2.6.5}/CHANGELOG.md +118 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/PKG-INFO +1 -1
- {sourcecode-2.6.2 → sourcecode-2.6.5}/pyproject.toml +1 -1
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/__init__.py +1 -1
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/cli.py +152 -3
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/mcp/registry.py +49 -1
- sourcecode-2.6.5/src/sourcecode/parse_cache.py +138 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/repository_ir.py +61 -12
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/spring_impact.py +1 -1
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/spring_tx_analyzer.py +141 -4
- sourcecode-2.6.5/src/sourcecode/verify_edit.py +588 -0
- sourcecode-2.6.5/src/sourcecode/verify_rules.py +304 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/.github/workflows/build-windows.yml +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/.gitignore +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/.ruff.toml +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/CONTRIBUTING.md +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/LICENSE +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/README.md +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/SECURITY.md +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/raw +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/scripts/compare_integration_engines.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/scripts/customer_smoke_test.sh +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/scripts/generate_jdk_exports.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/scripts/perf_harness.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/adaptive_scanner.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/archetype.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/architectural_baseline.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/architectural_delta.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/architecture_analyzer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/architecture_summary.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/ast_extractor.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/cache.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/call_surface.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/caller_metrics.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/canonical_ir.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/change_plan.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/cir_graphs.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/classifier.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/code_notes_analyzer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/compare.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/confidence_analyzer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/constraint_diff.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/context_cache.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/context_graph.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/context_scorer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/context_summarizer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/contract_diff.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/contract_model.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/contract_pipeline.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/coverage_parser.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/dependency_analyzer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/__init__.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/base.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/csproj_parser.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/dart.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/dotnet.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/elixir.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/go.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/heuristic.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/hybrid.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/java.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/jvm_ext.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/nodejs.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/parsers.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/php.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/project.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/python.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/ruby.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/rust.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/systems.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/terraform.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/detectors/tooling.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/doc_analyzer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/dynamic_argument_surface.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/endpoint_literals.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/endpoint_metrics.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/entrypoint_classifier.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/env_analyzer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/error_schema.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/evidence_provider.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/explain.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/file_chunker.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/file_classifier.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/filter_surface.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/format_contract.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/fqn_utils.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/git_analyzer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/graph_analyzer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/graph_evidence.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/hibernate_strat.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/jdk_exports.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/license.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/mcp/__init__.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/mcp/onboarding/applier.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/mcp/onboarding/backup.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/mcp/onboarding/detector.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/mcp/onboarding/planner.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/mcp/orchestrator.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/mcp/runner.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/mcp/server.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/mcp_nudge.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/metrics_analyzer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/migrate_check.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/openapi_surface.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/output_budget.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/path_filters.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/perf.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/pr_comment_renderer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/pr_impact.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/prepare_context.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/progress.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/ranking_engine.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/reconciliation.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/redactor.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/relevance_scorer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/rename_refactor.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/repo_classifier.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/__init__.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/context.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/errors.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/executor.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/planner.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/query.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/request.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/resolution.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/result.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/retriever.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/runtime.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/steps.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/steps_endpoint.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/steps_graph.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/steps_impact.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/steps_intf.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/steps_struct.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/retrieval/steps_txsec.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/ris.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/runtime_classifier.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/scanner.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/schema.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/security_config.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/security_posture.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/semantic_analyzer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/semantic_impact_engine.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/semantic_integration_engine.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/semantic_services.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/serializer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/spring_event_topology.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/spring_findings.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/spring_model.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/spring_security_audit.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/spring_semantic.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/summarizer.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/telemetry/__init__.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/telemetry/config.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/telemetry/consent.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/telemetry/events.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/telemetry/filters.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/telemetry/transport.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/tree_utils.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/type_usage_surface.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/validation_inference.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/validation_surface.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/version_check.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/src/sourcecode/workspace.py +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/supabase/functions/README.md +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/supabase/functions/get-license/index.ts +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/supabase/functions/lemonsqueezy-webhook/index.ts +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/supabase/functions/telemetry/index.ts +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/supabase/sql/license_event_ordering.sql +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/supabase/sql/licensing_schema.sql +0 -0
- {sourcecode-2.6.2 → sourcecode-2.6.5}/supabase/sql/telemetry_events.sql +0 -0
|
@@ -1,5 +1,123 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.6.5] — 2026-07-24
|
|
4
|
+
|
|
5
|
+
**Mistyped-subcommand ergonomics: `ask` no longer swallows a typo'd command as a path
|
|
6
|
+
and then blames the wrong token.** CLI-only; no analysis behaviour changes.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **A mistyped command now names the token the user got wrong, and suggests the real
|
|
11
|
+
command.** `ask` accepts a bare repository path (`ask ./repo`) as an implicit scan, so
|
|
12
|
+
a misspelled subcommand used to be swallowed as a path and surface a misleading error
|
|
13
|
+
about the *next* token — `ask notacommand /tmp` complained `No such command '/tmp'` (the
|
|
14
|
+
path), and `ask spring-audi` complained the directory did not exist. Now:
|
|
15
|
+
- a first positional followed by another non-subcommand positional
|
|
16
|
+
(`ask notacommand /tmp`) is rejected as an unknown command (exit `2`), naming the
|
|
17
|
+
first token and — when it is close to a real command — suggesting it;
|
|
18
|
+
- a lone token that does not resolve to a directory but closely matches a command
|
|
19
|
+
(`ask spring-audi`) adds a *did you mean `ask spring-audit`* hint to the path error.
|
|
20
|
+
|
|
21
|
+
The implicit-scan contract is unchanged: a single positional is still treated as a
|
|
22
|
+
repository path with existence validated downstream, and the documented
|
|
23
|
+
`ask <subcommand> <path>` order (and the `ask <path> <subcommand>` rejection) are
|
|
24
|
+
untouched.
|
|
25
|
+
|
|
26
|
+
## [2.6.4] — 2026-07-24
|
|
27
|
+
|
|
28
|
+
**TX-006: self-invocation proxy-bypass detection — a new Spring transaction rule that
|
|
29
|
+
also participates automatically in the `verify-edit` write-loop gate.** Plus a root-cause
|
|
30
|
+
false-positive fix in body-fact extraction and one internal-id output leak removed. No
|
|
31
|
+
change to any existing command's contract. Suite: **3742 passed, 5 skipped, 1 deselected**
|
|
32
|
+
(the deselected chunker test is a pre-existing external-fixture flake).
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **TX-006 — self-invocation of a `@Transactional` sibling (Spring proxy bypass).** A
|
|
37
|
+
same-instance call — an unqualified `foo()` or an explicit `this.foo()` to a method of
|
|
38
|
+
the same class — does not pass through the Spring proxy, so the target's method-level
|
|
39
|
+
`@Transactional` is silently ignored at runtime (the Sonar S2229 class of bug). Detection
|
|
40
|
+
is structural and vendor-agnostic, reading the Semantic-IR `invocation` atoms already
|
|
41
|
+
produced by the pipeline: the receiver is `None`/`this` and the call resolves to an
|
|
42
|
+
unambiguous same-class sibling that carries a method-level `@Transactional`. False-positive
|
|
43
|
+
guards are built in — overloaded/ambiguous targets are skipped, class-level-only
|
|
44
|
+
`@Transactional` is not flagged (the proxy already covers the outer entry method), and the
|
|
45
|
+
legitimate self-injection escape hatch (`self.foo()` through an injected same-type field)
|
|
46
|
+
uses a field receiver and never resolves as a sibling. Severity is tiered: `high` only when
|
|
47
|
+
the callee propagation mandates a distinct boundary (`REQUIRES_NEW`/`NESTED`/`NOT_SUPPORTED`/
|
|
48
|
+
`NEVER`/`MANDATORY`); a plain `REQUIRED`/`SUPPORTS` callee or a `readOnly` getter is `medium`.
|
|
49
|
+
Surfaced by `ask spring-audit` and, with no additional wiring, by the `verify-edit`
|
|
50
|
+
`tx_boundary_changed` axis — an edit that *introduces* a self-invocation now breaks the
|
|
51
|
+
write-loop verdict before commit, while a pre-existing one never fails an unrelated change.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- **Chained-call receivers no longer read as self-calls (body-fact extraction).** A
|
|
56
|
+
method-chained invocation such as `Context.getBean().method()` left the receiver empty
|
|
57
|
+
because the receiver is an expression, not a bare identifier — causing it to be mistaken
|
|
58
|
+
for an unqualified self-call. A callee immediately preceded by `.` is now marked as a
|
|
59
|
+
non-self expression receiver. This is a general sibling-resolution correctness fix
|
|
60
|
+
(it also prevents a chained sibling-named call from being mis-resolved to a call edge);
|
|
61
|
+
`body_facts` is not a `cir_hash` input, so the fix is hash-neutral. It removed roughly
|
|
62
|
+
half of the raw TX-006 candidates on a real Spring codebase — all of them true bypass
|
|
63
|
+
false-positives, including a service that correctly routes `REQUIRES_NEW` through a
|
|
64
|
+
service-locator proxy.
|
|
65
|
+
- **Internal bug id removed from user-facing output.** The self-referential-exclusion
|
|
66
|
+
warning in `impact` no longer prints the internal `(BUG #2)` tag; the explanation stays.
|
|
67
|
+
|
|
68
|
+
## [2.6.3] — 2026-07-24
|
|
69
|
+
|
|
70
|
+
**`ask verify-edit` — the in-loop semantic diff gate lands (the read-context → write-loop
|
|
71
|
+
verification pivot), plus a content-addressed parse cache and one field-test correctness
|
|
72
|
+
fix.** New user-facing capability; no change to any existing command's contract. Suite:
|
|
73
|
+
**3731 passed, 5 skipped, 1 deselected** (the deselected chunker test is a pre-existing
|
|
74
|
+
external-fixture flake).
|
|
75
|
+
|
|
76
|
+
### Added
|
|
77
|
+
|
|
78
|
+
- **`ask verify-edit` — a deterministic, in-loop semantic diff gate.** Verdict over the
|
|
79
|
+
diff of the working tree against HEAD, built for the agent write-loop and CI. Each axis
|
|
80
|
+
is a diff over two Canonical IRs (working vs a throwaway `git worktree` checkout of
|
|
81
|
+
HEAD), reporting only regressions the edit **introduced** — pre-existing debt never
|
|
82
|
+
fails an unrelated commit:
|
|
83
|
+
- `contract_broken` — a public symbol removed, its visibility downgraded, or (when
|
|
84
|
+
unambiguous) its signature changed.
|
|
85
|
+
- `tx_boundary_changed` — a `@Transactional` proxy boundary broken (e.g. a
|
|
86
|
+
`@Transactional` method flipped `public` → `private` → a silently inert CGLIB proxy).
|
|
87
|
+
- `security_delta` — a security-posture finding the edit introduced.
|
|
88
|
+
- `new_orphan_bean` — a bean that lost all its injection wiring.
|
|
89
|
+
- `custom_rule_violated` — the client's own invariants, declared in
|
|
90
|
+
`.ask/verify-rules.json` (below).
|
|
91
|
+
- plus an informational `blast_radius` (entry points reachable from the changed files).
|
|
92
|
+
|
|
93
|
+
Exit codes under `--ci` (default): `0` pass, `1` break, `2` unverified (a blocking axis
|
|
94
|
+
could not be computed — never a silent pass). JSON verdict to stdout, prose to stderr.
|
|
95
|
+
- **Custom domain rules (`.ask/verify-rules.json`).** Declare invariants of *your*
|
|
96
|
+
codebase and have `verify-edit` enforce them — `forbidden_edge` ("no `@RestController`
|
|
97
|
+
injects a `*DaoJpa`") and `require_endpoint_security` ("every `/payroll` endpoint
|
|
98
|
+
requires role X"). Selectors reason by structure (annotation / fqn-regex / kind /
|
|
99
|
+
modifier / source-file); the concrete names are config data, never branched on in the
|
|
100
|
+
engine. Zero-config: no rules file ⇒ nothing to enforce; a malformed ruleset degrades
|
|
101
|
+
to *unverified*, never a silent pass.
|
|
102
|
+
- **MCP tool `verify_edit`** (report-only) so an agent can call the gate after each edit,
|
|
103
|
+
and **`ask verify-edit --install-hook pre-commit|pre-push`** to install a git hook that
|
|
104
|
+
runs the same binary and blocks on a break (bypass with `git --no-verify`).
|
|
105
|
+
- **Content-addressed per-file parse cache.** Symbol extraction and the body-fact bundle
|
|
106
|
+
of an unchanged file are pure functions of its bytes, so they are memoised by a content
|
|
107
|
+
hash — a 1-file edit re-parses 1 file, not the whole repo. It **never changes the
|
|
108
|
+
result** (a miss just re-parses; the CIR is byte-identical whether the cache is cold,
|
|
109
|
+
warm, or disabled) and it accelerates every CIR consumer, not only `verify-edit`.
|
|
110
|
+
Measured on a 1076-file repo: the working-CIR build drops ~59% warm.
|
|
111
|
+
|
|
112
|
+
### Fixed
|
|
113
|
+
|
|
114
|
+
- **`verify-edit` contract axis no longer false-breaks on overloaded methods** (found in
|
|
115
|
+
a field-test of a real repo with a clean tree). An fqn carries no signature, so
|
|
116
|
+
overloaded methods/constructors share one id across several graph nodes; the axis now
|
|
117
|
+
aggregates visibility per fqn as *any overload public* instead of picking an
|
|
118
|
+
order-dependent representative, so a public name that also has a non-public overload no
|
|
119
|
+
longer reads as a downgrade on an unchanged tree.
|
|
120
|
+
|
|
3
121
|
## [2.6.2] — 2026-07-23
|
|
4
122
|
|
|
5
123
|
**Field-eval Fase 3 — cache reuse (C) + cold-scan honesty (E). No
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
|
+
import difflib
|
|
5
6
|
import os
|
|
6
7
|
import sys
|
|
7
8
|
import threading
|
|
@@ -254,6 +255,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
|
|
|
254
255
|
"explain",
|
|
255
256
|
# Spring Boot 2→3 migration readiness
|
|
256
257
|
"migrate-check",
|
|
258
|
+
# In-loop semantic diff gate (working tree vs HEAD)
|
|
259
|
+
"verify-edit",
|
|
257
260
|
# Native file rename (BLOCKER-A)
|
|
258
261
|
"rename-class",
|
|
259
262
|
# Large file semantic chunking (BLOCKER-B)
|
|
@@ -329,6 +332,54 @@ def _reject_path_before_subcommand(path_token: str, subcommand: str) -> "NoRetur
|
|
|
329
332
|
raise SystemExit(2)
|
|
330
333
|
|
|
331
334
|
|
|
335
|
+
def _reject_unknown_command(token: str) -> "NoReturn":
|
|
336
|
+
"""Refuse a first positional that is neither a known subcommand nor a real path.
|
|
337
|
+
|
|
338
|
+
`ask` accepts a bare repository path (`ask ./repo`) as an implicit scan, so a
|
|
339
|
+
mistyped subcommand used to be swallowed as a path and then surfaced a
|
|
340
|
+
misleading error naming the *wrong* token — `ask spring-audi` complained the
|
|
341
|
+
directory 'spring-audi' did not exist, and `ask notacommand /tmp` complained
|
|
342
|
+
'No such command /tmp' (the path, not the typo). Name the token the user
|
|
343
|
+
actually got wrong, and suggest the closest real commands.
|
|
344
|
+
"""
|
|
345
|
+
matches = difflib.get_close_matches(token, sorted(_SUBCOMMANDS), n=3, cutoff=0.6)
|
|
346
|
+
first = f"error: '{token}' is not a known command"
|
|
347
|
+
if matches:
|
|
348
|
+
first += f". Did you mean: {', '.join(matches)}?"
|
|
349
|
+
else:
|
|
350
|
+
first += " and is not an existing directory to scan."
|
|
351
|
+
print(
|
|
352
|
+
first + "\n run 'ask --help' to list commands, or pass an existing "
|
|
353
|
+
"repository path.",
|
|
354
|
+
file=sys.stderr,
|
|
355
|
+
)
|
|
356
|
+
raise SystemExit(2)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _has_trailing_nonsubcommand_positional(args: list[str], after: int) -> bool:
|
|
360
|
+
"""True when a bare positional that is NOT a subcommand appears after index
|
|
361
|
+
``after`` (options and the values they consume are skipped).
|
|
362
|
+
|
|
363
|
+
The implicit scan (`ask <path>`) owns exactly one positional, and a valid
|
|
364
|
+
invocation with two positionals always starts with a subcommand. So a first
|
|
365
|
+
non-subcommand positional followed by another non-subcommand positional —
|
|
366
|
+
`ask notacommand /tmp` — is malformed: the user meant a command. A trailing
|
|
367
|
+
token that IS a subcommand is left for ``_reject_path_before_subcommand``."""
|
|
368
|
+
skip_next = False
|
|
369
|
+
for arg in args[after + 1:]:
|
|
370
|
+
if skip_next:
|
|
371
|
+
skip_next = False
|
|
372
|
+
continue
|
|
373
|
+
if arg.startswith("-"):
|
|
374
|
+
if arg.split("=")[0] in _OPTIONS_WITH_VALUE and "=" not in arg:
|
|
375
|
+
skip_next = True
|
|
376
|
+
continue
|
|
377
|
+
if arg in _SUBCOMMANDS:
|
|
378
|
+
return False # wrong-order path/subcommand — handled elsewhere
|
|
379
|
+
return True
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
|
|
332
383
|
def _preprocess_args(args: list[str]) -> list[str]:
|
|
333
384
|
"""Extract a repository path token from an args list and store it in _detected_path.
|
|
334
385
|
|
|
@@ -361,7 +412,13 @@ def _preprocess_args(args: list[str]) -> list[str]:
|
|
|
361
412
|
return result # known subcommand — leave for Click to dispatch
|
|
362
413
|
if _path_index >= 0:
|
|
363
414
|
continue # a later positional is the subcommand's own business
|
|
364
|
-
# First genuine positional
|
|
415
|
+
# First genuine positional. `ask` treats it as the repository path to scan
|
|
416
|
+
# (existence is validated downstream, not here). But if it is followed by
|
|
417
|
+
# another non-subcommand positional, the one-path scan cannot own both —
|
|
418
|
+
# `ask notacommand /tmp` — so the user meant a command: name the token they
|
|
419
|
+
# got wrong instead of swallowing it and erroring on the next one.
|
|
420
|
+
if _has_trailing_nonsubcommand_positional(result, i):
|
|
421
|
+
_reject_unknown_command(arg)
|
|
365
422
|
_set_detected_path(arg)
|
|
366
423
|
_path_index = i
|
|
367
424
|
if _path_index >= 0:
|
|
@@ -1276,11 +1333,23 @@ def main(
|
|
|
1276
1333
|
_raw_path_input = _get_detected_path()
|
|
1277
1334
|
target = Path(_raw_path_input).resolve()
|
|
1278
1335
|
if not target.exists():
|
|
1336
|
+
# A non-existent path that closely matches a command name is almost always a
|
|
1337
|
+
# mistyped subcommand (`ask spring-audi`) swallowed as an implicit-scan path,
|
|
1338
|
+
# not a real directory. Point at the likely command instead of only the path.
|
|
1339
|
+
_cmd_matches = difflib.get_close_matches(
|
|
1340
|
+
_raw_path_input, sorted(_SUBCOMMANDS), n=3, cutoff=0.6
|
|
1341
|
+
)
|
|
1342
|
+
_hint = "Pass an existing repository directory."
|
|
1343
|
+
if _cmd_matches:
|
|
1344
|
+
_hint = (
|
|
1345
|
+
f"If you meant a command, try: {', '.join('ask ' + m for m in _cmd_matches)}. "
|
|
1346
|
+
"Otherwise pass an existing repository directory."
|
|
1347
|
+
)
|
|
1279
1348
|
_emit_error_json(
|
|
1280
1349
|
INVALID_INPUT_CODE,
|
|
1281
1350
|
f"Directory '{_raw_path_input}' does not exist.",
|
|
1282
1351
|
path=_raw_path_input,
|
|
1283
|
-
hint=
|
|
1352
|
+
hint=_hint,
|
|
1284
1353
|
expected="An existing directory path.",
|
|
1285
1354
|
)
|
|
1286
1355
|
raise typer.Exit(code=1)
|
|
@@ -5526,7 +5595,7 @@ def spring_audit_cmd(
|
|
|
5526
5595
|
help="Accepted for compatibility; this command always reads fresh source (no snapshot cache). No-op.",
|
|
5527
5596
|
),
|
|
5528
5597
|
) -> None:
|
|
5529
|
-
"""Spring semantic audit: TX anomalies (TX-001..
|
|
5598
|
+
"""Spring semantic audit: TX anomalies (TX-001..006) + security surface (SEC-001..003).
|
|
5530
5599
|
|
|
5531
5600
|
\b
|
|
5532
5601
|
Detects:
|
|
@@ -5535,6 +5604,7 @@ def spring_audit_cmd(
|
|
|
5535
5604
|
TX-003 readOnly=true boundary propagating to write operation
|
|
5536
5605
|
TX-004 NOT_SUPPORTED/NEVER within active TX chain
|
|
5537
5606
|
TX-005 Exception swallowing inside @Transactional
|
|
5607
|
+
TX-006 Self-invocation of @Transactional sibling (proxy bypass)
|
|
5538
5608
|
SEC-001 Unsecured endpoint in annotation_based security model
|
|
5539
5609
|
SEC-002 CVE-2025-41248: @PreAuthorize on inherited method from generic supertype
|
|
5540
5610
|
SEC-003 @Transactional on @Controller/@RestController (TX in wrong layer)
|
|
@@ -5678,6 +5748,85 @@ def spring_audit_cmd(
|
|
|
5678
5748
|
raise typer.Exit(code=1)
|
|
5679
5749
|
|
|
5680
5750
|
|
|
5751
|
+
# ── verify-edit: in-loop semantic diff gate ───────────────────────────────────
|
|
5752
|
+
|
|
5753
|
+
|
|
5754
|
+
@app.command("verify-edit")
|
|
5755
|
+
def verify_edit_cmd(
|
|
5756
|
+
path: Path = typer.Argument(
|
|
5757
|
+
Path("."),
|
|
5758
|
+
help="Repository path (default: current directory). Diffs the working tree vs HEAD.",
|
|
5759
|
+
),
|
|
5760
|
+
output_path: Optional[Path] = typer.Option(
|
|
5761
|
+
None, "--output", "-o", help="Write the verdict JSON to a file instead of stdout."
|
|
5762
|
+
),
|
|
5763
|
+
format: str = typer.Option("json", "--format", help="Output format: json."),
|
|
5764
|
+
ci: bool = typer.Option(
|
|
5765
|
+
True,
|
|
5766
|
+
"--ci/--no-ci",
|
|
5767
|
+
help="Exit non-zero on a broken/unverified verdict (default on — for gates and hooks).",
|
|
5768
|
+
),
|
|
5769
|
+
install_hook: Optional[str] = typer.Option(
|
|
5770
|
+
None,
|
|
5771
|
+
"--install-hook",
|
|
5772
|
+
help="Install a git hook (pre-commit|pre-push) that runs this gate, then exit. Does not verify.",
|
|
5773
|
+
),
|
|
5774
|
+
) -> None:
|
|
5775
|
+
"""Semantic diff gate: did the working-tree edits change runtime behavior?
|
|
5776
|
+
|
|
5777
|
+
Deterministic, in-loop verdict over the diff of the working tree against HEAD:
|
|
5778
|
+
public contract, @Transactional proxy boundary, security surface, bean wiring,
|
|
5779
|
+
custom domain rules, and blast radius. Built for the agent write-loop (MCP) and
|
|
5780
|
+
git hooks. Only regressions the edit INTRODUCED (vs HEAD) block — pre-existing
|
|
5781
|
+
debt never fails an unrelated commit.
|
|
5782
|
+
|
|
5783
|
+
Custom rules: drop a `.ask/verify-rules.json` at the repo root to enforce your own
|
|
5784
|
+
invariants (e.g. "no @RestController injects a *DaoJpa", "every /payroll endpoint
|
|
5785
|
+
requires role X"). They gate the same way as the built-in axes.
|
|
5786
|
+
|
|
5787
|
+
Exit codes (with --ci, the default): 0 = pass, 1 = break (a blocking axis proved
|
|
5788
|
+
a regression), 2 = unverified (a blocking axis could not be computed — never a
|
|
5789
|
+
silent pass) or the path is not a git repository.
|
|
5790
|
+
|
|
5791
|
+
Examples:
|
|
5792
|
+
ask verify-edit # verify the current working tree
|
|
5793
|
+
ask verify-edit /path/to/repo
|
|
5794
|
+
ask verify-edit --no-ci # always exit 0 (report only)
|
|
5795
|
+
ask verify-edit --install-hook pre-commit
|
|
5796
|
+
"""
|
|
5797
|
+
from sourcecode import verify_edit as _ve
|
|
5798
|
+
|
|
5799
|
+
if install_hook is not None:
|
|
5800
|
+
try:
|
|
5801
|
+
_hook_path = _ve.install_git_hook(path, install_hook)
|
|
5802
|
+
except _ve.VerifyEditError as exc:
|
|
5803
|
+
_emit_error_json(
|
|
5804
|
+
INVALID_INPUT_CODE,
|
|
5805
|
+
str(exc),
|
|
5806
|
+
hint=f"Choose a hook type: {' | '.join(_ve.HOOK_TYPES)}. The repo must be a git repository.",
|
|
5807
|
+
)
|
|
5808
|
+
raise typer.Exit(code=2)
|
|
5809
|
+
typer.echo(f"Installed verify-edit {install_hook} hook: {_hook_path}", err=True)
|
|
5810
|
+
typer.echo(f"It runs `ask verify-edit --ci` and blocks on a break; bypass with git --no-verify.", err=True)
|
|
5811
|
+
return
|
|
5812
|
+
|
|
5813
|
+
try:
|
|
5814
|
+
v = _ve.verify_edit(path)
|
|
5815
|
+
except _ve.VerifyEditError as exc:
|
|
5816
|
+
_emit_error_json(
|
|
5817
|
+
INVALID_INPUT_CODE,
|
|
5818
|
+
str(exc),
|
|
5819
|
+
hint="verify-edit needs a git repository with a HEAD commit (it diffs the working tree vs HEAD).",
|
|
5820
|
+
)
|
|
5821
|
+
raise typer.Exit(code=2 if ci else 0)
|
|
5822
|
+
|
|
5823
|
+
output = _serialize_dict(v.to_dict(), format)
|
|
5824
|
+
_emit_command_output(output, output_path, False,
|
|
5825
|
+
success_msg=f"verify-edit verdict written to {output_path}")
|
|
5826
|
+
if ci and v.exit_code != 0:
|
|
5827
|
+
raise typer.Exit(code=v.exit_code)
|
|
5828
|
+
|
|
5829
|
+
|
|
5681
5830
|
# ── Spring Boot Migration Check ───────────────────────────────────────────────
|
|
5682
5831
|
|
|
5683
5832
|
|
|
@@ -1269,6 +1269,7 @@ _MCP_HIDDEN_CANONICAL_TOOLS: frozenset[str] = frozenset({
|
|
|
1269
1269
|
"spring_audit", # curated: repo_path + scope + min_severity only (strips output_path/format/copy)
|
|
1270
1270
|
"impact_chain", # curated: repo_path + symbol + depth + query_type with choices
|
|
1271
1271
|
"migrate_check", # curated: repo_path + min_severity only (strips output_path/format/copy/ci)
|
|
1272
|
+
"verify_edit", # curated: repo_path only (report-only --no-ci; strips output_path/format/ci)
|
|
1272
1273
|
# MCP self-management (an agent is not the MCP client admin)
|
|
1273
1274
|
"mcp_init",
|
|
1274
1275
|
"mcp_serve",
|
|
@@ -1450,7 +1451,54 @@ min_severity: "low" (default) | "medium" | "high" | "critical" — filter thresh
|
|
|
1450
1451
|
docstring_override=_MIGRATE_CHECK_DOC,
|
|
1451
1452
|
)
|
|
1452
1453
|
|
|
1453
|
-
|
|
1454
|
+
_VERIFY_EDIT_DOC = """\
|
|
1455
|
+
Semantic diff gate: did the working-tree edits change runtime behavior? JAVA/SPRING.
|
|
1456
|
+
|
|
1457
|
+
When to call: right AFTER editing Java source, BEFORE proposing a commit — the
|
|
1458
|
+
in-loop guardrail. Deterministic verdict over the diff of the working tree vs HEAD.
|
|
1459
|
+
|
|
1460
|
+
Report-only over MCP: it ALWAYS returns the verdict payload (never errors on a
|
|
1461
|
+
break) so you can read the result and auto-correct. Read `verdict`:
|
|
1462
|
+
"pass" — no runtime-behavior regression proven; safe to proceed.
|
|
1463
|
+
"break" — a blocking axis proved a regression; fix before committing. See
|
|
1464
|
+
`axes` + `reasons`.
|
|
1465
|
+
"unverified" — an axis could not be computed (e.g. parse failure); do not treat
|
|
1466
|
+
as safe.
|
|
1467
|
+
|
|
1468
|
+
Axes (each a diff HEAD→working):
|
|
1469
|
+
contract_broken — public API removed / visibility-downgraded / signature changed
|
|
1470
|
+
tx_boundary_changed — @Transactional now on a private/final method = silent CGLIB
|
|
1471
|
+
proxy bypass (TX ignored at runtime)
|
|
1472
|
+
security_delta — a new security finding the edit introduced
|
|
1473
|
+
new_orphan_bean — a bean that lost all its injection wiring
|
|
1474
|
+
blast_radius — informational: entry points reached by the changed files
|
|
1475
|
+
|
|
1476
|
+
Needs a git repository with a HEAD commit (it diffs the working tree vs HEAD).
|
|
1477
|
+
"""
|
|
1478
|
+
|
|
1479
|
+
verify_edit = _alias_spec(
|
|
1480
|
+
"verify_edit",
|
|
1481
|
+
"Semantic diff gate over working-tree vs HEAD: contract / TX / security / bean-wiring. JAVA/SPRING.",
|
|
1482
|
+
("verify-edit",),
|
|
1483
|
+
(
|
|
1484
|
+
ToolParamSpec("repo_path", "argument", str, required=False, default=".", is_path=True,
|
|
1485
|
+
help="Absolute path to the git repository (default: current directory)."),
|
|
1486
|
+
),
|
|
1487
|
+
# Report-only: --no-ci so the tool always returns the verdict JSON (a break is
|
|
1488
|
+
# data for the agent, not a command failure that would surface as an error).
|
|
1489
|
+
lambda inputs: [
|
|
1490
|
+
"verify-edit",
|
|
1491
|
+
str(inputs.get("repo_path", ".")),
|
|
1492
|
+
"--no-ci",
|
|
1493
|
+
"--format", "json",
|
|
1494
|
+
],
|
|
1495
|
+
supported_targets=("repo_path",),
|
|
1496
|
+
unsupported_targets=("file_path",),
|
|
1497
|
+
validator=validate_repo_path,
|
|
1498
|
+
docstring_override=_VERIFY_EDIT_DOC,
|
|
1499
|
+
)
|
|
1500
|
+
|
|
1501
|
+
return [spring_audit, impact_chain, migrate_check, verify_edit]
|
|
1454
1502
|
|
|
1455
1503
|
|
|
1456
1504
|
@lru_cache(maxsize=1)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Content-addressed per-file parse cache (verify-edit Fase V4 — sub-second loop).
|
|
2
|
+
|
|
3
|
+
The bottleneck, measured: on a 1-file edit in a large repo, ``build_repo_ir`` re-parses
|
|
4
|
+
*every* file, and per-file symbol extraction dominates the working-tree CIR build (~85%
|
|
5
|
+
of warm time; ~1.0s of 1.16s at 4.8k symbols). Yet the parse of an unchanged file is a
|
|
6
|
+
**pure function of its bytes** — so it can be memoised across builds.
|
|
7
|
+
|
|
8
|
+
This cache stores the output of ``_extract_symbols`` — ``(package, imports, symbols)`` —
|
|
9
|
+
keyed by a content hash of ``(rel_path, capture_signature, source)``. Properties that
|
|
10
|
+
make it safe:
|
|
11
|
+
|
|
12
|
+
* **Content-addressed ⇒ never stale.** The key *is* the content; a changed file yields
|
|
13
|
+
a different key and misses (re-parses), it never serves an old parse. There is no
|
|
14
|
+
invalidation to get wrong — unlike a signature-keyed cache.
|
|
15
|
+
* **Correctness never depends on it.** A miss, a corrupt entry, or an unwritable dir all
|
|
16
|
+
degrade to "parse it" — the CIR is byte-identical whether the cache hit or missed
|
|
17
|
+
(proven by the cir_hash-equivalence test, not assumed).
|
|
18
|
+
* **Isolated from the whole-CIR cache.** Its own directory tree; it can never poison the
|
|
19
|
+
``java-cir`` knowledge entry that ``explain``/``impact``/``review-pr`` share.
|
|
20
|
+
|
|
21
|
+
Deserialisation was measured at ~5× cheaper than re-parsing (38ms vs 197ms for 4.8k
|
|
22
|
+
symbols), so the round-trip is a real win, not a wash.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import dataclasses
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import TYPE_CHECKING, Optional
|
|
32
|
+
|
|
33
|
+
from sourcecode.context_cache import _atomic_write, _base_dir
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from sourcecode.repository_ir import SymbolRecord
|
|
37
|
+
|
|
38
|
+
_CACHE_SUBDIR = "parse-cache-v1" # bump when the SymbolRecord shape or extractor changes
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_enabled() -> bool:
|
|
42
|
+
"""On unless explicitly disabled (shares the context-cache kill switch). Correctness
|
|
43
|
+
is identical either way; this only trades disk for CPU."""
|
|
44
|
+
return os.environ.get("SOURCECODE_CONTEXT_CACHE_DISABLE", "") not in ("1", "true", "yes")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _cache_root() -> Path:
|
|
48
|
+
return _base_dir() / _CACHE_SUBDIR
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def file_key(rel_path: str, source: str, capture_sig: str) -> str:
|
|
52
|
+
"""Content hash of everything ``_extract_symbols`` depends on. Two identical inputs
|
|
53
|
+
⇒ same key (reuse); any change ⇒ different key (miss, re-parse)."""
|
|
54
|
+
h = hashlib.sha256()
|
|
55
|
+
h.update(rel_path.encode("utf-8", "replace"))
|
|
56
|
+
h.update(b"\x00")
|
|
57
|
+
h.update(capture_sig.encode("utf-8", "replace"))
|
|
58
|
+
h.update(b"\x00")
|
|
59
|
+
h.update(source.encode("utf-8", "replace"))
|
|
60
|
+
return h.hexdigest()[:32]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _path_for(key: str) -> Path:
|
|
64
|
+
# Shard by the first 2 hex chars so no single directory grows unbounded.
|
|
65
|
+
return _cache_root() / key[:2] / f"{key}.json"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get(key: str) -> "Optional[tuple[str, list[str], list[SymbolRecord]]]":
|
|
69
|
+
"""Return the cached ``(package, imports, symbols)`` for *key*, or None on any miss.
|
|
70
|
+
Best-effort: a missing file, unreadable bytes, or a decode/shape error all read as a
|
|
71
|
+
miss (the caller then parses) — never an exception."""
|
|
72
|
+
if not is_enabled():
|
|
73
|
+
return None
|
|
74
|
+
from sourcecode.repository_ir import SymbolRecord
|
|
75
|
+
|
|
76
|
+
p = _path_for(key)
|
|
77
|
+
try:
|
|
78
|
+
raw = p.read_bytes()
|
|
79
|
+
except OSError:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
d = json.loads(raw)
|
|
83
|
+
pkg = d["p"]
|
|
84
|
+
imports = list(d["i"])
|
|
85
|
+
symbols = [SymbolRecord(**sd) for sd in d["s"]]
|
|
86
|
+
except (ValueError, KeyError, TypeError):
|
|
87
|
+
return None # corrupt or shape-drifted entry — treat as absent, will rebuild
|
|
88
|
+
return pkg, imports, symbols
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def put(key: str, package: str, imports: list[str], symbols: "list[SymbolRecord]") -> None:
|
|
92
|
+
"""Store a parse result. Best-effort and side-effect-free on failure: caching is a
|
|
93
|
+
performance aid, never a correctness dependency."""
|
|
94
|
+
if not is_enabled():
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
payload = {
|
|
98
|
+
"p": package,
|
|
99
|
+
"i": list(imports),
|
|
100
|
+
"s": [dataclasses.asdict(s) for s in symbols],
|
|
101
|
+
}
|
|
102
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
103
|
+
p = _path_for(key)
|
|
104
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
_atomic_write(p, data)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass # unwritable / serialisation fault — silently skip; correctness unaffected
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── generic JSON blob (V4b: the per-file *body-fact* bundle) ─────────────────────
|
|
111
|
+
# The Pass-2 fact extractors (body/literal/guard/field-type/…) are, like symbol
|
|
112
|
+
# extraction, pure functions of the file's bytes, so their combined output is memoised
|
|
113
|
+
# under the SAME content key with a distinct suffix. Values are already plain JSON dicts
|
|
114
|
+
# (atomic-fact IR), so no dataclass reconstruction is needed. Same best-effort contract:
|
|
115
|
+
# a miss/corrupt entry recomputes; the CIR is byte-identical either way.
|
|
116
|
+
def _blob_path(key: str) -> Path:
|
|
117
|
+
return _cache_root() / key[:2] / f"{key}.blob.json"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_blob(key: str) -> Optional[dict]:
|
|
121
|
+
if not is_enabled():
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
return json.loads(_blob_path(key).read_bytes())
|
|
125
|
+
except (OSError, ValueError):
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def put_blob(key: str, data: dict) -> None:
|
|
130
|
+
if not is_enabled():
|
|
131
|
+
return
|
|
132
|
+
try:
|
|
133
|
+
raw = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
134
|
+
p = _blob_path(key)
|
|
135
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
_atomic_write(p, raw)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|