|
| 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`. |
0 commit comments