Skip to content

Commit b4f22ee

Browse files
authored
feat: add application identity namespaces
1 parent 54acde4 commit b4f22ee

49 files changed

Lines changed: 2010 additions & 831 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.7.0] - 2026-10-31
9+
10+
### Added
11+
12+
- Add `--namespace` and namespace-qualified application IDs with strict identity metadata.
13+
14+
### Fixed
15+
16+
- Scope graph writes, URI resolution and caches to one namespace/application.
17+
- Reject incompatible stored graph anchors before writes.
18+
19+
Existing analyses and graphs must be regenerated from source for the new ID roots.
20+
821
## [1.6.2] - 2026-10-14
922

1023
### Fixed

‎README.md‎

Lines changed: 167 additions & 207 deletions
Large diffs are not rendered by default.

‎codeanalyzer/__main__.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ def main(
9797
"(default: input dir name).",
9898
),
9999
] = None,
100+
namespace: Annotated[
101+
Optional[str],
102+
typer.Option("--namespace", help="Identity namespace (default: resolved application name)."),
103+
] = None,
100104
neo4j_uri: Annotated[
101105
Optional[str],
102106
typer.Option(
@@ -313,6 +317,7 @@ def main(
313317

314318
emit=emit,
315319
app_name=app_name,
320+
namespace=namespace,
316321
neo4j_uri=neo4j_uri,
317322
neo4j_user=neo4j_user,
318323
neo4j_password=neo4j_password,
@@ -363,6 +368,13 @@ def main(
363368
if not options.input.exists():
364369
logger.error(f"Input path '{options.input}' does not exist.")
365370
raise typer.Exit(code=1)
371+
from codeanalyzer.schema.ids import application_id
372+
try:
373+
application_id(options.app_name if options.app_name is not None else options.input.resolve().name,
374+
options.namespace)
375+
except ValueError as exc:
376+
logger.error(str(exc))
377+
raise typer.Exit(code=1)
366378

367379
if options.file_name is not None:
368380
full_file_path = options.input / options.file_name

‎codeanalyzer/artifacts/discovery.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def _classify(rel_posix: str) -> Tuple[str, List[str]] | None:
117117
def discover_artifacts(
118118
project_dir: Path,
119119
app_name: str,
120+
namespace: str | None = None,
120121
*,
121122
capture_text: bool = True,
122123
exclude_paths: Iterable[Path] = (),
@@ -146,7 +147,9 @@ def discover_artifacts(
146147
decodable file is captured in full; ``capture_text=False`` empties
147148
``source`` everywhere (inventory otherwise identical), and an undecodable
148149
file gets ``""`` as ``binary``. ``sha256``/``size_bytes`` always reflect
149-
the full file regardless."""
150+
the full file regardless.
151+
152+
``namespace`` qualifies identity; keys and ``path`` stay project-relative."""
150153
out: Dict[str, PyArtifact] = {}
151154
excluded = _resolve_exclusions(project_dir, exclude_paths)
152155
for path in sorted(project_dir.rglob("*")):
@@ -185,7 +188,7 @@ def discover_artifacts(
185188
fmt, source = "binary", ""
186189

187190
out[rel_posix] = PyArtifact(
188-
id=artifact_id(app_name, rel_posix), path=rel_posix, format=fmt,
191+
id=artifact_id(app_name, rel_posix, namespace), path=rel_posix, format=fmt,
189192
roles=list(roles), size_bytes=len(raw),
190193
sha256=hashlib.sha256(raw).hexdigest(),
191194
source=source,

‎codeanalyzer/core.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -762,8 +762,8 @@ def analyze(self) -> Analysis:
762762
# analyzer identity rides the envelope below (keystone home).
763763
app.repository = repository_info(self.project_dir)
764764

765-
app_name = self.options.app_name or self.project_dir.name
766-
sig_to_id = assign_ids(app, app_name)
765+
app_name = self.options.app_name if self.options.app_name is not None else self.project_dir.name
766+
sig_to_id = assign_ids(app, app_name, self.options.namespace)
767767
# Home call-graph endpoints that are not declared in the symbol table
768768
# (imported library / builtin members) onto @external ids once, so the
769769
# JSON and Neo4j backends share one authoritative external-symbol set
@@ -788,7 +788,7 @@ def analyze(self) -> Analysis:
788788
)
789789

790790
app.artifacts = discover_artifacts(
791-
self.project_dir, app_name,
791+
self.project_dir, app_name, self.options.namespace,
792792
capture_text=self.options.artifact_text,
793793
exclude_paths=self._own_output_paths(),
794794
)
@@ -917,13 +917,13 @@ def analyze(self) -> Analysis:
917917
# k_limit is an L3+ envelope key: below the dataflow levels it stays
918918
# None and exclude_none drops it from the payload.
919919
analysis = Analysis(
920+
identity_version=2,
920921
max_level=self.analysis_level,
921922
k_limit=self.options.graph_field_depth if self.analysis_level >= 3 else None,
922923
analyzer=analyzer_info(self.analysis_level),
923924
application=app,
924925
)
925926
self._save_analysis_cache(analysis, cache_file)
926-
927927
return analysis
928928

929929
@staticmethod
@@ -964,6 +964,12 @@ def _load_pyapplication_from_cache(self, cache_file: Path) -> Optional[Analysis]
964964
if getattr(cached, "schema_version", None) != "2.0.0":
965965
logger.info("stale/incompatible analysis cache (schema_version) — rebuilding")
966966
return None
967+
app_name = self.options.app_name if self.options.app_name is not None else self.project_dir.name
968+
namespace = app_name if self.options.namespace is None else self.options.namespace
969+
if (cached.application.application_name != app_name
970+
or cached.application.identity_namespace != namespace):
971+
logger.info("analysis cache identity changed — rebuilding from source")
972+
return None
967973
# The cache keys only on file hash/mtime/size, not on level, so a cache
968974
# built at a different analysis_level would leak higher-level body/edge
969975
# content (or omit content when the cached level is lower). Reject the

‎codeanalyzer/neo4j/bolt.py‎

Lines changed: 114 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
4. upsert edges owned by changed modules (+ the shared edges).
2828
5. on a FULL run only, prune modules whose source file vanished.
2929
30+
An eager full run also reconciles the app's artifacts first (``_reconcile_artifacts``).
31+
3032
**A push never deletes by default** (#171). Steps 3 and 5 are the only destructive
3133
ones and both run on ``eager`` (``--eager``) only; a default ``--lazy`` push is purely
3234
additive — MERGE-upsert of nodes and edges, nothing removed. The cost of the default is
@@ -85,6 +87,67 @@
8587
"RETURN count(*) AS pruned"
8688
)
8789

90+
# Artifact reconciliation (--eager full runs). The artifact vocabulary is shared with the
91+
# sibling analyzers, so only python-owned edges are dropped and rebuilt: HAS_ARTIFACT from
92+
# a :PyApplication, DECLARES_DEPENDENCY/LOCKS to pkg:pypi/, incoming PY_ edges, and the
93+
# app's PY_READS_CONFIG_UNRESOLVED. Not sha-diffed: siblings overwrite the shared sha256.
94+
PURGE_ARTIFACT_EDGES = (
95+
"UNWIND $pres AS pre "
96+
"MATCH (t) WHERE (t:Artifact OR t:ConfigKey) AND t.id STARTS WITH pre "
97+
"CALL { WITH t "
98+
"WITH [(t)<-[r]-(s) WHERE (type(r) = 'HAS_ARTIFACT' AND s:PyApplication) "
99+
"OR type(r) STARTS WITH 'PY_' | r] "
100+
"+ [(t)-[d:DECLARES_DEPENDENCY|LOCKS]->(p:Package) WHERE p.id STARTS WITH 'pkg:pypi/' | d] AS rs "
101+
"FOREACH (r IN rs | DELETE r) } IN TRANSACTIONS OF 1000 ROWS"
102+
)
103+
PURGE_UNRESOLVED_CONFIG_READS = (
104+
"MATCH (:PyApplication {id: $app_id})-[r:PY_READS_CONFIG_UNRESOLVED|PY_UNRESOLVED_IMPORT]->() "
105+
"CALL { WITH r DELETE r } IN TRANSACTIONS OF 1000 ROWS"
106+
)
107+
# Dependency → import edges into this app's ghosts; scoped by the ghost's app id.
108+
PURGE_PROVIDES = (
109+
"MATCH (:Package)-[r:PY_PROVIDES]->(g:PyExternal) WHERE g.id STARTS WITH $app_pre "
110+
"CALL { WITH r DELETE r } IN TRANSACTIONS OF 1000 ROWS"
111+
)
112+
# Release python's claim on artifacts/config keys the run no longer emits. An artifact is
113+
# deleted once no HAS_ARTIFACT is left (every analyzer inventories with one, marked or not);
114+
# a config key once no other analyzer's *CanNode claims it. Pre-1.5.0 artifacts carry no
115+
# marker; legacy roots are outside the selected identity and are left alone.
116+
RELEASE_VANISHED_ARTIFACTS = (
117+
"UNWIND $pres AS pre "
118+
"MATCH (n) WHERE (n:Artifact OR n:ConfigKey) AND n.id STARTS WITH pre AND NOT n.id IN $keep "
119+
f"AND n:{CAN_NODE} "
120+
f"CALL {{ WITH n REMOVE n:{CAN_NODE} "
121+
"WITH n WHERE (n:Artifact AND NOT ()-[:HAS_ARTIFACT]->(n)) "
122+
"OR (n:ConfigKey AND none(l IN labels(n) WHERE l ENDS WITH 'CanNode')) "
123+
"DETACH DELETE n } IN TRANSACTIONS OF 1000 ROWS "
124+
"RETURN count(*) AS released"
125+
)
126+
127+
128+
def _artifact_prefixes(app_name: str, namespace: str | None = None) -> List[str]:
129+
"""Artifacts in the selected namespace/application only."""
130+
return [f"{application_prefix(app_name, namespace)}artifact/"]
131+
132+
133+
def _reconcile_artifacts(session, app_name: str, keep: List[str], namespace: str | None = None) -> None:
134+
"""Drop python-owned artifact edges and release vanished artifacts/config keys.
135+
Auto-commit and batched; a partial failure is finished by the next eager push."""
136+
pres = _artifact_prefixes(app_name, namespace)
137+
app_pre = application_prefix(app_name, namespace)
138+
params = {"pres": pres, "keep": keep, "app_id": app_pre[:-1], "app_pre": app_pre}
139+
with session() as s:
140+
s.run(PURGE_ARTIFACT_EDGES, **params).consume()
141+
s.run(PURGE_UNRESOLVED_CONFIG_READS, **params).consume()
142+
s.run(PURGE_PROVIDES, **params).consume()
143+
rec = s.run(RELEASE_VANISHED_ARTIFACTS, **params).single()
144+
logger.info(f"neo4j(bolt): released {rec['released'] if rec else 0} vanished artifact/config node(s)")
145+
146+
147+
def _artifact_keep(app) -> List[str]:
148+
"""Every artifact and config-key id this run emits."""
149+
return [i for a in (app.artifacts or {}).values() for i in (a.id, *(k.id for k in a.config_keys))]
150+
88151

89152
@dataclass
90153
class BoltConfig:
@@ -168,10 +231,12 @@ def finish(self):
168231
class _EdgeBatches(RowBuilder):
169232
"""Writes the relationships owned by changed modules (or by no module) in batches."""
170233

171-
def __init__(self, session, neo4j, changed, keys, module_by_id):
234+
def __init__(self, session, neo4j, changed, keys, module_by_id, reconcile=False):
172235
super().__init__()
173236
self._session, self._neo4j, self._changed = session, neo4j, changed
174237
self._keys, self._module_by_id = keys, module_by_id
238+
# After artifact reconciliation, edges into artifacts are rebuilt even from unchanged modules.
239+
self._reconcile = reconcile
175240
self._pending, self._waiting = [], set()
176241

177242
def _owner(self, value):
@@ -189,7 +254,8 @@ def node(self, labels, key_prop, value, props):
189254

190255
def edge(self, type_, from_ref, to_ref, props=None, key=None):
191256
owner = self._owner(from_ref.value)
192-
if owner is not None and owner not in self._changed:
257+
into_artifact = self._reconcile and to_ref.label in ("Artifact", "ConfigKey")
258+
if owner is not None and owner not in self._changed and not into_artifact:
193259
return
194260
identity = (type_, from_ref, to_ref, key)
195261
if identity in self._waiting:
@@ -212,8 +278,28 @@ def finish(self):
212278
return GraphRows([], [])
213279

214280

281+
def _validate_stored_identity(session, app_name, namespace):
282+
"""Read the selected anchor before schema or graph mutations; absence is fresh."""
283+
from codeanalyzer.schema.ids import application_id
284+
285+
app_id = application_id(app_name, namespace)
286+
record = session.run(
287+
"MATCH (a:PyApplication {id: $id}) RETURN properties(a) AS identity",
288+
id=app_id,
289+
).single()
290+
if record is None:
291+
return
292+
metadata = record["identity"]
293+
expected_namespace = app_name if namespace is None else namespace
294+
if (type(metadata.get("identity_version")) is not int
295+
or metadata.get("identity_version") != 2
296+
or metadata.get("application_name") != app_name
297+
or metadata.get("identity_namespace") != expected_namespace):
298+
raise ValueError("Stored application identity is missing or incompatible")
299+
300+
215301
def bolt_writer_streaming(app, app_name, sig_to_id, analyzer, cfg: BoltConfig, full_run: bool,
216-
eager: bool = False) -> None:
302+
eager: bool = False, namespace: str | None = None) -> None:
217303
""":func:`bolt_writer` without materializing :class:`GraphRows`: the projection
218304
runs twice (nodes, then relationships) and is written in bounded batches."""
219305
try:
@@ -225,7 +311,7 @@ def bolt_writer_streaming(app, app_name, sig_to_id, analyzer, cfg: BoltConfig, f
225311
) from exc
226312
from codeanalyzer.neo4j.project import project
227313

228-
app_prefix = application_prefix(app_name)
314+
app_prefix = application_prefix(app_name, namespace)
229315
driver = neo4j.GraphDatabase.driver(cfg.uri, auth=(cfg.user, cfg.password))
230316

231317
session_kwargs = {"database": cfg.database} if cfg.database else {}
@@ -235,6 +321,7 @@ def session():
235321

236322
try:
237323
with session() as s:
324+
_validate_stored_identity(s, app_name, namespace)
238325
for stmt in CONSTRAINTS + INDEXES:
239326
s.run(stmt).consume()
240327
db_hash = {rec["k"]: rec["h"] for rec in s.run(
@@ -246,11 +333,14 @@ def session():
246333
or mod.content_hash != db_hash[mod.id]}
247334
logger.info(f"neo4j(bolt): {len(app.symbol_table)} modules ({len(changed)} changed)")
248335

336+
reconcile = full_run and eager
337+
if reconcile:
338+
_reconcile_artifacts(session, app_name, _artifact_keep(app), namespace)
249339
nodes = _NodeBatches(session, neo4j, changed, app_prefix, eager)
250-
project(app, app_name, sig_to_id, analyzer=analyzer, builder=nodes, module_scoped=True)
340+
project(app, app_name, sig_to_id, analyzer=analyzer, namespace=namespace, builder=nodes, module_scoped=True)
251341
module_by_id = {mod.id: key for key, mod in app.symbol_table.items()}
252-
edges = _EdgeBatches(session, neo4j, changed, nodes._keys, module_by_id)
253-
project(app, app_name, sig_to_id, analyzer=analyzer, builder=edges)
342+
edges = _EdgeBatches(session, neo4j, changed, nodes._keys, module_by_id, reconcile)
343+
project(app, app_name, sig_to_id, analyzer=analyzer, namespace=namespace, builder=edges)
254344

255345
if full_run and eager:
256346
with session() as s:
@@ -281,11 +371,6 @@ def session():
281371
return driver.session(**session_kwargs)
282372

283373
try:
284-
# 1. schema (DDL runs in its own autocommit transactions).
285-
with session() as s:
286-
for stmt in [*CONSTRAINTS, *INDEXES]:
287-
s.run(stmt)
288-
289374
# The application anchor. Every destructive statement below is scoped to
290375
# ``can://<app>/``; an empty application is refused up front rather than
291376
# becoming ``STARTS WITH ''`` (every node in the database). The root row is
@@ -299,7 +384,17 @@ def session():
299384
),
300385
None,
301386
)
302-
app_prefix = application_prefix(app_name)
387+
namespace = next((n.props.get("identity_namespace") for n in rows.nodes
388+
if n.labels and n.labels[0] == "PyApplication"), None)
389+
app_prefix = application_prefix(app_name, namespace)
390+
391+
with session() as s:
392+
_validate_stored_identity(s, app_name, namespace)
393+
394+
# 1. schema (DDL runs in its own autocommit transactions).
395+
with session() as s:
396+
for stmt in [*CONSTRAINTS, *INDEXES]:
397+
s.run(stmt)
303398

304399
# Partition nodes by owning module (an in-memory field, never emitted, #173);
305400
# shared nodes have none.
@@ -336,7 +431,11 @@ def session():
336431
f"{len(shared)} shared nodes, {len(rows.edges)} edges"
337432
)
338433

339-
# 3. shared nodes are always upserted (MERGE-only).
434+
# 3. eager full run: reconcile artifacts; then shared nodes are upserted (MERGE-only).
435+
reconcile = full_run and eager
436+
if reconcile:
437+
_reconcile_artifacts(session, app_name, [
438+
n.value for n in rows.nodes if n.labels and n.labels[0] in ("Artifact", "ConfigKey")], namespace)
340439
_upsert_nodes(session, neo4j, shared)
341440

342441
# 4. per changed module: purge owned edges + vanished decls, then upsert its nodes.
@@ -369,6 +468,7 @@ def _purge(tx, mid=module_id, node_keys=keys):
369468
e
370469
for e in rows.edges
371470
if module_of.get(e.from_ref.value) is None or module_of.get(e.from_ref.value) in changed
471+
or (reconcile and e.to_ref.label in ("Artifact", "ConfigKey"))
372472
]
373473
_upsert_edges(session, neo4j, edges)
374474

0 commit comments

Comments
 (0)