From 98012dc9160e61d569b48647d5c23e784e7eea10 Mon Sep 17 00:00:00 2001 From: George Safta Date: Sun, 25 Oct 2026 20:02:32 +0200 Subject: [PATCH 1/6] feat: --workspace-root makes published file keys and ids repo-relative With --workspace-root, symbol_table keys, module ids and every id below them, resolved_module, and artifact keys/paths/ids are relative to the workspace root instead of --input, matching codeanalyzer-java and codeanalyzer-typescript. Signatures, test-file detection and the cache keep the input-relative key. --input outside the root is an error. --- README.md | 340 ++++++++++++---------------- codeanalyzer/__main__.py | 17 ++ codeanalyzer/artifacts/discovery.py | 8 +- codeanalyzer/core.py | 32 ++- codeanalyzer/options/options.py | 2 + codeanalyzer/schema/assign_ids.py | 7 +- test/test_workspace_root.py | 138 +++++++++++ 7 files changed, 339 insertions(+), 205 deletions(-) create mode 100644 test/test_workspace_root.py diff --git a/README.md b/README.md index 9fd4ef5..eeae988 100644 --- a/README.md +++ b/README.md @@ -159,205 +159,139 @@ $ canpy --help Static Analysis on Python source code using Jedi and Tree sitter. -╭─ Options ────────────────────────────────────────────────────────────────────╮ -│ --version Show the canpy │ -│ version and │ -│ exit. │ -│ --input -i Path to the │ -│ project root │ -│ directory (not │ -│ required for │ -│ --emit schema). │ -│ --output -o Output directory │ -│ for artifacts. │ -│ --emit json │ -│ (analysis.json, │ -│ default) | neo4j │ -│ (graph.cypher or │ -│ live Bolt push) │ -│ | schema (the │ -│ Neo4j │ -│ schema.json │ -│ contract). │ -│ [default: json] │ -│ --app-name Logical │ -│ application name │ -│ for the graph │ -│ :PyApplication │ -│ anchor (default: │ -│ input dir name). │ -│ --neo4j-uri Push the graph │ -│ to a live Neo4j │ -│ over Bolt │ -│ (incremental); │ -│ omit to write │ -│ graph.cypher. │ -│ [env var: │ -│ NEO4J_URI] │ -│ --neo4j-user Neo4j username. │ -│ [env var: │ -│ NEO4J_USERNAME] │ -│ [default: neo4j] │ -│ --neo4j-password Neo4j password. │ -│ Prefer the env │ -│ var over the │ -│ flag (the flag │ -│ is visible in │ -│ shell history / │ -│ process list). │ -│ [env var: │ -│ NEO4J_PASSWORD] │ -│ [default: neo4j] │ -│ --neo4j-database Neo4j database │ -│ name (default: │ -│ server default). │ -│ [env var: │ -│ NEO4J_DATABASE] │ -│ --analysis-level -a Analysis depth: │ -│ [1<=x<=4] 1=symbol │ -│ table+Jedi call │ -│ graph, │ -│ 2=+defuse-linker │ -│ call graph, │ -│ 3=+native │ -│ intraprocedural │ -│ dataflow │ -│ (CFG/PDG), │ -│ 4=+interprocedu… │ -│ SDG │ -│ (param/summary │ -│ edges, │ -│ alias-aware │ -│ DDG). │ -│ [default: (1)] │ -│ --graphs Level 3+ only: │ -│ comma-separated │ -│ program-graph │ -│ sections to emit │ -│ (cfg, dfg, pdg, │ -│ sdg). Default: │ -│ cfg,dfg,pdg. │ -│ `dfg` emits the │ -│ PDG's data edges │ -│ only; `sdg` │ -│ requires -a 4. │ -│ Incompatible │ -│ with --emit │ -│ neo4j (always │ -│ full-depth). │ -│ [default: │ -│ (cfg,dfg,pdg)] │ -│ --graph-field-de… Level 3 only: │ -│ [x>=1] k-limit on │ -│ access-path │ -│ depth (x.f.g.h │ -│ with k=3 becomes │ -│ x.f.g.*). │ -│ Mandatory bound │ -│ — it is what │ -│ guarantees the │ -│ interprocedural │ -│ fixpoint │ -│ terminates. │ -│ [default: 3] │ -│ --ray --no-ray Enable Ray for │ -│ distributed │ -│ analysis. │ -│ [default: │ -│ no-ray] │ -│ --eager --lazy Enable eager or │ -│ lazy analysis. │ -│ Defaults to │ -│ lazy. Also gates │ -│ every │ -│ destructive step │ -│ of a '--emit │ -│ neo4j' Bolt │ -│ push: a lazy │ -│ push only adds │ -│ and updates, an │ -│ eager one also │ -│ removes │ -│ declarations and │ -│ edges the source │ -│ no longer has. │ -│ [default: lazy] │ -│ --skip-tests --include-tests Skip test files │ -│ in analysis. │ -│ [default: │ -│ skip-tests] │ -│ --no-venv --venv Skip virtualenv │ -│ creation and │ -│ dependency │ -│ installation; │ -│ resolve imports │ -│ against the │ -│ ambient Python │ -│ environment │ -│ instead. │ -│ [default: venv] │ -│ --resolve-instal… Additionally │ -│ bind imports via │ -│ the project │ -│ venv's installed │ -│ metadata │ -│ (*.dist-info); │ -│ output becomes │ -│ machine-depende… │ -│ (prov: │ -│ installed-metad… │ -│ --file-name Analyze only the │ -│ specified file │ -│ (relative to │ -│ input │ -│ directory). │ -│ --cache-dir -c Directory to │ -│ store analysis │ -│ cache. Defaults │ -│ to │ -│ '.codeanalyzer' │ -│ in the input │ -│ directory. │ -│ --clear-cache --keep-cache Clear cache │ -│ after analysis. │ -│ By default, │ -│ cache is │ -│ retained. │ -│ [default: │ -│ keep-cache] │ -│ -v Increase │ -│ verbosity: -v, │ -│ -vv, -vvv │ -│ [default: 0] │ -│ --entrypoint-rul… Extra entrypoint │ -│ rules file │ -│ (YAML). │ -│ Repeatable; │ -│ merges with the │ -│ shipped rules. A │ -│ malformed file │ -│ is an error. │ -│ --artifact-text --no-artifact-… Capture verbatim │ -│ `source` text on │ -│ discovered │ -│ artifacts. │ -│ `source` is the │ -│ whole file; │ -│ --no-artifact-t… │ -│ empties it │ -│ everywhere │ -│ (inventory │ -│ unchanged). │ -│ sha256/size_byt… │ -│ always reflect │ -│ the full file. │ -│ [default: │ -│ artifact-text] │ -│ --help Show this │ -│ message and │ -│ exit. │ -╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --version Show the canpy version and │ +│ exit. │ +│ --input -i Path to the project root │ +│ directory (not required │ +│ for --emit schema). │ +│ --output -o Output directory for │ +│ artifacts. │ +│ --emit Output target: json │ +│ (analysis.json, default) | │ +│ neo4j (graph.cypher or │ +│ live Bolt push) | schema │ +│ (the Neo4j schema.json │ +│ contract). │ +│ [default: json] │ +│ --app-name Logical application name │ +│ for the graph │ +│ :PyApplication anchor │ +│ (default: input dir name). │ +│ --workspace-root Directory file keys, │ +│ module ids and artifact │ +│ ids are relative to │ +│ (default: --input). Must │ +│ contain --input. │ +│ --neo4j-uri Push the graph to a live │ +│ Neo4j over Bolt │ +│ (incremental); omit to │ +│ write graph.cypher. │ +│ [env var: NEO4J_URI] │ +│ --neo4j-user Neo4j username. │ +│ [env var: NEO4J_USERNAME] │ +│ [default: neo4j] │ +│ --neo4j-password Neo4j password. Prefer the │ +│ env var over the flag (the │ +│ flag is visible in shell │ +│ history / process list). │ +│ [env var: NEO4J_PASSWORD] │ +│ [default: neo4j] │ +│ --neo4j-database Neo4j database name │ +│ (default: server default). │ +│ [env var: NEO4J_DATABASE] │ +│ --analysis-level -a [1<=x<=4] Analysis depth: 1=symbol │ +│ table+Jedi call graph, │ +│ 2=+defuse-linker call │ +│ graph, 3=+native │ +│ intraprocedural dataflow │ +│ (CFG/PDG), │ +│ 4=+interprocedural SDG │ +│ (param/summary edges, │ +│ alias-aware DDG). │ +│ [default: (1)] │ +│ --graphs Level 3+ only: │ +│ comma-separated │ +│ program-graph sections to │ +│ emit (cfg, dfg, pdg, sdg). │ +│ Default: cfg,dfg,pdg. │ +│ `dfg` emits the PDG's data │ +│ edges only; `sdg` requires │ +│ -a 4. Incompatible with │ +│ --emit neo4j (always │ +│ full-depth). │ +│ [default: (cfg,dfg,pdg)] │ +│ --graph-field-depth [x>=1] Level 3 only: k-limit on │ +│ access-path depth (x.f.g.h │ +│ with k=3 becomes x.f.g.*). │ +│ Mandatory bound — it is │ +│ what guarantees the │ +│ interprocedural fixpoint │ +│ terminates. │ +│ [default: 3] │ +│ --ray --no-ray Enable Ray for distributed │ +│ analysis. │ +│ [default: no-ray] │ +│ --eager --lazy Enable eager or lazy │ +│ analysis. Defaults to │ +│ lazy. Also gates every │ +│ destructive step of a │ +│ '--emit neo4j' Bolt push: │ +│ a lazy push only adds and │ +│ updates, an eager one also │ +│ removes declarations and │ +│ edges the source no longer │ +│ has. │ +│ [default: lazy] │ +│ --skip-tests --include-tests Skip test files in │ +│ analysis. │ +│ [default: skip-tests] │ +│ --no-venv --venv Skip virtualenv creation │ +│ and dependency │ +│ installation; resolve │ +│ imports against the │ +│ ambient Python environment │ +│ instead. │ +│ [default: venv] │ +│ --resolve-installed Additionally bind imports │ +│ via the project venv's │ +│ installed metadata │ +│ (*.dist-info); output │ +│ becomes machine-dependent │ +│ (prov: │ +│ installed-metadata). │ +│ --file-name Analyze only the specified │ +│ file (relative to input │ +│ directory). │ +│ --cache-dir -c Directory to store │ +│ analysis cache. Defaults │ +│ to '.codeanalyzer' in the │ +│ input directory. │ +│ --clear-cache --keep-cache Clear cache after │ +│ analysis. By default, │ +│ cache is retained. │ +│ [default: keep-cache] │ +│ -v Increase verbosity: -v, │ +│ -vv, -vvv │ +│ [default: 0] │ +│ --entrypoint-rules Extra entrypoint rules │ +│ file (YAML). Repeatable; │ +│ merges with the shipped │ +│ rules. A malformed file is │ +│ an error. │ +│ --artifact-text --no-artifact-text Capture verbatim `source` │ +│ text on discovered │ +│ artifacts. `source` is the │ +│ whole file; │ +│ --no-artifact-text empties │ +│ it everywhere (inventory │ +│ unchanged). │ +│ sha256/size_bytes always │ +│ reflect the full file. │ +│ [default: artifact-text] │ +│ --help Show this message and │ +│ exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` @@ -416,6 +350,14 @@ $ canpy --help PY_-namespaced like every other row family so multi-language databases never mingle analyzers' edges). +8. **Repository-relative paths for a project inside a larger repo:** + ```sh + canpy --input ./monorepo/services/api --workspace-root ./monorepo + # symbol_table key: services/api/app/main.py + ``` + Only the published keys and ids change; the application id, signatures and the cache stay + as they are. + ## Analysis levels Each level is the same tree grown one layer deeper, plus the edge family over that new layer. The diff --git a/codeanalyzer/__main__.py b/codeanalyzer/__main__.py index 2a8bd22..027fcf6 100644 --- a/codeanalyzer/__main__.py +++ b/codeanalyzer/__main__.py @@ -97,6 +97,14 @@ def main( "(default: input dir name).", ), ] = None, + workspace_root: Annotated[ + Optional[Path], + typer.Option( + "--workspace-root", + help="Directory file keys, module ids and artifact ids are relative to " + "(default: --input). Must contain --input.", + ), + ] = None, neo4j_uri: Annotated[ Optional[str], typer.Option( @@ -313,6 +321,7 @@ def main( emit=emit, app_name=app_name, + workspace_root=workspace_root, neo4j_uri=neo4j_uri, neo4j_user=neo4j_user, neo4j_password=neo4j_password, @@ -363,6 +372,14 @@ def main( if not options.input.exists(): logger.error(f"Input path '{options.input}' does not exist.") raise typer.Exit(code=1) + if options.workspace_root is not None and not options.input.resolve().is_relative_to( + options.workspace_root.resolve() + ): + # Relativizing would yield `..` keys, which the schema forbids. + logger.error( + f"--input '{options.input}' is not inside --workspace-root '{options.workspace_root}'." + ) + raise typer.Exit(code=1) if options.file_name is not None: full_file_path = options.input / options.file_name diff --git a/codeanalyzer/artifacts/discovery.py b/codeanalyzer/artifacts/discovery.py index 1794d1f..4c6326c 100644 --- a/codeanalyzer/artifacts/discovery.py +++ b/codeanalyzer/artifacts/discovery.py @@ -120,6 +120,7 @@ def discover_artifacts( *, capture_text: bool = True, exclude_paths: Iterable[Path] = (), + id_prefix: str = "", ) -> Dict[str, PyArtifact]: """Walk the project and return every file as an artifact, sorted by path. @@ -146,7 +147,10 @@ def discover_artifacts( decodable file is captured in full; ``capture_text=False`` empties ``source`` everywhere (inventory otherwise identical), and an undecodable file gets ``""`` as ``binary``. ``sha256``/``size_bytes`` always reflect - the full file regardless.""" + the full file regardless. + + ``id_prefix`` is prepended to the path in each artifact id only + (--workspace-root); keys and ``path`` stay project-relative.""" out: Dict[str, PyArtifact] = {} excluded = _resolve_exclusions(project_dir, exclude_paths) for path in sorted(project_dir.rglob("*")): @@ -185,7 +189,7 @@ def discover_artifacts( fmt, source = "binary", "" out[rel_posix] = PyArtifact( - id=artifact_id(app_name, rel_posix), path=rel_posix, format=fmt, + id=artifact_id(app_name, id_prefix + rel_posix), path=rel_posix, format=fmt, roles=list(roles), size_bytes=len(raw), sha256=hashlib.sha256(raw).hexdigest(), source=source, diff --git a/codeanalyzer/core.py b/codeanalyzer/core.py index a8c3030..c56ccf5 100644 --- a/codeanalyzer/core.py +++ b/codeanalyzer/core.py @@ -56,6 +56,23 @@ def _artifact_full_text(project_dir: Path, path: str, art) -> str: return art.source +def _rekey_to_workspace(app: PyApplication, prefix: str) -> None: + """Rename the published file-keyed maps under `prefix` (--workspace-root). + + Ids were already minted with the prefix; this renames the symbol-table and + artifact keys, artifact `path`, and `resolved_module`, which names a key.""" + if not prefix: + return + for mod in app.symbol_table.values(): + for im in mod.imports or []: + if im.resolved_module: + im.resolved_module = prefix + im.resolved_module + app.symbol_table = {prefix + k: m for k, m in app.symbol_table.items()} + for art in (app.artifacts or {}).values(): + art.path = prefix + art.path + app.artifacts = {prefix + k: a for k, a in (app.artifacts or {}).items()} + + def _log_mem(label: str) -> None: """LOW-MEM PATCH: log current/peak RSS of the driver at phase boundaries.""" try: @@ -763,7 +780,8 @@ def analyze(self) -> Analysis: app.repository = repository_info(self.project_dir) app_name = self.options.app_name or self.project_dir.name - sig_to_id = assign_ids(app, app_name) + key_prefix = self._workspace_key_prefix() + sig_to_id = assign_ids(app, app_name, key_prefix) # Home call-graph endpoints that are not declared in the symbol table # (imported library / builtin members) onto @external ids once, so the # JSON and Neo4j backends share one authoritative external-symbol set @@ -791,6 +809,7 @@ def analyze(self) -> Analysis: self.project_dir, app_name, capture_text=self.options.artifact_text, exclude_paths=self._own_output_paths(), + id_prefix=key_prefix, ) app.dependencies, app.unresolved_imports = build_dependency_view( app.artifacts, @@ -923,9 +942,20 @@ def analyze(self) -> Analysis: application=app, ) self._save_analysis_cache(analysis, cache_file) + # After the save: the cache keeps input-relative keys, so it is reused + # whether or not --workspace-root is set. + _rekey_to_workspace(app, key_prefix) return analysis + def _workspace_key_prefix(self) -> str: + """The input's path under --workspace-root, `/`-terminated; "" when unset.""" + root = self.options.workspace_root + if root is None: + return "" + rel = self.project_dir.resolve().relative_to(Path(root).resolve()).as_posix() + return "" if rel == "." else rel + "/" + @staticmethod def _cache_analyzer_matches(cached: Optional[Analysis], current_version: str) -> bool: """A cache written by another analyzer version (or before versions were diff --git a/codeanalyzer/options/options.py b/codeanalyzer/options/options.py index fe55fc9..cb24055 100644 --- a/codeanalyzer/options/options.py +++ b/codeanalyzer/options/options.py @@ -31,6 +31,8 @@ class AnalysisOptions: output: Optional[Path] = None emit: EmitTarget = EmitTarget.JSON app_name: Optional[str] = None + # Root that published file keys and ids are relative to (None: `input`). + workspace_root: Optional[Path] = None neo4j_uri: Optional[str] = None neo4j_user: str = "neo4j" neo4j_password: str = "neo4j" diff --git a/codeanalyzer/schema/assign_ids.py b/codeanalyzer/schema/assign_ids.py index 29a62e9..eb37ba9 100644 --- a/codeanalyzer/schema/assign_ids.py +++ b/codeanalyzer/schema/assign_ids.py @@ -5,9 +5,10 @@ from codeanalyzer.schema.py_schema import PyApplication, PyModule, PyClass, PyCallable -def assign_ids(app: PyApplication, app_name: str) -> Dict[str, str]: +def assign_ids(app: PyApplication, app_name: str, key_prefix: str = "") -> Dict[str, str]: """Sets `.id` on the app + every module/class/callable. Returns a - `signature -> can://id` map for later stages (identity layer input).""" + `signature -> can://id` map for later stages (identity layer input). + `key_prefix` is prepended to each file key in module ids (--workspace-root).""" app.id = ids.application_id(app_name); app.kind = "application" sig_to_id: Dict[str, str] = {} @@ -29,7 +30,7 @@ def do_class(parent_id: str, cl: PyClass) -> None: do_class(cl.id, ic) for file_key, mod in app.symbol_table.items(): - mod.id = ids.module_id(app_name, file_key); mod.kind = "module" + mod.id = ids.module_id(app_name, key_prefix + file_key); mod.kind = "module" for fn in (mod.functions or {}).values(): do_callable(mod.id, fn) for cl in (mod.types or {}).values(): diff --git a/test/test_workspace_root.py b/test/test_workspace_root.py new file mode 100644 index 0000000..3516d16 --- /dev/null +++ b/test/test_workspace_root.py @@ -0,0 +1,138 @@ +"""``--workspace-root``: published file keys and the ids built from them are repo-relative. + +Same semantics as codeanalyzer-java and codeanalyzer-typescript, so a language-neutral artifact id +names the same node whichever analyzer emitted it. The re-key is the published name only: +signatures, test-file detection, import resolution and the cache keep the ``--input``-relative key. +""" +import json +import re +import shutil +from pathlib import Path + +from codeanalyzer.__main__ import app as cli +from codeanalyzer.core import Codeanalyzer +from codeanalyzer.options import AnalysisOptions + +FIXTURES = Path(__file__).parent / "fixtures" +# A `test` segment ABOVE --input: test-file skipping must judge the --input-relative path, or every +# file here would read as a test and vanish. +NESTED = "test/services/app" +PREFIX = NESTED + "/" + + +def _repo_with(tmp_path: Path, fixture: str) -> tuple[Path, Path]: + repo = tmp_path / "repo" + project = repo / NESTED + shutil.copytree(FIXTURES / fixture, project) + return repo, project + + +def _analyze(project: Path, cache: Path, workspace_root=None, level=1): + return Codeanalyzer(AnalysisOptions( + input=project, app_name="app", analysis_level=level, no_venv=True, cache_dir=cache, + workspace_root=workspace_root, + )).analyze() + + +def _code_ids(payload: str) -> list[str]: + return re.findall(r'"(can://app/python/[^"]*)"', payload) + + +def test_symbol_table_keys_and_every_code_id_are_repo_relative_through_l4(tmp_path): + repo, project = _repo_with(tmp_path, "single_functionalities/dataflow") + plain = _analyze(project, tmp_path / "c1", level=4).application + moved = _analyze(project, tmp_path / "c2", workspace_root=repo, level=4).application + + assert moved.id == "can://app" # the app segment does not move + assert sorted(moved.symbol_table) == sorted(PREFIX + k for k in plain.symbol_table) + for key, mod in moved.symbol_table.items(): + assert not key.startswith("/") and ".." not in key + assert mod.id == f"can://app/python/{key}" + + payload = moved.model_dump_json() + ids = _code_ids(payload) + assert ids, "precondition: code ids were emitted" + for i in ids: + assert i.startswith(f"can://app/python/{PREFIX}"), i + assert len(moved.call_graph) == len(plain.call_graph) + assert len(moved.param_in) == len(plain.param_in) > 0 # L4 overlays rode along + + +def test_signatures_stay_input_relative(tmp_path): + repo, project = _repo_with(tmp_path, "single_functionalities/internal_imports") + plain = _analyze(project, tmp_path / "c1", level=2).application + moved = _analyze(project, tmp_path / "c2", workspace_root=repo, level=2).application + + def sigs(app): + return sorted(f.signature for m in app.symbol_table.values() for f in (m.functions or {}).values()) + + assert sigs(moved) == sigs(plain) + + +def test_resolved_module_follows_the_prefixed_keys(): + from codeanalyzer.core import _rekey_to_workspace + from codeanalyzer.schema.py_schema import PyApplication, PyImport, PyModule + + imp = PyImport(module="pkg.util", name="pkg.util", resolved_module="pkg/util.py") + app = PyApplication(symbol_table={ + "main.py": PyModule(file_path="/x/main.py", module_name="main", imports=[imp]), + "pkg/util.py": PyModule(file_path="/x/pkg/util.py", module_name="pkg.util"), + }) + _rekey_to_workspace(app, PREFIX) + assert imp.resolved_module == PREFIX + "pkg/util.py" + assert imp.resolved_module in app.symbol_table + + +def test_artifacts_are_prefixed_and_classification_is_unchanged(tmp_path): + repo, project = _repo_with(tmp_path, "whole_applications/manifests_app") + plain = _analyze(project, tmp_path / "c1").application + moved = _analyze(project, tmp_path / "c2", workspace_root=repo).application + + assert sorted(moved.artifacts) == sorted(PREFIX + k for k in plain.artifacts) + for key, art in plain.artifacts.items(): + m = moved.artifacts[PREFIX + key] + assert m.path == PREFIX + key + assert m.id == f"can://app/artifact/{PREFIX}{key}" + assert (m.format, m.roles, m.extraction) == (art.format, art.roles, art.extraction) + assert len(m.config_keys) == len(art.config_keys) + assert all(ck.id.startswith(m.id) for ck in m.config_keys) + assert len(moved.dependencies) == len(plain.dependencies) > 0 + for d in moved.dependencies: + assert d.declared_in.startswith(f"can://app/artifact/{PREFIX}"), d.declared_in + + +def test_the_cache_stays_input_relative_so_toggling_reuses_it(tmp_path): + repo, project = _repo_with(tmp_path, "single_functionalities/internal_imports") + cache = tmp_path / "shared" + plain = _analyze(project, cache).application + cached = json.loads((cache / ".codeanalyzer" / "analysis_cache.json").read_text())["application"]["symbol_table"] + moved = _analyze(project, cache, workspace_root=repo).application + after = json.loads((cache / ".codeanalyzer" / "analysis_cache.json").read_text())["application"]["symbol_table"] + + assert sorted(after) == sorted(cached) == sorted(plain.symbol_table) # no prefix written to the cache + assert sorted(moved.symbol_table) == sorted(PREFIX + k for k in plain.symbol_table) + again = _analyze(project, cache).application + assert again.model_dump_json() == plain.model_dump_json() # and back again, unchanged + + +def test_a_workspace_root_equal_to_input_changes_nothing(tmp_path): + _, project = _repo_with(tmp_path, "whole_applications/manifests_app") + a = _analyze(project, tmp_path / "c1").application + b = _analyze(project, tmp_path / "c2", workspace_root=project).application + assert b.model_dump_json() == a.model_dump_json() + + +def test_cli_flag_reaches_the_analysis_and_an_outside_input_is_an_error(cli_runner, tmp_path): + repo, project = _repo_with(tmp_path, "single_functionalities/internal_imports") + out = tmp_path / "out" + ok = cli_runner.invoke(cli, ["-i", str(project), "-o", str(out), "--no-venv", "--app-name", "app", + "-c", str(tmp_path / "c"), "--workspace-root", str(repo)]) + assert ok.exit_code == 0, ok.output + keys = json.loads((out / "analysis.json").read_text())["application"]["symbol_table"] + assert keys and all(k.startswith(PREFIX) for k in keys) + + bad_out = tmp_path / "bad" + bad = cli_runner.invoke(cli, ["-i", str(project), "-o", str(bad_out), "--no-venv", + "--workspace-root", str(tmp_path / "elsewhere")]) + assert bad.exit_code != 0 + assert not (bad_out / "analysis.json").exists() From 4cd8c1f0c107a1fb23bfe5b26ccf7ae129499a84 Mon Sep 17 00:00:00 2001 From: George Safta Date: Thu, 29 Oct 2026 13:38:52 +0200 Subject: [PATCH 2/6] add artifact cleanup strategy --- README.md | 11 ++ codeanalyzer/neo4j/bolt.py | 83 ++++++++++++- test/test_neo4j_bolt.py | 240 +++++++++++++++++++++++++++++++++++++ 3 files changed, 330 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eeae988..c900317 100644 --- a/README.md +++ b/README.md @@ -536,6 +536,17 @@ inheritance, decorators, and call sites are relationships. At `-a 3`/`-a 4` the pruned. Requires the `neo4j` extra. Every graph carries a `schema_version` on its `:PyApplication` node. + An `--eager` full run (no `--file-name`) also reconciles the application's artifacts, so a + deleted or renamed file, a removed dependency, or re-keying under `--workspace-root` leaves + nothing stale. Python-owned artifact edges (`HAS_ARTIFACT`, `DECLARES_DEPENDENCY`/`LOCKS` to + `pkg:pypi/…`, `PY_PROVIDES`, `PY_USES_CONFIG`, `PY_READS_CONFIG_UNRESOLVED`, + `PY_UNRESOLVED_IMPORT`) are rebuilt. An `:Artifact` or + `:ConfigKey` the run no longer emits loses its `:PyCanNode` label. An artifact is then deleted only + if no other analyzer's `HAS_ARTIFACT` still points at it; a config key only if no other analyzer's + marker (`:JCanNode`, `:TSCanNode`) claims it. A `--lazy` push reconciles nothing, so push once + with `--eager` after toggling `--workspace-root`. Config keys written under the pre-1.5.0 + `can://artifact//` prefix by a sibling that did not yet mark them are treated as python's. + Call-graph endpoints that aren't present in the symbol table (third-party / framework / RPC targets) are materialized as `:PyExternal` ghost nodes, mirroring the analyzer's own ghost-node behaviour. diff --git a/codeanalyzer/neo4j/bolt.py b/codeanalyzer/neo4j/bolt.py index 4f18e8e..0fae723 100644 --- a/codeanalyzer/neo4j/bolt.py +++ b/codeanalyzer/neo4j/bolt.py @@ -27,6 +27,8 @@ 4. upsert edges owned by changed modules (+ the shared edges). 5. on a FULL run only, prune modules whose source file vanished. +An eager full run also reconciles the app's artifacts first (``_reconcile_artifacts``). + **A push never deletes by default** (#171). Steps 3 and 5 are the only destructive ones and both run on ``eager`` (``--eager``) only; a default ``--lazy`` push is purely additive — MERGE-upsert of nodes and edges, nothing removed. The cost of the default is @@ -85,6 +87,68 @@ "RETURN count(*) AS pruned" ) +# Artifact reconciliation (--eager full runs). The artifact vocabulary is shared with the +# sibling analyzers, so only python-owned edges are dropped and rebuilt: HAS_ARTIFACT from +# a :PyApplication, DECLARES_DEPENDENCY/LOCKS to pkg:pypi/, incoming PY_ edges, and the +# app's PY_READS_CONFIG_UNRESOLVED. Not sha-diffed: siblings overwrite the shared sha256. +PURGE_ARTIFACT_EDGES = ( + "UNWIND $pres AS pre " + "MATCH (t) WHERE (t:Artifact OR t:ConfigKey) AND t.id STARTS WITH pre " + "CALL { WITH t " + "WITH [(t)<-[r]-(s) WHERE (type(r) = 'HAS_ARTIFACT' AND s:PyApplication) " + "OR type(r) STARTS WITH 'PY_' | r] " + "+ [(t)-[d:DECLARES_DEPENDENCY|LOCKS]->(p:Package) WHERE p.id STARTS WITH 'pkg:pypi/' | d] AS rs " + "FOREACH (r IN rs | DELETE r) } IN TRANSACTIONS OF 1000 ROWS" +) +PURGE_UNRESOLVED_CONFIG_READS = ( + "MATCH (:PyApplication {id: $app_id})-[r:PY_READS_CONFIG_UNRESOLVED|PY_UNRESOLVED_IMPORT]->() " + "CALL { WITH r DELETE r } IN TRANSACTIONS OF 1000 ROWS" +) +# Dependency → import edges into this app's ghosts; scoped by the ghost's app id. +PURGE_PROVIDES = ( + "MATCH (:Package)-[r:PY_PROVIDES]->(g:PyExternal) WHERE g.id STARTS WITH $app_pre " + "CALL { WITH r DELETE r } IN TRANSACTIONS OF 1000 ROWS" +) +# Release python's claim on artifacts/config keys the run no longer emits. An artifact is +# deleted once no HAS_ARTIFACT is left (every analyzer inventories with one, marked or not); +# a config key once no other analyzer's *CanNode claims it. Pre-1.5.0 artifacts carry no +# marker at all, so under the legacy prefix an unmarked node counts as python's. +RELEASE_VANISHED_ARTIFACTS = ( + "UNWIND $pres AS pre " + "MATCH (n) WHERE (n:Artifact OR n:ConfigKey) AND n.id STARTS WITH pre AND NOT n.id IN $keep " + f"AND (n:{CAN_NODE} OR (pre = $legacy AND none(l IN labels(n) WHERE l ENDS WITH 'CanNode'))) " + f"CALL {{ WITH n REMOVE n:{CAN_NODE} " + "WITH n WHERE (n:Artifact AND NOT ()-[:HAS_ARTIFACT]->(n)) " + "OR (n:ConfigKey AND none(l IN labels(n) WHERE l ENDS WITH 'CanNode')) " + "DETACH DELETE n } IN TRANSACTIONS OF 1000 ROWS " + "RETURN count(*) AS released" +) + + +def _artifact_prefixes(app_name: str) -> List[str]: + """``can:///artifact/``, and ``can://artifact//`` for pre-1.5.0 pushes. + Both separator-terminated, so ``app`` never reaches ``apps``.""" + return [f"{application_prefix(app_name)}artifact/", f"can://artifact/{app_name}/"] + + +def _reconcile_artifacts(session, app_name: str, keep: List[str]) -> None: + """Drop python-owned artifact edges and release vanished artifacts/config keys. + Auto-commit and batched; a partial failure is finished by the next eager push.""" + pres = _artifact_prefixes(app_name) + app_pre = application_prefix(app_name) + params = {"pres": pres, "legacy": pres[1], "keep": keep, "app_id": app_pre[:-1], "app_pre": app_pre} + with session() as s: + s.run(PURGE_ARTIFACT_EDGES, **params).consume() + s.run(PURGE_UNRESOLVED_CONFIG_READS, **params).consume() + s.run(PURGE_PROVIDES, **params).consume() + rec = s.run(RELEASE_VANISHED_ARTIFACTS, **params).single() + logger.info(f"neo4j(bolt): released {rec['released'] if rec else 0} vanished artifact/config node(s)") + + +def _artifact_keep(app) -> List[str]: + """Every artifact and config-key id this run emits.""" + return [i for a in (app.artifacts or {}).values() for i in (a.id, *(k.id for k in a.config_keys))] + @dataclass class BoltConfig: @@ -168,10 +232,12 @@ def finish(self): class _EdgeBatches(RowBuilder): """Writes the relationships owned by changed modules (or by no module) in batches.""" - def __init__(self, session, neo4j, changed, keys, module_by_id): + def __init__(self, session, neo4j, changed, keys, module_by_id, reconcile=False): super().__init__() self._session, self._neo4j, self._changed = session, neo4j, changed self._keys, self._module_by_id = keys, module_by_id + # After artifact reconciliation, edges into artifacts are rebuilt even from unchanged modules. + self._reconcile = reconcile self._pending, self._waiting = [], set() def _owner(self, value): @@ -189,7 +255,8 @@ def node(self, labels, key_prop, value, props): def edge(self, type_, from_ref, to_ref, props=None, key=None): owner = self._owner(from_ref.value) - if owner is not None and owner not in self._changed: + into_artifact = self._reconcile and to_ref.label in ("Artifact", "ConfigKey") + if owner is not None and owner not in self._changed and not into_artifact: return identity = (type_, from_ref, to_ref, key) if identity in self._waiting: @@ -246,10 +313,13 @@ def session(): or mod.content_hash != db_hash[mod.id]} logger.info(f"neo4j(bolt): {len(app.symbol_table)} modules ({len(changed)} changed)") + reconcile = full_run and eager + if reconcile: + _reconcile_artifacts(session, app_name, _artifact_keep(app)) nodes = _NodeBatches(session, neo4j, changed, app_prefix, eager) project(app, app_name, sig_to_id, analyzer=analyzer, builder=nodes, module_scoped=True) module_by_id = {mod.id: key for key, mod in app.symbol_table.items()} - edges = _EdgeBatches(session, neo4j, changed, nodes._keys, module_by_id) + edges = _EdgeBatches(session, neo4j, changed, nodes._keys, module_by_id, reconcile) project(app, app_name, sig_to_id, analyzer=analyzer, builder=edges) if full_run and eager: @@ -336,7 +406,11 @@ def session(): f"{len(shared)} shared nodes, {len(rows.edges)} edges" ) - # 3. shared nodes are always upserted (MERGE-only). + # 3. eager full run: reconcile artifacts; then shared nodes are upserted (MERGE-only). + reconcile = full_run and eager + if reconcile: + _reconcile_artifacts(session, app_name, [ + n.value for n in rows.nodes if n.labels and n.labels[0] in ("Artifact", "ConfigKey")]) _upsert_nodes(session, neo4j, shared) # 4. per changed module: purge owned edges + vanished decls, then upsert its nodes. @@ -369,6 +443,7 @@ def _purge(tx, mid=module_id, node_keys=keys): e for e in rows.edges if module_of.get(e.from_ref.value) is None or module_of.get(e.from_ref.value) in changed + or (reconcile and e.to_ref.label in ("Artifact", "ConfigKey")) ] _upsert_edges(session, neo4j, edges) diff --git a/test/test_neo4j_bolt.py b/test/test_neo4j_bolt.py index 596bd18..3420ef6 100644 --- a/test/test_neo4j_bolt.py +++ b/test/test_neo4j_bolt.py @@ -289,3 +289,243 @@ def fail(*args): assert _num(driver, "MATCH ()-[r]->() RETURN count(r)") == len(rows.edges) assert _num(driver, "MATCH (m:PyModule) WHERE m.content_hash IS NOT NULL RETURN count(m)") == \ len(app.symbol_table) + + +# --- artifact reconciliation (--eager full runs) ------------------------------------------------- + +from codeanalyzer.schema.ids import artifact_id, config_key_id, purl_pypi + +_ART = "can://sample-app/artifact/" + + +def _rekeyed(prefix: str): + """The sample app with every artifact re-keyed under ``prefix`` — what toggling + --workspace-root does to an existing graph.""" + app, sig_to_id = make_sample_app() + old_to_new = {} + arts = {} + for path, art in app.artifacts.items(): + new_id = artifact_id("sample-app", prefix + path) + old_to_new[art.id] = new_id + for ck in art.config_keys: + new_ck = config_key_id(new_id, ck.key) + old_to_new[ck.id] = new_ck + ck.id = new_ck + art.id, art.path = new_id, prefix + path + arts[prefix + path] = art + app.artifacts = arts + for d in app.dependencies: + d.declared_in = old_to_new[d.declared_in] + for u in app.config_uses: + u.dst = old_to_new[u.dst] + return app, sig_to_id + + +def test_toggling_workspace_root_leaves_no_old_keyed_artifacts(driver, cfg): + app0, s0 = make_sample_app() + _push(app0, "sample-app", s0, cfg, full_run=True, eager=True) + assert _num(driver, "MATCH (a:Artifact {id: $id}) RETURN count(a)", id=_ART + "pyproject.toml") == 1 + + app, s = _rekeyed("services/app/") + _push(app, "sample-app", s, cfg, full_run=True, eager=True) + + rooted = _ART + "services/app/" + assert _num(driver, "MATCH (n) WHERE (n:Artifact OR n:ConfigKey) AND n.id STARTS WITH $p " + "AND NOT n.id STARTS WITH $r RETURN count(n)", p=_ART, r=rooted) == 0 + assert _num(driver, "MATCH (:PyApplication)-[r:HAS_ARTIFACT]->() RETURN count(r)") == len(app.artifacts) + assert _num(driver, "MATCH ()-[r:DECLARES_DEPENDENCY]->(:Package {id: $p}) RETURN count(r)", + p=purl_pypi("acme")) == 1 + assert _num(driver, "MATCH ()-[r:PY_USES_CONFIG]->(k) WHERE k.id STARTS WITH $r RETURN count(r)", + r=rooted) == 1 + assert _num(driver, "MATCH ()-[r:PY_USES_CONFIG]->() RETURN count(r)") == 1 + + +def test_a_lazy_push_reconciles_no_artifacts(driver, cfg): + app0, s0 = make_sample_app() + _push(app0, "sample-app", s0, cfg, full_run=True) + app, s = _rekeyed("services/app/") + _push(app, "sample-app", s, cfg, full_run=True) + assert _num(driver, "MATCH (a:Artifact {id: $id}) RETURN count(a)", id=_ART + "pyproject.toml") == 1 + + +def test_a_targeted_eager_push_reconciles_no_artifacts(driver, cfg): + app0, s0 = make_sample_app() + _push(app0, "sample-app", s0, cfg, full_run=True, eager=True) + app, s = _rekeyed("services/app/") + _push(app, "sample-app", s, cfg, full_run=False, eager=True) + assert _num(driver, "MATCH (a:Artifact {id: $id}) RETURN count(a)", id=_ART + "pyproject.toml") == 1 + + +def test_a_changed_manifest_drops_its_removed_dependency_config_key_and_reads(driver, cfg): + app0, s0 = make_sample_app() + _push(app0, "sample-app", s0, cfg, full_run=True, eager=True) + gone_key = app0.artifacts["pyproject.toml"].config_keys[0].id + assert _num(driver, "MATCH ()-[r:PY_USES_CONFIG]->(:ConfigKey {id: $k}) RETURN count(r)", k=gone_key) == 1 + + app, s = make_sample_app() + app.dependencies = [] + app.artifacts["pyproject.toml"].config_keys = [] + app.config_uses = [] + app.config_reads_unresolved = [] + _push(app, "sample-app", s, cfg, full_run=True, eager=True) + + assert _num(driver, "MATCH ()-[r:DECLARES_DEPENDENCY|LOCKS]->(:Package {id: $p}) RETURN count(r)", + p=purl_pypi("acme")) == 0 + assert _num(driver, "MATCH (k:ConfigKey {id: $k}) RETURN count(k)", k=gone_key) == 0 + assert _num(driver, "MATCH ()-[r:PY_USES_CONFIG]->() RETURN count(r)") == 0 + assert _num(driver, "MATCH ()-[r:PY_READS_CONFIG_UNRESOLVED]->() RETURN count(r)") == 0 + + +def test_a_lock_edge_follows_a_manifest_change_with_an_unchanged_lockfile(driver, cfg): + app0, s0 = make_sample_app() + _push(app0, "sample-app", s0, cfg, full_run=True, eager=True) + + app, s = make_sample_app() + app.dependencies[0].locked_version = None # the manifest no longer pins acme; poetry.lock bytes unchanged + _push(app, "sample-app", s, cfg, full_run=True, eager=True) + assert _num(driver, "MATCH (:Artifact {id: $l})-[r:LOCKS]->() RETURN count(r)", l=_ART + "poetry.lock") == 0 + + +def test_an_unchanged_eager_repush_is_idempotent(driver, cfg): + app, s = make_sample_app() + _push(app, "sample-app", s, cfg, full_run=True, eager=True) + shape = ("MATCH (n) OPTIONAL MATCH (n)-[r]-() " + "RETURN count(DISTINCT n) * 1000000 + count(DISTINCT r)") + before = _num(driver, shape) + app, s = make_sample_app() + _push(app, "sample-app", s, cfg, full_run=True, eager=True) + assert _num(driver, shape) == before + + +def test_a_sibling_analyzers_artifacts_and_edges_survive_a_python_push(driver, cfg): + app0, s0 = make_sample_app() + _push(app0, "sample-app", s0, cfg, full_run=True, eager=True) + # What codeanalyzer-java writes: a claim on the shared pyproject.toml with a maven + # dependency, plus an artifact and config key python never emits. + with driver.session() as session: + session.run( + "MATCH (a:Artifact {id: $pp}) SET a:JCanNode " + "MERGE (j:JApplication {id: 'can://sample-app'}) MERGE (j)-[:HAS_ARTIFACT]->(a) " + "MERGE (m:Package {id: 'pkg:maven/org.example/lib'}) MERGE (a)-[:DECLARES_DEPENDENCY {_k: 'compile'}]->(m) " + "MERGE (o:Artifact:JCanNode {id: $pom}) MERGE (j)-[:HAS_ARTIFACT]->(o) " + "MERGE (k:ConfigKey:JCanNode {id: $key}) MERGE (o)-[:DEFINES_CONFIG]->(k)", + pp=_ART + "pyproject.toml", pom=_ART + "pom.xml", key=config_key_id(_ART + "pom.xml", "x"), + ).consume() + + app, s = make_sample_app() + del app.artifacts["pyproject.toml"] + app.dependencies, app.config_uses = [], [] + _push(app, "sample-app", s, cfg, full_run=True, eager=True) + + pp = _ART + "pyproject.toml" + assert _num(driver, "MATCH (a:Artifact:JCanNode {id: $id}) WHERE NOT a:PyCanNode RETURN count(a)", id=pp) == 1 + assert _num(driver, "MATCH (:PyApplication)-[r:HAS_ARTIFACT]->(:Artifact {id: $id}) RETURN count(r)", id=pp) == 0 + assert _num(driver, "MATCH (:Artifact {id: $id})-[r:DECLARES_DEPENDENCY]->(:Package {id: 'pkg:maven/org.example/lib'}) " + "RETURN count(r)", id=pp) == 1 + assert _num(driver, "MATCH (:Artifact {id: $id})-[:DEFINES_CONFIG]->(:ConfigKey) RETURN count(*)", + id=_ART + "pom.xml") == 1 + + +def test_an_artifact_an_unmarked_sibling_inventories_survives(driver, cfg): + app0, s0 = make_sample_app() + _push(app0, "sample-app", s0, cfg, full_run=True, eager=True) + with driver.session() as session: + session.run("MATCH (a:Artifact {id: $id}) MERGE (t:TSApplication {id: 'can://sample-app'}) " + "MERGE (t)-[:HAS_ARTIFACT]->(a)", id=_ART + "poetry.lock").consume() + app, s = make_sample_app() + del app.artifacts["poetry.lock"] + _push(app, "sample-app", s, cfg, full_run=True, eager=True) + assert _num(driver, "MATCH (:TSApplication)-[:HAS_ARTIFACT]->(a:Artifact {id: $id}) WHERE NOT a:PyCanNode " + "RETURN count(a)", id=_ART + "poetry.lock") == 1 + + +def test_a_config_key_another_analyzer_claims_survives(driver, cfg): + app0, s0 = make_sample_app() + _push(app0, "sample-app", s0, cfg, full_run=True, eager=True) + key = app0.artifacts["pyproject.toml"].config_keys[0].id + with driver.session() as session: + session.run("MATCH (k:ConfigKey {id: $k}) SET k:TSCanNode", k=key).consume() + app, s = make_sample_app() + app.artifacts["pyproject.toml"].config_keys = [] + app.config_uses = [] + _push(app, "sample-app", s, cfg, full_run=True, eager=True) + assert _num(driver, "MATCH (:Artifact)-[:DEFINES_CONFIG]->(k:ConfigKey:TSCanNode {id: $k}) " + "WHERE NOT k:PyCanNode RETURN count(k)", k=key) == 1 + + +def test_an_app_whose_name_extends_anothers_is_not_reconciled_by_it(driver, cfg): + app_b, s_b = make_sample_app() + s_b = assign_ids(app_b, "sample-apps") + for path, art in app_b.artifacts.items(): + art.id = artifact_id("sample-apps", path) + app_b.dependencies, app_b.config_uses, app_b.config_reads_unresolved = [], [], [] + for art in app_b.artifacts.values(): + art.config_keys = [] + _push(app_b, "sample-apps", s_b, cfg, full_run=True, eager=True) + before = _num(driver, "MATCH (a:Artifact) WHERE a.id STARTS WITH 'can://sample-apps/' RETURN count(a)") + assert before > 0 + + app, s = make_sample_app() + app.artifacts = {} + app.dependencies, app.config_uses = [], [] + _push(app, "sample-app", s, cfg, full_run=True, eager=True) + assert _num(driver, "MATCH (a:Artifact:PyCanNode) WHERE a.id STARTS WITH 'can://sample-apps/' " + "RETURN count(a)") == before + + +def test_pre_rekey_artifacts_are_released_too(driver, cfg): + legacy = "can://artifact/sample-app/pyproject.toml" + with driver.session() as session: + session.run("MERGE (a:Artifact:PyCanNode {id: $id}) MERGE (k:ConfigKey:PyCanNode {id: $k}) " + "MERGE (a)-[:DEFINES_CONFIG]->(k)", id=legacy, k=config_key_id(legacy, "x")).consume() + app, s = make_sample_app() + _push(app, "sample-app", s, cfg, full_run=True, eager=True) + assert _num(driver, "MATCH (n) WHERE n.id STARTS WITH 'can://artifact/sample-app/' RETURN count(n)") == 0 + + +def test_a_removed_dependency_and_a_resolved_import_drop_their_import_edges(driver, cfg): + app0, s0 = make_sample_app() + _push(app0, "sample-app", s0, cfg, full_run=True, eager=True) + assert _num(driver, "MATCH ()-[r:PY_PROVIDES]->() RETURN count(r)") == 1 + assert _num(driver, "MATCH ()-[r:PY_UNRESOLVED_IMPORT]->() RETURN count(r)") == 1 + + app, s = make_sample_app() + app.dependencies, app.unresolved_imports = [], [] + _push(app, "sample-app", s, cfg, full_run=True, eager=True) + + assert _num(driver, "MATCH ()-[r:PY_PROVIDES]->() RETURN count(r)") == 0 + assert _num(driver, "MATCH ()-[r:PY_UNRESOLVED_IMPORT]->() RETURN count(r)") == 0 + + +def test_the_rows_writer_reconciles_artifacts_too(driver, cfg): + app0, s0 = make_sample_app() + bolt.bolt_writer(project(app0, "sample-app", s0), cfg, full_run=True, eager=True) + app, s = _rekeyed("services/app/") + bolt.bolt_writer(project(app, "sample-app", s), cfg, full_run=True, eager=True) + assert _num(driver, "MATCH (n) WHERE (n:Artifact OR n:ConfigKey) AND n.id STARTS WITH $p " + "AND NOT n.id STARTS WITH $r RETURN count(n)", p=_ART, r=_ART + "services/app/") == 0 + assert _num(driver, "MATCH ()-[r:PY_USES_CONFIG]->() RETURN count(r)") == 1 + + +def test_a_sibling_owned_pre_rekey_artifact_survives(driver, cfg): + legacy = "can://artifact/sample-app/pom.xml" + with driver.session() as session: + session.run("MERGE (a:Artifact:JCanNode {id: $id}) " + "MERGE (k:ConfigKey:JCanNode {id: $k}) MERGE (a)-[:DEFINES_CONFIG]->(k)", + id=legacy, k=config_key_id(legacy, "x")).consume() + app, s = make_sample_app() + _push(app, "sample-app", s, cfg, full_run=True, eager=True) + assert _num(driver, "MATCH (n:JCanNode) WHERE n.id STARTS WITH 'can://artifact/sample-app/' " + "RETURN count(n)") == 2 + + +def test_reconciliation_spans_several_batches(driver, cfg): + n = 2500 + with driver.session() as session: + session.run("UNWIND range(1, $n) AS i " + "CREATE (:Artifact:PyCanNode {id: $p + 'stale/' + toString(i) + '.txt'})", + n=n, p=_ART).consume() + app, s = make_sample_app() + _push(app, "sample-app", s, cfg, full_run=True, eager=True) + assert _num(driver, "MATCH (a:Artifact) WHERE a.id STARTS WITH $p RETURN count(a)", p=_ART + "stale/") == 0 + assert _num(driver, "MATCH (a:Artifact) WHERE a.id STARTS WITH $p RETURN count(a)", p=_ART) == len(app.artifacts) From 94b331e9cb56902420409a3e569e2b1e91608e2d Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 30 Oct 2026 13:41:08 -0400 Subject: [PATCH 3/6] test: add namespace identity contract fixtures --- test/fixtures/identity/namespaces.json | 139 +++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 test/fixtures/identity/namespaces.json diff --git a/test/fixtures/identity/namespaces.json b/test/fixtures/identity/namespaces.json new file mode 100644 index 0000000..8a89219 --- /dev/null +++ b/test/fixtures/identity/namespaces.json @@ -0,0 +1,139 @@ +{ + "identity_version": 2, + "roots": [ + { + "application_name": "inventory", + "namespace": null, + "expected_root": "can://inventory/inventory" + }, + { + "application_name": "currencyservice", + "namespace": "online-boutique", + "expected_root": "can://online-boutique/currencyservice" + }, + { + "application_name": "a%b", + "namespace": "team space", + "expected_root": "can://team%20space/a%25b" + }, + { + "application_name": "caf\u00e9", + "namespace": "\u56e2\u961f", + "expected_root": "can://%E5%9B%A2%E9%98%9F/caf%C3%A9" + }, + { + "application_name": "Inventory", + "namespace": "Team", + "expected_root": "can://Team/Inventory" + }, + { + "application_name": "inventory", + "namespace": "team", + "expected_root": "can://team/inventory" + }, + { + "application_name": " cafe\u0301 ", + "namespace": "team", + "expected_root": "can://team/%20cafe%CC%81%20" + } + ], + "invalid": [ + { + "value": "", + "reason": "empty" + }, + { + "value": " ", + "reason": "whitespace-only" + }, + { + "value": "a/b", + "reason": "slash" + }, + { + "value": "a\\b", + "reason": "backslash" + }, + { + "value": ".", + "reason": "dot" + }, + { + "value": "..", + "reason": "dot-dot" + }, + { + "value": "a\u0001b", + "reason": "control" + }, + { + "value": "\ud800", + "reason": "unpaired-high-surrogate" + }, + { + "value": "\udc00", + "reason": "unpaired-low-surrogate" + } + ], + "modules": [ + { + "application_name": "currencyservice", + "namespace": "online-boutique", + "language": "javascript", + "file_key": "server.js", + "expected_uri": "can://online-boutique/currencyservice/javascript/server.js" + }, + { + "application_name": "paymentservice", + "namespace": "online-boutique", + "language": "javascript", + "file_key": "server.js", + "expected_uri": "can://online-boutique/paymentservice/javascript/server.js" + }, + { + "application_name": "inventory", + "namespace": null, + "language": "python", + "file_key": "src/main.py", + "expected_uri": "can://inventory/inventory/python/src/main.py" + }, + { + "application_name": "inventory", + "namespace": null, + "language": "typescript", + "file_key": "src/main.ts", + "expected_uri": "can://inventory/inventory/typescript/src/main.ts" + }, + { + "application_name": "inventory", + "namespace": null, + "language": "java", + "file_key": "src/Main.java", + "expected_uri": "can://inventory/inventory/java/src/Main.java" + }, + { + "application_name": "inventory", + "namespace": null, + "language": "go", + "file_key": "main.go", + "expected_uri": "can://inventory/inventory/go/main.go" + } + ], + "duplicate_module_rows": { + "rows": [ + { + "module_id": "can://online-boutique/currencyservice/javascript/server.js", + "file_key": "server.js" + }, + { + "module_id": "can://online-boutique/paymentservice/javascript/server.js", + "file_key": "server.js" + }, + { + "module_id": "can://online-boutique/currencyservice/javascript/server.js", + "file_key": "server.js" + } + ], + "expected_unique_id_count": 2 + } +} From d644a1c84bd6b0ed88548a977a4a169e7d547cf5 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 30 Oct 2026 13:56:07 -0400 Subject: [PATCH 4/6] feat: qualify Python analysis identities by namespace --- README.md | 49 +- codeanalyzer/__main__.py | 25 +- codeanalyzer/artifacts/discovery.py | 7 +- codeanalyzer/core.py | 44 +- codeanalyzer/neo4j/bolt.py | 35 +- codeanalyzer/neo4j/cypher.py | 18 +- codeanalyzer/neo4j/emit.py | 20 +- codeanalyzer/neo4j/project.py | 21 +- codeanalyzer/neo4j/rows.py | 4 +- codeanalyzer/neo4j/schema.py | 3 + codeanalyzer/options/options.py | 3 +- codeanalyzer/schema/assign_ids.py | 11 +- codeanalyzer/schema/ids.py | 38 +- codeanalyzer/schema/py_schema.py | 19 +- .../syntactic_analysis/import_resolver.py | 10 +- docs/handoff/README.md | 25 +- docs/handoff/analysis.l1.json | 170 +++--- docs/handoff/analysis.l2.json | 180 ++++--- docs/handoff/analysis.l3.json | 239 +++++---- docs/handoff/analysis.l4.json | 502 +++++++++++++----- docs/handoff/graph.cypher | 314 ++++++++--- docs/handoff/schema.neo4j.json | 3 + test/conftest_v2.py | 14 +- test/sample_graph_app.py | 4 +- test/test_artifact_discovery.py | 2 +- test/test_artifact_models.py | 2 +- test/test_config_key_models.py | 8 +- test/test_config_use_dataflow.py | 4 +- test/test_config_use_literal.py | 6 +- test/test_dependency_view.py | 2 +- test/test_entrypoint_bases.py | 4 +- test/test_namespace.py | 131 +++++ test/test_neo4j_artifacts.py | 6 +- test/test_neo4j_bolt.py | 20 +- test/test_neo4j_schema.py | 2 +- test/test_neo4j_scope.py | 16 +- test/test_neo4j_streamed_nodes.py | 6 +- test/test_provenance.py | 2 +- test/test_v2_conformance.py | 8 +- test/test_v2_ids.py | 14 +- test/test_v2_l2.py | 18 +- test/test_v2_l3.py | 8 +- test/test_v2_l4.py | 4 +- test/test_v2_schema.py | 8 +- test/test_v2_two_projection_agreement.py | 6 +- test/test_workspace_root.py | 138 ----- 46 files changed, 1332 insertions(+), 841 deletions(-) create mode 100644 test/test_namespace.py delete mode 100644 test/test_workspace_root.py diff --git a/README.md b/README.md index c900317..3bda30d 100644 --- a/README.md +++ b/README.md @@ -178,11 +178,8 @@ $ canpy --help │ for the graph │ │ :PyApplication anchor │ │ (default: input dir name). │ -│ --workspace-root Directory file keys, │ -│ module ids and artifact │ -│ ids are relative to │ -│ (default: --input). Must │ -│ contain --input. │ +│ --namespace Identity namespace │ +│ (default: application name).│ │ --neo4j-uri Push the graph to a live │ │ Neo4j over Bolt │ │ (incremental); omit to │ @@ -350,13 +347,19 @@ $ canpy --help PY_-namespaced like every other row family so multi-language databases never mingle analyzers' edges). -8. **Repository-relative paths for a project inside a larger repo:** +8. **Namespace-qualified identity for a service inside a larger repo:** ```sh - canpy --input ./monorepo/services/api --workspace-root ./monorepo - # symbol_table key: services/api/app/main.py + canpy --input ./monorepo/services/api --app-name api --namespace monorepo + # application id: can://monorepo/api + # symbol_table key: app/main.py (relative to --input) ``` - Only the published keys and ids change; the application id, signatures and the cache stay - as they are. + The namespace defaults to the resolved application name: `--app-name api` alone + produces `can://api/api`. Both segments are case-sensitive raw strings, encoded as + UTF-8 URI components; empty names, controls, separators, `.` and `..` are invalid. + JSON envelopes and graph application anchors carry `identity_version: 2`, plus raw + `application_name` and `identity_namespace` on the application. `schema_version` + remains `2.0.0`. Changing either identity segment rebuilds the cache from source; + regenerate legacy outputs rather than translating existing IDs. ## Analysis levels @@ -421,16 +424,20 @@ just populate more of the same tree: ```jsonc { "schema_version": "2.0.0", + "identity_version": 2, "language": "python", "max_level": 4, // the level this run was produced at "k_limit": 3, // access-path depth bound (--graph-field-depth); L3+ only "analyzer": { "name": "codeanalyzer-python", "version": "1.0.0" }, "application": { - "id": "can://", + "id": "can:///", "kind": "application", + "identity_version": 2, + "application_name": "", + "identity_namespace": "", "symbol_table": { // relative POSIX path → module "pkg/mod.py": { - "id": "can:///python/pkg/mod.py", + "id": "can:////python/pkg/mod.py", "kind": "module", "source": "…full file text, stored once per module…", "types": { "": { "id": "…", "kind": "class", "callables": { /* methods */ } } }, @@ -440,8 +447,8 @@ just populate more of the same tree: "call_graph": [ { "src": "can://…/main(a)", "dst": "can://…/helper(x)", "weight": 1, "prov": ["defuse", "jedi"] } ], "external_symbols": { // imported/builtin call targets, keyed by id - "can:///@external/os/getcwd": - { "id": "can:///@external/os/getcwd", "kind": "external", + "can:////@external/os/getcwd": + { "id": "can:////@external/os/getcwd", "kind": "external", "name": "getcwd", "module": "os" } }, "param_in": [ { "src": "can://…/main(a)@6:4/actual_in:0", "dst": "can://…/helper(x)@formal_in:0", @@ -478,7 +485,7 @@ The application envelope also contains three substrate sections: - **`artifacts`** — discovered non-code files (manifests, configs, Docker files, CI workflows, packaging files, scripts, docs, and legal files) with extraction status (`none`, `partial`, or `full`; default `none`), keyed by relative path; each artifact carries the - `can:///artifact/` id namespace. Config files carry extracted `config_keys` + `can:////artifact/` id namespace. Config files carry extracted `config_keys` (keys, values, namespaces, and references) and `DEFINES_CONFIG` Neo4j edges. - **`dependencies`** — declared packages with kind (`runtime`/`dev`/`optional`/`build`), spec, locked version, and provenance (`prov`): where each binding came from (manifest file, lock file, @@ -489,7 +496,7 @@ The application envelope also contains three substrate sections: Notable properties: - **Durable `can://` ids** identify every node at callable granularity and above - (`can:///python//`); nodes below a callable use ordinal ids + (`can:////python//`); nodes below a callable use ordinal ids (`@entry`, `@exit`, `line:col`, `@formal_in:N`, `line:col/actual_in:N`). - **`source` lives once per module**; every node's text is the `module.source[span.bytes]` slice. - **Cross-function edges** — `call_graph`, `param_in`, `param_out`, `config_uses` — live at **application** scope; @@ -537,15 +544,15 @@ inheritance, decorators, and call sites are relationships. At `-a 3`/`-a 4` the node. An `--eager` full run (no `--file-name`) also reconciles the application's artifacts, so a - deleted or renamed file, a removed dependency, or re-keying under `--workspace-root` leaves + deleted or renamed file or a removed dependency leaves nothing stale. Python-owned artifact edges (`HAS_ARTIFACT`, `DECLARES_DEPENDENCY`/`LOCKS` to `pkg:pypi/…`, `PY_PROVIDES`, `PY_USES_CONFIG`, `PY_READS_CONFIG_UNRESOLVED`, `PY_UNRESOLVED_IMPORT`) are rebuilt. An `:Artifact` or `:ConfigKey` the run no longer emits loses its `:PyCanNode` label. An artifact is then deleted only if no other analyzer's `HAS_ARTIFACT` still points at it; a config key only if no other analyzer's marker (`:JCanNode`, `:TSCanNode`) claims it. A `--lazy` push reconciles nothing, so push once - with `--eager` after toggling `--workspace-root`. Config keys written under the pre-1.5.0 - `can://artifact//` prefix by a sibling that did not yet mark them are treated as python's. + with `--eager` to reconcile changes within the selected namespace/application. Other + namespaces and legacy identity roots are outside this scope and remain untouched. Call-graph endpoints that aren't present in the symbol table (third-party / framework / RPC targets) are materialized as `:PyExternal` ghost nodes, mirroring the analyzer's own ghost-node behaviour. @@ -618,7 +625,7 @@ RETURN src.id, d.var, d.prov // interprocedural flow through a parameter (level 4) MATCH (a:PyBodyNode)-[:PY_PARAM_IN]->(f:PyBodyNode) -WHERE f.id STARTS WITH "can://myapp/python/src/api.py" +WHERE f.id STARTS WITH "can://myapp/myapp/python/src/api.py" RETURN a.id, f.id ``` @@ -650,7 +657,7 @@ Apache 2.0 — see [LICENSE](./LICENSE). ## Polyglot applications: all languages, or none -A `--emit neo4j` push is **destructive**. It sweeps everything under `can:///` that this +A `--emit neo4j` push is **destructive**. It sweeps everything under `can:////` that this analyzer marked, then rewrites what it found. Since the id grammar puts the application outermost, every analyzer over the same `` shares that prefix — so a push reclaims stale rows belonging to *this* analyzer and, in the shared namespaces, sweeps rows a sibling wrote. diff --git a/codeanalyzer/__main__.py b/codeanalyzer/__main__.py index 027fcf6..178dae8 100644 --- a/codeanalyzer/__main__.py +++ b/codeanalyzer/__main__.py @@ -97,13 +97,9 @@ def main( "(default: input dir name).", ), ] = None, - workspace_root: Annotated[ - Optional[Path], - typer.Option( - "--workspace-root", - help="Directory file keys, module ids and artifact ids are relative to " - "(default: --input). Must contain --input.", - ), + namespace: Annotated[ + Optional[str], + typer.Option("--namespace", help="Identity namespace (default: resolved application name)."), ] = None, neo4j_uri: Annotated[ Optional[str], @@ -321,7 +317,7 @@ def main( emit=emit, app_name=app_name, - workspace_root=workspace_root, + namespace=namespace, neo4j_uri=neo4j_uri, neo4j_user=neo4j_user, neo4j_password=neo4j_password, @@ -372,13 +368,12 @@ def main( if not options.input.exists(): logger.error(f"Input path '{options.input}' does not exist.") raise typer.Exit(code=1) - if options.workspace_root is not None and not options.input.resolve().is_relative_to( - options.workspace_root.resolve() - ): - # Relativizing would yield `..` keys, which the schema forbids. - logger.error( - f"--input '{options.input}' is not inside --workspace-root '{options.workspace_root}'." - ) + from codeanalyzer.schema.ids import application_id + try: + application_id(options.app_name if options.app_name is not None else options.input.resolve().name, + options.namespace) + except ValueError as exc: + logger.error(str(exc)) raise typer.Exit(code=1) if options.file_name is not None: diff --git a/codeanalyzer/artifacts/discovery.py b/codeanalyzer/artifacts/discovery.py index 4c6326c..25fc68d 100644 --- a/codeanalyzer/artifacts/discovery.py +++ b/codeanalyzer/artifacts/discovery.py @@ -117,10 +117,10 @@ def _classify(rel_posix: str) -> Tuple[str, List[str]] | None: def discover_artifacts( project_dir: Path, app_name: str, + namespace: str | None = None, *, capture_text: bool = True, exclude_paths: Iterable[Path] = (), - id_prefix: str = "", ) -> Dict[str, PyArtifact]: """Walk the project and return every file as an artifact, sorted by path. @@ -149,8 +149,7 @@ def discover_artifacts( file gets ``""`` as ``binary``. ``sha256``/``size_bytes`` always reflect the full file regardless. - ``id_prefix`` is prepended to the path in each artifact id only - (--workspace-root); keys and ``path`` stay project-relative.""" + ``namespace`` qualifies identity; keys and ``path`` stay project-relative.""" out: Dict[str, PyArtifact] = {} excluded = _resolve_exclusions(project_dir, exclude_paths) for path in sorted(project_dir.rglob("*")): @@ -189,7 +188,7 @@ def discover_artifacts( fmt, source = "binary", "" out[rel_posix] = PyArtifact( - id=artifact_id(app_name, id_prefix + rel_posix), path=rel_posix, format=fmt, + id=artifact_id(app_name, rel_posix, namespace), path=rel_posix, format=fmt, roles=list(roles), size_bytes=len(raw), sha256=hashlib.sha256(raw).hexdigest(), source=source, diff --git a/codeanalyzer/core.py b/codeanalyzer/core.py index c56ccf5..e7749b3 100644 --- a/codeanalyzer/core.py +++ b/codeanalyzer/core.py @@ -56,23 +56,6 @@ def _artifact_full_text(project_dir: Path, path: str, art) -> str: return art.source -def _rekey_to_workspace(app: PyApplication, prefix: str) -> None: - """Rename the published file-keyed maps under `prefix` (--workspace-root). - - Ids were already minted with the prefix; this renames the symbol-table and - artifact keys, artifact `path`, and `resolved_module`, which names a key.""" - if not prefix: - return - for mod in app.symbol_table.values(): - for im in mod.imports or []: - if im.resolved_module: - im.resolved_module = prefix + im.resolved_module - app.symbol_table = {prefix + k: m for k, m in app.symbol_table.items()} - for art in (app.artifacts or {}).values(): - art.path = prefix + art.path - app.artifacts = {prefix + k: a for k, a in (app.artifacts or {}).items()} - - def _log_mem(label: str) -> None: """LOW-MEM PATCH: log current/peak RSS of the driver at phase boundaries.""" try: @@ -779,9 +762,8 @@ def analyze(self) -> Analysis: # analyzer identity rides the envelope below (keystone home). app.repository = repository_info(self.project_dir) - app_name = self.options.app_name or self.project_dir.name - key_prefix = self._workspace_key_prefix() - sig_to_id = assign_ids(app, app_name, key_prefix) + app_name = self.options.app_name if self.options.app_name is not None else self.project_dir.name + sig_to_id = assign_ids(app, app_name, self.options.namespace) # Home call-graph endpoints that are not declared in the symbol table # (imported library / builtin members) onto @external ids once, so the # JSON and Neo4j backends share one authoritative external-symbol set @@ -806,10 +788,9 @@ def analyze(self) -> Analysis: ) app.artifacts = discover_artifacts( - self.project_dir, app_name, + self.project_dir, app_name, self.options.namespace, capture_text=self.options.artifact_text, exclude_paths=self._own_output_paths(), - id_prefix=key_prefix, ) app.dependencies, app.unresolved_imports = build_dependency_view( app.artifacts, @@ -936,26 +917,15 @@ def analyze(self) -> Analysis: # k_limit is an L3+ envelope key: below the dataflow levels it stays # None and exclude_none drops it from the payload. analysis = Analysis( + identity_version=2, max_level=self.analysis_level, k_limit=self.options.graph_field_depth if self.analysis_level >= 3 else None, analyzer=analyzer_info(self.analysis_level), application=app, ) self._save_analysis_cache(analysis, cache_file) - # After the save: the cache keeps input-relative keys, so it is reused - # whether or not --workspace-root is set. - _rekey_to_workspace(app, key_prefix) - return analysis - def _workspace_key_prefix(self) -> str: - """The input's path under --workspace-root, `/`-terminated; "" when unset.""" - root = self.options.workspace_root - if root is None: - return "" - rel = self.project_dir.resolve().relative_to(Path(root).resolve()).as_posix() - return "" if rel == "." else rel + "/" - @staticmethod def _cache_analyzer_matches(cached: Optional[Analysis], current_version: str) -> bool: """A cache written by another analyzer version (or before versions were @@ -994,6 +964,12 @@ def _load_pyapplication_from_cache(self, cache_file: Path) -> Optional[Analysis] if getattr(cached, "schema_version", None) != "2.0.0": logger.info("stale/incompatible analysis cache (schema_version) — rebuilding") return None + app_name = self.options.app_name if self.options.app_name is not None else self.project_dir.name + namespace = app_name if self.options.namespace is None else self.options.namespace + if (cached.application.application_name != app_name + or cached.application.identity_namespace != namespace): + logger.info("analysis cache identity changed — rebuilding from source") + return None # The cache keys only on file hash/mtime/size, not on level, so a cache # built at a different analysis_level would leak higher-level body/edge # content (or omit content when the cached level is lower). Reject the diff --git a/codeanalyzer/neo4j/bolt.py b/codeanalyzer/neo4j/bolt.py index 0fae723..0a69569 100644 --- a/codeanalyzer/neo4j/bolt.py +++ b/codeanalyzer/neo4j/bolt.py @@ -112,11 +112,11 @@ # Release python's claim on artifacts/config keys the run no longer emits. An artifact is # deleted once no HAS_ARTIFACT is left (every analyzer inventories with one, marked or not); # a config key once no other analyzer's *CanNode claims it. Pre-1.5.0 artifacts carry no -# marker at all, so under the legacy prefix an unmarked node counts as python's. +# marker; legacy roots are outside the selected identity and are left alone. RELEASE_VANISHED_ARTIFACTS = ( "UNWIND $pres AS pre " "MATCH (n) WHERE (n:Artifact OR n:ConfigKey) AND n.id STARTS WITH pre AND NOT n.id IN $keep " - f"AND (n:{CAN_NODE} OR (pre = $legacy AND none(l IN labels(n) WHERE l ENDS WITH 'CanNode'))) " + f"AND n:{CAN_NODE} " f"CALL {{ WITH n REMOVE n:{CAN_NODE} " "WITH n WHERE (n:Artifact AND NOT ()-[:HAS_ARTIFACT]->(n)) " "OR (n:ConfigKey AND none(l IN labels(n) WHERE l ENDS WITH 'CanNode')) " @@ -125,18 +125,17 @@ ) -def _artifact_prefixes(app_name: str) -> List[str]: - """``can:///artifact/``, and ``can://artifact//`` for pre-1.5.0 pushes. - Both separator-terminated, so ``app`` never reaches ``apps``.""" - return [f"{application_prefix(app_name)}artifact/", f"can://artifact/{app_name}/"] +def _artifact_prefixes(app_name: str, namespace: str | None = None) -> List[str]: + """Artifacts in the selected namespace/application only.""" + return [f"{application_prefix(app_name, namespace)}artifact/"] -def _reconcile_artifacts(session, app_name: str, keep: List[str]) -> None: +def _reconcile_artifacts(session, app_name: str, keep: List[str], namespace: str | None = None) -> None: """Drop python-owned artifact edges and release vanished artifacts/config keys. Auto-commit and batched; a partial failure is finished by the next eager push.""" - pres = _artifact_prefixes(app_name) - app_pre = application_prefix(app_name) - params = {"pres": pres, "legacy": pres[1], "keep": keep, "app_id": app_pre[:-1], "app_pre": app_pre} + pres = _artifact_prefixes(app_name, namespace) + app_pre = application_prefix(app_name, namespace) + params = {"pres": pres, "keep": keep, "app_id": app_pre[:-1], "app_pre": app_pre} with session() as s: s.run(PURGE_ARTIFACT_EDGES, **params).consume() s.run(PURGE_UNRESOLVED_CONFIG_READS, **params).consume() @@ -280,7 +279,7 @@ def finish(self): def bolt_writer_streaming(app, app_name, sig_to_id, analyzer, cfg: BoltConfig, full_run: bool, - eager: bool = False) -> None: + eager: bool = False, namespace: str | None = None) -> None: """:func:`bolt_writer` without materializing :class:`GraphRows`: the projection runs twice (nodes, then relationships) and is written in bounded batches.""" try: @@ -292,7 +291,7 @@ def bolt_writer_streaming(app, app_name, sig_to_id, analyzer, cfg: BoltConfig, f ) from exc from codeanalyzer.neo4j.project import project - app_prefix = application_prefix(app_name) + app_prefix = application_prefix(app_name, namespace) driver = neo4j.GraphDatabase.driver(cfg.uri, auth=(cfg.user, cfg.password)) session_kwargs = {"database": cfg.database} if cfg.database else {} @@ -315,12 +314,12 @@ def session(): reconcile = full_run and eager if reconcile: - _reconcile_artifacts(session, app_name, _artifact_keep(app)) + _reconcile_artifacts(session, app_name, _artifact_keep(app), namespace) nodes = _NodeBatches(session, neo4j, changed, app_prefix, eager) - project(app, app_name, sig_to_id, analyzer=analyzer, builder=nodes, module_scoped=True) + project(app, app_name, sig_to_id, analyzer=analyzer, namespace=namespace, builder=nodes, module_scoped=True) module_by_id = {mod.id: key for key, mod in app.symbol_table.items()} edges = _EdgeBatches(session, neo4j, changed, nodes._keys, module_by_id, reconcile) - project(app, app_name, sig_to_id, analyzer=analyzer, builder=edges) + project(app, app_name, sig_to_id, analyzer=analyzer, namespace=namespace, builder=edges) if full_run and eager: with session() as s: @@ -369,7 +368,9 @@ def session(): ), None, ) - app_prefix = application_prefix(app_name) + namespace = next((n.props.get("identity_namespace") for n in rows.nodes + if n.labels and n.labels[0] == "PyApplication"), None) + app_prefix = application_prefix(app_name, namespace) # Partition nodes by owning module (an in-memory field, never emitted, #173); # shared nodes have none. @@ -410,7 +411,7 @@ def session(): reconcile = full_run and eager if reconcile: _reconcile_artifacts(session, app_name, [ - n.value for n in rows.nodes if n.labels and n.labels[0] in ("Artifact", "ConfigKey")]) + n.value for n in rows.nodes if n.labels and n.labels[0] in ("Artifact", "ConfigKey")], namespace) _upsert_nodes(session, neo4j, shared) # 4. per changed module: purge owned edges + vanished decls, then upsert its nodes. diff --git a/codeanalyzer/neo4j/cypher.py b/codeanalyzer/neo4j/cypher.py index 7fd7c4f..c5bd582 100644 --- a/codeanalyzer/neo4j/cypher.py +++ b/codeanalyzer/neo4j/cypher.py @@ -43,7 +43,7 @@ BATCH = 500 -def render_cypher(rows: GraphRows, app_name: str) -> str: +def render_cypher(rows: GraphRows, app_name: str, namespace: str | None = None) -> str: out: List[str] = [] out.append("// ── constraints & indexes ──") @@ -54,7 +54,7 @@ def render_cypher(rows: GraphRows, app_name: str) -> str: out.append("") out.append("// ── wipe this project's prior subgraph (externals/packages/decorators are shared) ──") - out.append(_wipe(app_name)) + out.append(_wipe(app_name, namespace)) out.append("") out.append("// ── nodes ──") @@ -68,7 +68,7 @@ def render_cypher(rows: GraphRows, app_name: str) -> str: return "\n".join(out) -def _wipe(app_name: str) -> str: +def _wipe(app_name: str, namespace: str | None = None) -> str: """The application root by equality plus everything under ``can:///`` (#173). Scoped by id prefix, so it is one application by construction — a second python app sharing a module path, and a sibling analyzer's :Py* graph, are outside it. @@ -81,8 +81,8 @@ def _wipe(app_name: str) -> str: instead of leaving them to accumulate. That is deliberate, and it is the one behavioural widening here: a cross-language edge into a shared :Artifact is dropped by a python snapshot and restored on that analyzer's next push.""" - prefix = cypher_value(application_prefix(app_name)) - app_id = cypher_value(application_id(app_name)) + prefix = cypher_value(application_prefix(app_name, namespace)) + app_id = cypher_value(application_id(app_name, namespace)) return "\n".join( [ f"MATCH (x:{CAN_NODE}) WHERE x.id = {app_id} OR x.id STARTS WITH {prefix}", @@ -259,7 +259,7 @@ def finish(self) -> GraphRows: return GraphRows([], []) -def write_cypher_streaming(fh, app, app_name: str, sig_to_id: dict, analyzer=None) -> None: +def write_cypher_streaming(fh, app, app_name: str, sig_to_id: dict, analyzer=None, namespace: str | None = None) -> None: """Write the ``graph.cypher`` snapshot for ``app`` to ``fh`` without holding the node/edge rows or the rendered text in memory (two projection passes: nodes, then edges gated on the node key set). Rows come in emission order rather than @@ -272,12 +272,12 @@ def write_cypher_streaming(fh, app, app_name: str, sig_to_id: dict, analyzer=Non for stmt in INDEXES: fh.write(f"{stmt};\n") fh.write("\n// ── wipe this project's prior subgraph (externals/packages/decorators are shared) ──\n") - fh.write(_wipe(app_name) + "\n") + fh.write(_wipe(app_name, namespace) + "\n") fh.write("\n// ── nodes ──\n") nodes_b = _NodeStreamBuilder(fh) - project(app, app_name, sig_to_id, analyzer=analyzer, builder=nodes_b) + project(app, app_name, sig_to_id, analyzer=analyzer, namespace=namespace, builder=nodes_b) keys = nodes_b._keys del nodes_b fh.write("\n// ── relationships ──\n") - project(app, app_name, sig_to_id, analyzer=analyzer, builder=_EdgeStreamBuilder(keys, fh)) + project(app, app_name, sig_to_id, analyzer=analyzer, namespace=namespace, builder=_EdgeStreamBuilder(keys, fh)) diff --git a/codeanalyzer/neo4j/emit.py b/codeanalyzer/neo4j/emit.py index 7f4cf17..5836009 100644 --- a/codeanalyzer/neo4j/emit.py +++ b/codeanalyzer/neo4j/emit.py @@ -52,11 +52,17 @@ def emit_schema(output: Optional[Path]) -> None: def emit_neo4j(analysis: Analysis, options: AnalysisOptions) -> None: """Project the analysis to a graph and write it: a live Bolt push when ``--neo4j-uri`` is set, otherwise a self-contained ``graph.cypher`` snapshot.""" - app_name = options.app_name or Path(options.input).resolve().name - # ``assign_ids`` is idempotent: it stamps every module/class/callable with its - # canonical ``can://`` id and returns the ``signature -> id`` map the projection - # keys nodes on, so the JSON and Neo4j projections agree. - sig_to_id = assign_ids(analysis.application, app_name) + app_name = options.app_name if options.app_name is not None else Path(options.input).resolve().name + # Ownership changes require regeneration from source: never re-key an existing + # analysis tree while its body/edge/artifact references still carry old IDs. + from codeanalyzer.schema.ids import application_id + expected = application_id(app_name, options.namespace) + app = analysis.application + if (analysis.identity_version != 2 or app.identity_version != 2 + or app.id != expected or app.application_name != app_name + or app.identity_namespace != (app_name if options.namespace is None else options.namespace)): + raise ValueError("analysis identity does not match requested scope; regenerate from source") + sig_to_id = assign_ids(app, app_name, options.namespace) if options.neo4j_uri: cfg = BoltConfig( @@ -69,7 +75,7 @@ def emit_neo4j(analysis: Analysis, options: AnalysisOptions) -> None: # is what permits any deletion at all (#171). full_run = options.file_name is None bolt_writer_streaming(analysis.application, app_name, sig_to_id, analysis.analyzer, cfg, - full_run, eager=options.rebuild_analysis) + full_run, eager=options.rebuild_analysis, namespace=options.namespace) return out_dir = options.output if options.output is not None else Path.cwd() @@ -77,5 +83,5 @@ def emit_neo4j(analysis: Analysis, options: AnalysisOptions) -> None: target = out_dir / GRAPH_CYPHER with target.open("w") as fh: # LOW-MEM PATCH: streamed, see cypher.write_cypher_streaming write_cypher_streaming(fh, analysis.application, app_name, sig_to_id, - analyzer=analysis.analyzer) + analyzer=analysis.analyzer, namespace=options.namespace) logger.info(f"Neo4j graph written to {target}") diff --git a/codeanalyzer/neo4j/project.py b/codeanalyzer/neo4j/project.py index ec38391..9206c7b 100644 --- a/codeanalyzer/neo4j/project.py +++ b/codeanalyzer/neo4j/project.py @@ -57,7 +57,7 @@ def project(app: PyApplication, app_name: str, sig_to_id: dict, analyzer: Optional[Any] = None, builder: Optional[RowBuilder] = None, - *, module_scoped: bool = False) -> GraphRows: + *, module_scoped: bool = False, namespace: Optional[str] = None) -> GraphRows: """``analyzer`` is the envelope-level ``PyAnalyzerInfo`` (the keystone home for analyzer identity); the caller that holds the ``Analysis`` envelope passes it through so the :PyApplication node carries it as props.""" @@ -69,11 +69,14 @@ def project(app: PyApplication, app_name: str, sig_to_id: dict, app_ref = b.node( ["PyApplication"], "id", - application_id(app_name), + application_id(app_name, namespace), prune( { - "id": application_id(app_name), + "id": application_id(app_name, namespace), "name": app_name, + "application_name": app_name, + "identity_namespace": app_name if namespace is None else namespace, + "identity_version": 2, "schema_version": SCHEMA_VERSION, "analyzer_name": analyzer.name if analyzer else None, "analyzer_version": analyzer.version if analyzer else None, @@ -107,16 +110,16 @@ def project(app: PyApplication, app_name: str, sig_to_id: dict, mod_ref = b.node(["PyModule"], "id", mod.id, _module_props(mod, file_key)) b.edge("PY_HAS_MODULE", app_ref, mod_ref) _project_module_body(b, file_key, mod_ref, mod, externals, sig_to_id, module_id_by_key, - application_id(app_name)) + application_id(app_name, namespace)) if module_scoped: _project_program_graphs( - b, app, externals, sig_to_id, application_id(app_name), + b, app, externals, sig_to_id, application_id(app_name, namespace), modules=((file_key, mod),), include_parameters=False, ) b.end_module() # The aggregated :PY_CALLS twin. - app_can_id = application_id(app_name) + app_can_id = application_id(app_name, namespace) for e in app.call_graph: src = _call_endpoint(b, e.src, externals, sig_to_id, app_can_id) tgt = _call_endpoint(b, e.dst, externals, sig_to_id, app_can_id) @@ -133,7 +136,7 @@ def project(app: PyApplication, app_name: str, sig_to_id: dict, # Neutral artifact/dependency subgraph (Task 6). L1 data — always present, # full-depth-always regardless of -a. - _project_artifacts(b, app, app_name, app_ref) + _project_artifacts(b, app, app_name, app_ref, namespace) # config_use (#162): the resolved-read bridge (PyBodyNode from # _project_program_graphs above) into the config-key subgraph @@ -336,13 +339,13 @@ def _import_ghost(b: RowBuilder, app_can_id: str, name: str) -> NodeRef: ) -def _project_artifacts(b: RowBuilder, app: PyApplication, app_name: str, app_ref: NodeRef) -> None: +def _project_artifacts(b: RowBuilder, app: PyApplication, app_name: str, app_ref: NodeRef, namespace: Optional[str] = None) -> None: """Non-code artifacts, declared dependencies and undeclared imports (Tasks 1-5) -- neutral ``Artifact``/``Package`` nodes with no ``Py`` prefix (deliberate: cross-language merge targets, unlike everything else this module projects). Always emitted regardless of ``-a`` -- this section is L1 data, identical at every analysis level (mirrors ``analysis.json``).""" - app_can_id = application_id(app_name) + app_can_id = application_id(app_name, namespace) for path in sorted(app.artifacts or {}): art = app.artifacts[path] diff --git a/codeanalyzer/neo4j/rows.py b/codeanalyzer/neo4j/rows.py index a555197..ba98bee 100644 --- a/codeanalyzer/neo4j/rows.py +++ b/codeanalyzer/neo4j/rows.py @@ -80,7 +80,7 @@ def descendant_prefix(can_id: str) -> str: return f"{can_id}/" -def application_prefix(app_name: Optional[str]) -> str: +def application_prefix(app_name: Optional[str], namespace: Optional[str] = None) -> str: """``can:///`` — the scope of every destructive statement. Refuses an empty application: ``STARTS WITH ''`` would match every node in the database. @@ -89,7 +89,7 @@ def application_prefix(app_name: Optional[str]) -> str: outside every scope and so never cleaned up.""" if not app_name: raise ValueError("neo4j: refusing a destructive statement without an application id") - return descendant_prefix(application_id(app_name)) + return descendant_prefix(application_id(app_name, namespace)) @dataclass diff --git a/codeanalyzer/neo4j/schema.py b/codeanalyzer/neo4j/schema.py index 9b455da..3c238ae 100644 --- a/codeanalyzer/neo4j/schema.py +++ b/codeanalyzer/neo4j/schema.py @@ -91,6 +91,9 @@ class RelType: "id": "string", "name": "string", "schema_version": "string", + "identity_version": "integer", + "application_name": "string", + "identity_namespace": "string", "analyzer_name": "string", "analyzer_version": "string", "repo_uri": "string", diff --git a/codeanalyzer/options/options.py b/codeanalyzer/options/options.py index cb24055..bcd6c76 100644 --- a/codeanalyzer/options/options.py +++ b/codeanalyzer/options/options.py @@ -31,8 +31,7 @@ class AnalysisOptions: output: Optional[Path] = None emit: EmitTarget = EmitTarget.JSON app_name: Optional[str] = None - # Root that published file keys and ids are relative to (None: `input`). - workspace_root: Optional[Path] = None + namespace: Optional[str] = None neo4j_uri: Optional[str] = None neo4j_user: str = "neo4j" neo4j_password: str = "neo4j" diff --git a/codeanalyzer/schema/assign_ids.py b/codeanalyzer/schema/assign_ids.py index eb37ba9..ead4b1d 100644 --- a/codeanalyzer/schema/assign_ids.py +++ b/codeanalyzer/schema/assign_ids.py @@ -5,11 +5,14 @@ from codeanalyzer.schema.py_schema import PyApplication, PyModule, PyClass, PyCallable -def assign_ids(app: PyApplication, app_name: str, key_prefix: str = "") -> Dict[str, str]: +def assign_ids(app: PyApplication, app_name: str, namespace: str | None = None) -> Dict[str, str]: """Sets `.id` on the app + every module/class/callable. Returns a `signature -> can://id` map for later stages (identity layer input). - `key_prefix` is prepended to each file key in module ids (--workspace-root).""" - app.id = ids.application_id(app_name); app.kind = "application" + Namespace and application name are raw validated identity segments.""" + app.id = ids.application_id(app_name, namespace); app.kind = "application" + app.application_name = app_name + app.identity_namespace = app_name if namespace is None else namespace + app.identity_version = 2 sig_to_id: Dict[str, str] = {} def do_callable(parent_id: str, c: PyCallable) -> None: @@ -30,7 +33,7 @@ def do_class(parent_id: str, cl: PyClass) -> None: do_class(cl.id, ic) for file_key, mod in app.symbol_table.items(): - mod.id = ids.module_id(app_name, key_prefix + file_key); mod.kind = "module" + mod.id = ids.module_id(app_name, file_key, namespace); mod.kind = "module" for fn in (mod.functions or {}).values(): do_callable(mod.id, fn) for cl in (mod.types or {}).values(): diff --git a/codeanalyzer/schema/ids.py b/codeanalyzer/schema/ids.py index 5887d4b..f0dc8e6 100644 --- a/codeanalyzer/schema/ids.py +++ b/codeanalyzer/schema/ids.py @@ -2,13 +2,10 @@ Ordinal ids (< callable) are `ordinal_id(callable_id, tag)`. Pure functions; ids are opaque handles (the segment itself contains '/'). -The **application is the outermost segment** and the language sits inside it: -``can:///python///``. So ``can://`` is a -prefix of every id this analyzer mints for that application — code, externals -and artifacts alike — which is what the prefix-scoped destructive statements -(#173) rely on. Nothing may be identified by its *language* prefix any more: -an application named ``python`` mints ``can://python/python/...``, so a test -for ``can://python/`` no longer means "a python id"; test the scheme instead.""" +The root is ``can:///``. Every application-owned ID +shares this root; language-specific suffixes and app-relative file keys are +unchanged. Package URLs remain outside application ownership. +""" from __future__ import annotations from typing import Iterable, Iterator, List, Optional, Tuple @@ -17,14 +14,25 @@ # This analyzer's language segment, which sits INSIDE the app rather than above it. LANG = "python" -def application_id(app_name: str) -> str: - """``can://`` — the application root, and the prefix every id below it shares.""" - return f"{SCHEME}{app_name}" +def _root_segment(value: str) -> str: + """Validate raw identity text and percent-encode its UTF-8 bytes.""" + from urllib.parse import quote + import unicodedata + if (not isinstance(value, str) or not value.strip() or value in (".", "..") + or any(c in "/\\" or unicodedata.category(c) in ("Cc", "Cs") for c in value)): + raise ValueError("identity names must be nonempty names without separators or controls") + return quote(value, safe="-._~", encoding="utf-8", errors="strict") -def module_id(app_name: str, file_key: str) -> str: - """``can:///python/`` (separators normalized to ``/``).""" + +def application_id(app_name: str, namespace: str | None = None) -> str: + """The namespace-qualified application root.""" + return f"{SCHEME}{_root_segment(app_name if namespace is None else namespace)}/{_root_segment(app_name)}" + + +def module_id(app_name: str, file_key: str, namespace: str | None = None) -> str: rel = file_key.replace("\\", "/").lstrip("./") - return f"{application_id(app_name)}/{LANG}/{rel}" + return f"{application_id(app_name, namespace)}/{LANG}/{rel}" + def child_id(parent_id: str, segment: str) -> str: return f"{parent_id}/{segment}" @@ -105,14 +113,14 @@ def stamp_body_ids(callable) -> None: p.id = ordinal_id(callable.id, f"formal_in:{i}") -def artifact_id(app_name: str, rel_path: str) -> str: +def artifact_id(app_name: str, rel_path: str, namespace: str | None = None) -> str: """Language-neutral artifact id: ``can:///artifact/``. ``artifact`` is a reserved segment in the same position the language occupies for code nodes, so sibling analyzers over the same repo (same ````) still emit the same id for the same file — and, unlike the old ``can://artifact//...``, it now sits inside the application prefix.""" - return f"{application_id(app_name)}/artifact/{rel_path}" + return f"{application_id(app_name, namespace)}/artifact/{rel_path}" def config_key_id(artifact_id: str, dotted_key: str) -> str: diff --git a/codeanalyzer/schema/py_schema.py b/codeanalyzer/schema/py_schema.py index 2cfa4b0..4ab5702 100644 --- a/codeanalyzer/schema/py_schema.py +++ b/codeanalyzer/schema/py_schema.py @@ -24,7 +24,7 @@ import dataclasses as _dataclasses from dataclasses import dataclass, field as _dc_field from typing import Any, Dict, List, Optional, Tuple -from pydantic import BaseModel +from pydantic import BaseModel, model_validator from typing_extensions import Literal @@ -630,6 +630,9 @@ class PyApplication(BaseModel): symbol_table: Dict[str, PyModule] id: str = "" kind: str = "application" + identity_version: Literal[2] = 2 + application_name: str = "" + identity_namespace: str = "" call_graph: List[PyCallEdge] = [] # Call-graph endpoints not declared in the symbol table (imported library / # builtin members), keyed by signature. Populated by the analyzer so every @@ -658,8 +661,22 @@ class Analysis(BaseModel): """v2 payload root: envelope + the application tree node. ``k_limit`` is an L3+ envelope key (None below the dataflow levels; exclude_none drops it).""" schema_version: str = "2.0.0" + identity_version: Literal[2] language: str = "python" max_level: int = 1 k_limit: Optional[int] = None analyzer: PyAnalyzerInfo = PyAnalyzerInfo() application: PyApplication + + @model_validator(mode="before") + @classmethod + def validate_identity_markers(cls, value): + # Parsing must not silently upgrade legacy caches using model defaults. + if isinstance(value, dict): + if type(value.get("identity_version")) is not int or value["identity_version"] != 2: + raise ValueError("analysis identity_version must be integer 2") + app = value.get("application") + if isinstance(app, dict) and (type(app.get("identity_version")) is not int + or app["identity_version"] != 2): + raise ValueError("application identity_version must be integer 2") + return value diff --git a/codeanalyzer/syntactic_analysis/import_resolver.py b/codeanalyzer/syntactic_analysis/import_resolver.py index 7fdef4e..f8f1447 100644 --- a/codeanalyzer/syntactic_analysis/import_resolver.py +++ b/codeanalyzer/syntactic_analysis/import_resolver.py @@ -18,12 +18,13 @@ def _dotted_candidates(app: PyApplication, project_dir: Union[Path, str]) -> Dic """dotted module path -> file_key, for every analyzed module. ``pkg/util.py`` -> ``pkg.util``; ``pkg/__init__.py`` -> ``pkg``. - file_keys share project_dir's form (both come from the same CLI arg), - so os.path.relpath keeps mixed absolute/relative setups consistent. + Core file keys are application-relative; direct builder callers may still + provide absolute paths, which are relativized against project_dir. """ mapping: Dict[str, str] = {} for file_key in app.symbol_table: - rel = os.path.relpath(file_key, str(project_dir)) + rel = (os.path.relpath(file_key, str(project_dir)) + if Path(file_key).is_absolute() else file_key) if rel.startswith(".."): continue parts = Path(rel).with_suffix("").parts @@ -57,7 +58,8 @@ def resolve_imports(app: PyApplication, project_dir: Union[Path, str]) -> None: """Stamp ``resolved_module`` on every import of every module, in place.""" candidates = _dotted_candidates(app, project_dir) for file_key, module in app.symbol_table.items(): - rel = os.path.relpath(file_key, str(project_dir)) + rel = (os.path.relpath(file_key, str(project_dir)) + if Path(file_key).is_absolute() else file_key) importer_parts = Path(rel).parts for im in module.imports or []: if not im.module: diff --git a/docs/handoff/README.md b/docs/handoff/README.md index 572a39f..242482f 100644 --- a/docs/handoff/README.md +++ b/docs/handoff/README.md @@ -14,14 +14,14 @@ the contract lives in the spec, the decision log, and the emitter Pin the analyzer that produced these samples: ``` -pip install "codeanalyzer-python==1.0.1" +pip install "codeanalyzer-python==1.6.2" ``` For L4 alias analysis (the Scalpel points-to oracle), install the optional extra — without it, L4 degrades to the type-based fallback oracle (still sound, coarser): ``` -pip install "codeanalyzer-python[scalpel]==1.0.1" +pip install "codeanalyzer-python[scalpel]==1.6.2" ``` ## Schema contract @@ -57,7 +57,7 @@ no per-language mapping layer is needed (issue #98 closed the old `can://…/@external//` entries in `external_symbols` (keyed by id, `kind:"external"`). -Root envelope keys: `schema_version`, `language`, `max_level`, +Root envelope keys: `identity_version`, `schema_version`, `language`, `max_level`, `analyzer{name,version}`, `k_limit` (present at L3+ only), `application`. `application` keys: `symbol_table`, `id`, `kind`, `call_graph`, `external_symbols`, `param_in`, `param_out`. @@ -92,8 +92,13 @@ These samples were produced with `--no-venv` on a single-module sample project. ## Sample files — one per level (cumulative; L1 ⊆ L2 ⊆ L3 ⊆ L4) -Each `analysis.lN.json` carries `"schema_version": "2.0.0"` and `"max_level": N`. -Pretty-printed for reading; machine paths sanitized to `/path/to`. +Each `analysis.lN.json` carries `"identity_version": 2`, `"schema_version": "2.0.0"` and `"max_level": N`. +Application ownership is `can://sample-app/sample-app`, with raw `application_name` +and `identity_namespace` on the application and graph anchor. +Pretty-printed for reading; machine paths sanitized to `/path/to/sample-app`. +The source file mtime is fixed to zero for reproducible graph metadata. +These references describe the namespace identity contract on this branch; use a +matching analyzer build when consuming them. | File | Level | What it adds over the previous level | | --- | --- | --- | @@ -132,11 +137,11 @@ def run(flag): ``` ``` -python -m codeanalyzer -i -a 1 -o --no-venv # → analysis.l1.json -python -m codeanalyzer -i -a 2 -o --no-venv # → analysis.l2.json -python -m codeanalyzer -i -a 3 -o --no-venv # → analysis.l3.json -python -m codeanalyzer -i -a 4 -o --no-venv # → analysis.l4.json -python -m codeanalyzer -i --emit neo4j -o --no-venv # → graph.cypher +python -m codeanalyzer -i --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app -a 1 -o --no-venv # → analysis.l1.json +python -m codeanalyzer -i --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app -a 2 -o --no-venv # → analysis.l2.json +python -m codeanalyzer -i --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app -a 3 -o --no-venv # → analysis.l3.json +python -m codeanalyzer -i --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app -a 4 -o --no-venv # → analysis.l4.json +python -m codeanalyzer -i --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app --emit neo4j -o --no-venv # → graph.cypher python -m codeanalyzer --emit schema -o # → schema.json ``` diff --git a/docs/handoff/analysis.l1.json b/docs/handoff/analysis.l1.json index 421d462..39a9082 100644 --- a/docs/handoff/analysis.l1.json +++ b/docs/handoff/analysis.l1.json @@ -1,10 +1,11 @@ { "schema_version": "2.0.0", + "identity_version": 2, "language": "python", "max_level": 1, "analyzer": { "name": "codeanalyzer-python", - "version": "1.0.1", + "version": "1.6.2", "config": { "analysis_level": 1 } @@ -12,9 +13,9 @@ "application": { "symbol_table": { "service.py": { - "file_path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "file_path": "/path/to/sample-app/service.py", "module_name": "service", - "id": "can://python/sample_proj/service.py", + "id": "can://sample-app/sample-app/python/service.py", "kind": "module", "source": "class BaseService:\n pass\n\nclass Service(BaseService):\n def announce(self, flag):\n message = build(flag)\n if flag:\n message = message + \"!\"\n return message\n\ndef build(x):\n y = x\n return y\n\ndef run(flag):\n svc = Service()\n return svc.announce(flag)\n", "imports": [], @@ -23,7 +24,7 @@ "service.BaseService": { "name": "BaseService", "signature": "service.BaseService", - "id": "can://python/sample_proj/service.py/BaseService", + "id": "can://sample-app/sample-app/python/service.py/BaseService", "kind": "class", "span": { "start": [ @@ -41,6 +42,9 @@ }, "comments": [], "base_classes": [], + "decorators": [], + "entrypoints": [], + "is_entrypoint": false, "callables": {}, "attributes": {}, "types": {}, @@ -50,7 +54,7 @@ "service.Service": { "name": "Service", "signature": "service.Service", - "id": "can://python/sample_proj/service.py/Service", + "id": "can://sample-app/sample-app/python/service.py/Service", "kind": "class", "span": { "start": [ @@ -70,12 +74,15 @@ "base_classes": [ "BaseService" ], + "decorators": [], + "entrypoints": [], + "is_entrypoint": false, "callables": { "announce": { "name": "announce", - "path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "path": "/path/to/sample-app/service.py", "signature": "service.Service.announce", - "id": "can://python/sample_proj/service.py/Service/announce(self,flag)", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", "kind": "function", "span": { "start": [ @@ -93,10 +100,15 @@ }, "comments": [], "decorators": [], + "modifiers": [], + "entrypoints": [], + "is_entrypoint": false, "parameters": [ { "name": "self", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:0", "type": "Service", + "decorators": [], "start_line": 5, "end_line": 5, "start_column": 17, @@ -104,6 +116,8 @@ }, { "name": "flag", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:1", + "decorators": [], "start_line": 5, "end_line": 5, "start_column": 23, @@ -159,25 +173,6 @@ "col_offset": 22 } ], - "call_sites": [ - { - "method_name": "build", - "argument_types": [ - "Name" - ], - "arguments": [ - { - "ast_kind": "Name" - } - ], - "callee_signature": "service.build", - "is_constructor_call": false, - "start_line": 6, - "start_column": 18, - "end_line": 6, - "end_column": 29 - } - ], "callables": {}, "types": {}, "local_variables": [ @@ -205,6 +200,7 @@ "body": { "6:18": { "kind": "call", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:18", "span": { "start": [ 6, @@ -218,7 +214,15 @@ 105, 116 ] - } + }, + "method_name": "build", + "is_constructor_call": false, + "arguments": [ + { + "ast_kind": "Name", + "name": "flag" + } + ] } }, "cfg": [], @@ -236,9 +240,9 @@ "functions": { "build": { "name": "build", - "path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "path": "/path/to/sample-app/service.py", "signature": "service.build", - "id": "can://python/sample_proj/service.py/build(x)", + "id": "can://sample-app/sample-app/python/service.py/build(x)", "kind": "function", "span": { "start": [ @@ -256,9 +260,14 @@ }, "comments": [], "decorators": [], + "modifiers": [], + "entrypoints": [], + "is_entrypoint": false, "parameters": [ { "name": "x", + "id": "can://sample-app/sample-app/python/service.py/build(x)@formal_in:0", + "decorators": [], "start_line": 11, "end_line": 11, "start_column": 10, @@ -286,7 +295,6 @@ "col_offset": 11 } ], - "call_sites": [], "callables": {}, "types": {}, "local_variables": [ @@ -309,9 +317,9 @@ }, "run": { "name": "run", - "path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "path": "/path/to/sample-app/service.py", "signature": "service.run", - "id": "can://python/sample_proj/service.py/run(flag)", + "id": "can://sample-app/sample-app/python/service.py/run(flag)", "kind": "function", "span": { "start": [ @@ -329,9 +337,14 @@ }, "comments": [], "decorators": [], + "modifiers": [], + "entrypoints": [], + "is_entrypoint": false, "parameters": [ { "name": "flag", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@formal_in:0", + "decorators": [], "start_line": 15, "end_line": 15, "start_column": 8, @@ -371,40 +384,6 @@ "col_offset": 11 } ], - "call_sites": [ - { - "method_name": "Service", - "argument_types": [], - "arguments": [], - "return_type": "Service", - "callee_signature": "service.Service.__init__", - "is_constructor_call": true, - "start_line": 16, - "start_column": 10, - "end_line": 16, - "end_column": 19 - }, - { - "method_name": "announce", - "receiver_expr": "svc", - "receiver_type": "Service", - "argument_types": [ - "Name" - ], - "arguments": [ - { - "ast_kind": "Name" - } - ], - "return_type": "str", - "callee_signature": "service.Service.announce", - "is_constructor_call": false, - "start_line": 17, - "start_column": 11, - "end_line": 17, - "end_column": 29 - } - ], "callables": {}, "types": {}, "local_variables": [ @@ -423,6 +402,7 @@ "body": { "16:10": { "kind": "call", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@16:10", "span": { "start": [ 16, @@ -436,10 +416,15 @@ 257, 266 ] - } + }, + "method_name": "Service", + "return_type": "Service", + "is_constructor_call": true, + "arguments": [] }, "17:11": { "kind": "call", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@17:11", "span": { "start": [ 17, @@ -453,7 +438,18 @@ 278, 296 ] - } + }, + "method_name": "announce", + "receiver_expr": "svc", + "receiver_type": "Service", + "return_type": "str", + "is_constructor_call": false, + "arguments": [ + { + "ast_kind": "Name", + "name": "flag" + } + ] } }, "cfg": [], @@ -464,32 +460,35 @@ }, "variables": [], "content_hash": "7097c745b302b502071549264a3eab63d8ba4db7c11174aca96a06d023db62e9", - "last_modified": 1784055599.9118228, + "last_modified": 1793382888.634782, "file_size": 297 } }, - "id": "can://python/sample_proj", + "id": "can://sample-app/sample-app", "kind": "application", + "identity_version": 2, + "application_name": "sample-app", + "identity_namespace": "sample-app", "call_graph": [ { - "src": "can://python/sample_proj/service.py/Service/announce(self,flag)", - "dst": "can://python/sample_proj/service.py/build(x)", + "src": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", + "dst": "can://sample-app/sample-app/python/service.py/build(x)", "weight": 1, "prov": [ "jedi" ] }, { - "src": "can://python/sample_proj/service.py/run(flag)", - "dst": "can://python/sample_proj/@external/service.Service/__init__", + "src": "can://sample-app/sample-app/python/service.py/run(flag)", + "dst": "can://sample-app/sample-app/@external/service.Service/__init__", "weight": 1, "prov": [ "jedi" ] }, { - "src": "can://python/sample_proj/service.py/run(flag)", - "dst": "can://python/sample_proj/service.py/Service/announce(self,flag)", + "src": "can://sample-app/sample-app/python/service.py/run(flag)", + "dst": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", "weight": 1, "prov": [ "jedi" @@ -497,14 +496,27 @@ } ], "external_symbols": { - "can://python/sample_proj/@external/service.Service/__init__": { - "id": "can://python/sample_proj/@external/service.Service/__init__", + "can://sample-app/sample-app/@external/service.Service/__init__": { + "id": "can://sample-app/sample-app/@external/service.Service/__init__", "kind": "external", "name": "__init__", "module": "service.Service" } }, + "artifacts": {}, + "dependencies": [], + "unresolved_imports": [], + "entrypoint_report": { + "frameworks_detected": [], + "rulesets": [ + "shipped" + ], + "unresolved": {}, + "errors": [] + }, "param_in": [], - "param_out": [] + "param_out": [], + "config_uses": [], + "config_reads_unresolved": [] } } diff --git a/docs/handoff/analysis.l2.json b/docs/handoff/analysis.l2.json index 765a400..c2d5761 100644 --- a/docs/handoff/analysis.l2.json +++ b/docs/handoff/analysis.l2.json @@ -1,10 +1,11 @@ { "schema_version": "2.0.0", + "identity_version": 2, "language": "python", "max_level": 2, "analyzer": { "name": "codeanalyzer-python", - "version": "1.0.1", + "version": "1.6.2", "config": { "analysis_level": 2 } @@ -12,9 +13,9 @@ "application": { "symbol_table": { "service.py": { - "file_path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "file_path": "/path/to/sample-app/service.py", "module_name": "service", - "id": "can://python/sample_proj/service.py", + "id": "can://sample-app/sample-app/python/service.py", "kind": "module", "source": "class BaseService:\n pass\n\nclass Service(BaseService):\n def announce(self, flag):\n message = build(flag)\n if flag:\n message = message + \"!\"\n return message\n\ndef build(x):\n y = x\n return y\n\ndef run(flag):\n svc = Service()\n return svc.announce(flag)\n", "imports": [], @@ -23,7 +24,7 @@ "service.BaseService": { "name": "BaseService", "signature": "service.BaseService", - "id": "can://python/sample_proj/service.py/BaseService", + "id": "can://sample-app/sample-app/python/service.py/BaseService", "kind": "class", "span": { "start": [ @@ -41,6 +42,9 @@ }, "comments": [], "base_classes": [], + "decorators": [], + "entrypoints": [], + "is_entrypoint": false, "callables": {}, "attributes": {}, "types": {}, @@ -50,7 +54,7 @@ "service.Service": { "name": "Service", "signature": "service.Service", - "id": "can://python/sample_proj/service.py/Service", + "id": "can://sample-app/sample-app/python/service.py/Service", "kind": "class", "span": { "start": [ @@ -70,12 +74,15 @@ "base_classes": [ "BaseService" ], + "decorators": [], + "entrypoints": [], + "is_entrypoint": false, "callables": { "announce": { "name": "announce", - "path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "path": "/path/to/sample-app/service.py", "signature": "service.Service.announce", - "id": "can://python/sample_proj/service.py/Service/announce(self,flag)", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", "kind": "function", "span": { "start": [ @@ -93,10 +100,15 @@ }, "comments": [], "decorators": [], + "modifiers": [], + "entrypoints": [], + "is_entrypoint": false, "parameters": [ { "name": "self", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:0", "type": "Service", + "decorators": [], "start_line": 5, "end_line": 5, "start_column": 17, @@ -104,6 +116,8 @@ }, { "name": "flag", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:1", + "decorators": [], "start_line": 5, "end_line": 5, "start_column": 23, @@ -159,25 +173,6 @@ "col_offset": 22 } ], - "call_sites": [ - { - "method_name": "build", - "argument_types": [ - "Name" - ], - "arguments": [ - { - "ast_kind": "Name" - } - ], - "callee_signature": "service.build", - "is_constructor_call": false, - "start_line": 6, - "start_column": 18, - "end_line": 6, - "end_column": 29 - } - ], "callables": {}, "types": {}, "local_variables": [ @@ -205,6 +200,7 @@ "body": { "6:18": { "kind": "call", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:18", "span": { "start": [ 6, @@ -219,7 +215,15 @@ 116 ] }, - "callee": "can://python/sample_proj/service.py/build(x)" + "callee": "can://sample-app/sample-app/python/service.py/build(x)", + "method_name": "build", + "is_constructor_call": false, + "arguments": [ + { + "ast_kind": "Name", + "name": "flag" + } + ] } }, "cfg": [], @@ -237,9 +241,9 @@ "functions": { "build": { "name": "build", - "path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "path": "/path/to/sample-app/service.py", "signature": "service.build", - "id": "can://python/sample_proj/service.py/build(x)", + "id": "can://sample-app/sample-app/python/service.py/build(x)", "kind": "function", "span": { "start": [ @@ -257,9 +261,14 @@ }, "comments": [], "decorators": [], + "modifiers": [], + "entrypoints": [], + "is_entrypoint": false, "parameters": [ { "name": "x", + "id": "can://sample-app/sample-app/python/service.py/build(x)@formal_in:0", + "decorators": [], "start_line": 11, "end_line": 11, "start_column": 10, @@ -287,7 +296,6 @@ "col_offset": 11 } ], - "call_sites": [], "callables": {}, "types": {}, "local_variables": [ @@ -310,9 +318,9 @@ }, "run": { "name": "run", - "path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "path": "/path/to/sample-app/service.py", "signature": "service.run", - "id": "can://python/sample_proj/service.py/run(flag)", + "id": "can://sample-app/sample-app/python/service.py/run(flag)", "kind": "function", "span": { "start": [ @@ -330,9 +338,14 @@ }, "comments": [], "decorators": [], + "modifiers": [], + "entrypoints": [], + "is_entrypoint": false, "parameters": [ { "name": "flag", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@formal_in:0", + "decorators": [], "start_line": 15, "end_line": 15, "start_column": 8, @@ -372,40 +385,6 @@ "col_offset": 11 } ], - "call_sites": [ - { - "method_name": "Service", - "argument_types": [], - "arguments": [], - "return_type": "Service", - "callee_signature": "service.Service.__init__", - "is_constructor_call": true, - "start_line": 16, - "start_column": 10, - "end_line": 16, - "end_column": 19 - }, - { - "method_name": "announce", - "receiver_expr": "svc", - "receiver_type": "Service", - "argument_types": [ - "Name" - ], - "arguments": [ - { - "ast_kind": "Name" - } - ], - "return_type": "str", - "callee_signature": "service.Service.announce", - "is_constructor_call": false, - "start_line": 17, - "start_column": 11, - "end_line": 17, - "end_column": 29 - } - ], "callables": {}, "types": {}, "local_variables": [ @@ -424,6 +403,7 @@ "body": { "16:10": { "kind": "call", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@16:10", "span": { "start": [ 16, @@ -438,10 +418,15 @@ 266 ] }, - "callee": "can://python/sample_proj/@external/service.Service/__init__" + "callee": "can://sample-app/sample-app/@external/service.Service/__init__", + "method_name": "Service", + "return_type": "Service", + "is_constructor_call": true, + "arguments": [] }, "17:11": { "kind": "call", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@17:11", "span": { "start": [ 17, @@ -456,7 +441,18 @@ 296 ] }, - "callee": "can://python/sample_proj/service.py/Service/announce(self,flag)" + "callee": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", + "method_name": "announce", + "receiver_expr": "svc", + "receiver_type": "Service", + "return_type": "str", + "is_constructor_call": false, + "arguments": [ + { + "ast_kind": "Name", + "name": "flag" + } + ] } }, "cfg": [], @@ -467,49 +463,63 @@ }, "variables": [], "content_hash": "7097c745b302b502071549264a3eab63d8ba4db7c11174aca96a06d023db62e9", - "last_modified": 1784055599.9118228, + "last_modified": 1793382888.634782, "file_size": 297 } }, - "id": "can://python/sample_proj", + "id": "can://sample-app/sample-app", "kind": "application", + "identity_version": 2, + "application_name": "sample-app", + "identity_namespace": "sample-app", "call_graph": [ { - "src": "can://python/sample_proj/service.py/Service/announce(self,flag)", - "dst": "can://python/sample_proj/service.py/build(x)", - "weight": 2, + "src": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", + "dst": "can://sample-app/sample-app/python/service.py/build(x)", + "weight": 1, "prov": [ - "jedi", - "pycg" + "jedi" ] }, { - "src": "can://python/sample_proj/service.py/run(flag)", - "dst": "can://python/sample_proj/@external/service.Service/__init__", + "src": "can://sample-app/sample-app/python/service.py/run(flag)", + "dst": "can://sample-app/sample-app/@external/service.Service/__init__", "weight": 1, "prov": [ "jedi" ] }, { - "src": "can://python/sample_proj/service.py/run(flag)", - "dst": "can://python/sample_proj/service.py/Service/announce(self,flag)", - "weight": 2, + "src": "can://sample-app/sample-app/python/service.py/run(flag)", + "dst": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", + "weight": 1, "prov": [ - "jedi", - "pycg" + "jedi" ] } ], "external_symbols": { - "can://python/sample_proj/@external/service.Service/__init__": { - "id": "can://python/sample_proj/@external/service.Service/__init__", + "can://sample-app/sample-app/@external/service.Service/__init__": { + "id": "can://sample-app/sample-app/@external/service.Service/__init__", "kind": "external", "name": "__init__", "module": "service.Service" } }, + "artifacts": {}, + "dependencies": [], + "unresolved_imports": [], + "entrypoint_report": { + "frameworks_detected": [], + "rulesets": [ + "shipped" + ], + "unresolved": {}, + "errors": [] + }, "param_in": [], - "param_out": [] + "param_out": [], + "config_uses": [], + "config_reads_unresolved": [] } } diff --git a/docs/handoff/analysis.l3.json b/docs/handoff/analysis.l3.json index de29010..662a5e9 100644 --- a/docs/handoff/analysis.l3.json +++ b/docs/handoff/analysis.l3.json @@ -1,11 +1,12 @@ { "schema_version": "2.0.0", + "identity_version": 2, "language": "python", "max_level": 3, "k_limit": 3, "analyzer": { "name": "codeanalyzer-python", - "version": "1.0.1", + "version": "1.6.2", "config": { "analysis_level": 3 } @@ -13,9 +14,9 @@ "application": { "symbol_table": { "service.py": { - "file_path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "file_path": "/path/to/sample-app/service.py", "module_name": "service", - "id": "can://python/sample_proj/service.py", + "id": "can://sample-app/sample-app/python/service.py", "kind": "module", "source": "class BaseService:\n pass\n\nclass Service(BaseService):\n def announce(self, flag):\n message = build(flag)\n if flag:\n message = message + \"!\"\n return message\n\ndef build(x):\n y = x\n return y\n\ndef run(flag):\n svc = Service()\n return svc.announce(flag)\n", "imports": [], @@ -24,7 +25,7 @@ "service.BaseService": { "name": "BaseService", "signature": "service.BaseService", - "id": "can://python/sample_proj/service.py/BaseService", + "id": "can://sample-app/sample-app/python/service.py/BaseService", "kind": "class", "span": { "start": [ @@ -42,6 +43,9 @@ }, "comments": [], "base_classes": [], + "decorators": [], + "entrypoints": [], + "is_entrypoint": false, "callables": {}, "attributes": {}, "types": {}, @@ -51,7 +55,7 @@ "service.Service": { "name": "Service", "signature": "service.Service", - "id": "can://python/sample_proj/service.py/Service", + "id": "can://sample-app/sample-app/python/service.py/Service", "kind": "class", "span": { "start": [ @@ -71,12 +75,15 @@ "base_classes": [ "BaseService" ], + "decorators": [], + "entrypoints": [], + "is_entrypoint": false, "callables": { "announce": { "name": "announce", - "path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "path": "/path/to/sample-app/service.py", "signature": "service.Service.announce", - "id": "can://python/sample_proj/service.py/Service/announce(self,flag)", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", "kind": "function", "span": { "start": [ @@ -94,10 +101,15 @@ }, "comments": [], "decorators": [], + "modifiers": [], + "entrypoints": [], + "is_entrypoint": false, "parameters": [ { "name": "self", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:0", "type": "Service", + "decorators": [], "start_line": 5, "end_line": 5, "start_column": 17, @@ -105,6 +117,8 @@ }, { "name": "flag", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:1", + "decorators": [], "start_line": 5, "end_line": 5, "start_column": 23, @@ -160,25 +174,6 @@ "col_offset": 22 } ], - "call_sites": [ - { - "method_name": "build", - "argument_types": [ - "Name" - ], - "arguments": [ - { - "ast_kind": "Name" - } - ], - "callee_signature": "service.build", - "is_constructor_call": false, - "start_line": 6, - "start_column": 18, - "end_line": 6, - "end_column": 29 - } - ], "callables": {}, "types": {}, "local_variables": [ @@ -206,6 +201,7 @@ "body": { "6:18": { "kind": "call", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:18", "span": { "start": [ 6, @@ -220,13 +216,25 @@ 116 ] }, - "callee": "can://python/sample_proj/service.py/build(x)" + "callee": "can://sample-app/sample-app/python/service.py/build(x)", + "parent": "6:8", + "method_name": "build", + "is_constructor_call": false, + "arguments": [ + { + "ast_kind": "Name", + "name": "flag" + } + ] }, "@entry": { - "kind": "entry" + "kind": "entry", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@entry", + "arguments": [] }, "6:8": { "kind": "statement", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8", "span": { "start": [ 6, @@ -240,10 +248,12 @@ 95, 116 ] - } + }, + "arguments": [] }, "7:11": { "kind": "branch", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@7:11", "span": { "start": [ 7, @@ -257,10 +267,12 @@ 128, 132 ] - } + }, + "arguments": [] }, "8:12": { "kind": "statement", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@8:12", "span": { "start": [ 8, @@ -274,10 +286,12 @@ 146, 169 ] - } + }, + "arguments": [] }, "9:8": { "kind": "return", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@9:8", "span": { "start": [ 9, @@ -291,10 +305,13 @@ 178, 192 ] - } + }, + "arguments": [] }, "@exit": { - "kind": "exit" + "kind": "exit", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@exit", + "arguments": [] } }, "cfg": [ @@ -422,9 +439,9 @@ "functions": { "build": { "name": "build", - "path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "path": "/path/to/sample-app/service.py", "signature": "service.build", - "id": "can://python/sample_proj/service.py/build(x)", + "id": "can://sample-app/sample-app/python/service.py/build(x)", "kind": "function", "span": { "start": [ @@ -442,9 +459,14 @@ }, "comments": [], "decorators": [], + "modifiers": [], + "entrypoints": [], + "is_entrypoint": false, "parameters": [ { "name": "x", + "id": "can://sample-app/sample-app/python/service.py/build(x)@formal_in:0", + "decorators": [], "start_line": 11, "end_line": 11, "start_column": 10, @@ -472,7 +494,6 @@ "col_offset": 11 } ], - "call_sites": [], "callables": {}, "types": {}, "local_variables": [ @@ -489,10 +510,13 @@ "cyclomatic_complexity": 2, "body": { "@entry": { - "kind": "entry" + "kind": "entry", + "id": "can://sample-app/sample-app/python/service.py/build(x)@entry", + "arguments": [] }, "12:4": { "kind": "statement", + "id": "can://sample-app/sample-app/python/service.py/build(x)@12:4", "span": { "start": [ 12, @@ -506,10 +530,12 @@ 212, 217 ] - } + }, + "arguments": [] }, "13:4": { "kind": "return", + "id": "can://sample-app/sample-app/python/service.py/build(x)@13:4", "span": { "start": [ 13, @@ -523,10 +549,13 @@ 222, 230 ] - } + }, + "arguments": [] }, "@exit": { - "kind": "exit" + "kind": "exit", + "id": "can://sample-app/sample-app/python/service.py/build(x)@exit", + "arguments": [] } }, "cfg": [ @@ -578,9 +607,9 @@ }, "run": { "name": "run", - "path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "path": "/path/to/sample-app/service.py", "signature": "service.run", - "id": "can://python/sample_proj/service.py/run(flag)", + "id": "can://sample-app/sample-app/python/service.py/run(flag)", "kind": "function", "span": { "start": [ @@ -598,9 +627,14 @@ }, "comments": [], "decorators": [], + "modifiers": [], + "entrypoints": [], + "is_entrypoint": false, "parameters": [ { "name": "flag", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@formal_in:0", + "decorators": [], "start_line": 15, "end_line": 15, "start_column": 8, @@ -640,40 +674,6 @@ "col_offset": 11 } ], - "call_sites": [ - { - "method_name": "Service", - "argument_types": [], - "arguments": [], - "return_type": "Service", - "callee_signature": "service.Service.__init__", - "is_constructor_call": true, - "start_line": 16, - "start_column": 10, - "end_line": 16, - "end_column": 19 - }, - { - "method_name": "announce", - "receiver_expr": "svc", - "receiver_type": "Service", - "argument_types": [ - "Name" - ], - "arguments": [ - { - "ast_kind": "Name" - } - ], - "return_type": "str", - "callee_signature": "service.Service.announce", - "is_constructor_call": false, - "start_line": 17, - "start_column": 11, - "end_line": 17, - "end_column": 29 - } - ], "callables": {}, "types": {}, "local_variables": [ @@ -692,6 +692,7 @@ "body": { "16:10": { "kind": "call", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@16:10", "span": { "start": [ 16, @@ -706,10 +707,16 @@ 266 ] }, - "callee": "can://python/sample_proj/@external/service.Service/__init__" + "callee": "can://sample-app/sample-app/@external/service.Service/__init__", + "parent": "16:4", + "method_name": "Service", + "return_type": "Service", + "is_constructor_call": true, + "arguments": [] }, "17:11": { "kind": "call", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@17:11", "span": { "start": [ 17, @@ -724,13 +731,28 @@ 296 ] }, - "callee": "can://python/sample_proj/service.py/Service/announce(self,flag)" + "callee": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", + "parent": "17:4", + "method_name": "announce", + "receiver_expr": "svc", + "receiver_type": "Service", + "return_type": "str", + "is_constructor_call": false, + "arguments": [ + { + "ast_kind": "Name", + "name": "flag" + } + ] }, "@entry": { - "kind": "entry" + "kind": "entry", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@entry", + "arguments": [] }, "16:4": { "kind": "statement", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@16:4", "span": { "start": [ 16, @@ -744,10 +766,12 @@ 251, 266 ] - } + }, + "arguments": [] }, "17:4": { "kind": "return", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@17:4", "span": { "start": [ 17, @@ -761,10 +785,13 @@ 271, 296 ] - } + }, + "arguments": [] }, "@exit": { - "kind": "exit" + "kind": "exit", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@exit", + "arguments": [] } }, "cfg": [ @@ -843,49 +870,63 @@ }, "variables": [], "content_hash": "7097c745b302b502071549264a3eab63d8ba4db7c11174aca96a06d023db62e9", - "last_modified": 1784055599.9118228, + "last_modified": 1793382888.634782, "file_size": 297 } }, - "id": "can://python/sample_proj", + "id": "can://sample-app/sample-app", "kind": "application", + "identity_version": 2, + "application_name": "sample-app", + "identity_namespace": "sample-app", "call_graph": [ { - "src": "can://python/sample_proj/service.py/Service/announce(self,flag)", - "dst": "can://python/sample_proj/service.py/build(x)", - "weight": 2, + "src": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", + "dst": "can://sample-app/sample-app/python/service.py/build(x)", + "weight": 1, "prov": [ - "jedi", - "pycg" + "jedi" ] }, { - "src": "can://python/sample_proj/service.py/run(flag)", - "dst": "can://python/sample_proj/@external/service.Service/__init__", + "src": "can://sample-app/sample-app/python/service.py/run(flag)", + "dst": "can://sample-app/sample-app/@external/service.Service/__init__", "weight": 1, "prov": [ "jedi" ] }, { - "src": "can://python/sample_proj/service.py/run(flag)", - "dst": "can://python/sample_proj/service.py/Service/announce(self,flag)", - "weight": 2, + "src": "can://sample-app/sample-app/python/service.py/run(flag)", + "dst": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", + "weight": 1, "prov": [ - "jedi", - "pycg" + "jedi" ] } ], "external_symbols": { - "can://python/sample_proj/@external/service.Service/__init__": { - "id": "can://python/sample_proj/@external/service.Service/__init__", + "can://sample-app/sample-app/@external/service.Service/__init__": { + "id": "can://sample-app/sample-app/@external/service.Service/__init__", "kind": "external", "name": "__init__", "module": "service.Service" } }, + "artifacts": {}, + "dependencies": [], + "unresolved_imports": [], + "entrypoint_report": { + "frameworks_detected": [], + "rulesets": [ + "shipped" + ], + "unresolved": {}, + "errors": [] + }, "param_in": [], - "param_out": [] + "param_out": [], + "config_uses": [], + "config_reads_unresolved": [] } } diff --git a/docs/handoff/analysis.l4.json b/docs/handoff/analysis.l4.json index 3bcdd19..1df90b1 100644 --- a/docs/handoff/analysis.l4.json +++ b/docs/handoff/analysis.l4.json @@ -1,11 +1,12 @@ { "schema_version": "2.0.0", + "identity_version": 2, "language": "python", "max_level": 4, "k_limit": 3, "analyzer": { "name": "codeanalyzer-python", - "version": "1.0.1", + "version": "1.6.2", "config": { "analysis_level": 4 } @@ -13,9 +14,9 @@ "application": { "symbol_table": { "service.py": { - "file_path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "file_path": "/path/to/sample-app/service.py", "module_name": "service", - "id": "can://python/sample_proj/service.py", + "id": "can://sample-app/sample-app/python/service.py", "kind": "module", "source": "class BaseService:\n pass\n\nclass Service(BaseService):\n def announce(self, flag):\n message = build(flag)\n if flag:\n message = message + \"!\"\n return message\n\ndef build(x):\n y = x\n return y\n\ndef run(flag):\n svc = Service()\n return svc.announce(flag)\n", "imports": [], @@ -24,7 +25,7 @@ "service.BaseService": { "name": "BaseService", "signature": "service.BaseService", - "id": "can://python/sample_proj/service.py/BaseService", + "id": "can://sample-app/sample-app/python/service.py/BaseService", "kind": "class", "span": { "start": [ @@ -42,6 +43,9 @@ }, "comments": [], "base_classes": [], + "decorators": [], + "entrypoints": [], + "is_entrypoint": false, "callables": {}, "attributes": {}, "types": {}, @@ -51,7 +55,7 @@ "service.Service": { "name": "Service", "signature": "service.Service", - "id": "can://python/sample_proj/service.py/Service", + "id": "can://sample-app/sample-app/python/service.py/Service", "kind": "class", "span": { "start": [ @@ -71,12 +75,15 @@ "base_classes": [ "BaseService" ], + "decorators": [], + "entrypoints": [], + "is_entrypoint": false, "callables": { "announce": { "name": "announce", - "path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "path": "/path/to/sample-app/service.py", "signature": "service.Service.announce", - "id": "can://python/sample_proj/service.py/Service/announce(self,flag)", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", "kind": "function", "span": { "start": [ @@ -94,10 +101,15 @@ }, "comments": [], "decorators": [], + "modifiers": [], + "entrypoints": [], + "is_entrypoint": false, "parameters": [ { "name": "self", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:0", "type": "Service", + "decorators": [], "start_line": 5, "end_line": 5, "start_column": 17, @@ -105,6 +117,8 @@ }, { "name": "flag", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:1", + "decorators": [], "start_line": 5, "end_line": 5, "start_column": 23, @@ -160,25 +174,6 @@ "col_offset": 22 } ], - "call_sites": [ - { - "method_name": "build", - "argument_types": [ - "Name" - ], - "arguments": [ - { - "ast_kind": "Name" - } - ], - "callee_signature": "service.build", - "is_constructor_call": false, - "start_line": 6, - "start_column": 18, - "end_line": 6, - "end_column": 29 - } - ], "callables": {}, "types": {}, "local_variables": [ @@ -206,6 +201,7 @@ "body": { "6:18": { "kind": "call", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:18", "span": { "start": [ 6, @@ -220,13 +216,25 @@ 116 ] }, - "callee": "can://python/sample_proj/service.py/build(x)" + "callee": "can://sample-app/sample-app/python/service.py/build(x)", + "parent": "6:8", + "method_name": "build", + "is_constructor_call": false, + "arguments": [ + { + "ast_kind": "Name", + "name": "flag" + } + ] }, "@entry": { - "kind": "entry" + "kind": "entry", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@entry", + "arguments": [] }, "6:8": { "kind": "statement", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8", "span": { "start": [ 6, @@ -240,10 +248,12 @@ 95, 116 ] - } + }, + "arguments": [] }, "7:11": { "kind": "branch", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@7:11", "span": { "start": [ 7, @@ -257,10 +267,12 @@ 128, 132 ] - } + }, + "arguments": [] }, "8:12": { "kind": "statement", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@8:12", "span": { "start": [ 8, @@ -274,10 +286,12 @@ 146, 169 ] - } + }, + "arguments": [] }, "9:8": { "kind": "return", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@9:8", "span": { "start": [ 9, @@ -291,40 +305,57 @@ 178, 192 ] - } + }, + "arguments": [] }, "@exit": { - "kind": "exit" + "kind": "exit", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@exit", + "arguments": [] }, "@formal_in:0": { "kind": "formal_in", - "of": "self" + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:0", + "of": "self", + "arguments": [] }, "@formal_in:1": { "kind": "formal_in", - "of": "flag" + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:1", + "of": "flag", + "arguments": [] }, "@formal_in:2": { "kind": "formal_in", - "of": ":service::build" + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:2", + "of": ":service::build", + "arguments": [] }, "@formal_out:0": { "kind": "formal_out", - "of": "" + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_out:0", + "of": "", + "arguments": [] }, "@formal_out:1": { "kind": "formal_out", - "of": "flag" + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_out:1", + "of": "flag", + "arguments": [] }, "6:8/actual_in:0": { "kind": "actual_in", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_in:0", "of": "x", - "parent": "6:8" + "parent": "6:8", + "arguments": [] }, "6:8/actual_out": { "kind": "actual_out", + "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_out", "of": "", - "parent": "6:8" + "parent": "6:8", + "arguments": [] } }, "cfg": [ @@ -438,6 +469,62 @@ "prov": [ "ssa" ] + }, + { + "src": "6:8", + "dst": "@formal_out:1", + "var": "flag.*", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "6:8/actual_out", + "dst": "6:8", + "var": "", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "9:8", + "dst": "@formal_out:0", + "var": "", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "@formal_in:1", + "dst": "6:8", + "var": "flag", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "@formal_in:1", + "dst": "6:8/actual_in:0", + "var": "flag", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "@formal_in:1", + "dst": "7:11", + "var": "flag", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "@formal_in:2", + "dst": "6:8", + "var": "service::build", + "prov": [ + "reaching-defs" + ] } ], "summary": [ @@ -457,9 +544,9 @@ "functions": { "build": { "name": "build", - "path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "path": "/path/to/sample-app/service.py", "signature": "service.build", - "id": "can://python/sample_proj/service.py/build(x)", + "id": "can://sample-app/sample-app/python/service.py/build(x)", "kind": "function", "span": { "start": [ @@ -477,9 +564,14 @@ }, "comments": [], "decorators": [], + "modifiers": [], + "entrypoints": [], + "is_entrypoint": false, "parameters": [ { "name": "x", + "id": "can://sample-app/sample-app/python/service.py/build(x)@formal_in:0", + "decorators": [], "start_line": 11, "end_line": 11, "start_column": 10, @@ -507,7 +599,6 @@ "col_offset": 11 } ], - "call_sites": [], "callables": {}, "types": {}, "local_variables": [ @@ -524,10 +615,13 @@ "cyclomatic_complexity": 2, "body": { "@entry": { - "kind": "entry" + "kind": "entry", + "id": "can://sample-app/sample-app/python/service.py/build(x)@entry", + "arguments": [] }, "12:4": { "kind": "statement", + "id": "can://sample-app/sample-app/python/service.py/build(x)@12:4", "span": { "start": [ 12, @@ -541,10 +635,12 @@ 212, 217 ] - } + }, + "arguments": [] }, "13:4": { "kind": "return", + "id": "can://sample-app/sample-app/python/service.py/build(x)@13:4", "span": { "start": [ 13, @@ -558,18 +654,25 @@ 222, 230 ] - } + }, + "arguments": [] }, "@exit": { - "kind": "exit" + "kind": "exit", + "id": "can://sample-app/sample-app/python/service.py/build(x)@exit", + "arguments": [] }, "@formal_in:0": { "kind": "formal_in", - "of": "x" + "id": "can://sample-app/sample-app/python/service.py/build(x)@formal_in:0", + "of": "x", + "arguments": [] }, "@formal_out": { "kind": "formal_out", - "of": "" + "id": "can://sample-app/sample-app/python/service.py/build(x)@formal_out", + "of": "", + "arguments": [] } }, "cfg": [ @@ -615,15 +718,31 @@ "prov": [ "ssa" ] + }, + { + "src": "13:4", + "dst": "@formal_out", + "var": "", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "@formal_in:0", + "dst": "12:4", + "var": "x", + "prov": [ + "reaching-defs" + ] } ], "summary": [] }, "run": { "name": "run", - "path": "/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py", + "path": "/path/to/sample-app/service.py", "signature": "service.run", - "id": "can://python/sample_proj/service.py/run(flag)", + "id": "can://sample-app/sample-app/python/service.py/run(flag)", "kind": "function", "span": { "start": [ @@ -641,9 +760,14 @@ }, "comments": [], "decorators": [], + "modifiers": [], + "entrypoints": [], + "is_entrypoint": false, "parameters": [ { "name": "flag", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@formal_in:0", + "decorators": [], "start_line": 15, "end_line": 15, "start_column": 8, @@ -683,40 +807,6 @@ "col_offset": 11 } ], - "call_sites": [ - { - "method_name": "Service", - "argument_types": [], - "arguments": [], - "return_type": "Service", - "callee_signature": "service.Service.__init__", - "is_constructor_call": true, - "start_line": 16, - "start_column": 10, - "end_line": 16, - "end_column": 19 - }, - { - "method_name": "announce", - "receiver_expr": "svc", - "receiver_type": "Service", - "argument_types": [ - "Name" - ], - "arguments": [ - { - "ast_kind": "Name" - } - ], - "return_type": "str", - "callee_signature": "service.Service.announce", - "is_constructor_call": false, - "start_line": 17, - "start_column": 11, - "end_line": 17, - "end_column": 29 - } - ], "callables": {}, "types": {}, "local_variables": [ @@ -735,6 +825,7 @@ "body": { "16:10": { "kind": "call", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@16:10", "span": { "start": [ 16, @@ -749,10 +840,16 @@ 266 ] }, - "callee": "can://python/sample_proj/@external/service.Service/__init__" + "callee": "can://sample-app/sample-app/@external/service.Service/__init__", + "parent": "16:4", + "method_name": "Service", + "return_type": "Service", + "is_constructor_call": true, + "arguments": [] }, "17:11": { "kind": "call", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@17:11", "span": { "start": [ 17, @@ -767,13 +864,28 @@ 296 ] }, - "callee": "can://python/sample_proj/service.py/Service/announce(self,flag)" + "callee": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", + "parent": "17:4", + "method_name": "announce", + "receiver_expr": "svc", + "receiver_type": "Service", + "return_type": "str", + "is_constructor_call": false, + "arguments": [ + { + "ast_kind": "Name", + "name": "flag" + } + ] }, "@entry": { - "kind": "entry" + "kind": "entry", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@entry", + "arguments": [] }, "16:4": { "kind": "statement", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@16:4", "span": { "start": [ 16, @@ -787,10 +899,12 @@ 251, 266 ] - } + }, + "arguments": [] }, "17:4": { "kind": "return", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@17:4", "span": { "start": [ 17, @@ -804,55 +918,78 @@ 271, 296 ] - } + }, + "arguments": [] }, "@exit": { - "kind": "exit" + "kind": "exit", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@exit", + "arguments": [] }, "@formal_in:0": { "kind": "formal_in", - "of": "flag" + "id": "can://sample-app/sample-app/python/service.py/run(flag)@formal_in:0", + "of": "flag", + "arguments": [] }, "@formal_in:1": { "kind": "formal_in", - "of": ":service::Service" + "id": "can://sample-app/sample-app/python/service.py/run(flag)@formal_in:1", + "of": ":service::Service", + "arguments": [] }, "@formal_in:2": { "kind": "formal_in", - "of": ":service::build" + "id": "can://sample-app/sample-app/python/service.py/run(flag)@formal_in:2", + "of": ":service::build", + "arguments": [] }, "@formal_out:0": { "kind": "formal_out", - "of": "" + "id": "can://sample-app/sample-app/python/service.py/run(flag)@formal_out:0", + "of": "", + "arguments": [] }, "@formal_out:1": { "kind": "formal_out", - "of": "flag" + "id": "can://sample-app/sample-app/python/service.py/run(flag)@formal_out:1", + "of": "flag", + "arguments": [] }, "17:4/actual_in:0": { "kind": "actual_in", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:0", "of": "self", - "parent": "17:4" + "parent": "17:4", + "arguments": [] }, "17:4/actual_in:1": { "kind": "actual_in", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:1", "of": "flag", - "parent": "17:4" + "parent": "17:4", + "arguments": [] }, "17:4/actual_in:2": { "kind": "actual_in", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:2", "of": ":service::build", - "parent": "17:4" + "parent": "17:4", + "arguments": [] }, "17:4/actual_out:0": { "kind": "actual_out", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:0", "of": "", - "parent": "17:4" + "parent": "17:4", + "arguments": [] }, "17:4/actual_out:1": { "kind": "actual_out", + "id": "can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:1", "of": "flag", - "parent": "17:4" + "parent": "17:4", + "arguments": [] } }, "cfg": [ @@ -925,6 +1062,86 @@ "ssa" ] }, + { + "src": "16:4", + "dst": "17:4/actual_in:0", + "var": "svc", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "16:4", + "dst": "17:4/actual_in:0", + "var": "svc.announce", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "17:4", + "dst": "@formal_out:0", + "var": "", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "17:4", + "dst": "@formal_out:1", + "var": "flag.*", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "17:4/actual_out:0", + "dst": "17:4", + "var": "", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "17:4/actual_out:1", + "dst": "17:4", + "var": "flag", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "@entry", + "dst": "17:4/actual_in:0", + "var": "svc.announce", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "@formal_in:0", + "dst": "17:4", + "var": "flag", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "@formal_in:0", + "dst": "17:4/actual_in:1", + "var": "flag", + "prov": [ + "reaching-defs" + ] + }, + { + "src": "@formal_in:1", + "dst": "16:4", + "var": "service::Service", + "prov": [ + "reaching-defs" + ] + }, { "src": "@entry", "dst": "17:4", @@ -956,79 +1173,100 @@ }, "variables": [], "content_hash": "7097c745b302b502071549264a3eab63d8ba4db7c11174aca96a06d023db62e9", - "last_modified": 1784055599.9118228, + "last_modified": 1793382888.634782, "file_size": 297 } }, - "id": "can://python/sample_proj", + "id": "can://sample-app/sample-app", "kind": "application", + "identity_version": 2, + "application_name": "sample-app", + "identity_namespace": "sample-app", "call_graph": [ { - "src": "can://python/sample_proj/service.py/Service/announce(self,flag)", - "dst": "can://python/sample_proj/service.py/build(x)", - "weight": 2, + "src": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", + "dst": "can://sample-app/sample-app/python/service.py/build(x)", + "weight": 1, "prov": [ - "jedi", - "pycg" + "jedi" ] }, { - "src": "can://python/sample_proj/service.py/run(flag)", - "dst": "can://python/sample_proj/@external/service.Service/__init__", + "src": "can://sample-app/sample-app/python/service.py/run(flag)", + "dst": "can://sample-app/sample-app/@external/service.Service/__init__", "weight": 1, "prov": [ "jedi" ] }, { - "src": "can://python/sample_proj/service.py/run(flag)", - "dst": "can://python/sample_proj/service.py/Service/announce(self,flag)", - "weight": 2, + "src": "can://sample-app/sample-app/python/service.py/run(flag)", + "dst": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)", + "weight": 1, "prov": [ - "jedi", - "pycg" + "jedi" ] } ], "external_symbols": { - "can://python/sample_proj/@external/service.Service/__init__": { - "id": "can://python/sample_proj/@external/service.Service/__init__", + "can://sample-app/sample-app/@external/service.Service/__init__": { + "id": "can://sample-app/sample-app/@external/service.Service/__init__", "kind": "external", "name": "__init__", "module": "service.Service" } }, + "artifacts": {}, + "dependencies": [], + "unresolved_imports": [], + "entrypoint_report": { + "frameworks_detected": [], + "rulesets": [ + "shipped" + ], + "unresolved": {}, + "errors": [] + }, "param_in": [ { - "src": "can://python/sample_proj/service.py/Service/announce(self,flag)@6:8/actual_in:0", - "dst": "can://python/sample_proj/service.py/build(x)@formal_in:0" + "src": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_in:0", + "dst": "can://sample-app/sample-app/python/service.py/build(x)@formal_in:0", + "var": "x" }, { - "src": "can://python/sample_proj/service.py/run(flag)@17:4/actual_in:0", - "dst": "can://python/sample_proj/service.py/Service/announce(self,flag)@formal_in:0" + "src": "can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:0", + "dst": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:0", + "var": "self" }, { - "src": "can://python/sample_proj/service.py/run(flag)@17:4/actual_in:1", - "dst": "can://python/sample_proj/service.py/Service/announce(self,flag)@formal_in:1" + "src": "can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:1", + "dst": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:1", + "var": "flag" }, { - "src": "can://python/sample_proj/service.py/run(flag)@17:4/actual_in:2", - "dst": "can://python/sample_proj/service.py/Service/announce(self,flag)@formal_in:2" + "src": "can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:2", + "dst": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:2", + "var": ":service::build" } ], "param_out": [ { - "src": "can://python/sample_proj/service.py/Service/announce(self,flag)@formal_out:0", - "dst": "can://python/sample_proj/service.py/run(flag)@17:4/actual_out:0" + "src": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_out:0", + "dst": "can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:0", + "var": "" }, { - "src": "can://python/sample_proj/service.py/Service/announce(self,flag)@formal_out:1", - "dst": "can://python/sample_proj/service.py/run(flag)@17:4/actual_out:1" + "src": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_out:1", + "dst": "can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:1", + "var": "flag" }, { - "src": "can://python/sample_proj/service.py/build(x)@formal_out", - "dst": "can://python/sample_proj/service.py/Service/announce(self,flag)@6:8/actual_out" + "src": "can://sample-app/sample-app/python/service.py/build(x)@formal_out", + "dst": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_out", + "var": "" } - ] + ], + "config_uses": [], + "config_reads_unresolved": [] } } diff --git a/docs/handoff/graph.cypher b/docs/handoff/graph.cypher index b8757f4..168f9f9 100644 --- a/docs/handoff/graph.cypher +++ b/docs/handoff/graph.cypher @@ -1,142 +1,310 @@ // ── constraints & indexes ── -CREATE CONSTRAINT pyapplication_name IF NOT EXISTS FOR (x:PyApplication) REQUIRE x.name IS UNIQUE; +CREATE CONSTRAINT pyapplication_id IF NOT EXISTS FOR (x:PyApplication) REQUIRE x.id IS UNIQUE; CREATE CONSTRAINT pymodule_id IF NOT EXISTS FOR (x:PyModule) REQUIRE x.id IS UNIQUE; CREATE CONSTRAINT pysymbol_id IF NOT EXISTS FOR (x:PySymbol) REQUIRE x.id IS UNIQUE; CREATE CONSTRAINT pypackage_name IF NOT EXISTS FOR (x:PyPackage) REQUIRE x.name IS UNIQUE; CREATE CONSTRAINT pydecorator_name IF NOT EXISTS FOR (x:PyDecorator) REQUIRE x.name IS UNIQUE; -CREATE CONSTRAINT pycallsite_id IF NOT EXISTS FOR (x:PyCallSite) REQUIRE x.id IS UNIQUE; CREATE CONSTRAINT pyattribute_id IF NOT EXISTS FOR (x:PyAttribute) REQUIRE x.id IS UNIQUE; CREATE CONSTRAINT pyvariable_id IF NOT EXISTS FOR (x:PyVariable) REQUIRE x.id IS UNIQUE; -CREATE CONSTRAINT pycfgnode_id IF NOT EXISTS FOR (x:PyCFGNode) REQUIRE x.id IS UNIQUE; +CREATE CONSTRAINT pybodynode_id IF NOT EXISTS FOR (x:PyBodyNode) REQUIRE x.id IS UNIQUE; +CREATE CONSTRAINT artifact_id IF NOT EXISTS FOR (x:Artifact) REQUIRE x.id IS UNIQUE; +CREATE CONSTRAINT package_id IF NOT EXISTS FOR (x:Package) REQUIRE x.id IS UNIQUE; +CREATE CONSTRAINT configkey_id IF NOT EXISTS FOR (x:ConfigKey) REQUIRE x.id IS UNIQUE; CREATE INDEX py_callable_name IF NOT EXISTS FOR (c:PyCallable) ON (c.name); CREATE INDEX py_class_name IF NOT EXISTS FOR (c:PyClass) ON (c.name); CREATE FULLTEXT INDEX py_code_fts IF NOT EXISTS FOR (c:PyCallable) ON EACH [c.code, c.docstring]; +CREATE INDEX py_can_node_id IF NOT EXISTS FOR (n:PyCanNode) ON (n.id); // ── wipe this project's prior subgraph (externals/packages/decorators are shared) ── -MATCH (a:PyApplication {name: 'sample_proj'}) -OPTIONAL MATCH (a)-[:PY_HAS_MODULE]->(m:PyModule) -OPTIONAL MATCH (m)-[:PY_DECLARES|PY_HAS_METHOD|PY_HAS_ATTRIBUTE|PY_DECLARES_VAR|PY_HAS_CALLSITE*1..]->(x) -DETACH DELETE x, m, a; +MATCH (x:PyCanNode) WHERE x.id = 'can://sample-app/sample-app' OR x.id STARTS WITH 'can://sample-app/sample-app/' +CALL { WITH x DETACH DELETE x } IN TRANSACTIONS OF 1000 ROWS; // ── nodes ── UNWIND [ - {k: 'sample_proj', p: {schema_version: '2.0.0', analyzer_name: 'codeanalyzer-python', analyzer_version: '1.0.1'}} + {k: 'can://sample-app/sample-app/@external/service.Service/__init__', p: {name: '__init__', module: 'service.Service'}} ] AS row -MERGE (n:PyApplication {name: row.k}) -SET n += row.p; -UNWIND [ - {k: 'can://python/sample_proj/service.py/Service/announce(self,flag)@6:18', p: {kind: 'call', start_line: 6, end_line: 6, _module: 'service.py'}}, - {k: 'can://python/sample_proj/service.py/run(flag)@16:10', p: {kind: 'call', start_line: 16, end_line: 16, _module: 'service.py'}}, - {k: 'can://python/sample_proj/service.py/run(flag)@17:11', p: {kind: 'call', start_line: 17, end_line: 17, _module: 'service.py'}} -] AS row -MERGE (n:PyCFGNode {id: row.k}) -SET n += row.p; +MERGE (n:PySymbol {id: row.k}) +SET n += row.p, n:PyExternal:PyCanNode; UNWIND [ - {k: 'service.py#16:10-16:19', p: {id: 'service.py#16:10-16:19', method_name: 'Service', argument_types: [], return_type: 'Service', callee_signature: 'service.Service.__init__', is_constructor_call: true, start_line: 16, start_column: 10, end_line: 16, end_column: 19, _module: 'service.py'}}, - {k: 'service.py#17:11-17:29', p: {id: 'service.py#17:11-17:29', method_name: 'announce', receiver_expr: 'svc', receiver_type: 'Service', argument_types: ['Name'], arguments_json: '[{"ast_kind": "Name", "inferred_type": null}]', return_type: 'str', callee_signature: 'service.Service.announce', is_constructor_call: false, start_line: 17, start_column: 11, end_line: 17, end_column: 29, _module: 'service.py'}}, - {k: 'service.py#6:18-6:29', p: {id: 'service.py#6:18-6:29', method_name: 'build', argument_types: ['Name'], arguments_json: '[{"ast_kind": "Name", "inferred_type": null}]', callee_signature: 'service.build', is_constructor_call: false, start_line: 6, start_column: 18, end_line: 6, end_column: 29, _module: 'service.py'}} + {k: 'can://sample-app/sample-app', p: {id: 'can://sample-app/sample-app', name: 'sample-app', application_name: 'sample-app', identity_namespace: 'sample-app', identity_version: 2, schema_version: '2.0.0', analyzer_name: 'codeanalyzer-python', analyzer_version: '1.6.2', entrypoint_frameworks: [], entrypoint_report_json: '{"errors": [], "frameworks_detected": [], "rulesets": ["shipped"], "unresolved": {}}'}} ] AS row -MERGE (n:PyCallSite {id: row.k}) -SET n += row.p; +MERGE (n:PyApplication {id: row.k}) +SET n += row.p, n:PyCanNode; UNWIND [ - {k: 'can://python/sample_proj/service.py', p: {id: 'can://python/sample_proj/service.py', file_key: 'service.py', module_name: 'service', content_hash: '7097c745b302b502071549264a3eab63d8ba4db7c11174aca96a06d023db62e9', last_modified: 1784055599.9118228, file_size: 297, _module: 'service.py'}} + {k: 'can://sample-app/sample-app/python/service.py', p: {id: 'can://sample-app/sample-app/python/service.py', file_key: 'service.py', module_name: 'service', source: 'class BaseService:\n pass\n\nclass Service(BaseService):\n def announce(self, flag):\n message = build(flag)\n if flag:\n message = message + "!"\n return message\n\ndef build(x):\n y = x\n return y\n\ndef run(flag):\n svc = Service()\n return svc.announce(flag)\n', content_hash: '7097c745b302b502071549264a3eab63d8ba4db7c11174aca96a06d023db62e9', last_modified: 1793382888.634782, file_size: 297}} ] AS row MERGE (n:PyModule {id: row.k}) -SET n += row.p; +SET n += row.p, n:PyCanNode; UNWIND [ - {k: 'can://python/sample_proj/@external/service.Service/__init__', p: {name: '__init__', module: 'service.Service'}} + {k: 'can://sample-app/sample-app/python/service.py/build(x)', p: {id: 'can://sample-app/sample-app/python/service.py/build(x)', signature: 'service.build', name: 'build', path: '/path/to/sample-app/service.py', cyclomatic_complexity: 2, code: 'def build(x):\n y = x\n return y', code_start_line: 12, start_line: 11, start_column: 0, end_line: 13, end_column: 12, start_byte: 194, end_byte: 230, decorators: [], modifiers: [], parameters_json: '[{"decorators": [], "default_value": null, "end_column": 11, "end_line": 11, "id": "can://sample-app/sample-app/python/service.py/build(x)@formal_in:0", "name": "x", "start_column": 10, "start_line": 11, "type": null}]', accessed_symbols_json: '[{"col_offset": 8, "is_builtin": false, "kind": "variable", "lineno": 12, "name": "x", "qualified_name": null, "scope": "local", "type": null}, {"col_offset": 11, "is_builtin": false, "kind": "variable", "lineno": 13, "name": "y", "qualified_name": null, "scope": "local", "type": null}]', is_entrypoint: false, entrypoint_frameworks: []}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)', p: {id: 'can://sample-app/sample-app/python/service.py/run(flag)', signature: 'service.run', name: 'run', path: '/path/to/sample-app/service.py', cyclomatic_complexity: 2, code: 'def run(flag):\n svc = Service()\n return svc.announce(flag)', code_start_line: 16, start_line: 15, start_column: 0, end_line: 17, end_column: 29, start_byte: 232, end_byte: 296, decorators: [], modifiers: [], parameters_json: '[{"decorators": [], "default_value": null, "end_column": 12, "end_line": 15, "id": "can://sample-app/sample-app/python/service.py/run(flag)@formal_in:0", "name": "flag", "start_column": 8, "start_line": 15, "type": null}]', accessed_symbols_json: '[{"col_offset": 10, "is_builtin": false, "kind": "class", "lineno": 16, "name": "Service", "qualified_name": "service.Service", "scope": "local", "type": "Service"}, {"col_offset": 24, "is_builtin": false, "kind": "variable", "lineno": 17, "name": "flag", "qualified_name": null, "scope": "local", "type": null}, {"col_offset": 11, "is_builtin": false, "kind": "variable", "lineno": 17, "name": "svc", "qualified_name": "service.Service", "scope": "local", "type": "Service"}]', is_entrypoint: false, entrypoint_frameworks: []}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', p: {id: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', signature: 'service.Service.announce', name: 'announce', path: '/path/to/sample-app/service.py', cyclomatic_complexity: 3, code: 'def announce(self, flag):\n message = build(flag)\n if flag:\n message = message + "!"\n return message', code_start_line: 6, start_line: 5, start_column: 4, end_line: 9, end_column: 22, start_byte: 61, end_byte: 192, decorators: [], modifiers: [], parameters_json: '[{"decorators": [], "default_value": null, "end_column": 21, "end_line": 5, "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:0", "name": "self", "start_column": 17, "start_line": 5, "type": "Service"}, {"decorators": [], "default_value": null, "end_column": 27, "end_line": 5, "id": "can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:1", "name": "flag", "start_column": 23, "start_line": 5, "type": null}]', accessed_symbols_json: '[{"col_offset": 11, "is_builtin": false, "kind": "variable", "lineno": 7, "name": "flag", "qualified_name": null, "scope": "local", "type": null}, {"col_offset": 15, "is_builtin": false, "kind": "variable", "lineno": 9, "name": "message", "qualified_name": "builtins.str", "scope": "local", "type": "str"}, {"col_offset": 18, "is_builtin": false, "kind": "function", "lineno": 6, "name": "build", "qualified_name": "service.build", "scope": "local", "type": "build"}, {"col_offset": 24, "is_builtin": false, "kind": "variable", "lineno": 6, "name": "flag", "qualified_name": null, "scope": "local", "type": null}, {"col_offset": 22, "is_builtin": false, "kind": "variable", "lineno": 8, "name": "message", "qualified_name": null, "scope": "local", "type": null}]', is_entrypoint: false, entrypoint_frameworks: []}} ] AS row MERGE (n:PySymbol {id: row.k}) -SET n += row.p, n:PyExternal; +SET n += row.p, n:PyCallable:PyCanNode; UNWIND [ - {k: 'can://python/sample_proj/service.py/BaseService', p: {id: 'can://python/sample_proj/service.py/BaseService', signature: 'service.BaseService', name: 'BaseService', base_classes: [], start_line: 1, end_line: 2, _module: 'service.py'}}, - {k: 'can://python/sample_proj/service.py/Service', p: {id: 'can://python/sample_proj/service.py/Service', signature: 'service.Service', name: 'Service', base_classes: ['BaseService'], start_line: 4, end_line: 9, _module: 'service.py'}} + {k: 'can://sample-app/sample-app/python/service.py/build(x)/y@12', p: {id: 'can://sample-app/sample-app/python/service.py/build(x)/y@12', name: 'y', initializer: 'x', scope: 'function', start_line: 12, start_column: 4, end_line: 12, end_column: 5, start_byte: 212, end_byte: 213}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)/svc@16', p: {id: 'can://sample-app/sample-app/python/service.py/run(flag)/svc@16', name: 'svc', type: 'Service', initializer: 'Service()', scope: 'function', start_line: 16, start_column: 4, end_line: 16, end_column: 7, start_byte: 251, end_byte: 254}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)/message@6', p: {id: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)/message@6', name: 'message', initializer: 'build(flag)', scope: 'function', start_line: 6, start_column: 8, end_line: 6, end_column: 15, start_byte: 95, end_byte: 102}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)/message@8', p: {id: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)/message@8', name: 'message', type: 'str', initializer: 'message + \'!\'', scope: 'function', start_line: 8, start_column: 12, end_line: 8, end_column: 19, start_byte: 146, end_byte: 153}} ] AS row -MERGE (n:PySymbol {id: row.k}) -SET n += row.p, n:PyClass; +MERGE (n:PyVariable {id: row.k}) +SET n += row.p, n:PyCanNode; UNWIND [ - {k: 'can://python/sample_proj/service.py/Service/announce(self,flag)', p: {id: 'can://python/sample_proj/service.py/Service/announce(self,flag)', signature: 'service.Service.announce', name: 'announce', path: '/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py', cyclomatic_complexity: 3, code_start_line: 6, start_line: 5, end_line: 9, decorators: [], parameters_json: '[{"default_value": null, "end_column": 21, "end_line": 5, "name": "self", "start_column": 17, "start_line": 5, "type": "Service"}, {"default_value": null, "end_column": 27, "end_line": 5, "name": "flag", "start_column": 23, "start_line": 5, "type": null}]', accessed_symbols_json: '[{"col_offset": 11, "is_builtin": false, "kind": "variable", "lineno": 7, "name": "flag", "qualified_name": null, "scope": "local", "type": null}, {"col_offset": 15, "is_builtin": false, "kind": "variable", "lineno": 9, "name": "message", "qualified_name": "builtins.str", "scope": "local", "type": "str"}, {"col_offset": 18, "is_builtin": false, "kind": "function", "lineno": 6, "name": "build", "qualified_name": "service.build", "scope": "local", "type": "build"}, {"col_offset": 24, "is_builtin": false, "kind": "variable", "lineno": 6, "name": "flag", "qualified_name": null, "scope": "local", "type": null}, {"col_offset": 22, "is_builtin": false, "kind": "variable", "lineno": 8, "name": "message", "qualified_name": null, "scope": "local", "type": null}]', _module: 'service.py'}}, - {k: 'can://python/sample_proj/service.py/build(x)', p: {id: 'can://python/sample_proj/service.py/build(x)', signature: 'service.build', name: 'build', path: '/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py', cyclomatic_complexity: 2, code_start_line: 12, start_line: 11, end_line: 13, decorators: [], parameters_json: '[{"default_value": null, "end_column": 11, "end_line": 11, "name": "x", "start_column": 10, "start_line": 11, "type": null}]', accessed_symbols_json: '[{"col_offset": 8, "is_builtin": false, "kind": "variable", "lineno": 12, "name": "x", "qualified_name": null, "scope": "local", "type": null}, {"col_offset": 11, "is_builtin": false, "kind": "variable", "lineno": 13, "name": "y", "qualified_name": null, "scope": "local", "type": null}]', _module: 'service.py'}}, - {k: 'can://python/sample_proj/service.py/run(flag)', p: {id: 'can://python/sample_proj/service.py/run(flag)', signature: 'service.run', name: 'run', path: '/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-codeanalyzer-python/679d9d16-db02-445a-9e18-8e43de0e0565/scratchpad/sample_proj/service.py', cyclomatic_complexity: 2, code_start_line: 16, start_line: 15, end_line: 17, decorators: [], parameters_json: '[{"default_value": null, "end_column": 12, "end_line": 15, "name": "flag", "start_column": 8, "start_line": 15, "type": null}]', accessed_symbols_json: '[{"col_offset": 10, "is_builtin": false, "kind": "class", "lineno": 16, "name": "Service", "qualified_name": "service.Service", "scope": "local", "type": "Service"}, {"col_offset": 24, "is_builtin": false, "kind": "variable", "lineno": 17, "name": "flag", "qualified_name": null, "scope": "local", "type": null}, {"col_offset": 11, "is_builtin": false, "kind": "variable", "lineno": 17, "name": "svc", "qualified_name": "service.Service", "scope": "local", "type": "Service"}]', _module: 'service.py'}} + {k: 'can://sample-app/sample-app/python/service.py/BaseService', p: {id: 'can://sample-app/sample-app/python/service.py/BaseService', signature: 'service.BaseService', name: 'BaseService', code: 'class BaseService:\n pass', base_classes: [], decorators: [], start_line: 1, start_column: 0, end_line: 2, end_column: 8, start_byte: 0, end_byte: 27, is_entrypoint: false, entrypoint_frameworks: []}}, + {k: 'can://sample-app/sample-app/python/service.py/Service', p: {id: 'can://sample-app/sample-app/python/service.py/Service', signature: 'service.Service', name: 'Service', code: 'class Service(BaseService):\n def announce(self, flag):\n message = build(flag)\n if flag:\n message = message + "!"\n return message', base_classes: ['BaseService'], decorators: [], start_line: 4, start_column: 0, end_line: 9, end_column: 22, start_byte: 29, end_byte: 192, is_entrypoint: false, entrypoint_frameworks: []}} ] AS row MERGE (n:PySymbol {id: row.k}) -SET n += row.p, n:PyCallable; +SET n += row.p, n:PyClass:PyCanNode; UNWIND [ - {k: 'service.Service.announce#message@6', p: {id: 'service.Service.announce#message@6', name: 'message', initializer: 'build(flag)', scope: 'function', start_line: 6, end_line: 6, _module: 'service.py'}}, - {k: 'service.Service.announce#message@8', p: {id: 'service.Service.announce#message@8', name: 'message', type: 'str', initializer: 'message + \'!\'', scope: 'function', start_line: 8, end_line: 8, _module: 'service.py'}}, - {k: 'service.build#y@12', p: {id: 'service.build#y@12', name: 'y', initializer: 'x', scope: 'function', start_line: 12, end_line: 12, _module: 'service.py'}}, - {k: 'service.run#svc@16', p: {id: 'service.run#svc@16', name: 'svc', type: 'Service', initializer: 'Service()', scope: 'function', start_line: 16, end_line: 16, _module: 'service.py'}} + {k: 'can://sample-app/sample-app/python/service.py/build(x)@entry', p: {kind: 'entry'}}, + {k: 'can://sample-app/sample-app/python/service.py/build(x)@12:4', p: {kind: 'statement', start_line: 12, start_column: 4, end_line: 12, end_column: 9, start_byte: 212, end_byte: 217}}, + {k: 'can://sample-app/sample-app/python/service.py/build(x)@13:4', p: {kind: 'return', start_line: 13, start_column: 4, end_line: 13, end_column: 12, start_byte: 222, end_byte: 230}}, + {k: 'can://sample-app/sample-app/python/service.py/build(x)@exit', p: {kind: 'exit'}}, + {k: 'can://sample-app/sample-app/python/service.py/build(x)@formal_in:0', p: {kind: 'formal_in', var: 'x'}}, + {k: 'can://sample-app/sample-app/python/service.py/build(x)@formal_out', p: {kind: 'formal_out', var: ''}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@16:10', p: {kind: 'call', start_line: 16, start_column: 10, end_line: 16, end_column: 19, start_byte: 257, end_byte: 266, callee_signature: 'service.Service.__init__', call_node: '16:4', method_name: 'Service', return_type: 'Service', is_constructor_call: true}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@17:11', p: {kind: 'call', start_line: 17, start_column: 11, end_line: 17, end_column: 29, start_byte: 278, end_byte: 296, callee_signature: 'service.Service.announce', call_node: '17:4', method_name: 'announce', receiver_expr: 'svc', receiver_type: 'Service', return_type: 'str', is_constructor_call: false, arguments_json: '[{"ast_kind": "Name", "inferred_type": null, "name": "flag", "value": null}]'}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@entry', p: {kind: 'entry'}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@16:4', p: {kind: 'statement', start_line: 16, start_column: 4, end_line: 16, end_column: 19, start_byte: 251, end_byte: 266}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', p: {kind: 'return', start_line: 17, start_column: 4, end_line: 17, end_column: 29, start_byte: 271, end_byte: 296}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@exit', p: {kind: 'exit'}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_in:0', p: {kind: 'formal_in', var: 'flag'}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_in:1', p: {kind: 'formal_in', var: ':service::Service'}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_in:2', p: {kind: 'formal_in', var: ':service::build'}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_out:0', p: {kind: 'formal_out', var: ''}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_out:1', p: {kind: 'formal_out', var: 'flag'}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:0', p: {kind: 'actual_in', var: 'self', call_node: '17:4'}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:1', p: {kind: 'actual_in', var: 'flag', call_node: '17:4'}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:2', p: {kind: 'actual_in', var: ':service::build', call_node: '17:4'}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:0', p: {kind: 'actual_out', var: '', call_node: '17:4'}}, + {k: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:1', p: {kind: 'actual_out', var: 'flag', call_node: '17:4'}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:18', p: {kind: 'call', start_line: 6, start_column: 18, end_line: 6, end_column: 29, start_byte: 105, end_byte: 116, callee_signature: 'service.build', call_node: '6:8', method_name: 'build', is_constructor_call: false, arguments_json: '[{"ast_kind": "Name", "inferred_type": null, "name": "flag", "value": null}]'}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@entry', p: {kind: 'entry'}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', p: {kind: 'statement', start_line: 6, start_column: 8, end_line: 6, end_column: 29, start_byte: 95, end_byte: 116}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@7:11', p: {kind: 'branch', start_line: 7, start_column: 11, end_line: 7, end_column: 15, start_byte: 128, end_byte: 132}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@8:12', p: {kind: 'statement', start_line: 8, start_column: 12, end_line: 8, end_column: 35, start_byte: 146, end_byte: 169}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@9:8', p: {kind: 'return', start_line: 9, start_column: 8, end_line: 9, end_column: 22, start_byte: 178, end_byte: 192}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@exit', p: {kind: 'exit'}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:0', p: {kind: 'formal_in', var: 'self'}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:1', p: {kind: 'formal_in', var: 'flag'}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:2', p: {kind: 'formal_in', var: ':service::build'}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_out:0', p: {kind: 'formal_out', var: ''}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_out:1', p: {kind: 'formal_out', var: 'flag'}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_in:0', p: {kind: 'actual_in', var: 'x', call_node: '6:8'}}, + {k: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_out', p: {kind: 'actual_out', var: '', call_node: '6:8'}} ] AS row -MERGE (n:PyVariable {id: row.k}) -SET n += row.p; +MERGE (n:PyBodyNode {id: row.k}) +SET n += row.p, n:PyCanNode; +UNWIND [ + {k: 'can://sample-app/sample-app/@external/service.Service/__init__', p: {name: '__init__', module: 'service.Service'}} +] AS row +MERGE (n:PySymbol {id: row.k}) +SET n += row.p, n:PyExternal:PyCanNode; // ── relationships ── UNWIND [ - {f: 'can://python/sample_proj/service.py/Service/announce(self,flag)', t: 'can://python/sample_proj/service.py/build(x)', p: {weight: 1, prov: ['jedi']}}, - {f: 'can://python/sample_proj/service.py/run(flag)', t: 'can://python/sample_proj/@external/service.Service/__init__', p: {weight: 1, prov: ['jedi']}}, - {f: 'can://python/sample_proj/service.py/run(flag)', t: 'can://python/sample_proj/service.py/Service/announce(self,flag)', p: {weight: 1, prov: ['jedi']}} + {f: 'can://sample-app/sample-app', t: 'can://sample-app/sample-app/python/service.py', p: {}} ] AS row -MATCH (a:PySymbol {id: row.f}) -MATCH (b:PySymbol {id: row.t}) -MERGE (a)-[r:PY_CALLS]->(b) +MATCH (a:PyApplication {id: row.f}) +MATCH (b:PyModule {id: row.t}) +MERGE (a)-[r:PY_HAS_MODULE]->(b) SET r += row.p; UNWIND [ - {f: 'can://python/sample_proj/service.py', t: 'can://python/sample_proj/service.py/BaseService', p: {}}, - {f: 'can://python/sample_proj/service.py', t: 'can://python/sample_proj/service.py/Service', p: {}}, - {f: 'can://python/sample_proj/service.py', t: 'can://python/sample_proj/service.py/build(x)', p: {}}, - {f: 'can://python/sample_proj/service.py', t: 'can://python/sample_proj/service.py/run(flag)', p: {}} + {f: 'can://sample-app/sample-app/python/service.py', t: 'can://sample-app/sample-app/python/service.py/build(x)', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py', t: 'can://sample-app/sample-app/python/service.py/run(flag)', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py', t: 'can://sample-app/sample-app/python/service.py/BaseService', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py', t: 'can://sample-app/sample-app/python/service.py/Service', p: {}} ] AS row MATCH (a:PyModule {id: row.f}) MATCH (b:PySymbol {id: row.t}) MERGE (a)-[r:PY_DECLARES]->(b) SET r += row.p; UNWIND [ - {f: 'can://python/sample_proj/service.py/Service/announce(self,flag)', t: 'service.Service.announce#message@6', p: {}}, - {f: 'can://python/sample_proj/service.py/Service/announce(self,flag)', t: 'service.Service.announce#message@8', p: {}}, - {f: 'can://python/sample_proj/service.py/build(x)', t: 'service.build#y@12', p: {}}, - {f: 'can://python/sample_proj/service.py/run(flag)', t: 'service.run#svc@16', p: {}} + {f: 'can://sample-app/sample-app/python/service.py/build(x)', t: 'can://sample-app/sample-app/python/service.py/build(x)/y@12', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)/svc@16', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)/message@6', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)/message@8', p: {}} ] AS row MATCH (a:PySymbol {id: row.f}) MATCH (b:PyVariable {id: row.t}) MERGE (a)-[r:PY_DECLARES_VAR]->(b) SET r += row.p; UNWIND [ - {f: 'can://python/sample_proj/service.py/Service/announce(self,flag)', t: 'service.py#6:18-6:29', p: {}}, - {f: 'can://python/sample_proj/service.py/run(flag)', t: 'service.py#16:10-16:19', p: {}}, - {f: 'can://python/sample_proj/service.py/run(flag)', t: 'service.py#17:11-17:29', p: {}} + {f: 'can://sample-app/sample-app/python/service.py/Service', t: 'can://sample-app/sample-app/python/service.py/BaseService', p: {}} ] AS row MATCH (a:PySymbol {id: row.f}) -MATCH (b:PyCallSite {id: row.t}) -MERGE (a)-[r:PY_HAS_CALLSITE]->(b) +MATCH (b:PySymbol {id: row.t}) +MERGE (a)-[r:PY_EXTENDS]->(b) SET r += row.p; UNWIND [ - {f: 'can://python/sample_proj/service.py/Service/announce(self,flag)', t: 'can://python/sample_proj/service.py/Service/announce(self,flag)@6:18', p: {}}, - {f: 'can://python/sample_proj/service.py/run(flag)', t: 'can://python/sample_proj/service.py/run(flag)@16:10', p: {}}, - {f: 'can://python/sample_proj/service.py/run(flag)', t: 'can://python/sample_proj/service.py/run(flag)@17:11', p: {}} + {f: 'can://sample-app/sample-app/python/service.py/Service', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', p: {}} ] AS row MATCH (a:PySymbol {id: row.f}) -MATCH (b:PyCFGNode {id: row.t}) -MERGE (a)-[r:PY_HAS_CFG_NODE]->(b) +MATCH (b:PySymbol {id: row.t}) +MERGE (a)-[r:PY_HAS_METHOD]->(b) SET r += row.p; UNWIND [ - {f: 'can://python/sample_proj/service.py/Service', t: 'can://python/sample_proj/service.py/Service/announce(self,flag)', p: {}} + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/build(x)', p: {weight: 1, prov: ['jedi']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/@external/service.Service/__init__', p: {weight: 1, prov: ['jedi']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', p: {weight: 1, prov: ['jedi']}} ] AS row MATCH (a:PySymbol {id: row.f}) MATCH (b:PySymbol {id: row.t}) -MERGE (a)-[r:PY_HAS_METHOD]->(b) +MERGE (a)-[r:PY_CALLS]->(b) SET r += row.p; UNWIND [ - {f: 'sample_proj', t: 'can://python/sample_proj/service.py', p: {}} + {f: 'can://sample-app/sample-app/python/service.py/build(x)', t: 'can://sample-app/sample-app/python/service.py/build(x)@entry', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/build(x)', t: 'can://sample-app/sample-app/python/service.py/build(x)@12:4', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/build(x)', t: 'can://sample-app/sample-app/python/service.py/build(x)@13:4', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/build(x)', t: 'can://sample-app/sample-app/python/service.py/build(x)@exit', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/build(x)', t: 'can://sample-app/sample-app/python/service.py/build(x)@formal_in:0', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/build(x)', t: 'can://sample-app/sample-app/python/service.py/build(x)@formal_out', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@16:10', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:11', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@entry', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@16:4', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@exit', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_in:0', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_in:1', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_in:2', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_out:0', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_out:1', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:0', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:1', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:2', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:0', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:1', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:18', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@entry', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@7:11', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@8:12', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@9:8', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@exit', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:0', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:1', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:2', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_out:0', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_out:1', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_in:0', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_out', p: {}} ] AS row -MATCH (a:PyApplication {name: row.f}) -MATCH (b:PyModule {id: row.t}) -MERGE (a)-[r:PY_HAS_MODULE]->(b) +MATCH (a:PySymbol {id: row.f}) +MATCH (b:PyBodyNode {id: row.t}) +MERGE (a)-[r:PY_HAS_BODY_NODE]->(b) +SET r += row.p; +UNWIND [ + {f: 'can://sample-app/sample-app/python/service.py/build(x)@entry', t: 'can://sample-app/sample-app/python/service.py/build(x)@12:4', k: 'fallthrough', p: {kind: 'fallthrough'}}, + {f: 'can://sample-app/sample-app/python/service.py/build(x)@12:4', t: 'can://sample-app/sample-app/python/service.py/build(x)@13:4', k: 'fallthrough', p: {kind: 'fallthrough'}}, + {f: 'can://sample-app/sample-app/python/service.py/build(x)@13:4', t: 'can://sample-app/sample-app/python/service.py/build(x)@exit', k: 'return', p: {kind: 'return'}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@entry', t: 'can://sample-app/sample-app/python/service.py/run(flag)@16:4', k: 'fallthrough', p: {kind: 'fallthrough'}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@16:4', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', k: 'fallthrough', p: {kind: 'fallthrough'}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@16:4', t: 'can://sample-app/sample-app/python/service.py/run(flag)@exit', k: 'exception', p: {kind: 'exception'}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', t: 'can://sample-app/sample-app/python/service.py/run(flag)@exit', k: 'exception', p: {kind: 'exception'}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', t: 'can://sample-app/sample-app/python/service.py/run(flag)@exit', k: 'return', p: {kind: 'return'}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@entry', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', k: 'fallthrough', p: {kind: 'fallthrough'}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@7:11', k: 'fallthrough', p: {kind: 'fallthrough'}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@exit', k: 'exception', p: {kind: 'exception'}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@7:11', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@8:12', k: 'true', p: {kind: 'true'}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@7:11', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@9:8', k: 'false', p: {kind: 'false'}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@8:12', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@9:8', k: 'fallthrough', p: {kind: 'fallthrough'}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@9:8', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@exit', k: 'return', p: {kind: 'return'}} +] AS row +MATCH (a:PyBodyNode {id: row.f}) +MATCH (b:PyBodyNode {id: row.t}) +MERGE (a)-[r:PY_CFG_NEXT {_k: row.k}]->(b) +SET r += row.p; +UNWIND [ + {f: 'can://sample-app/sample-app/python/service.py/build(x)@entry', t: 'can://sample-app/sample-app/python/service.py/build(x)@12:4', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/build(x)@entry', t: 'can://sample-app/sample-app/python/service.py/build(x)@13:4', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@entry', t: 'can://sample-app/sample-app/python/service.py/run(flag)@16:4', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@16:4', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@entry', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@7:11', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@9:8', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@7:11', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@8:12', p: {}} +] AS row +MATCH (a:PyBodyNode {id: row.f}) +MATCH (b:PyBodyNode {id: row.t}) +MERGE (a)-[r:PY_CDG]->(b) +SET r += row.p; +UNWIND [ + {f: 'can://sample-app/sample-app/python/service.py/build(x)@entry', t: 'can://sample-app/sample-app/python/service.py/build(x)@12:4', k: 'x|ssa', p: {var: 'x', prov: ['ssa']}}, + {f: 'can://sample-app/sample-app/python/service.py/build(x)@12:4', t: 'can://sample-app/sample-app/python/service.py/build(x)@13:4', k: 'y|ssa', p: {var: 'y', prov: ['ssa']}}, + {f: 'can://sample-app/sample-app/python/service.py/build(x)@13:4', t: 'can://sample-app/sample-app/python/service.py/build(x)@formal_out', k: '|reaching-defs', p: {var: '', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/build(x)@formal_in:0', t: 'can://sample-app/sample-app/python/service.py/build(x)@12:4', k: 'x|reaching-defs', p: {var: 'x', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@entry', t: 'can://sample-app/sample-app/python/service.py/run(flag)@16:4', k: 'service::Service|ssa', p: {var: 'service::Service', prov: ['ssa']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@entry', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', k: 'flag|ssa', p: {var: 'flag', prov: ['ssa']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@16:4', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', k: 'svc|ssa', p: {var: 'svc', prov: ['ssa']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@16:4', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', k: 'svc.announce|ssa', p: {var: 'svc.announce', prov: ['ssa']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@16:4', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:0', k: 'svc|reaching-defs', p: {var: 'svc', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@16:4', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:0', k: 'svc.announce|reaching-defs', p: {var: 'svc.announce', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', t: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_out:0', k: '|reaching-defs', p: {var: '', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', t: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_out:1', k: 'flag.*|reaching-defs', p: {var: 'flag.*', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:0', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', k: '|reaching-defs', p: {var: '', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:1', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', k: 'flag|reaching-defs', p: {var: 'flag', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@entry', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:0', k: 'svc.announce|reaching-defs', p: {var: 'svc.announce', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_in:0', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', k: 'flag|reaching-defs', p: {var: 'flag', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_in:0', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:1', k: 'flag|reaching-defs', p: {var: 'flag', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@formal_in:1', t: 'can://sample-app/sample-app/python/service.py/run(flag)@16:4', k: 'service::Service|reaching-defs', p: {var: 'service::Service', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@entry', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4', k: 'svc.announce|points-to', p: {var: 'svc.announce', prov: ['points-to']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@entry', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', k: 'flag|ssa', p: {var: 'flag', prov: ['ssa']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@entry', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', k: 'service::build|ssa', p: {var: 'service::build', prov: ['ssa']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@entry', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@7:11', k: 'flag|ssa', p: {var: 'flag', prov: ['ssa']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@7:11', k: 'flag|ssa', p: {var: 'flag', prov: ['ssa']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@8:12', k: 'message|ssa', p: {var: 'message', prov: ['ssa']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@9:8', k: 'message|ssa', p: {var: 'message', prov: ['ssa']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@8:12', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@9:8', k: 'message|ssa', p: {var: 'message', prov: ['ssa']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_out:1', k: 'flag.*|reaching-defs', p: {var: 'flag.*', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_out', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', k: '|reaching-defs', p: {var: '', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@9:8', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_out:0', k: '|reaching-defs', p: {var: '', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:1', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', k: 'flag|reaching-defs', p: {var: 'flag', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:1', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_in:0', k: 'flag|reaching-defs', p: {var: 'flag', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:1', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@7:11', k: 'flag|reaching-defs', p: {var: 'flag', prov: ['reaching-defs']}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:2', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8', k: 'service::build|reaching-defs', p: {var: 'service::build', prov: ['reaching-defs']}} +] AS row +MATCH (a:PyBodyNode {id: row.f}) +MATCH (b:PyBodyNode {id: row.t}) +MERGE (a)-[r:PY_DDG {_k: row.k}]->(b) SET r += row.p; UNWIND [ - {f: 'service.py#17:11-17:29', t: 'can://python/sample_proj/service.py/Service/announce(self,flag)', p: {}}, - {f: 'service.py#6:18-6:29', t: 'can://python/sample_proj/service.py/build(x)', p: {}} + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@16:10', t: 'can://sample-app/sample-app/@external/service.Service/__init__', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:11', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:18', t: 'can://sample-app/sample-app/python/service.py/build(x)', p: {}} ] AS row -MATCH (a:PyCallSite {id: row.f}) +MATCH (a:PyBodyNode {id: row.f}) MATCH (b:PySymbol {id: row.t}) MERGE (a)-[r:PY_RESOLVES_TO]->(b) SET r += row.p; +UNWIND [ + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:1', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:0', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:1', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:1', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:2', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:0', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:2', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:1', p: {}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_in:0', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_out', p: {}} +] AS row +MATCH (a:PyBodyNode {id: row.f}) +MATCH (b:PyBodyNode {id: row.t}) +MERGE (a)-[r:PY_SUMMARY]->(b) +SET r += row.p; +UNWIND [ + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_in:0', t: 'can://sample-app/sample-app/python/service.py/build(x)@formal_in:0', p: {var: 'x'}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:0', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:0', p: {var: 'self'}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:1', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:1', p: {var: 'flag'}}, + {f: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_in:2', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_in:2', p: {var: ':service::build'}} +] AS row +MATCH (a:PyBodyNode {id: row.f}) +MATCH (b:PyBodyNode {id: row.t}) +MERGE (a)-[r:PY_PARAM_IN]->(b) +SET r += row.p; +UNWIND [ + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_out:0', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:0', p: {var: ''}}, + {f: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@formal_out:1', t: 'can://sample-app/sample-app/python/service.py/run(flag)@17:4/actual_out:1', p: {var: 'flag'}}, + {f: 'can://sample-app/sample-app/python/service.py/build(x)@formal_out', t: 'can://sample-app/sample-app/python/service.py/Service/announce(self,flag)@6:8/actual_out', p: {var: ''}} +] AS row +MATCH (a:PyBodyNode {id: row.f}) +MATCH (b:PyBodyNode {id: row.t}) +MERGE (a)-[r:PY_PARAM_OUT]->(b) +SET r += row.p; diff --git a/docs/handoff/schema.neo4j.json b/docs/handoff/schema.neo4j.json index 7a91e07..9b2f171 100644 --- a/docs/handoff/schema.neo4j.json +++ b/docs/handoff/schema.neo4j.json @@ -13,6 +13,9 @@ "id": "string", "name": "string", "schema_version": "string", + "identity_version": "integer", + "application_name": "string", + "identity_namespace": "string", "analyzer_name": "string", "analyzer_version": "string", "repo_uri": "string", diff --git a/test/conftest_v2.py b/test/conftest_v2.py index 4883603..18e54e7 100644 --- a/test/conftest_v2.py +++ b/test/conftest_v2.py @@ -1,18 +1,19 @@ import re -# ``can://`` — the application root: scheme plus exactly one segment. The -# language sits INSIDE it, so an old-shape ``can://python/`` id fails here -# (it has two segments) rather than flowing through as if nothing had moved. -_APP_ID = re.compile(r"^can://[^/]+$") +# Namespace-qualified ownership requires exactly two root segments. +_APP_ID = re.compile(r"^can://[^/]+/[^/]+$") def _assert_id_grammar(app: dict) -> None: - """Every durable id is ``can:///...`` — code under ``python/``, and the + """Every durable id is ``can:////...`` — code under ``python/``, and the two language-neutral reserved segments ``@external`` and ``artifact`` directly under the app. Asserted on the payload, not on the minting functions, so a hand-built or cached id cannot slip past.""" + from codeanalyzer.schema.ids import application_id app_id = app["id"] - assert _APP_ID.match(app_id), f"application id must be can://, got {app_id!r}" + assert app["identity_version"] == 2 + assert app_id == application_id(app["application_name"], app["identity_namespace"]) + assert _APP_ID.match(app_id), f"application id must be can:///, got {app_id!r}" code = f"{app_id}/python/" for key, mod in app["symbol_table"].items(): assert mod["id"].startswith(code), f"module {key} id {mod['id']!r} is not under {code!r}" @@ -61,6 +62,7 @@ def walk_class(cl): def assert_conformant(payload: dict, max_level: int) -> None: _assert_no_nulls(payload) + assert payload["identity_version"] == 2 assert payload["schema_version"] == "2.0.0" app = payload["application"] _assert_id_grammar(app) diff --git a/test/sample_graph_app.py b/test/sample_graph_app.py index dcc96c3..0283253 100644 --- a/test/sample_graph_app.py +++ b/test/sample_graph_app.py @@ -190,7 +190,7 @@ def make_sample_app() -> Tuple[PyApplication, Dict[str, str]]: # Identity + L1 bodies, then the intraprocedural (syntactic) L3 overlay. sig_to_id = assign_ids(app, "sample-app") - ext_id = "can://sample-app/@external/os/getcwd" + ext_id = "can://sample-app/sample-app/@external/os/getcwd" app.external_symbols = { ext_id: PyExternalSymbol(id=ext_id, name="getcwd", module="os") } @@ -207,7 +207,7 @@ def make_sample_app() -> Tuple[PyApplication, Dict[str, str]]: # like call_graph/artifacts above -- addresses `read_config`'s two real # `os.getenv` call sites (so the projector has authentic GLOBAL ordinal # ids) against the manifest's own flattened config key from Task 6 above. - getenv_ext_id = "can://sample-app/@external/os/getenv" + getenv_ext_id = "can://sample-app/sample-app/@external/os/getenv" app.external_symbols[getenv_ext_id] = PyExternalSymbol( id=getenv_ext_id, name="getenv", module="os" ) diff --git a/test/test_artifact_discovery.py b/test/test_artifact_discovery.py index d46d4f5..de1c8ed 100644 --- a/test/test_artifact_discovery.py +++ b/test/test_artifact_discovery.py @@ -34,7 +34,7 @@ def test_discovers_known_shapes(tmp_path): ] assert arts["notes.md"].roles == ["docs"] py = arts["pyproject.toml"] - assert py.id == "can://myapp/artifact/pyproject.toml" + assert py.id == "can://myapp/myapp/artifact/pyproject.toml" assert py.format == "toml" and "dependency-manifest" in py.roles assert arts["Dockerfile"].roles == ["container-image"] assert arts["svc/Dockerfile"].roles == ["container-image"] diff --git a/test/test_artifact_models.py b/test/test_artifact_models.py index 5e73db2..0246a9b 100644 --- a/test/test_artifact_models.py +++ b/test/test_artifact_models.py @@ -8,7 +8,7 @@ def test_artifact_id_is_language_neutral(): assert artifact_id("myapp", "deploy/docker-compose.yml") == \ - "can://myapp/artifact/deploy/docker-compose.yml" + "can://myapp/myapp/artifact/deploy/docker-compose.yml" def test_purl_pypi(): diff --git a/test/test_config_key_models.py b/test/test_config_key_models.py index 4aff3a3..492442b 100644 --- a/test/test_config_key_models.py +++ b/test/test_config_key_models.py @@ -5,13 +5,13 @@ def test_config_key_id_shape(): - assert config_key_id("can://a/artifact/pyproject.toml", "project.name") == \ - "can://a/artifact/pyproject.toml@key/project.name" + assert config_key_id("can://a/a/artifact/pyproject.toml", "project.name") == \ + "can://a/a/artifact/pyproject.toml@key/project.name" def test_config_key_id_numeric_array_segment(): - assert config_key_id("can://a/artifact/compose.yml", "services.web.ports.0") == \ - "can://a/artifact/compose.yml@key/services.web.ports.0" + assert config_key_id("can://a/a/artifact/compose.yml", "services.web.ports.0") == \ + "can://a/a/artifact/compose.yml@key/services.web.ports.0" def test_models_round_trip(): diff --git a/test/test_config_use_dataflow.py b/test/test_config_use_dataflow.py index 11a0aac..13b2ba2 100644 --- a/test/test_config_use_dataflow.py +++ b/test/test_config_use_dataflow.py @@ -48,7 +48,7 @@ def _app(tmp_path, tag, mod_source, files, level): # callable lives in its own tiny module so span byte-offsets never need to # account for another callable's text sharing the same `source` string. -_EXT_ID = "can://app/@external/os/getenv" +_EXT_ID = "can://app/app/@external/os/getenv" _ENV_RULE = Rule(id="os.getenv", module="os", callable="getenv", key_arg=0, namespaces=("env",)) @@ -85,7 +85,7 @@ def _hand_app(modules: dict, call_graph=(), artifacts=None) -> PyApplication: def _env_artifact(*keys: str) -> dict: - art_id = "can://app/artifact/.env" + art_id = "can://app/app/artifact/.env" return {".env": PyArtifact( id=art_id, path=".env", format="env", config_keys=[PyConfigKey(id=f"{art_id}@key/{k}", key=k, namespace="env") for k in keys], diff --git a/test/test_config_use_literal.py b/test/test_config_use_literal.py index 478a409..49300bd 100644 --- a/test/test_config_use_literal.py +++ b/test/test_config_use_literal.py @@ -50,7 +50,7 @@ def _literal_arg(value) -> PyCallArgument: def _artifact(path: str, fmt: str, key: str, namespace: str) -> dict: - art_id = f"can://app/artifact/{path}" + art_id = f"can://app/app/artifact/{path}" return {path: PyArtifact( id=art_id, path=path, format=fmt, config_keys=[PyConfigKey(id=f"{art_id}@key/{key}", key=key, namespace=namespace)], @@ -62,9 +62,9 @@ def _hand_app(module: str, callable_name: str, arguments, artifacts=None) -> PyA `module.callable_name` at its detector-rule key-argument position(s) -- the minimal substrate `detect_config_reads`/`resolve_uses` need, with no analyzer pipeline involved.""" - ext_id = f"can://app/@external/{module}/{callable_name}" + ext_id = f"can://app/app/@external/{module}/{callable_name}" fn = PyCallable( - name="f", path="mod.py", signature="mod.f", id="can://app/python/mod.py/f()", + name="f", path="mod.py", signature="mod.f", id="can://app/app/python/mod.py/f()", body={"1:0": BodyNode(kind="call", callee=ext_id, arguments=list(arguments))}, ) mod = PyModule(file_path="mod.py", module_name="mod", functions={"f": fn}) diff --git a/test/test_dependency_view.py b/test/test_dependency_view.py index 4be6a99..8910758 100644 --- a/test/test_dependency_view.py +++ b/test/test_dependency_view.py @@ -32,7 +32,7 @@ def test_records_lock_and_binding(tmp_path): by = {d.name: d for d in deps} assert by["requests"].prov == ["declared", "lockfile"] assert by["requests"].locked_version == "2.32.3" - assert by["requests"].declared_in == "can://app/artifact/pyproject.toml" + assert by["requests"].declared_in == "can://app/app/artifact/pyproject.toml" # pyyaml's prov gains "heuristic" via the alias-table binding below (same # cause as the dropped provides_imports==[] assert); see test_known_alias_binding. assert by["pyyaml"].locked_version is None diff --git a/test/test_entrypoint_bases.py b/test/test_entrypoint_bases.py index d76df14..c7757bd 100644 --- a/test/test_entrypoint_bases.py +++ b/test/test_entrypoint_bases.py @@ -28,9 +28,9 @@ def test_class_is_flagged_and_only_defined_methods_dispatch(): def test_methods_point_back_at_the_routed_class_via(): cls = _cls("get") - cls.id = "can://app/python/a.py/V" + cls.id = "can://app/app/python/a.py/V" _, method_eps = entrypoints_from_bases(cls, "drf", [RULE], lambda b: b) - assert method_eps["get"][0].via == "can://app/python/a.py/V" + assert method_eps["get"][0].via == "can://app/app/python/a.py/V" def test_transitive_base_resolves_one_hop(): diff --git a/test/test_namespace.py b/test/test_namespace.py new file mode 100644 index 0000000..a17ed0d --- /dev/null +++ b/test/test_namespace.py @@ -0,0 +1,131 @@ +import json +from pathlib import Path +import pytest +from codeanalyzer.schema import ids +from codeanalyzer.core import Codeanalyzer +from codeanalyzer.options import AnalysisOptions +from codeanalyzer.__main__ import app as cli + +GOLDEN = json.loads((Path(__file__).parent / "fixtures/identity/namespaces.json").read_text()) + +@pytest.mark.parametrize("case", GOLDEN["roots"]) +def test_golden_roots(case): + app, namespace, root = case["application_name"], case["namespace"], case["expected_root"] + assert ids.application_id(app, namespace) == root + assert ids.module_id(app, "src/main.py", namespace) == root + "/python/src/main.py" + artifact = ids.artifact_id(app, "config/settings.json", namespace) + assert artifact == root + "/artifact/config/settings.json" + assert ids.config_key_id(artifact, "service.port") == artifact + "@key/service.port" + assert ids.external_id(root, "os", "getcwd") == root + "/@external/os/getcwd" + assert ids.purl_pypi("requests") == "pkg:pypi/requests" + +@pytest.mark.parametrize("case", GOLDEN["invalid"]) +def test_invalid_roots(case): + for app, namespace in [(case["value"], None), ("app", case["value"])]: + with pytest.raises(ValueError): + ids.application_id(app, namespace) + +@pytest.mark.parametrize("level", [2, 4]) +def test_namespace_analysis_and_cache(tmp_path, level): + project = tmp_path / "inventory" + project.mkdir() + (project / "main.py").write_text("import os\nfrom helper import identity\ndef run(value):\n return identity(os.getcwd())\n") + (project / "helper.py").write_text("def identity(value):\n return value\n") + (project / "settings.json").write_text('{"port": 80}') + def analyze(namespace): + return Codeanalyzer(AnalysisOptions(input=project, namespace=namespace, + no_venv=True, cache_dir=tmp_path / "cache", analysis_level=level)).analyze() + for namespace in ["team", "other", None]: + analysis = analyze(namespace) + app = analysis.application + root = f"can://{namespace or 'inventory'}/inventory" + assert analysis.identity_version == 2 + assert app.application_name == "inventory" + assert app.identity_namespace == (namespace or "inventory") + assert app.id == root + assert sorted(app.symbol_table) == ["helper.py", "main.py"] + imports = app.symbol_table["main.py"].imports + assert any(im.resolved_module == "helper.py" for im in imports) + run = next(iter(app.symbol_table["main.py"].functions.values())) + assert run.id == root + "/python/main.py/run(value)" + assert run.parameters[0].id == run.id + "@formal_in:0" + if level == 4: + assert app.param_in + assert app.symbol_table["main.py"].id == root + "/python/main.py" + assert app.artifacts["settings.json"].id == root + "/artifact/settings.json" + import re + assert all(value == root or value.startswith(root + "/") for value in + re.findall(r'can://[^" ]+', app.model_dump_json())) + +def test_namespace_cli(cli_runner, tmp_path): + project = tmp_path / "inventory" + project.mkdir() + (project / "main.py").write_text("def run(): return 1\n") + output = tmp_path / "out" + args = ["-i", str(project), "-o", str(output), "--no-venv", "-c", str(tmp_path / "cache")] + result = cli_runner.invoke(cli, args + ["--namespace", "team"]) + assert result.exit_code == 0, result.output + payload = json.loads((output / "analysis.json").read_text()) + assert payload["identity_version"] == 2 + assert payload["application"]["id"] == "can://team/inventory" + assert cli_runner.invoke(cli, args + ["--namespace", ""]).exit_code != 0 + assert cli_runner.invoke(cli, args + ["--workspace-root", str(tmp_path)]).exit_code != 0 + + +def test_graph_emission_uses_namespace_and_rejects_changed_scope(tmp_path): + from codeanalyzer.neo4j.emit import emit_neo4j + from codeanalyzer.neo4j.project import project + from codeanalyzer.schema.assign_ids import assign_ids + project_dir = tmp_path / "inventory" + project_dir.mkdir() + (project_dir / "main.py").write_text("import os\ndef run():\n return os.getcwd()\n") + (project_dir / "settings.json").write_text('{"port": 80}') + options = AnalysisOptions(input=project_dir, namespace="team", no_venv=True, + cache_dir=tmp_path / "cache", output=tmp_path / "output") + analysis = Codeanalyzer(options).analyze() + mapping = assign_ids(analysis.application, "inventory", "team") + rows = project(analysis.application, "inventory", mapping, namespace="team") + anchor = next(n for n in rows.nodes if n.labels[0] == "PyApplication") + assert anchor.props["identity_version"] == 2 + assert anchor.props["identity_namespace"] == "team" + assert anchor.props["application_name"] == "inventory" + assert all(n.value.startswith("can://team/inventory/") or n.value == "can://team/inventory" + for n in rows.nodes if n.value.startswith("can://")) + emit_neo4j(analysis, options) + first = (options.output / "graph.cypher").read_text() + assert "can://team/inventory/" in first + assert "can://inventory/inventory" not in first + emit_neo4j(analysis, options) + assert (options.output / "graph.cypher").read_text() == first + options.namespace = "other" + with pytest.raises(ValueError, match="identity"): + emit_neo4j(analysis, options) + assert (options.output / "graph.cypher").read_text() == first + + +def test_analysis_requires_identity_version(): + from codeanalyzer.schema import Analysis, PyApplication + from pydantic import ValidationError + with pytest.raises(ValidationError): + Analysis(application=PyApplication(symbol_table={})) + with pytest.raises(ValidationError): + Analysis(identity_version=1, application=PyApplication(symbol_table={})) + + +def test_bolt_reconciliation_prefix_is_exact_namespace_scope(): + from codeanalyzer.neo4j.bolt import _artifact_prefixes + from codeanalyzer.neo4j.rows import application_prefix + assert _artifact_prefixes("inventory", "team") == ["can://team/inventory/artifact/"] + assert application_prefix("inventory", "team") == "can://team/inventory/" + + +def test_raw_analysis_rejects_legacy_anchor_and_noninteger_marker(): + from codeanalyzer.schema import Analysis, PyApplication + from pydantic import ValidationError + payload = Analysis(identity_version=2, application=PyApplication(symbol_table={})).model_dump() + for value in [2.0, True, "2"]: + with pytest.raises(ValidationError): + Analysis.model_validate({**payload, "identity_version": value}) + del payload["application"]["identity_version"] + with pytest.raises(ValidationError): + Analysis.model_validate(payload) diff --git a/test/test_neo4j_artifacts.py b/test/test_neo4j_artifacts.py index be51d60..3002933 100644 --- a/test/test_neo4j_artifacts.py +++ b/test/test_neo4j_artifacts.py @@ -26,11 +26,11 @@ def test_rows_projected(tmp_path): rows = project(app, "p", assign_ids(app, "p")) nodes = {(n.labels[0], n.value) for n in rows.nodes} - assert ("Artifact", "can://p/artifact/pyproject.toml") in nodes + assert ("Artifact", "can://p/p/artifact/pyproject.toml") in nodes assert ("Package", "pkg:pypi/requests") in nodes pyproject_node = next( n for n in rows.nodes - if n.labels[0] == "Artifact" and n.value == "can://p/artifact/pyproject.toml" + if n.labels[0] == "Artifact" and n.value == "can://p/p/artifact/pyproject.toml" ) rel_types = {e.type for e in rows.edges} assert {"HAS_ARTIFACT", "DECLARES_DEPENDENCY", "PY_PROVIDES"} <= rel_types @@ -64,7 +64,7 @@ def test_locks_and_provides_dedup_across_multi_manifest_declarations(tmp_path): declares = [e for e in rows.edges if e.type == "DECLARES_DEPENDENCY"] assert len(locks) == 1, f"expected exactly one LOCKS row, got {len(locks)}" - assert locks[0].from_ref.value == "can://p/artifact/poetry.lock" + assert locks[0].from_ref.value == "can://p/p/artifact/poetry.lock" assert locks[0].to_ref.value == "pkg:pypi/requests" assert len(provides) == 1, f"expected exactly one PY_PROVIDES row, got {len(provides)}" diff --git a/test/test_neo4j_bolt.py b/test/test_neo4j_bolt.py index 3420ef6..f4f19f3 100644 --- a/test/test_neo4j_bolt.py +++ b/test/test_neo4j_bolt.py @@ -239,7 +239,7 @@ def test_eager_push_of_a_second_python_app_with_a_colliding_module_path_leaves_t file_key = "appb/main.py" app_a, sig_a = _single_module_app(file_key) _push(app_a, "app-a", assign_ids(app_a, "app-a"), cfg, full_run=True, eager=True) - a_nodes = _num(driver, "MATCH (n:PyCanNode) WHERE n.id STARTS WITH 'can://app-a/python/' RETURN count(n)") + a_nodes = _num(driver, "MATCH (n:PyCanNode) WHERE n.id STARTS WITH 'can://app-a/app-a/python/' RETURN count(n)") assert a_nodes > 0 app_b, sig_b = _single_module_app(file_key) @@ -247,7 +247,7 @@ def test_eager_push_of_a_second_python_app_with_a_colliding_module_path_leaves_t # ...and again, so app-b's purge runs against a graph that already holds app-b too. _push(app_b, "app-b", sig_b, cfg, full_run=True, eager=True) - assert _num(driver, "MATCH (n:PyCanNode) WHERE n.id STARTS WITH 'can://app-a/python/' RETURN count(n)") == a_nodes + assert _num(driver, "MATCH (n:PyCanNode) WHERE n.id STARTS WITH 'can://app-a/app-a/python/' RETURN count(n)") == a_nodes assert _num(driver, "MATCH (:PyApplication {name:'app-a'})-[:PY_HAS_MODULE]->(m) RETURN count(m)") == 1 assert _num(driver, "MATCH (:PyApplication {name:'app-b'})-[:PY_HAS_MODULE]->(m) RETURN count(m)") == 1 @@ -261,7 +261,7 @@ def test_external_nodes_sit_inside_the_application_prefix(driver, cfg): _push(app, "sample-app", sig_to_id, cfg, full_run=True, eager=True) total = _num(driver, "MATCH (e:PyExternal) RETURN count(e)") assert total >= 1 - inside = _num(driver, "MATCH (e:PyExternal:PyCanNode) WHERE e.id STARTS WITH 'can://sample-app/@external/' RETURN count(e)") + inside = _num(driver, "MATCH (e:PyExternal:PyCanNode) WHERE e.id STARTS WITH 'can://sample-app/sample-app/@external/' RETURN count(e)") assert inside == total @@ -295,7 +295,7 @@ def fail(*args): from codeanalyzer.schema.ids import artifact_id, config_key_id, purl_pypi -_ART = "can://sample-app/artifact/" +_ART = "can://sample-app/sample-app/artifact/" def _rekeyed(prefix: str): @@ -405,7 +405,7 @@ def test_a_sibling_analyzers_artifacts_and_edges_survive_a_python_push(driver, c with driver.session() as session: session.run( "MATCH (a:Artifact {id: $pp}) SET a:JCanNode " - "MERGE (j:JApplication {id: 'can://sample-app'}) MERGE (j)-[:HAS_ARTIFACT]->(a) " + "MERGE (j:JApplication {id: 'can://sample-app/sample-app'}) MERGE (j)-[:HAS_ARTIFACT]->(a) " "MERGE (m:Package {id: 'pkg:maven/org.example/lib'}) MERGE (a)-[:DECLARES_DEPENDENCY {_k: 'compile'}]->(m) " "MERGE (o:Artifact:JCanNode {id: $pom}) MERGE (j)-[:HAS_ARTIFACT]->(o) " "MERGE (k:ConfigKey:JCanNode {id: $key}) MERGE (o)-[:DEFINES_CONFIG]->(k)", @@ -430,7 +430,7 @@ def test_an_artifact_an_unmarked_sibling_inventories_survives(driver, cfg): app0, s0 = make_sample_app() _push(app0, "sample-app", s0, cfg, full_run=True, eager=True) with driver.session() as session: - session.run("MATCH (a:Artifact {id: $id}) MERGE (t:TSApplication {id: 'can://sample-app'}) " + session.run("MATCH (a:Artifact {id: $id}) MERGE (t:TSApplication {id: 'can://sample-app/sample-app'}) " "MERGE (t)-[:HAS_ARTIFACT]->(a)", id=_ART + "poetry.lock").consume() app, s = make_sample_app() del app.artifacts["poetry.lock"] @@ -462,25 +462,25 @@ def test_an_app_whose_name_extends_anothers_is_not_reconciled_by_it(driver, cfg) for art in app_b.artifacts.values(): art.config_keys = [] _push(app_b, "sample-apps", s_b, cfg, full_run=True, eager=True) - before = _num(driver, "MATCH (a:Artifact) WHERE a.id STARTS WITH 'can://sample-apps/' RETURN count(a)") + before = _num(driver, "MATCH (a:Artifact) WHERE a.id STARTS WITH 'can://sample-apps/sample-apps/' RETURN count(a)") assert before > 0 app, s = make_sample_app() app.artifacts = {} app.dependencies, app.config_uses = [], [] _push(app, "sample-app", s, cfg, full_run=True, eager=True) - assert _num(driver, "MATCH (a:Artifact:PyCanNode) WHERE a.id STARTS WITH 'can://sample-apps/' " + assert _num(driver, "MATCH (a:Artifact:PyCanNode) WHERE a.id STARTS WITH 'can://sample-apps/sample-apps/' " "RETURN count(a)") == before -def test_pre_rekey_artifacts_are_released_too(driver, cfg): +def test_legacy_artifacts_are_outside_selected_scope(driver, cfg): legacy = "can://artifact/sample-app/pyproject.toml" with driver.session() as session: session.run("MERGE (a:Artifact:PyCanNode {id: $id}) MERGE (k:ConfigKey:PyCanNode {id: $k}) " "MERGE (a)-[:DEFINES_CONFIG]->(k)", id=legacy, k=config_key_id(legacy, "x")).consume() app, s = make_sample_app() _push(app, "sample-app", s, cfg, full_run=True, eager=True) - assert _num(driver, "MATCH (n) WHERE n.id STARTS WITH 'can://artifact/sample-app/' RETURN count(n)") == 0 + assert _num(driver, "MATCH (n) WHERE n.id STARTS WITH 'can://artifact/sample-app/' RETURN count(n)") == 2 def test_a_removed_dependency_and_a_resolved_import_drop_their_import_edges(driver, cfg): diff --git a/test/test_neo4j_schema.py b/test/test_neo4j_schema.py index 462959e..a76e52a 100644 --- a/test/test_neo4j_schema.py +++ b/test/test_neo4j_schema.py @@ -130,7 +130,7 @@ def test_call_edge_to_imported_module_name_is_not_dropped(): # #173: an unhomed target lands on an @external ghost under the application # prefix (never a bare-signature id), the same node `_import_ghost` builds. - ghost = "can://app/@external/os" + ghost = "can://app/app/@external/os" calls_to_os = [ e for e in rows.edges if e.type == "PY_CALLS" and e.to_ref.value == ghost ] diff --git a/test/test_neo4j_scope.py b/test/test_neo4j_scope.py index 587cd7d..7436b9d 100644 --- a/test/test_neo4j_scope.py +++ b/test/test_neo4j_scope.py @@ -41,7 +41,7 @@ def test_attribute_and_variable_ids_hang_under_their_owner_can_id(): for n in attrs + variables: owner, _, leaf = n.value.rpartition("/") assert owner in owners, f"{n.value} is not under a declared owner" - assert n.value.startswith("can://sample-app/python/") + assert n.value.startswith("can://sample-app/sample-app/python/") assert all("@" in n.value.rsplit("/", 1)[1] for n in variables) # @ @@ -62,8 +62,8 @@ def test_two_application_names_never_share_an_id_keyed_node(): def test_prefix_helpers_guard_the_two_ways_this_goes_wrong(): - assert descendant_prefix("can://app/python/src/foo.py") == "can://app/python/src/foo.py/" - assert application_prefix("app") == "can://app/" + assert descendant_prefix("can://app/app/python/src/foo.py") == "can://app/app/python/src/foo.py/" + assert application_prefix("app") == "can://app/app/" for bad in ("", None): with pytest.raises(ValueError): application_prefix(bad) @@ -71,8 +71,8 @@ def test_prefix_helpers_guard_the_two_ways_this_goes_wrong(): def test_row_builder_lifts_module_and_marks_every_can_id(): b = RowBuilder() - r1 = b.node(["PyModule"], "id", "can://app/python/m.py", {"_module": "m.py", "x": 1}) - b.node(["Artifact"], "id", "can://app/artifact/Dockerfile", {"_module": "Dockerfile"}) + r1 = b.node(["PyModule"], "id", "can://app/app/python/m.py", {"_module": "m.py", "x": 1}) + b.node(["Artifact"], "id", "can://app/app/artifact/Dockerfile", {"_module": "Dockerfile"}) b.node(["PyDecorator"], "name", "functools.lru_cache", {"name": "functools.lru_cache"}) rows = b.finish() by = {n.value: n for n in rows.nodes} @@ -81,8 +81,8 @@ def test_row_builder_lifts_module_and_marks_every_can_id(): # The artifact id used to be can://artifact/app/... — outside every # application prefix, so it never carried the marker and no destructive # statement could reach it. Nested under the app it is an ordinary can node. - assert CAN_NODE in by["can://app/artifact/Dockerfile"].labels - assert by["can://app/artifact/Dockerfile"].module == "Dockerfile" + assert CAN_NODE in by["can://app/app/artifact/Dockerfile"].labels + assert by["can://app/app/artifact/Dockerfile"].module == "Dockerfile" assert CAN_NODE not in by["functools.lru_cache"].labels @@ -98,7 +98,7 @@ def test_two_applications_project_as_two_distinct_roots(): ] assert [r.key_prop for r in roots] == ["id", "id"], \ "the root must merge on its id, not on a display name" - assert [r.value for r in roots] == ["can://svc-quotes", "can://svc-orders"] + assert [r.value for r in roots] == ["can://svc-quotes/svc-quotes", "can://svc-orders/svc-orders"] assert [r.props["name"] for r in roots] == ["svc-quotes", "svc-orders"], \ "name survives as a display property" assert roots[0].value != roots[1].value, "two services must not share a root node" diff --git a/test/test_neo4j_streamed_nodes.py b/test/test_neo4j_streamed_nodes.py index 7721497..079b1ab 100644 --- a/test/test_neo4j_streamed_nodes.py +++ b/test/test_neo4j_streamed_nodes.py @@ -47,7 +47,7 @@ def test_reemitted_nodes_replay_to_merged_rows(monkeypatch): for i in range(4 * BATCH): labels = rnd.choice([["PySymbol"], ["PySymbol", "PyClass"], ["Artifact"], ["Artifact", "Extra"]]) props = {"n": i, f"p{i % 5}": True, "_module": "m.py"} - args = (labels, "id", f"can://a/{rnd.randrange(BATCH)}", props) + args = (labels, "id", f"can://a/a/{rnd.randrange(BATCH)}", props) assert streamed.node(*args) == memory.node(*args) streamed.finish() assert _replay(batches) == _merged(memory.finish().nodes) @@ -58,8 +58,8 @@ def test_no_statement_repeats_a_node(monkeypatch): batches = _capture_batches(monkeypatch) b = _NodeStreamBuilder(io.StringIO()) for i in range(3): - b.node(["PySymbol"], "id", "can://a/x", {"n": i}) - b.node(["PySymbol"], "id", f"can://a/y{i}", {}) + b.node(["PySymbol"], "id", "can://a/a/x", {"n": i}) + b.node(["PySymbol"], "id", f"can://a/a/y{i}", {}) b.finish() for batch in batches: ids = [n.value for n in batch] diff --git a/test/test_provenance.py b/test/test_provenance.py index b959aa4..2b4e01e 100644 --- a/test/test_provenance.py +++ b/test/test_provenance.py @@ -105,7 +105,7 @@ def _envelope(version=None): analyzer = PyAnalyzerInfo(name="codeanalyzer-python", version=version, config={}) \ if version is not None else PyAnalyzerInfo() - return Analysis(analyzer=analyzer, application=PyApplication(symbol_table={})) + return Analysis(identity_version=2, analyzer=analyzer, application=PyApplication(symbol_table={})) def test_cache_analyzer_matches_none_envelope_is_false(): diff --git a/test/test_v2_conformance.py b/test/test_v2_conformance.py index 4d85e61..f0c2cd1 100644 --- a/test/test_v2_conformance.py +++ b/test/test_v2_conformance.py @@ -16,10 +16,10 @@ def test_ids_assigned_down_the_tree(): mod = PyModule(file_path="pkg/m.py", module_name="m", types={"m.Hasher": cl}) app = PyApplication(symbol_table={"pkg/m.py": mod}) assign_ids(app, "myapp") - assert app.id == "can://myapp" - assert mod.id == "can://myapp/python/pkg/m.py" - assert cl.id == "can://myapp/python/pkg/m.py/Hasher" - assert fn.id == "can://myapp/python/pkg/m.py/Hasher/hash()" + assert app.id == "can://myapp/myapp" + assert mod.id == "can://myapp/myapp/python/pkg/m.py" + assert cl.id == "can://myapp/myapp/python/pkg/m.py/Hasher" + assert fn.id == "can://myapp/myapp/python/pkg/m.py/Hasher/hash()" def test_cli_emits_v2_envelope(tmp_path: Path): diff --git a/test/test_v2_ids.py b/test/test_v2_ids.py index 2f507cb..62121ad 100644 --- a/test/test_v2_ids.py +++ b/test/test_v2_ids.py @@ -1,8 +1,8 @@ from codeanalyzer.schema import ids def test_application_and_module_ids_put_the_app_outermost(): - assert ids.application_id("myapp") == "can://myapp" - assert ids.module_id("myapp", "pkg/mod.py") == "can://myapp/python/pkg/mod.py" + assert ids.application_id("myapp") == "can://myapp/myapp" + assert ids.module_id("myapp", "pkg/mod.py") == "can://myapp/myapp/python/pkg/mod.py" def test_callable_signature_segment_uses_param_names(): assert ids.callable_sig_segment("hash", ["self", "s"]) == "hash(self,s)" @@ -12,18 +12,18 @@ def test_child_and_ordinal_ids_compose(): mod = ids.module_id("myapp", "pkg/mod.py") cls = ids.child_id(mod, "Hasher") fn = ids.child_id(cls, ids.callable_sig_segment("hash", ["self", "s"])) - assert fn == "can://myapp/python/pkg/mod.py/Hasher/hash(self,s)" + assert fn == "can://myapp/myapp/python/pkg/mod.py/Hasher/hash(self,s)" assert ids.ordinal_id(fn, "15:4") == fn + "@15:4" assert ids.ordinal_id(fn, "entry") == fn + "@entry" def test_external_id_is_app_scoped_and_language_neutral(): app = ids.application_id("myapp") - assert ids.external_id(app, "os", "getcwd") == "can://myapp/@external/os/getcwd" - assert ids.external_id(app, None, "print") == "can://myapp/@external/print" + assert ids.external_id(app, "os", "getcwd") == "can://myapp/myapp/@external/os/getcwd" + assert ids.external_id(app, None, "print") == "can://myapp/myapp/@external/print" def test_artifact_id_nests_under_the_app_instead_of_a_parallel_scheme(): # Was can://artifact// — a second outermost shape. Now one rule. - assert ids.artifact_id("myapp", "pyproject.toml") == "can://myapp/artifact/pyproject.toml" + assert ids.artifact_id("myapp", "pyproject.toml") == "can://myapp/myapp/artifact/pyproject.toml" def test_every_id_shares_the_application_prefix(): # This is what makes the prefix-scoped delete correct, so assert it directly. @@ -39,4 +39,4 @@ def test_every_id_shares_the_application_prefix(): def test_an_app_named_python_is_not_a_language_prefix(): # The collision the old grammar could not produce: nothing may read the # language off the first segment any more. - assert ids.module_id("python", "m.py") == "can://python/python/m.py" + assert ids.module_id("python", "m.py") == "can://python/python/python/m.py" diff --git a/test/test_v2_l2.py b/test/test_v2_l2.py index 9d12876..e30e6fc 100644 --- a/test/test_v2_l2.py +++ b/test/test_v2_l2.py @@ -14,12 +14,12 @@ def _app_with_one_call(callee_sig): def test_declared_callee_resolves_to_can_id(): app, fn = _app_with_one_call("m.g") - backfill_callees(app, {"m.g": "can://app/python/m.py/g()"}) - assert fn.body["2:4"].callee == "can://app/python/m.py/g()" + backfill_callees(app, {"m.g": "can://app/app/python/m.py/g()"}) + assert fn.body["2:4"].callee == "can://app/app/python/m.py/g()" def test_external_callee_keeps_dotted_signature(): app, fn = _app_with_one_call("requests.get") - backfill_callees(app, {"m.g": "can://app/python/m.py/g()"}) # requests.get not declared + backfill_callees(app, {"m.g": "can://app/app/python/m.py/g()"}) # requests.get not declared assert fn.body["2:4"].callee == "requests.get" def test_unresolved_callsite_leaves_callee_absent(): @@ -30,16 +30,16 @@ def test_unresolved_callsite_leaves_callee_absent(): def test_call_graph_endpoints_reidentified(): edge = PyCallEdge(src="m.f", dst="m.g") app = PyApplication(symbol_table={}, call_graph=[edge]) - reidentify_call_graph(app, {"m.f": "can://app/python/m.py/f()", - "m.g": "can://app/python/m.py/g()"}) - assert app.call_graph[0].src == "can://app/python/m.py/f()" - assert app.call_graph[0].dst == "can://app/python/m.py/g()" + reidentify_call_graph(app, {"m.f": "can://app/app/python/m.py/f()", + "m.g": "can://app/app/python/m.py/g()"}) + assert app.call_graph[0].src == "can://app/app/python/m.py/f()" + assert app.call_graph[0].dst == "can://app/app/python/m.py/g()" def test_call_graph_external_target_unchanged(): edge = PyCallEdge(src="m.f", dst="requests.get") app = PyApplication(symbol_table={}, call_graph=[edge]) - reidentify_call_graph(app, {"m.f": "can://app/python/m.py/f()"}) - assert app.call_graph[0].src == "can://app/python/m.py/f()" + reidentify_call_graph(app, {"m.f": "can://app/app/python/m.py/f()"}) + assert app.call_graph[0].src == "can://app/app/python/m.py/f()" assert app.call_graph[0].dst == "requests.get" diff --git a/test/test_v2_l3.py b/test/test_v2_l3.py index a84e48c..d7be46f 100644 --- a/test/test_v2_l3.py +++ b/test/test_v2_l3.py @@ -26,15 +26,15 @@ def __init__(self, cfg): self.cfg = cfg def test_local_and_global_ids_for_entry_exit_and_statements(): nodes = [_Node(0, 1, 0, "entry"), _Node(1, 2, 4, "statement"), _Node(2, 3, 4, "exit")] pdg = _PDG(_CFG(nodes, entry_id=0, exit_id=2)) - im = IdentityMap.for_function("can://app/python/m.py/f()", pdg) + im = IdentityMap.for_function("can://app/app/python/m.py/f()", pdg) # LOCAL ids: intra-callable keys (match l1_body's "line:col" format). assert im.local(0) == "@entry" assert im.local(1) == "2:4" assert im.local(2) == "@exit" # GLOBAL id: fully addressable form for Neo4j / cross-callable use. - assert im.global_id(0) == "can://app/python/m.py/f()@entry" # entry (single @, no double) - assert im.global_id(1) == "can://app/python/m.py/f()@2:4" # statement - assert im.global_id(2) == "can://app/python/m.py/f()@exit" # exit + assert im.global_id(0) == "can://app/app/python/m.py/f()@entry" # entry (single @, no double) + assert im.global_id(1) == "can://app/app/python/m.py/f()@2:4" # statement + assert im.global_id(2) == "can://app/app/python/m.py/f()@exit" # exit assert set(im.node_ids()) == {0, 1, 2} diff --git a/test/test_v2_l4.py b/test/test_v2_l4.py index 3d9659e..1c7093f 100644 --- a/test/test_v2_l4.py +++ b/test/test_v2_l4.py @@ -43,7 +43,7 @@ def test_identity_map_param_vertex_local_and_global_ids(): ParamNode(id=12, kind="actual_in", var="arg0", call_node=3), ParamNode(id=13, kind="actual_out", var="", call_node=3), ] - cid = "can://app/python/m.py/f(a)" + cid = "can://app/app/python/m.py/f(a)" im = IdentityMap.for_function(cid, pdg, param_nodes=params) # LOCAL ids @@ -79,7 +79,7 @@ def test_identity_map_param_vertices_multiplicity_gets_idx_suffix(): ParamNode(id=26, kind="actual_out", var="", call_node=3), ParamNode(id=27, kind="actual_out", var="a", call_node=3), ] - im = IdentityMap.for_function("can://app/python/m.py/f(a,b)", pdg, param_nodes=params) + im = IdentityMap.for_function("can://app/app/python/m.py/f(a,b)", pdg, param_nodes=params) assert im.local(20) == "@formal_in:0" assert im.local(21) == "@formal_in:1" diff --git a/test/test_v2_schema.py b/test/test_v2_schema.py index f3d59be..e12b2d7 100644 --- a/test/test_v2_schema.py +++ b/test/test_v2_schema.py @@ -11,16 +11,16 @@ def _fields(model_cls): def test_envelope_round_trips(): fn = PyCallable( name="f", path="m.py", signature="m.f", - id="can://app/python/m.py/f()", kind="function", + id="can://app/app/python/m.py/f()", kind="function", span=Span(start=(1, 0), end=(2, 12), bytes=(0, 21)), body={"@entry": BodyNode(kind="entry")}, ) mod = PyModule(file_path="m.py", module_name="m", - id="can://app/python/m.py", kind="module", + id="can://app/app/python/m.py", kind="module", source="def f():\n return 1\n", functions={"f": fn}) - app = PyApplication(id="can://app", kind="application", + app = PyApplication(id="can://app/app", kind="application", symbol_table={"m.py": mod}) - analysis = Analysis(max_level=1, k_limit=3, application=app) + analysis = Analysis(identity_version=2, max_level=1, k_limit=3, application=app) blob = model_dump_json(analysis) back = model_validate_json(Analysis, blob) assert back.schema_version == "2.0.0" diff --git a/test/test_v2_two_projection_agreement.py b/test/test_v2_two_projection_agreement.py index 8d41cd8..f7cb865 100644 --- a/test/test_v2_two_projection_agreement.py +++ b/test/test_v2_two_projection_agreement.py @@ -318,11 +318,11 @@ def test_py_extends_targets_declared_base_by_can_id_and_external_by_ghost(tmp_pa rows = project(app, "app", sig_to_id) ext = {(e.from_ref.value, e.to_ref.value) for e in rows.edges if e.type == "PY_EXTENDS"} assert (by_name["Child"].id, by_name["Base"].id) in ext - assert (by_name["Ext"].id, "can://app/@external/lib.core/Thing") in ext - assert (by_name["Dotted"].id, "can://app/@external/lib.views/View") in ext + assert (by_name["Ext"].id, "can://app/app/@external/lib.core/Thing") in ext + assert (by_name["Dotted"].id, "can://app/app/@external/lib.views/View") in ext assert len(ext) == 3 ghosts = {n.value for n in rows.nodes if "PyExternal" in n.labels} - assert "can://app/@external/lib.core/Thing" in ghosts + assert "can://app/app/@external/lib.core/Thing" in ghosts def test_pyapplication_carries_the_entrypoint_report(): diff --git a/test/test_workspace_root.py b/test/test_workspace_root.py deleted file mode 100644 index 3516d16..0000000 --- a/test/test_workspace_root.py +++ /dev/null @@ -1,138 +0,0 @@ -"""``--workspace-root``: published file keys and the ids built from them are repo-relative. - -Same semantics as codeanalyzer-java and codeanalyzer-typescript, so a language-neutral artifact id -names the same node whichever analyzer emitted it. The re-key is the published name only: -signatures, test-file detection, import resolution and the cache keep the ``--input``-relative key. -""" -import json -import re -import shutil -from pathlib import Path - -from codeanalyzer.__main__ import app as cli -from codeanalyzer.core import Codeanalyzer -from codeanalyzer.options import AnalysisOptions - -FIXTURES = Path(__file__).parent / "fixtures" -# A `test` segment ABOVE --input: test-file skipping must judge the --input-relative path, or every -# file here would read as a test and vanish. -NESTED = "test/services/app" -PREFIX = NESTED + "/" - - -def _repo_with(tmp_path: Path, fixture: str) -> tuple[Path, Path]: - repo = tmp_path / "repo" - project = repo / NESTED - shutil.copytree(FIXTURES / fixture, project) - return repo, project - - -def _analyze(project: Path, cache: Path, workspace_root=None, level=1): - return Codeanalyzer(AnalysisOptions( - input=project, app_name="app", analysis_level=level, no_venv=True, cache_dir=cache, - workspace_root=workspace_root, - )).analyze() - - -def _code_ids(payload: str) -> list[str]: - return re.findall(r'"(can://app/python/[^"]*)"', payload) - - -def test_symbol_table_keys_and_every_code_id_are_repo_relative_through_l4(tmp_path): - repo, project = _repo_with(tmp_path, "single_functionalities/dataflow") - plain = _analyze(project, tmp_path / "c1", level=4).application - moved = _analyze(project, tmp_path / "c2", workspace_root=repo, level=4).application - - assert moved.id == "can://app" # the app segment does not move - assert sorted(moved.symbol_table) == sorted(PREFIX + k for k in plain.symbol_table) - for key, mod in moved.symbol_table.items(): - assert not key.startswith("/") and ".." not in key - assert mod.id == f"can://app/python/{key}" - - payload = moved.model_dump_json() - ids = _code_ids(payload) - assert ids, "precondition: code ids were emitted" - for i in ids: - assert i.startswith(f"can://app/python/{PREFIX}"), i - assert len(moved.call_graph) == len(plain.call_graph) - assert len(moved.param_in) == len(plain.param_in) > 0 # L4 overlays rode along - - -def test_signatures_stay_input_relative(tmp_path): - repo, project = _repo_with(tmp_path, "single_functionalities/internal_imports") - plain = _analyze(project, tmp_path / "c1", level=2).application - moved = _analyze(project, tmp_path / "c2", workspace_root=repo, level=2).application - - def sigs(app): - return sorted(f.signature for m in app.symbol_table.values() for f in (m.functions or {}).values()) - - assert sigs(moved) == sigs(plain) - - -def test_resolved_module_follows_the_prefixed_keys(): - from codeanalyzer.core import _rekey_to_workspace - from codeanalyzer.schema.py_schema import PyApplication, PyImport, PyModule - - imp = PyImport(module="pkg.util", name="pkg.util", resolved_module="pkg/util.py") - app = PyApplication(symbol_table={ - "main.py": PyModule(file_path="/x/main.py", module_name="main", imports=[imp]), - "pkg/util.py": PyModule(file_path="/x/pkg/util.py", module_name="pkg.util"), - }) - _rekey_to_workspace(app, PREFIX) - assert imp.resolved_module == PREFIX + "pkg/util.py" - assert imp.resolved_module in app.symbol_table - - -def test_artifacts_are_prefixed_and_classification_is_unchanged(tmp_path): - repo, project = _repo_with(tmp_path, "whole_applications/manifests_app") - plain = _analyze(project, tmp_path / "c1").application - moved = _analyze(project, tmp_path / "c2", workspace_root=repo).application - - assert sorted(moved.artifacts) == sorted(PREFIX + k for k in plain.artifacts) - for key, art in plain.artifacts.items(): - m = moved.artifacts[PREFIX + key] - assert m.path == PREFIX + key - assert m.id == f"can://app/artifact/{PREFIX}{key}" - assert (m.format, m.roles, m.extraction) == (art.format, art.roles, art.extraction) - assert len(m.config_keys) == len(art.config_keys) - assert all(ck.id.startswith(m.id) for ck in m.config_keys) - assert len(moved.dependencies) == len(plain.dependencies) > 0 - for d in moved.dependencies: - assert d.declared_in.startswith(f"can://app/artifact/{PREFIX}"), d.declared_in - - -def test_the_cache_stays_input_relative_so_toggling_reuses_it(tmp_path): - repo, project = _repo_with(tmp_path, "single_functionalities/internal_imports") - cache = tmp_path / "shared" - plain = _analyze(project, cache).application - cached = json.loads((cache / ".codeanalyzer" / "analysis_cache.json").read_text())["application"]["symbol_table"] - moved = _analyze(project, cache, workspace_root=repo).application - after = json.loads((cache / ".codeanalyzer" / "analysis_cache.json").read_text())["application"]["symbol_table"] - - assert sorted(after) == sorted(cached) == sorted(plain.symbol_table) # no prefix written to the cache - assert sorted(moved.symbol_table) == sorted(PREFIX + k for k in plain.symbol_table) - again = _analyze(project, cache).application - assert again.model_dump_json() == plain.model_dump_json() # and back again, unchanged - - -def test_a_workspace_root_equal_to_input_changes_nothing(tmp_path): - _, project = _repo_with(tmp_path, "whole_applications/manifests_app") - a = _analyze(project, tmp_path / "c1").application - b = _analyze(project, tmp_path / "c2", workspace_root=project).application - assert b.model_dump_json() == a.model_dump_json() - - -def test_cli_flag_reaches_the_analysis_and_an_outside_input_is_an_error(cli_runner, tmp_path): - repo, project = _repo_with(tmp_path, "single_functionalities/internal_imports") - out = tmp_path / "out" - ok = cli_runner.invoke(cli, ["-i", str(project), "-o", str(out), "--no-venv", "--app-name", "app", - "-c", str(tmp_path / "c"), "--workspace-root", str(repo)]) - assert ok.exit_code == 0, ok.output - keys = json.loads((out / "analysis.json").read_text())["application"]["symbol_table"] - assert keys and all(k.startswith(PREFIX) for k in keys) - - bad_out = tmp_path / "bad" - bad = cli_runner.invoke(cli, ["-i", str(project), "-o", str(bad_out), "--no-venv", - "--workspace-root", str(tmp_path / "elsewhere")]) - assert bad.exit_code != 0 - assert not (bad_out / "analysis.json").exists() From b6599662ee17947bedc4231fa1b0875c2d172999 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Sat, 31 Oct 2026 00:14:32 -0400 Subject: [PATCH 5/6] Guard stored application identity before Bolt mutations --- codeanalyzer/neo4j/bolt.py | 34 ++++++++++++++++++++---- codeanalyzer/schema/py_schema.py | 10 ++++++- docs/handoff/README.md | 10 +++---- test/test_neo4j_bolt_streaming.py | 44 +++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/codeanalyzer/neo4j/bolt.py b/codeanalyzer/neo4j/bolt.py index 0a69569..571b007 100644 --- a/codeanalyzer/neo4j/bolt.py +++ b/codeanalyzer/neo4j/bolt.py @@ -278,6 +278,26 @@ def finish(self): return GraphRows([], []) +def _validate_stored_identity(session, app_name, namespace): + """Read the selected anchor before schema or graph mutations; absence is fresh.""" + from codeanalyzer.schema.ids import application_id + + app_id = application_id(app_name, namespace) + record = session.run( + "MATCH (a:PyApplication {id: $id}) RETURN properties(a) AS identity", + id=app_id, + ).single() + if record is None: + return + metadata = record["identity"] + expected_namespace = app_name if namespace is None else namespace + if (type(metadata.get("identity_version")) is not int + or metadata.get("identity_version") != 2 + or metadata.get("application_name") != app_name + or metadata.get("identity_namespace") != expected_namespace): + raise ValueError("Stored application identity is missing or incompatible") + + def bolt_writer_streaming(app, app_name, sig_to_id, analyzer, cfg: BoltConfig, full_run: bool, eager: bool = False, namespace: str | None = None) -> None: """:func:`bolt_writer` without materializing :class:`GraphRows`: the projection @@ -301,6 +321,7 @@ def session(): try: with session() as s: + _validate_stored_identity(s, app_name, namespace) for stmt in CONSTRAINTS + INDEXES: s.run(stmt).consume() db_hash = {rec["k"]: rec["h"] for rec in s.run( @@ -350,11 +371,6 @@ def session(): return driver.session(**session_kwargs) try: - # 1. schema (DDL runs in its own autocommit transactions). - with session() as s: - for stmt in [*CONSTRAINTS, *INDEXES]: - s.run(stmt) - # The application anchor. Every destructive statement below is scoped to # ``can:///``; an empty application is refused up front rather than # becoming ``STARTS WITH ''`` (every node in the database). The root row is @@ -372,6 +388,14 @@ def session(): if n.labels and n.labels[0] == "PyApplication"), None) app_prefix = application_prefix(app_name, namespace) + with session() as s: + _validate_stored_identity(s, app_name, namespace) + + # 1. schema (DDL runs in its own autocommit transactions). + with session() as s: + for stmt in [*CONSTRAINTS, *INDEXES]: + s.run(stmt) + # Partition nodes by owning module (an in-memory field, never emitted, #173); # shared nodes have none. by_module: Dict[str, List[NodeRow]] = {} diff --git a/codeanalyzer/schema/py_schema.py b/codeanalyzer/schema/py_schema.py index 4ab5702..2f16a13 100644 --- a/codeanalyzer/schema/py_schema.py +++ b/codeanalyzer/schema/py_schema.py @@ -24,7 +24,7 @@ import dataclasses as _dataclasses from dataclasses import dataclass, field as _dc_field from typing import Any, Dict, List, Optional, Tuple -from pydantic import BaseModel, model_validator +from pydantic import BaseModel, field_validator, model_validator from typing_extensions import Literal @@ -656,6 +656,14 @@ class PyApplication(BaseModel): config_reads_unresolved: List[PyConfigRead] = [] + @field_validator("identity_version", mode="before") + @classmethod + def validate_identity_version(cls, value): + if type(value) is not int or value != 2: + raise ValueError("application identity_version must be integer 2") + return value + + @builder class Analysis(BaseModel): """v2 payload root: envelope + the application tree node. ``k_limit`` is an diff --git a/docs/handoff/README.md b/docs/handoff/README.md index 242482f..423fb23 100644 --- a/docs/handoff/README.md +++ b/docs/handoff/README.md @@ -137,11 +137,11 @@ def run(flag): ``` ``` -python -m codeanalyzer -i --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app -a 1 -o --no-venv # → analysis.l1.json -python -m codeanalyzer -i --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app -a 2 -o --no-venv # → analysis.l2.json -python -m codeanalyzer -i --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app -a 3 -o --no-venv # → analysis.l3.json -python -m codeanalyzer -i --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app -a 4 -o --no-venv # → analysis.l4.json -python -m codeanalyzer -i --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app --app-name sample-app --namespace sample-app --emit neo4j -o --no-venv # → graph.cypher +python -m codeanalyzer -i --app-name sample-app --namespace sample-app -a 1 -o --no-venv # → analysis.l1.json +python -m codeanalyzer -i --app-name sample-app --namespace sample-app -a 2 -o --no-venv # → analysis.l2.json +python -m codeanalyzer -i --app-name sample-app --namespace sample-app -a 3 -o --no-venv # → analysis.l3.json +python -m codeanalyzer -i --app-name sample-app --namespace sample-app -a 4 -o --no-venv # → analysis.l4.json +python -m codeanalyzer -i --app-name sample-app --namespace sample-app --emit neo4j -o --no-venv # → graph.cypher python -m codeanalyzer --emit schema -o # → schema.json ``` diff --git a/test/test_neo4j_bolt_streaming.py b/test/test_neo4j_bolt_streaming.py index b4876e1..7b865b7 100644 --- a/test/test_neo4j_bolt_streaming.py +++ b/test/test_neo4j_bolt_streaming.py @@ -26,3 +26,47 @@ def test_batches_do_not_retain_the_graph(monkeypatch): tracemalloc.stop() assert sum(sizes) == 100_000 and max(sizes) <= bolt.BATCH assert peak < 4 * 1024 * 1024, f"streaming retained {peak} bytes for 100k rows" + + +import sys +from types import SimpleNamespace +import pytest +from codeanalyzer.neo4j.rows import GraphRows, NodeRow + + +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("metadata", [ + {}, {"identity_version": 2, "application_name": "app"}, + {"identity_version": 2, "identity_namespace": "team"}, + {"identity_version": 2.0, "application_name": "app", "identity_namespace": "team"}, + {"identity_version": True, "application_name": "app", "identity_namespace": "team"}, + {"identity_version": 2, "application_name": "other", "identity_namespace": "team"}, + {"identity_version": 2, "application_name": "app", "identity_namespace": "other"}, +]) +def test_stored_identity_rejected_before_any_write(monkeypatch, streaming, metadata): + queries = [] + class Session: + def __enter__(self): return self + def __exit__(self, *args): pass + def run(self, query, **params): + queries.append(query) + assert query.startswith("MATCH (a:PyApplication"), "write or unrelated read before identity guard" + assert params == {"id": "can://team/app"} + return SimpleNamespace(single=lambda: {"identity": metadata}) + driver = SimpleNamespace(session=lambda **kw: Session(), close=lambda: None) + monkeypatch.setitem(sys.modules, "neo4j", SimpleNamespace(GraphDatabase=SimpleNamespace(driver=lambda *a, **kw: driver))) + cfg = bolt.BoltConfig("bolt://unused", "user", "password") + with pytest.raises(ValueError, match="identity"): + if streaming: + bolt.bolt_writer_streaming(SimpleNamespace(symbol_table={}), "app", {}, None, cfg, True, namespace="team") + else: + rows = GraphRows([NodeRow(["PyApplication"], "id", "can://team/app", {"name": "app", "identity_namespace": "team"})], []) + bolt.bolt_writer(rows, cfg, True) + assert len(queries) == 1 + + +def test_stored_identity_allows_fresh_or_matching_anchor(): + for metadata in [None, {"identity_version": 2, "application_name": "app", "identity_namespace": "team"}]: + result = None if metadata is None else {"identity": metadata} + session = SimpleNamespace(run=lambda *args, **kwargs: SimpleNamespace(single=lambda: result)) + bolt._validate_stored_identity(session, "app", "team") From ae9964faff31b8800ba4d1c69f684b8febc72ae1 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Sat, 31 Oct 2026 09:12:00 -0400 Subject: [PATCH 6/6] chore: prepare 1.7.0 release --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a02755..6032af1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.7.0] - 2026-10-31 + +### Added + +- Add `--namespace` and namespace-qualified application IDs with strict identity metadata. + +### Fixed + +- Scope graph writes, URI resolution and caches to one namespace/application. +- Reject incompatible stored graph anchors before writes. + +Existing analyses and graphs must be regenerated from source for the new ID roots. + ## [1.6.2] - 2026-10-14 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index c86a098..ac27dd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codeanalyzer-python" -version = "1.6.2" +version = "1.7.0" description = "Static analysis for Python — canonical schema v2 (symbol table, call graph, and native CFG/PDG/SDG dataflow) as analysis.json or a Neo4j property graph." readme = "README.md" authors = [