Skip to content

Commit 72ce5c5

Browse files
authored
Merge pull request #158 from codellm-devkit/design/issue-157-artifacts-spec
docs(design): spec artifacts/dependencies schema v2 foundation
2 parents b33b15d + 6b1b5ca commit 72ce5c5

7 files changed

Lines changed: 504 additions & 0 deletions

File tree

.claude/SCHEMA_DECISIONS.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,30 @@ made output load-dependent (#145).
284284
- Refinement contract unchanged: the linker runs inside the L2 build, so
285285
`callee: null→id` remains the single sanctioned L1→L2 refinement.
286286
- Neo4j projection unchanged (`PY_CALLS` carries `prov` as data).
287+
288+
## 2026-08-27 — Artifacts, dependencies, and the `can://artifact/` namespace
289+
290+
Design: `docs/design/specs/2026-08-27-artifacts-and-dependencies-design.md`.
291+
292+
Schema v2 gains non-code coverage: `application.artifacts` (sibling map,
293+
`symbol_table` stays code-only), `application.dependencies`, and
294+
`application.unresolved_imports`. All three are L1 data — emitted identically
295+
at every level, like entrypoints.
296+
297+
- **Artifact ids are language-neutral**: `can://artifact/<app>/<path>`. The
298+
first `can://` segment is now a namespace — a language for code nodes, the
299+
literal `artifact` for files — so sibling analyzers over the same repo emit
300+
the same artifact id (one node in a merged graph). Precondition: `<app>`
301+
must agree (`--app-name` pinned for joint analysis).
302+
- **Dependency `prov` vocabulary** (coined once, parity clause applies):
303+
`declared`, `lockfile`, `installed-metadata`, `heuristic`. Deterministic
304+
default reads repo files only; `installed-metadata` requires the new
305+
`--resolve-installed` flag.
306+
- **Neo4j**: neutral labels `:Artifact` / `:Package` (no `Py` prefix, shared
307+
MERGE targets across analyzers); `:Package.id` is a purl (`pkg:pypi/<name>`).
308+
`PY_PROVIDES` joins packages to the existing `:PyExternal` ghost ids, wiring
309+
dependencies into the call graph. New edges: `HAS_ARTIFACT`,
310+
`DECLARES_DEPENDENCY`, `LOCKS`, `PY_PROVIDES`, `PY_UNRESOLVED_IMPORT`.
311+
- Capture broad (config files as nodes with `roles`), extract narrow
312+
(dependency manifests only this unit). Lock files backfill
313+
`locked_version`, never create records; transitive packages out of scope.

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ Respect the global `~/.claude/CLAUDE.md` instructions strictly.
1717
tracked past a global gitignore via `!`-negations in `.gitignore`; keep those
1818
negations if you touch the ignore file.
1919

20+
## Querying the emitted graph
21+
22+
`docs/skills/analyzing-canpy-graphs/` is a reference skill (tool-neutral, repo-shared)
23+
for querying the analyzer's own output — vocabulary tables and recipes for entrypoint,
24+
taint, exit-point, and slicing queries over `analysis.json` and the Neo4j projection.
25+
Read it before writing Cypher or JSON traversals over schema v2.
26+
2027
## Schema v2 — the model this analyzer emits
2128

2229
`codeanalyzer-python` emits **canonical schema v2** (`schema_version` `2.0.0`): one

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,56 @@ runtime (Docker or Podman) and is enabled with an environment variable:
576576
RUN_CONTAINER_TESTS=1 uv run pytest test/test_neo4j_bolt.py -s
577577
```
578578

579+
## Graph query cookbook
580+
581+
Example Cypher over the projected graph (`--emit neo4j`, then load `graph.cypher` or push via Bolt).
582+
583+
```cypher
584+
// who calls this function? (direct callers)
585+
MATCH (c:PyCallable)-[:PY_CALLS]->(t:PyCallable {name: "process_payment"})
586+
RETURN c.id
587+
588+
// every callable that reaches a given library, via the external ghosts
589+
MATCH (c:PyCallable)-[:PY_CALLS]->(e:PyExternal)
590+
WHERE e.id CONTAINS "/@external/requests/"
591+
RETURN DISTINCT c.id
592+
593+
// entrypoints and the frameworks that invoke them
594+
MATCH (m:PyCallable {is_entrypoint: true})
595+
RETURN m.id, m.entrypoint_frameworks
596+
597+
// data dependences into one statement (level 3+)
598+
MATCH (s:PyBodyNode {id: $stmt})<-[d:PY_DDG]-(src:PyBodyNode)
599+
RETURN src.id, d.var, d.prov
600+
601+
// interprocedural flow through a parameter (level 4)
602+
MATCH (a:PyBodyNode)-[:PY_PARAM_IN]->(f:PyBodyNode)
603+
WHERE f.id STARTS WITH "can://python/myapp/src/api.py"
604+
RETURN a.id, f.id
605+
```
606+
607+
Landing with #157 (schema v2 artifacts + dependencies, 1.3.0) — not in the current release:
608+
609+
```cypher
610+
// all container/orchestration configs in the app
611+
MATCH (a:PyApplication)-[:HAS_ARTIFACT]->(f:Artifact)
612+
WHERE any(r IN f.roles WHERE r IN ["service-topology", "container-image"])
613+
RETURN f.id, f.format
614+
615+
// every callable that reaches code from a declared package
616+
MATCH (c:PyCallable)-[:PY_CALLS]->(:PyExternal)<-[:PY_PROVIDES]-(p:Package {id: "pkg:pypi/requests"})
617+
RETURN c.id
618+
619+
// undeclared imports (dependency hygiene)
620+
MATCH (a:PyApplication)-[u:PY_UNRESOLVED_IMPORT]->(e:PyExternal)
621+
WHERE NOT (e)<-[:PY_PROVIDES]-(:Package)
622+
RETURN e.id, u.prov
623+
624+
// which lock file pins this package, and to what
625+
MATCH (f:Artifact)-[l:LOCKS]->(p:Package {id: "pkg:pypi/numpy"})
626+
RETURN f.id, l.version
627+
```
628+
579629
## License
580630

581631
Apache 2.0 — see [LICENSE](./LICENSE).
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# Artifacts and Dependencies: Schema v2 Foundation for Non-Code Files
2+
3+
**Date:** 2026-08-27
4+
**Status:** Approved (design dialogue in-session; decomposition: one issue, one PR)
5+
**Scope:** codeanalyzer-python, schema v2 additive change (targets 1.3.0)
6+
7+
## Problem
8+
9+
Schema v2 is code-only by construction: `symbol_table` is keyed by `.py` file, and
10+
nothing represents configuration files, dependency manifests, or the packages an
11+
application depends on. This blocks three future capabilities — cross-service
12+
topology, cross-language links, and dependency-aware queries — and leaves basic
13+
questions ("which packages does this app declare?", "which imports are
14+
undeclared?") unanswerable from the analysis output.
15+
16+
This spec is unit 1 of a four-unit arc (foundation, cross-service topology,
17+
cross-language identity, config extractors). It designs the foundation only:
18+
how a non-code file becomes a node, and dependency manifests as the first
19+
extracted meaning.
20+
21+
## Locked decisions
22+
23+
### 1. Placement: sibling map, not symbol_table widening
24+
25+
`symbol_table` stays strictly code (`Dict[file, PyModule]`). Non-code files live
26+
in a parallel map on the application node:
27+
28+
```
29+
application.artifacts: Dict[str, PyArtifact] # keyed by repo-relative POSIX path
30+
```
31+
32+
### 2. Artifact identity is language-neutral
33+
34+
```
35+
can://artifact/<app>/<repo-relative-path>
36+
```
37+
38+
The first `can://` segment becomes a namespace: a language (`python`, `java`,
39+
`typescript`) for code nodes, the literal `artifact` for non-code files. Two
40+
analyzers over the same monorepo emit the **same id** for the same file — one
41+
node in a merged graph, never per-language duplicates.
42+
43+
**Precondition for cross-analyzer joins:** `<app>` must agree between analyzers.
44+
`--app-name` defaults to the input directory name, so analyzers pointed at
45+
different subdirectories of a monorepo will disagree; joint analysis must pin
46+
`--app-name` explicitly.
47+
48+
### 3. PyArtifact model
49+
50+
| field | type | notes |
51+
| --- | --- | --- |
52+
| `id` | str | `can://artifact/<app>/<path>` |
53+
| `kind` | `"artifact"` | schema-v2 node discriminant |
54+
| `format` | str | `toml` \| `yaml` \| `json` \| `ini` \| `requirements` \| `dockerfile` \| `text` |
55+
| `roles` | List[str] | `dependency-manifest`, `service-topology`, `container-image`, `ci`, `env`, `tool-config`, `unknown` |
56+
| `size_bytes` | int | |
57+
| `sha256` | str | |
58+
| `source` | str | verbatim content, **no size bound** (user decision: do not bound file size yet) |
59+
| `extraction` | `"full"` \| `"partial"` \| `"none"` | `partial` currently only for dynamic `setup.py` |
60+
61+
Capture is **broad** (every recognized config-shaped file becomes a node);
62+
extraction is **narrow** (only dependency manifests get an extractor in this
63+
unit). Later units add extractors — additive edges/records on existing nodes,
64+
never re-keying.
65+
66+
Discovery is a shipped rules table of filename patterns → `(format, roles)`,
67+
same mechanism family as `entrypoints/rules.yml`. No user-extension flag yet.
68+
69+
### 4. Dependency model: declared + evidence-tagged binding
70+
71+
```
72+
application.dependencies: List[PyDependency]
73+
application.unresolved_imports: List[PyImportBinding]
74+
```
75+
76+
`PyDependency`: `name` (PEP 503 normalized), `spec`, `kind`
77+
(`runtime`|`dev`|`optional`|`build`), `extras`, `declared_in` (artifact id),
78+
`locked_version` (optional), `provides_imports` (top-level import names),
79+
`prov` (list).
80+
81+
`prov` vocabulary (same idiom as call-edge provenance):
82+
83+
- `declared` — read from a manifest
84+
- `lockfile` — pinned version from a lock file
85+
- `installed-metadata` — read from the venv's `.dist-info` (opt-in only)
86+
- `heuristic` — name-match fallback for import binding
87+
88+
`unresolved_imports` is first-class output: every top-level import the symbol
89+
table saw that no declared dependency accounts for, with any partial binding
90+
and its `prov`. This is deliberately the interesting section — it surfaces
91+
undeclared dependencies instead of silently omitting them.
92+
93+
Lock files never create dependency records; they only backfill
94+
`locked_version` on declared ones. Transitive (lock-only) packages are
95+
deliberately skipped — no transitive graph in this unit.
96+
97+
### 5. Determinism: deterministic default, probing opt-in
98+
99+
The default run reads only files in the repo — byte-identical output across
100+
machines. A new flag `--resolve-installed` additionally probes the venv's
101+
installed metadata for import→distribution mapping; those records carry
102+
`prov ["installed-metadata"]`. The CI determinism gate runs the default.
103+
104+
### 6. Extraction targets (this unit)
105+
106+
| manifest | extracted | notes |
107+
| --- | --- | --- |
108+
| `requirements*.txt` | declared deps; `-r`/`-c` includes chased | `kind` from filename convention |
109+
| `pyproject.toml` | PEP 621 `[project.dependencies]` + `optional-dependencies`; Poetry `[tool.poetry.*]`; `[build-system].requires` (`kind: build`) | one parser, three dialects |
110+
| `setup.py` | `install_requires`/`extras_require` via **static AST only**; literals lifted, never executed | dynamic values → artifact `extraction: "partial"`; imports then surface via `unresolved_imports` |
111+
| `setup.cfg` | `[options] install_requires`, `extras_require` | ini parse |
112+
| `Pipfile` / `Pipfile.lock` | declared + pins | |
113+
| `poetry.lock`, `uv.lock` | `locked_version` backfill | |
114+
| `environment.yml` | conda deps incl. `pip:` sublist | |
115+
116+
### 7. Neo4j projection
117+
118+
Language-neutral subgraph gets language-neutral labels (no `Py` prefix), so
119+
sibling analyzers MERGE onto the same nodes:
120+
121+
| label | merge key | properties |
122+
| --- | --- | --- |
123+
| `:Artifact` | `id` (`can://artifact/...`) | path, format, roles, sha256, size_bytes, source |
124+
| `:Package` | `id` = purl (`pkg:pypi/<name>`) | ecosystem, name |
125+
126+
purl as package id is the cross-language join: `pkg:maven/...` and
127+
`pkg:pypi/...` coexist uniformly.
128+
129+
Edges:
130+
131+
```
132+
(:PyApplication)-[:HAS_ARTIFACT]->(:Artifact)
133+
(:Artifact)-[:DECLARES_DEPENDENCY {spec, kind, extras, prov}]->(:Package)
134+
(:Artifact)-[:LOCKS {version}]->(:Package)
135+
(:Package)-[:PY_PROVIDES]->(:PyExternal)
136+
(:PyApplication)-[:PY_UNRESOLVED_IMPORT {prov}]->(:PyExternal)
137+
```
138+
139+
`PY_PROVIDES` targets the **existing** `:PyExternal` ghosts (same
140+
`can://python/<app>/@external/<module>` ids the L2 call graph MERGEs on), so
141+
dependencies join the call graph rather than sit beside it:
142+
143+
```cypher
144+
MATCH (c:PyCallable)-[:PY_CALLS]->(:PyExternal)<-[:PY_PROVIDES]-(p:Package {id:"pkg:pypi/requests"})
145+
RETURN c.id
146+
```
147+
148+
Config-role artifacts get node + roles + source and zero extracted edges this
149+
unit. New DDL: unique constraints on `Artifact.id` and `Package.id`. Neo4j
150+
schema version moves additively within 2.x. Full-depth-always rule unchanged.
151+
152+
### 8. Pipeline and CLI
153+
154+
- Artifact scan is **L1 data**: runs at every level, output must not vary with
155+
`-a` (same posture as entrypoints). Monotonicity gate holds trivially.
156+
- Runs after the symbol table (needs module import lists for
157+
`unresolved_imports`), before the call graph.
158+
- New package `codeanalyzer/artifacts/`: `discovery.py` (walk + rules table),
159+
`parsers.py` (toml/yaml/ini/requirements/setup.py-AST readers),
160+
`dependencies.py` (records, lock backfill, import binding). Walk reuses the
161+
symbol table's ignore set, sorted order.
162+
- CLI: exactly one new flag, `--resolve-installed`. Scan is default-on with no
163+
toggle.
164+
- Not cached: scan cost is trivial; caching would add invalidation surface for
165+
nothing.
166+
167+
## Caveats
168+
169+
- `<app>` agreement is a precondition for cross-analyzer artifact joins (see §2).
170+
- `setup.py` extraction is static-AST only; computed dependency lists are
171+
recorded as `extraction: "partial"`, never executed (determinism).
172+
- Lock-only (transitive) packages are out of scope by decision, not omission.
173+
- `source` is unbounded by decision; revisit only with measured payload numbers.
174+
- Import→package binding without `--resolve-installed` relies on
175+
`declared` names + `heuristic` matching; `installed-metadata` precision is
176+
opt-in and machine-dependent by design.
177+
178+
## Decomposition and release plan
179+
180+
One work-item issue on codeanalyzer-python, closed by one PR; ships in the
181+
next minor (1.3.0, additive). Sibling analyzers adopt the `can://artifact/`
182+
namespace, neutral Neo4j labels, and purl ids when their own work starts — no
183+
epic until a second repo does.
184+
185+
## Definition of done
186+
187+
- `application.artifacts` / `dependencies` / `unresolved_imports` emitted at
188+
every level with identical content; monotonicity gate green.
189+
- All §6 formats parsed on a fixture project carrying every format; prov and
190+
purl ids asserted.
191+
- Neo4j rows for §7 vocabulary via existing row tests; DDL constraints added.
192+
- Default run byte-identical across two consecutive runs;
193+
`--resolve-installed` exercised in one gated test.
194+
- Full suite green; schema decision recorded in `.claude/SCHEMA_DECISIONS.md`.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
name: analyzing-canpy-graphs
3+
description: Use when querying codeanalyzer-python (canpy) output — analysis.json or the Neo4j projection — for call-graph, dataflow, taint, entrypoint, exit-point, or slicing questions, or when writing Cypher/JSON traversals over schema v2.
4+
---
5+
6+
# Analyzing canpy graphs (schema v2)
7+
8+
One additive tree + typed edge overlays, two projections: `analysis.json` and Neo4j.
9+
Vocabulary is fixed — **never guess a label, property, or key shape**; it is all in
10+
[references/vocabulary.md](references/vocabulary.md). Query recipes (entrypoints, taint,
11+
exit points, slicing, both projections) are in [references/recipes.md](references/recipes.md).
12+
13+
## What exists at which level
14+
15+
| `-a` | tree | edges |
16+
| --- | --- | --- |
17+
| 1 | callables + `call` body nodes (`callee: null`) + entrypoints ||
18+
| 2 | `callee` backfilled | `call_graph` / `PY_CALLS` (prov `jedi`/`defuse`) |
19+
| 3 | full `body`, `@entry`/`@exit` | `cfg`, `cdg`, `ddg` (prov `ssa`, `reaching-defs`) |
20+
| 4 | `formal_in/out`, `actual_in/out` vertices | `param_in`, `param_out`, `summary`, ddg widened with prov `points-to` |
21+
22+
Entrypoints (`is_entrypoint`, `entrypoint_frameworks` on callables **and** classes) are
23+
L1 data — present at every level. Interprocedural anything needs `-a 4`.
24+
25+
## Identity in 20 seconds
26+
27+
- **`can://` ids are opaque** — read fields, never delimiter-split.
28+
- **LOCAL ids** (body-map keys, intra-callable edge endpoints): `"line:col"`,
29+
`"@entry"`, `"@exit"`, `"@formal_in:<i>"`, `"@formal_out"`,
30+
`"<callsite-local>/actual_in:<i>"`, `"<callsite-local>/actual_out"`.
31+
- **GLOBAL ids** (Neo4j `PyBodyNode.id`, `param_in/out` endpoints): `"<callable-id>@<local>"`.
32+
33+
## The four traps (each observed in baseline testing)
34+
35+
1. **Container key asymmetry.** `PyModule.types` and `PyClass.types` are keyed by the
36+
**dotted signature** (`"src.flask.sessions.SessionMixin"`); `callables` / `functions`
37+
are keyed by the **bare name** (`"permanent"`). Class signatures derive from the
38+
module path — a repo with a `src/` layout has `src.`-prefixed signatures.
39+
2. **Span slicing is bytes, not str.** `span.bytes` are UTF-8 byte offsets:
40+
`module.source.encode("utf-8")[lo:hi].decode("utf-8")`. A plain `source[lo:hi]`
41+
is silently wrong after the first non-ASCII character in the file.
42+
3. **Bound your taint walks.** `-[:PY_DDG|…*1..]->` enumerates paths — exponential on
43+
real corpora (odoo L4: 4.6M DDG edges). Use the bounded/frontier recipes in
44+
references/recipes.md.
45+
4. **`PY_DDG` is one edge per `(var, prov)`** (internal `_k` discriminant), and ddg
46+
`prov` at L4 mixes `ssa`/`reaching-defs`/`points-to` — filter on `prov` when you
47+
need only the syntactic subset.
48+
49+
## Exit points, defined
50+
51+
Data leaves a callable through three channels; class-level = union over
52+
`PY_HAS_METHOD`:
53+
54+
- **return channel**`body` nodes with `kind: "return"` (L4: `@formal_out`).
55+
- **external calls**`PY_CALLS` into `:PyExternal` / `callee` present in
56+
`application.external_symbols`.
57+
- **caller-visible writes** — ddg edges whose `var` is rooted at `self.` or a
58+
`global:` path; L4 `summary` edges expose the transitive in→out relation.
59+
60+
Taint is **not a schema section** (provider/client boundary: sources/sinks are the
61+
SDK's job) — you compose it as reachability over `PY_DDG ∪ PY_PARAM_IN ∪
62+
PY_PARAM_OUT ∪ PY_SUMMARY`. Recipes file has the exact patterns.

0 commit comments

Comments
 (0)