Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,6 @@ Options:
binding (default: repo files only)
--no-artifact-text keep the artifact inventory but drop captured
raw text
--artifact-text-max-bytes <n> per-file byte cap for captured artifact text;
larger files are truncated and flagged
(default: "262144")
-c, --cache-dir <dir> cache/intermediate directory
-v, --verbose increase verbosity (repeatable)
-h, --help display help for command
Expand Down
8 changes: 4 additions & 4 deletions docs/design/specs/2026-08-30-artifact-layer-v130-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ application.config_uses: TSConfigUse[] // C
application.config_reads: TSConfigRead[] // C

TSArtifact { id: `can://artifact/<app>/<path>`, kind: "artifact", path, format, roles[],
size_bytes, sha256, source, text_truncated, extraction: none|partial|full,
size_bytes, sha256, source, extraction: none|partial|full,
config_keys: TSConfigKey[] }
TSConfigKey { id: `${artifactId}@key/${dotted}`, key, namespace, value?, span?, references[] }
TSDependency { name, ecosystem: "npm", spec, kind: runtime|dev|optional|peer|build, extras[],
Expand All @@ -64,7 +64,7 @@ code-only.
but decodable → `roles: ["unknown"]`; undecodable → `format: "binary"`, `source: ""`, hash and
size only. (Today's branch skips unmatched files — the largest divergence from shipped python.)
2. **Text policy.** Capture on by default; `--artifact-text` / `--no-artifact-text`;
`--artifact-text-max-bytes` (default 256 KiB). `text_truncated` marks a stored prefix.
No byte cap: `source` is the whole file, or `""` under `--no-artifact-text` (superseded by #116).
`sha256` and `size_bytes` are always the **full file**. Extraction (dependencies, config keys)
parses the **full on-disk text**, never the stored copy — truncation can never change
extracted meaning.
Expand Down Expand Up @@ -236,8 +236,8 @@ record why a package is present.
- **`--app-name` is the cross-analyzer join precondition** — artifact ids are language-neutral so
a TS and a python analysis of one monorepo MERGE onto the same `:Artifact`; they only do so if
both runs pinned the same app name. Analyzers pointed at different subdirectories disagree.
- **`sha256` is always the full file; `source` may not be.** Under `--artifact-text-max-bytes`,
`text_truncated: true` means the stored text is a prefix — hash-compare on `sha256`, never on
- **`sha256` is always the full file, and so is `source`** (the byte cap was removed in #116) —
hash-compare on `sha256`, never on
`source`, and never re-derive meaning from a truncated copy (the analyzer itself parses the
full on-disk text).
- **`value` is present by default** and absent under `--no-artifact-text` — an absent `value` is a
Expand Down
14 changes: 5 additions & 9 deletions docs/skills/analyzing-cants-graphs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,11 @@ specifically so a TS and a Python analysis of one repository MERGE onto the same
subdirectories of one monorepo, or run with mismatched app names, will silently disagree on every
artifact and package id and never merge.

**`sha256` is always the full file; `source` may not be — and in Neo4j, `Artifact` has no
`source` at all.** Under `--artifact-text-max-bytes`, `text_truncated: true` in `analysis.json`
means the stored `source` is a prefix — hash-compare on `sha256`, never on `source`, and never
re-derive meaning from a truncated copy (the analyzer itself always parses the full on-disk text
for extraction). In the graph, this is moot for a different reason: `Artifact` carries `sha256` +
`size_bytes` but **no `source` property at all** — verbatim text lives only in `analysis.json`, by
design (`src/build/neo4j/project.ts`: "`source` text stays off the graph — hash and size
dereference to it"). A query like `WHERE f.source CONTAINS "..."` will not error; it will silently
match nothing.
**`source` is the whole file, in both projections.** There is no byte cap: an artifact's text is
captured complete or, under `--no-artifact-text`, not at all (`source: ""`). `Artifact` carries
`source` in Neo4j as well as in `analysis.json`, matching codeanalyzer-python — so
`WHERE a.source CONTAINS "..."` works against the graph. Compare on `sha256` when you want
identity rather than content; it is always the hash of the full file.

**`value` is present by default and absent under `--no-artifact-text`** — an absent `ConfigKey.value`
is a capture setting, not an empty or unset config key. Check `references/vocabulary.md`'s
Expand Down
18 changes: 10 additions & 8 deletions docs/skills/analyzing-cants-graphs/references/vocabulary.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ assuming the graph is empty.
| label | merge key | properties | notes |
| --- | --- | --- | --- |
| `TSApplication` | `id` | id, schema_version, language, max_level, k_limit, analyzer_name, analyzer_version | one per run; the `:Application` anchor |
| `Artifact` | `id` (`can://artifact/<app>/<path>`) | id, kind, path, format, roles[], size_bytes, sha256, extraction | **language-neutral, no TS prefix by design** — sibling analyzers MERGE onto the same node. No `source`/`text_truncated`/`config_keys` here (see "No verbatim text in the graph" below) |
| `Artifact` | `id` (`can://artifact/<app>/<path>`) | id, kind, path, format, roles[], size_bytes, sha256, source, extraction | **language-neutral, no TS prefix by design** — sibling analyzers MERGE onto the same node. `source` is the WHOLE file (no byte cap, #116) or `""` under `--no-artifact-text`; `config_keys` are separate `ConfigKey` nodes |
| `Package` | `id` (purl `pkg:npm/<name>`, scoped `pkg:npm/%40scope/<name>`) | id, ecosystem, name | language-neutral |
| `ConfigKey` | `id` (`<artifactId>@key/<dotted>`) | id, key, namespace, value, references[] | language-neutral; `key` is always the bare dotted name even when `id` carries an internal `arg.`/`env.` disambiguation prefix (see SKILL.md's identity section) |
| `TSModule` | `id` | _module, content_hash, id, is_declaration_file, is_tsx, kind, name, start_line, end_line | `name` is the file key (e.g. `"src/config.ts"`, WITH extension) — same value as `_module` |
Expand All @@ -36,14 +36,16 @@ matching rule): `dependency-manifest`, `tool-config`, `container-image`, `servic
\| `full`. `ConfigKey.namespace`: `env` \| `json` \| `yaml` \| `toml` \| `ini` \| `properties` \|
`dockerfile`.

### No verbatim text in the graph
### Source text in the graph

Neither `TSModule` nor `TSCallable` nor `Artifact` carries source text, a file path, or column
positions in Neo4j — only `_module`/`path` (the file key) and `start_line`/`end_line`. This is a
deliberate design line (`src/build/neo4j/project.ts`: "`source` text stays off the graph — hash
and size dereference to it"), not an omission. To read exact text: re-open the file at
`start_line`/`end_line`, or read `analysis.json`, where every module's `source` is stored once and
every node's exact text is `source.slice(...span.bytes)`.
`Artifact` carries `source` — the whole file, matching codeanalyzer-python, so
`WHERE a.source CONTAINS "..."` works. There is no byte cap; the only way `source` is empty is
`--no-artifact-text`.

Code nodes are different: neither `TSModule` nor `TSCallable` carries source text, a file path, or
column positions — only `_module`/`path` (the file key) and `start_line`/`end_line`. To read exact
code text, re-open the file at those lines, or read `analysis.json`, where every module's `source`
is stored once and every node's exact text is `source.slice(...span.bytes)`.

### External ghosts (`TSExternal`) — two grains, one label

Expand Down
3 changes: 2 additions & 1 deletion schema.neo4j.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"roles": "string[]",
"size_bytes": "integer",
"sha256": "string",
"extraction": "string"
"extraction": "string",
"source": "string"
}
},
{
Expand Down
14 changes: 4 additions & 10 deletions src/artifacts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { DEFAULT_ARTIFACT_TEXT_MAX_BYTES } from "../options";
import type { AnalysisOptions } from "../options";
import type { TSArtifact, TSDependency, TSImportBinding, TSModule } from "../schema";
import { sha256 } from "../utils";
Expand Down Expand Up @@ -74,15 +73,11 @@ export function inventoryArtifacts(
}
}
const text = decodeLossy(raw);
// Captured whole or not at all -- there is no byte cap. A truncated `source` is a prefix that
// reads like a complete file, and every consumer then needs a flag to tell the two apart;
// `--no-artifact-text` remains the way to opt out of the payload entirely.
const capture = opts.artifactText ?? true;
const cap = opts.artifactTextMaxBytes ?? DEFAULT_ARTIFACT_TEXT_MAX_BYTES;
const textByteLength = text === undefined ? 0 : Buffer.byteLength(text, "utf8");
const stored =
!capture || text === undefined
? ""
: textByteLength > cap
? Buffer.from(text, "utf8").subarray(0, cap).toString("utf8")
: text;
const stored = !capture || text === undefined ? "" : text;
const node: TSArtifact = {
id: "",
kind: "artifact",
Expand All @@ -92,7 +87,6 @@ export function inventoryArtifacts(
size_bytes: raw.length,
sha256: sha256(raw),
source: stored,
text_truncated: capture && text !== undefined && textByteLength > cap,
extraction: "none",
config_keys: [],
};
Expand Down
97 changes: 46 additions & 51 deletions src/build/neo4j/bolt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,32 +40,28 @@ export function shouldForceFullUpsert(dbVersion: string | null, producerVersion:
}

/**
* #46/#68 migration: wipe the pre-2.0.0 (schema 1.x) residue when the version gate forces a full
* upsert. Pushing 2.0.0 onto a 1.1.0 DB otherwise orphans the whole 1.x subgraph AND poisons v2
* queries — 1.x nodes carry twin TS labels (`:Module:TSModule`, `:Symbol:TSCallable`) so they match
* v2 patterns, and a second `:Application` (keyed on name, no `id`) makes the version read
* nondeterministic. These run ONCE, before the per-module loop; they are ordered, idempotent, and
* no-op on a fresh DB.
* `--eager` purge (#116): delete THIS APPLICATION's own nodes, then repopulate from scratch.
*
* They are intentionally UNANCHORED (no `:CanNode` guard on the MATCH) — that is the whole point:
* they must match the *legacy* nodes, which never carry `:CanNode`. Every v2 project-owned node DOES
* carry `:CanNode`, so the `AND NOT n:CanNode` predicate spares everything current. RETURN count so
* the caller can log what each statement removed.
* Scoped two ways at once, and both matter. `id STARTS WITH <app id>` keeps it to this
* application, so a second app in the same database survives. `:CanNode` keeps it to nodes this
* analyzer wrote, so a sibling analyzer's graph survives — codeanalyzer-python and
* codeanalyzer-java both tag nodes with `_module` and neither applies `:CanNode`, so a predicate
* like "has _module but no :CanNode" is an exact description of THEIR nodes, not of stale ours.
* That is what the pre-2.0.0 wipe this replaces got wrong: it deleted foreign graphs.
*
* Batched, because deleting a whole application in one transaction exhausts
* `dbms.memory.transaction.total.max` on a modestly-sized server (#116, measured at 2.7 GiB).
*/
export const LEGACY_WIPE_STATEMENTS: readonly string[] = [
// 1.x project-owned nodes carried `_module` under twin labels (:Module:TSModule, …) but not :CanNode.
"MATCH (n) WHERE n._module IS NOT NULL AND NOT n:CanNode DETACH DELETE n RETURN count(n) AS wiped",
// 1.x shared nodes (externals / packages / decorators) had no `_module`; keyed on name, not id.
"MATCH (n) WHERE (n:External OR n:Package OR n:Decorator) AND NOT n:CanNode DETACH DELETE n RETURN count(n) AS wiped",
// The 1.x :Application node was keyed on `name`, so it has no `id`.
"MATCH (a:Application) WHERE a.id IS NULL DETACH DELETE a RETURN count(a) AS wiped",
];
export const EAGER_PURGE =
"MATCH (n:CanNode) WHERE n.id STARTS WITH $prefix " +
"CALL { WITH n DETACH DELETE n } IN TRANSACTIONS OF 5000 ROWS";

export async function boltWriter(
rows: GraphRows,
cfg: BoltConfig,
log: Logger,
fullRun: boolean,
eager: boolean,
): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const neo4j: any = (await import("neo4j-driver")).default;
Expand Down Expand Up @@ -107,28 +103,20 @@ export async function boltWriter(
dbSchemaVersion = res.records[0]?.get("v") ?? null;
});
}
// Version mismatch no longer deletes anything: a schema change forces a full re-UPSERT, and
// MERGE overwrites in place. Removing nodes is `--eager`'s job alone (#116).
const forceAll = shouldForceFullUpsert(dbSchemaVersion, SCHEMA_VERSION);
if (forceAll) {
log.info(
`neo4j(bolt): schema ${dbSchemaVersion ?? "(none)"} → ${SCHEMA_VERSION}, full upsert forced`,
);
// Detect-and-wipe the pre-2.0.0 subgraph in one step, BEFORE any new write, so stale twin-label
// nodes can't survive to poison v2 queries or the content-hash diff. Idempotent on fresh DBs.
const counts: number[] = [];
await withSession(session, async (s) => {
for (const stmt of LEGACY_WIPE_STATEMENTS) {
const res = await s.run(stmt);
const c = res.records[0]?.get("wiped");
counts.push(typeof c?.toNumber === "function" ? c.toNumber() : Number(c ?? 0));
}
});
const wiped = counts.reduce((a, b) => a + b, 0);
if (wiped > 0) {
log.info(
`neo4j(bolt): wiped ${wiped} legacy (pre-2.0.0) nodes ` +
`(module=${counts[0]}, shared=${counts[1]}, app=${counts[2]})`,
);
}
}

// --eager: drop this application's own nodes and rebuild. Without it the push only ever adds
// and updates -- managing the database's lifetime is the operator's call, not the analyzer's.
if (eager && appId !== null) {
await withSession(session, (s) => s.run(EAGER_PURGE, { prefix: appId }));
log.info(`neo4j(bolt): --eager, purged the existing graph for ${appId}`);
}

// 3. diff content_hash.
Expand All @@ -154,18 +142,21 @@ export async function boltWriter(
for (const m of changed) {
const nodes = byModule.get(m)!;
const keys = nodes.map((n) => n.value);
await withSession(session, async (s) => {
await s.executeWrite(async (tx: any) => {
// Anchor on :CanNode so these seek the module's index slice instead of scanning the whole
// store (and never touch non-CanNode nodes). The `x.id IS NULL` guard defends the sweep
// against a null key — three-valued logic would otherwise drop the row from `NOT x.id IN`.
await tx.run(`MATCH (x:CanNode {_module: $m})-[r]->() DELETE r`, { m });
await tx.run(
`MATCH (x:CanNode {_module: $m}) WHERE x.id IS NULL OR NOT x.id IN $keys DETACH DELETE x`,
{ m, keys },
);
// Only --eager removes a module's vanished declarations. A default push MERGEs current
// nodes over the old ones and leaves anything no longer emitted in place: deleting is the
// operator's call (#116). Anchored on :CanNode either way, so a sibling analyzer's nodes
// sharing this `_module` key are never in scope.
if (eager) {
await withSession(session, async (s) => {
await s.executeWrite(async (tx: any) => {
await tx.run(`MATCH (x:CanNode {_module: $m})-[r]->() DELETE r`, { m });
await tx.run(
`MATCH (x:CanNode {_module: $m}) WHERE x.id IS NULL OR NOT x.id IN $keys DETACH DELETE x`,
{ m, keys },
);
});
});
});
}
await upsertNodes(session, neo4j, nodes);
}

Expand All @@ -177,19 +168,23 @@ export async function boltWriter(
await upsertEdges(session, neo4j, edges);

// 7. orphan prune — only safe on a full run (a targeted run can't tell deleted from untargeted).
if (fullRun) {
// appId === null would make `STARTS WITH ""` match every node in the store.
if (fullRun && eager && appId !== null) {
const present = [...byModule.keys()];
await withSession(session, async (s) => {
// Anchored on :CanNode AND this app's id prefix, same as EAGER_PURGE. `MATCH (m:TSModule)`
// alone would reach a SECOND TypeScript application in the same database -- every one of
// its modules is "not in this app's $present" -- and any 1.x twin-labelled node too (#116).
const res = await s.run(
`MATCH (m:TSModule) WHERE NOT m._module IN $present ` +
`OPTIONAL MATCH (m)-${DESCENDANTS}->(x) DETACH DELETE x, m RETURN count(m) AS pruned`,
{ present },
`MATCH (m:TSModule:CanNode) WHERE m.id STARTS WITH $prefix AND NOT m._module IN $present ` +
`OPTIONAL MATCH (m)-${DESCENDANTS}->(x) DETACH DELETE x, m RETURN count(DISTINCT m) AS pruned`,
{ present, prefix: appId },
);
const pruned = res.records[0]?.get("pruned") ?? 0;
log.info(`neo4j(bolt): pruned ${pruned} vanished module(s)`);
});
} else {
log.info("neo4j(bolt): targeted run — orphan pruning skipped (deleted files not removed)");
log.info("neo4j(bolt): orphan pruning skipped (use --eager to remove vanished modules)");
}
} finally {
await driver.close();
Expand Down
4 changes: 4 additions & 0 deletions src/build/neo4j/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ export function project(app: TSAnalysis, _appName?: string): GraphRows {
id: art.id, kind: "artifact", path: art.path, format: art.format,
roles: art.roles.length ? art.roles : null, size_bytes: art.size_bytes,
sha256: art.sha256, extraction: art.extraction,
// `source` belongs on the graph: python has carried it on :Artifact since it shipped the
// layer, and a consumer reading the same neutral :Artifact node from two analyzers must not
// get the text from one and nothing from the other. `--no-artifact-text` still empties it.
source: art.source,
}));
b.edge("HAS_ARTIFACT", appRef, aRef);
for (const ck of art.config_keys) {
Expand Down
1 change: 1 addition & 0 deletions src/build/neo4j/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const NODE_LABELS: NodeLabel[] = [
properties: {
id: "string", kind: "string", path: "string", format: "string", roles: "string[]",
size_bytes: "integer", sha256: "string", extraction: "string",
source: "string",
},
},
{
Expand Down
15 changes: 0 additions & 15 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as path from "node:path";
import { Command, Option } from "commander";
import type { AnalysisOptions, EmitTarget } from "./options";
import { DEFAULT_ARTIFACT_TEXT_MAX_BYTES } from "./options";
import { ALL_GRAPHS, type GraphSelector } from "./schema";

/**
Expand Down Expand Up @@ -60,11 +59,6 @@ export function buildProgram(): Command {
.option("--no-phantoms", "disable phantom (external) nodes for imported/required library calls")
.option("--resolve-installed", "probe node_modules metadata for import→package binding (default: repo files only)")
.option("--no-artifact-text", "keep the artifact inventory but drop captured raw text")
.option(
"--artifact-text-max-bytes <n>",
"per-file byte cap for captured artifact text; larger files are truncated and flagged",
String(DEFAULT_ARTIFACT_TEXT_MAX_BYTES),
)
.option("-c, --cache-dir <dir>", "cache/intermediate directory")
.option("-v, --verbose", "increase verbosity (repeatable)", (_v: string, prev: number) => prev + 1, 0)
.allowExcessArguments(true);
Expand Down Expand Up @@ -160,15 +154,6 @@ export function parseArgs(argv: string[]): AnalysisOptions {
phantoms: o.phantoms !== false,
resolveInstalled: Boolean(o.resolveInstalled),
artifactText: o.artifactText !== false,
// Malformed input (e.g. "abc") must fall back, not silently disable truncation via NaN --
// every `> cap` comparison against NaN is false.
artifactTextMaxBytes: (() => {
// An empty value is malformed too -- Number("") is 0, which would cap every
// artifact's text at zero bytes. An explicit 0 still means exactly that.
const raw = String(o.artifactTextMaxBytes ?? "").trim();
const n = raw === "" ? NaN : Number(raw);
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_ARTIFACT_TEXT_MAX_BYTES;
})(),
cacheDir: o.cacheDir ? path.resolve(String(o.cacheDir)) : null,
verbosity: typeof o.verbose === "number" ? o.verbose : 0,
};
Expand Down
Loading
Loading