Skip to content

Commit d554ac6

Browse files
authored
feat(artifacts): source is the whole file; scope and gate every Bolt delete (#174)
Two contract fixes on the Neo4j/artifact surface. fix(neo4j): the per-module Bolt purge matched `MATCH (x {_module: $m})` with no label. `_module` is a shared convention -- codeanalyzer-java and codeanalyzer-typescript set it on their nodes too -- so wherever a python module and a sibling analyzer's module shared a file key, a python push detach-deleted that sibling's nodes and their relationships, silently. Both statements are now anchored on the python-owned labels, derived from the schema catalog (`MODULE_OWNED_LABELS`) so a new module-scoped label is covered automatically. A push also no longer deletes anything by default: the per-module purge and the full-run orphan prune both run under `--eager` only, so a default `--lazy` push is purely additive. Adds an `_module` index per module-owned label, the purge having been an unindexed scan once per changed module. BREAKING (released as a minor by maintainer decision -- see CHANGELOG): `PyArtifact.text_truncated`, `--artifact-text-max-bytes`, and the Neo4j `:Artifact.text_truncated` property are removed. An artifact's `source` is the whole file, or `""` because the file is binary or `--no-artifact-text` was passed -- never a prefix. A truncated `source` read exactly like a complete small file, and the flag meant to distinguish them conflated "whole file" with "capture off"; measured on microsoft/vscode the cap fired on 32 of 4,953 artifacts (0.6%). python's `dependency-manifest` exemption goes with the cap. Matches codeanalyzer-typescript, which removed both in its #117. Migration: drop any `text_truncated` check (`source` is trustworthy whenever non-empty); drop `--artifact-text-max-bytes` (use `--no-artifact-text` to omit the payload); pass `--eager` if you relied on a push removing stale nodes. Specs: codellm-devkit/.github docs/design/specs/2026-09-02-artifact-source-whole-file.md Epic: codellm-devkit/.github#51 Closes #171 Closes #172
1 parent 882ea0b commit d554ac6

20 files changed

Lines changed: 339 additions & 344 deletions

CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,46 @@ 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+
## [Unreleased]
9+
10+
### Removed
11+
12+
- **BREAKING:** the artifact-text byte cap, its `--artifact-text-max-bytes`
13+
flag, and the `PyArtifact.text_truncated` field (also the Neo4j `:Artifact`
14+
property). An artifact's `source` is now the whole file, or `""` because it
15+
is binary or `--no-artifact-text` was passed -- never a prefix. A truncated
16+
`source` read exactly like a complete small file, and the flag meant to
17+
distinguish them conflated "whole file" with "capture off"; measured on
18+
microsoft/vscode the cap fired on 32 of 4,953 artifacts (0.6%). Matches
19+
codeanalyzer-typescript, which removed both in its #117. `--no-artifact-text`
20+
is unchanged. python's `dependency-manifest` exemption from the cap is gone
21+
with the cap -- every decodable file is captured in full (#172).
22+
23+
### Fixed
24+
25+
- Neo4j incremental push no longer deletes a sibling analyzer's nodes. The
26+
per-module purge matched `MATCH (x {_module: $m})` with no label, and
27+
`_module` is a shared convention -- `codeanalyzer-java` and
28+
`codeanalyzer-typescript` set it on their nodes too -- so where a file key
29+
collided, a python push silently detach-deleted their graph. Both statements
30+
are now anchored on the python-owned labels, derived from the schema catalog
31+
(`MODULE_OWNED_LABELS`) so a new module-scoped label is covered
32+
automatically (#171).
33+
34+
### Changed
35+
36+
- A Neo4j Bolt push no longer deletes anything by default. The per-module purge
37+
and the full-run orphan prune are the only destructive steps, and both now run
38+
under `--eager` only; a default `--lazy` push is purely additive. The cost of
39+
the default is staleness -- a declaration or call edge the source no longer has
40+
survives until an `--eager` push reconciles it -- and the gain is that an
41+
incremental push into a shared database cannot destroy anything (#171).
42+
43+
### Added
44+
45+
- An `_module` index per module-owned Neo4j label. The per-module purge ran as
46+
an unindexed scan once per changed module -- quadratic on a full push (#171).
47+
848
## [1.3.0] - 2026-08-29
949

1050
### Added

README.md

Lines changed: 131 additions & 196 deletions
Large diffs are not rendered by default.

codeanalyzer/__main__.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,10 @@ def main(
175175
bool,
176176
typer.Option(
177177
"--eager/--lazy",
178-
help="Enable eager or lazy analysis. Defaults to lazy.",
178+
help="Enable eager or lazy analysis. Defaults to lazy. Also gates every "
179+
"destructive step of a '--emit neo4j' Bolt push: a lazy push only adds and "
180+
"updates, an eager one also removes declarations and edges the source no "
181+
"longer has.",
179182
),
180183
] = False,
181184
skip_tests: Annotated[
@@ -239,19 +242,11 @@ def main(
239242
typer.Option(
240243
"--artifact-text/--no-artifact-text",
241244
help="Capture verbatim `source` text on discovered artifacts. "
242-
"--no-artifact-text empties `source` everywhere (inventory unchanged).",
245+
"`source` is the whole file; --no-artifact-text empties it "
246+
"everywhere (inventory unchanged). sha256/size_bytes always "
247+
"reflect the full file.",
243248
),
244249
] = True,
245-
artifact_text_max_bytes: Annotated[
246-
int,
247-
typer.Option(
248-
"--artifact-text-max-bytes",
249-
help="Per-file byte cap on captured artifact `source`; a decodable "
250-
"file over the cap is truncated (text_truncated=True). "
251-
"sha256/size_bytes always reflect the full file.",
252-
min=1,
253-
),
254-
] = 262144,
255250
):
256251
# Determinism: pin the interpreter hash seed before any analysis (no-op
257252
# when PYTHONHASHSEED is already set; --version exits before this).
@@ -336,7 +331,6 @@ def main(
336331
verbosity=verbosity,
337332
entrypoint_rules=tuple(entrypoint_rules or ()),
338333
artifact_text=artifact_text,
339-
artifact_text_max_bytes=artifact_text_max_bytes,
340334
)
341335

342336
_set_log_level(options.verbosity)

codeanalyzer/artifacts/dependencies.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ def _resolve_ref(manifest_path: str, ref: str) -> Optional[str]:
8181

8282
def _full_text(project_dir: Path, path: str, art: PyArtifact) -> str:
8383
"""Manifest/lock extraction must never depend on the stored ``source`` --
84-
that's capped by ``text_max_bytes`` and emptied by ``capture_text=False``
85-
(both payload-size controls on the JSON/Neo4j payload, not extraction
86-
controls). Read the real file fresh instead; fall back to ``art.source``
84+
that's emptied by ``capture_text=False`` (a payload-size control on the
85+
JSON/Neo4j payload, not an extraction control). Read the real file fresh
86+
instead; fall back to ``art.source``
8787
only if it is gone (e.g. a synthetic artifact in a unit test, or the file
8888
vanished mid-run).
8989

codeanalyzer/artifacts/discovery.py

Lines changed: 8 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -76,27 +76,11 @@ def _classify(rel_posix: str) -> Tuple[str, List[str]] | None:
7676
return None
7777

7878

79-
def _capture_source(
80-
raw: bytes, text: str, capture_text: bool, text_max_bytes: int
81-
) -> Tuple[str, bool]:
82-
"""Decide ``(source, text_truncated)`` for a decodable file.
83-
84-
Slices ``raw`` (not ``text``) for the cap, so it is a true byte cap even
85-
when it lands inside a multi-byte character -- ``errors="ignore"`` drops
86-
the dangling partial char at the cut, so this never raises."""
87-
if not capture_text:
88-
return "", False
89-
if len(raw) <= text_max_bytes:
90-
return text, False
91-
return raw[:text_max_bytes].decode("utf-8", errors="ignore"), True
92-
93-
9479
def discover_artifacts(
9580
project_dir: Path,
9681
app_name: str,
9782
*,
9883
capture_text: bool = True,
99-
text_max_bytes: int = 262144,
10084
) -> Dict[str, PyArtifact]:
10185
"""Walk the project and return every file as an artifact, sorted by path.
10286
@@ -109,15 +93,11 @@ def discover_artifacts(
10993
deliberate exception -- it IS rule-matched (a dependency-manifest), so it
11094
is captured like any other manifest despite the `.py` suffix.
11195
112-
``capture_text=False`` empties ``source`` everywhere (inventory otherwise
113-
identical); a decodable file over ``text_max_bytes`` gets a truncated
114-
``source`` and ``text_truncated=True`` -- except a ``dependency-manifest``
115-
role artifact, which is always captured in full when decodable and
116-
``capture_text`` is on: its source is what ``build_dependency_view``
117-
parses, not bulk/incidental content, so the byte cap does not apply to
118-
it (``capture_text=False`` still empties it like everything else).
119-
``sha256``/``size_bytes`` always reflect the full file regardless of
120-
either knob."""
96+
``source`` is the WHOLE file or nothing -- never a prefix (#172). A
97+
decodable file is captured in full; ``capture_text=False`` empties
98+
``source`` everywhere (inventory otherwise identical), and an undecodable
99+
file gets ``""`` as ``binary``. ``sha256``/``size_bytes`` always reflect
100+
the full file regardless."""
121101
out: Dict[str, PyArtifact] = {}
122102
for path in sorted(project_dir.rglob("*")):
123103
if not path.is_file():
@@ -148,20 +128,14 @@ def discover_artifacts(
148128
if decodable and "." not in name and text.startswith("#!"):
149129
roles = ["script"]
150130
if decodable:
151-
# A dependency-manifest's source IS the extracted meaning (build_
152-
# dependency_view parses it) -- the byte cap targets bulk/incidental
153-
# assets, never the files extraction depends on, so manifests are
154-
# exempt from it. capture_text=False still empties source (handled
155-
# inside _capture_source); only the byte CAP is bypassed here.
156-
cap = len(raw) if "dependency-manifest" in roles else text_max_bytes
157-
source, text_truncated = _capture_source(raw, text, capture_text, cap)
131+
source = text if capture_text else ""
158132
else:
159-
fmt, source, text_truncated = "binary", "", False
133+
fmt, source = "binary", ""
160134

161135
out[rel_posix] = PyArtifact(
162136
id=artifact_id(app_name, rel_posix), path=rel_posix, format=fmt,
163137
roles=list(roles), size_bytes=len(raw),
164138
sha256=hashlib.sha256(raw).hexdigest(),
165-
source=source, text_truncated=text_truncated,
139+
source=source,
166140
)
167141
return out

codeanalyzer/core.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@
4040
def _artifact_full_text(project_dir: Path, path: str, art) -> str:
4141
"""Mirrors ``artifacts.dependencies._full_text`` verbatim (not imported
4242
-- that name is module-private to ``dependencies.py``): config-key
43-
extraction (#152) must never depend on the stored ``source`` -- capped
44-
by ``text_max_bytes`` and emptied by ``capture_text=False`` (payload-size
45-
controls, not extraction controls). Read the real file fresh instead;
43+
extraction (#152) must never depend on the stored ``source`` -- emptied
44+
by ``capture_text=False`` (a payload-size control, not an extraction
45+
control). Read the real file fresh instead;
4646
fall back to ``art.source`` only if it's gone (e.g. a synthetic artifact
4747
in a unit test, or the file vanished mid-run). Keep the two in sync if
4848
this logic changes."""
@@ -673,7 +673,6 @@ def analyze(self) -> Analysis:
673673
app.artifacts = discover_artifacts(
674674
self.project_dir, app_name,
675675
capture_text=self.options.artifact_text,
676-
text_max_bytes=self.options.artifact_text_max_bytes,
677676
)
678677
app.dependencies, app.unresolved_imports = build_dependency_view(
679678
app.artifacts,

codeanalyzer/neo4j/bolt.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,24 @@
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+
**A push never deletes by default** (#171). Steps 3 and 5 are the only destructive
31+
ones and both run on ``eager`` (``--eager``) only; a default ``--lazy`` push is purely
32+
additive — MERGE-upsert of nodes and edges, nothing removed. The cost of the default is
33+
staleness: a declaration or a call edge the source no longer has stays in the graph until
34+
an ``--eager`` push reconciles it. That is the deliberate trade — an incremental push into
35+
a shared database should not be able to destroy anything, and the destructive rebuild is
36+
opt-in under the same flag that already forces a clean analysis rebuild.
37+
3038
Nodes are MERGE-upserted, never blindly deleted, so a declaration another
3139
(unchanged) module still references survives and its incoming edges stay valid.
3240
``:PyExternal`` / ``:PyPackage`` / ``:PyDecorator`` are shared (no ``_module``) and are
3341
MERGE-only.
3442
43+
Every ``_module`` match is anchored on the python-owned labels
44+
(``schema.MODULE_OWNED_PATTERN``). ``_module`` is a shared convention, not a python-private
45+
one -- codeanalyzer-java and codeanalyzer-typescript set it on their nodes too -- so an
46+
unlabelled match reaches a sibling analyzer's graph in a shared database (#171).
47+
3548
The ``neo4j`` driver is imported lazily so it stays an optional dependency and
3649
off the default (json) output path entirely.
3750
"""
@@ -41,7 +54,7 @@
4154
from typing import Dict, List, Optional
4255

4356
from codeanalyzer.neo4j.rows import EdgeRow, GraphRows, NodeRow, chunk
44-
from codeanalyzer.neo4j.schema import CONSTRAINTS, INDEXES
57+
from codeanalyzer.neo4j.schema import CONSTRAINTS, INDEXES, MODULE_OWNED_PATTERN
4558
from codeanalyzer.utils import logger
4659

4760
DESCENDANTS = (
@@ -59,7 +72,7 @@ class BoltConfig:
5972
database: Optional[str] = None
6073

6174

62-
def bolt_writer(rows: GraphRows, cfg: BoltConfig, full_run: bool) -> None:
75+
def bolt_writer(rows: GraphRows, cfg: BoltConfig, full_run: bool, eager: bool = False) -> None:
6376
try:
6477
import neo4j # noqa: WPS433 (lazy, optional dependency)
6578
except ImportError as exc: # pragma: no cover - exercised only without the extra
@@ -119,15 +132,26 @@ def session():
119132
_upsert_nodes(session, neo4j, shared)
120133

121134
# 4. per changed module: purge owned edges + vanished decls, then upsert its nodes.
135+
# The purge is the only destructive step in a push, so it runs on --eager only.
122136
for m in changed:
123137
nodes = by_module[m]
124138
keys = [n.value for n in nodes]
139+
if not eager:
140+
_upsert_nodes(session, neo4j, nodes)
141+
continue
125142
with session() as s:
126143
def _purge(tx, module=m, node_keys=keys):
127-
tx.run("MATCH (x {_module: $m})-[r]->() DELETE r", m=module)
144+
# Anchored on python-owned labels: `_module` is also set by the java
145+
# and typescript analyzers, so an unlabelled match would delete a
146+
# sibling's nodes wherever a file key collides (#171).
128147
tx.run(
129-
"MATCH (x {_module: $m}) "
130-
"WHERE NOT coalesce(x.signature, x.id, x.file_key) IN $keys "
148+
f"MATCH (x:{MODULE_OWNED_PATTERN}) WHERE x._module = $m "
149+
"MATCH (x)-[r]->() DELETE r",
150+
m=module,
151+
)
152+
tx.run(
153+
f"MATCH (x:{MODULE_OWNED_PATTERN}) WHERE x._module = $m "
154+
"AND NOT coalesce(x.signature, x.id, x.file_key) IN $keys "
131155
"DETACH DELETE x",
132156
m=module,
133157
keys=node_keys,
@@ -147,7 +171,7 @@ def _purge(tx, module=m, node_keys=keys):
147171
# 6. orphan prune — only safe on a full run (a targeted run can't tell deleted from untargeted).
148172
# Scope to THIS application's anchor so a full run for application B never
149173
# deletes application A's modules from a shared database.
150-
if full_run and app_name is not None:
174+
if full_run and eager and app_name is not None:
151175
present = list(by_module.keys())
152176
with session() as s:
153177
res = s.run(
@@ -161,6 +185,11 @@ def _purge(tx, module=m, node_keys=keys):
161185
pruned = res.single()
162186
pruned_count = pruned["pruned"] if pruned else 0
163187
logger.info(f"neo4j(bolt): pruned {pruned_count} vanished module(s)")
188+
elif not eager:
189+
logger.info(
190+
"neo4j(bolt): additive push (--lazy) — nothing deleted; "
191+
"re-run with --eager to reconcile removed declarations and edges"
192+
)
164193
else:
165194
logger.info(
166195
"neo4j(bolt): targeted run — orphan pruning skipped (deleted files not removed)"

codeanalyzer/neo4j/emit.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,10 @@ def emit_neo4j(analysis: Analysis, options: AnalysisOptions) -> None:
6767
password=options.neo4j_password,
6868
database=options.neo4j_database,
6969
)
70-
# A full run (no single-file restriction) makes orphan pruning safe.
70+
# A full run (no single-file restriction) makes orphan pruning safe; --eager
71+
# is what permits any deletion at all (#171).
7172
full_run = options.file_name is None
72-
bolt_writer(rows, cfg, full_run)
73+
bolt_writer(rows, cfg, full_run, eager=options.rebuild_analysis)
7374
return
7475

7576
out_dir = options.output if options.output is not None else Path.cwd()

codeanalyzer/neo4j/project.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,6 @@ def _project_artifacts(b: RowBuilder, app: PyApplication, app_name: str, app_ref
309309
"size_bytes": art.size_bytes,
310310
"sha256": art.sha256,
311311
"source": art.source,
312-
"text_truncated": art.text_truncated,
313312
"extraction": art.extraction,
314313
}
315314
),

codeanalyzer/neo4j/schema.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ class RelType:
210210
NodeLabel("Artifact", "Artifact", "id", {
211211
"id": "string", "path": "string", "format": "string",
212212
"roles": "string[]", "size_bytes": "integer", "sha256": "string",
213-
"source": "string", "text_truncated": "boolean", "extraction": "string",
213+
"source": "string", "extraction": "string",
214214
}),
215215
NodeLabel("Package", "Package", "id", {
216216
"id": "string", "ecosystem": "string", "name": "string",
@@ -332,10 +332,27 @@ def uniqueness_constraints() -> list[str]:
332332

333333
CONSTRAINTS: List[str] = uniqueness_constraints()
334334

335+
# The labels this analyzer owns per module -- the ones carrying the internal ``_module``
336+
# provenance property. Derived from NODE_LABELS so a new module-scoped label is covered
337+
# without a second list to maintain. `_module` is NOT python-private: codeanalyzer-java
338+
# and codeanalyzer-typescript set the same property on their nodes, so every statement
339+
# matching on it must be anchored to these labels or it matches a sibling analyzer's graph
340+
# in a shared database (#171).
341+
MODULE_OWNED_LABELS: List[str] = [n.label for n in NODE_LABELS if "_module" in n.properties]
342+
343+
# The label disjunction to anchor such a statement with: ``MATCH (x:PyModule|PyClass|...)``.
344+
MODULE_OWNED_PATTERN: str = "|".join(MODULE_OWNED_LABELS)
345+
335346
INDEXES: List[str] = [
336347
"CREATE INDEX py_callable_name IF NOT EXISTS FOR (c:PyCallable) ON (c.name)",
337348
"CREATE INDEX py_class_name IF NOT EXISTS FOR (c:PyClass) ON (c.name)",
338349
"CREATE FULLTEXT INDEX py_code_fts IF NOT EXISTS FOR (c:PyCallable) ON EACH [c.code, c.docstring]",
350+
] + [
351+
# One per module-owned label: the incremental writer's per-module purge matches on
352+
# `_module` once per changed module, which without these is a label scan per label per
353+
# module -- quadratic on a full push (#171).
354+
f"CREATE INDEX {label.lower()}_module IF NOT EXISTS FOR (x:{label}) ON (x._module)"
355+
for label in MODULE_OWNED_LABELS
339356
]
340357

341358

0 commit comments

Comments
 (0)