From e65d73f1ff358dc9f43e21b12b774b4d68e5578c Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 30 Oct 2026 13:41:08 -0400 Subject: [PATCH 1/5] test: add namespace identity contract fixtures --- internal/schema/v2/testdata/namespaces.json | 139 ++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 internal/schema/v2/testdata/namespaces.json diff --git a/internal/schema/v2/testdata/namespaces.json b/internal/schema/v2/testdata/namespaces.json new file mode 100644 index 0000000..8a89219 --- /dev/null +++ b/internal/schema/v2/testdata/namespaces.json @@ -0,0 +1,139 @@ +{ + "identity_version": 2, + "roots": [ + { + "application_name": "inventory", + "namespace": null, + "expected_root": "can://inventory/inventory" + }, + { + "application_name": "currencyservice", + "namespace": "online-boutique", + "expected_root": "can://online-boutique/currencyservice" + }, + { + "application_name": "a%b", + "namespace": "team space", + "expected_root": "can://team%20space/a%25b" + }, + { + "application_name": "caf\u00e9", + "namespace": "\u56e2\u961f", + "expected_root": "can://%E5%9B%A2%E9%98%9F/caf%C3%A9" + }, + { + "application_name": "Inventory", + "namespace": "Team", + "expected_root": "can://Team/Inventory" + }, + { + "application_name": "inventory", + "namespace": "team", + "expected_root": "can://team/inventory" + }, + { + "application_name": " cafe\u0301 ", + "namespace": "team", + "expected_root": "can://team/%20cafe%CC%81%20" + } + ], + "invalid": [ + { + "value": "", + "reason": "empty" + }, + { + "value": " ", + "reason": "whitespace-only" + }, + { + "value": "a/b", + "reason": "slash" + }, + { + "value": "a\\b", + "reason": "backslash" + }, + { + "value": ".", + "reason": "dot" + }, + { + "value": "..", + "reason": "dot-dot" + }, + { + "value": "a\u0001b", + "reason": "control" + }, + { + "value": "\ud800", + "reason": "unpaired-high-surrogate" + }, + { + "value": "\udc00", + "reason": "unpaired-low-surrogate" + } + ], + "modules": [ + { + "application_name": "currencyservice", + "namespace": "online-boutique", + "language": "javascript", + "file_key": "server.js", + "expected_uri": "can://online-boutique/currencyservice/javascript/server.js" + }, + { + "application_name": "paymentservice", + "namespace": "online-boutique", + "language": "javascript", + "file_key": "server.js", + "expected_uri": "can://online-boutique/paymentservice/javascript/server.js" + }, + { + "application_name": "inventory", + "namespace": null, + "language": "python", + "file_key": "src/main.py", + "expected_uri": "can://inventory/inventory/python/src/main.py" + }, + { + "application_name": "inventory", + "namespace": null, + "language": "typescript", + "file_key": "src/main.ts", + "expected_uri": "can://inventory/inventory/typescript/src/main.ts" + }, + { + "application_name": "inventory", + "namespace": null, + "language": "java", + "file_key": "src/Main.java", + "expected_uri": "can://inventory/inventory/java/src/Main.java" + }, + { + "application_name": "inventory", + "namespace": null, + "language": "go", + "file_key": "main.go", + "expected_uri": "can://inventory/inventory/go/main.go" + } + ], + "duplicate_module_rows": { + "rows": [ + { + "module_id": "can://online-boutique/currencyservice/javascript/server.js", + "file_key": "server.js" + }, + { + "module_id": "can://online-boutique/paymentservice/javascript/server.js", + "file_key": "server.js" + }, + { + "module_id": "can://online-boutique/currencyservice/javascript/server.js", + "file_key": "server.js" + } + ], + "expected_unique_id_count": 2 + } +} From 8fabbf69a11c2c3a338ee60577ca300c52280165 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 30 Oct 2026 15:09:30 -0400 Subject: [PATCH 2/5] feat: qualify Go analysis identities by namespace --- README.md | 49 ++++++++----- cmd/codeanalyzer/main.go | 31 +++++--- cmd/codeanalyzer/main_test.go | 52 +++++++++++++- internal/core/analyzer.go | 36 +++++++++- internal/core/analyzer_test.go | 41 ++++++++++- internal/dataflow/run_test.go | 17 ++++- internal/metamorphic/run.go | 3 +- internal/neo4j/bolt.go | 17 +++-- internal/neo4j/bolt_session_test.go | 43 +++++++++-- internal/neo4j/bolt_test.go | 18 ++--- internal/neo4j/cypher.go | 46 +++++++++++- internal/neo4j/cypher_test.go | 58 +++++++++++---- internal/neo4j/emit.go | 8 ++- internal/neo4j/project.go | 4 +- internal/neo4j/project_test.go | 32 +++++++-- internal/neo4j/rows.go | 39 +++++++--- internal/neo4j/rows_test.go | 67 +++++++++++------ internal/neo4j/schema.go | 2 +- internal/options/options.go | 4 +- internal/schema/v2/ids.go | 62 +++++++++++++--- internal/schema/v2/ids_test.go | 30 ++++---- internal/schema/v2/namespace_test.go | 62 ++++++++++++++++ internal/schema/v2/schema.go | 22 +++--- .../v2emit/determinism_freshbuild_test.go | 2 +- .../v2emit/determinism_test.go | 10 +-- internal/syntactic_analysis/v2emit/emit.go | 37 +++++----- .../syntactic_analysis/v2emit/emit_l4_test.go | 2 +- .../syntactic_analysis/v2emit/emit_test.go | 26 ++++--- .../v2emit/gate_l2_inits_test.go | 2 +- .../syntactic_analysis/v2emit/gate_l2_test.go | 26 +++---- .../syntactic_analysis/v2emit/gate_l3_test.go | 4 +- .../v2emit/gate_l4_done_test.go | 8 +-- .../syntactic_analysis/v2emit/gate_l4_test.go | 71 ++++++++++++++++--- .../syntactic_analysis/v2emit/gate_test.go | 18 ++--- 34 files changed, 735 insertions(+), 214 deletions(-) create mode 100644 internal/schema/v2/namespace_test.go diff --git a/README.md b/README.md index 11f93f5..8481ee6 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ The binary is fully self-contained: a single static executable with no runtime d imports, struct tags, and generic type parameters, with precise source spans. - **Call graph** — a resolver-based call graph via `go/types`: each call site is resolved to its full import-path signature. Level 2 emits edges to project callees and to library callees - (homed in `application.external_symbols` under `can:///@external//`). + (homed in `application.external_symbols` under `can:////@external//`). Level 3 (`-a 3`) adds per-callable `cfg`, `cdg` and `ddg`. Level 4 (`-a 4`) adds the interprocedural system dependence graph (SDG): parameter vertices, `param_in` / `param_out`, call-site `summary` edges, VTA dispatch targets and library stand-ins — what the SDK's @@ -251,7 +251,7 @@ cango -i /path/to/go/app -a 2 -o /path/to/output/ > of writing a file. **3. (Optional) name the application anchor.** `--app-name` sets the `` in every -`can:///…` id and defaults to the input directory's base name. Set it when the +`can:////…` id and defaults to the input directory's base name. Set it when the directory name isn't the identity you want: ```bash @@ -358,20 +358,24 @@ and the [step-by-step section above](#generating-analysisjson-for-a-go-app) prod ### Canonical v2 shape The document is a manifest envelope wrapping one `application` containment tree. Every -node carries a `can:///…` `id`, a `kind`, and a `span`: +node carries a `can:////…` `id`, a `kind`, and a `span`: ```json { + "identity_version": 2, "schema_version": "2.0.0", "language": "go", "max_level": 2, "analyzer": { "name": "codeanalyzer-go", "version": "0.1.0" }, "application": { - "id": "can://myapp", + "id": "can://myapp/myapp", + "identity_version": 2, + "application_name": "myapp", + "identity_namespace": "myapp", "kind": "application", "symbol_table": { "pkg/greeter/greeter.go": { - "id": "can://myapp/go/pkg/greeter/greeter.go", + "id": "can://myapp/myapp/go/pkg/greeter/greeter.go", "kind": "module", "package": "greeter", "span": { "start": [1, 1], "end": [40, 2], "bytes": [0, 812] }, @@ -380,7 +384,7 @@ node carries a `can:///…` `id`, a `kind`, and a `span`: "imports": [ { "name": "fmt", "path": "fmt", "span": {…} } ], "types": { "Greeter": { - "id": "can://myapp/go/pkg/greeter/greeter.go/example.com/pkg/greeter.Greeter", + "id": "can://myapp/myapp/go/pkg/greeter/greeter.go/example.com/pkg/greeter.Greeter", "kind": "struct", "span": { "start": [5, 1], "end": [7, 2], "bytes": [17, 63] }, "fields": { @@ -388,7 +392,7 @@ node carries a `can:///…` `id`, a `kind`, and a `span`: }, "callables": { "example.com/pkg/greeter.Greeter.Greet": { - "id": "can://myapp/go/pkg/greeter/greeter.go/example.com/pkg/greeter.Greeter/example.com/pkg/greeter.Greeter.Greet", + "id": "can://myapp/myapp/go/pkg/greeter/greeter.go/example.com/pkg/greeter.Greeter/example.com/pkg/greeter.Greeter.Greet", "kind": "method", "signature": "example.com/pkg/greeter.Greeter.Greet", "span": { "start": [9, 1], "end": [11, 2], "bytes": [65, 140] }, @@ -399,9 +403,9 @@ node carries a `can:///…` `id`, a `kind`, and a `span`: "body": { "10:2": { "kind": "call", - "id": "can://myapp/go/pkg/greeter/greeter.go/…/example.com/pkg/greeter.Greeter.Greet@10:2", + "id": "can://myapp/myapp/go/pkg/greeter/greeter.go/…/example.com/pkg/greeter.Greeter.Greet@10:2", "span": { "start": [10, 2], "end": [10, 30], "bytes": [90, 118] }, - "callee": "can://myapp/go/pkg/greeter/greeter.go/…/example.com/pkg/greeter.format" + "callee": "can://myapp/myapp/go/pkg/greeter/greeter.go/…/example.com/pkg/greeter.format" } } } @@ -415,8 +419,8 @@ node carries a `can:///…` `id`, a `kind`, and a `span`: }, "call_graph": [ { - "src": "can://myapp/go/main.go/…/example.com/main.main", - "dst": "can://myapp/go/pkg/greeter/greeter.go/…/example.com/pkg/greeter.Greeter.Greet", + "src": "can://myapp/myapp/go/main.go/…/example.com/main.main", + "dst": "can://myapp/myapp/go/pkg/greeter/greeter.go/…/example.com/pkg/greeter.Greeter.Greet", "prov": ["go/types"], "weight": 1 } @@ -431,7 +435,7 @@ Key v2 schema properties: degraded) and `analyzer{name,version}` wrap a single `application` node. - **One containment tree** — `application → module → type → callable → body`, keyed as `symbol_table` (modules, by **project-relative file path**), `types`, `callables`, `body`. -- **`id`** — every node carries a durable `can:///go///` id; +- **`id`** — every node carries a durable `can:////go///` id; `` is the `--app-name` anchor. A body node is keyed by `line:col` in its callable's `body` map and carries `id` = `@` at every level, from `-a 1` on. - **`kind`** — `module` · `type` kinds `struct | interface | alias | defined` (collapses v1's @@ -448,7 +452,7 @@ Key v2 schema properties: `return_type`). **Closures** nest as `callables{}` on the enclosing callable. - **`body` call nodes** — `callee` is the sanctioned `null → id` slot: `null` at L1; at L2 a project callable's `can://` id, or for a library (stdlib or dependency) callee its - `can:///@external//` id, a key of `external_symbols`. It stays `null` + `can:////@external//` id, a key of `external_symbols`. It stays `null` for conversions, builtins, calls through function values and unresolved sites. `is_goroutine` / `is_deferred` are boolean flags emitted **only when true** (a plain call omits them), for `go f()` / `defer f()`. - **`call_graph` edges** — `{src, dst, prov, weight}`; `src` is a callable id in the tree and @@ -476,7 +480,7 @@ Key v2 schema properties: ## Neo4j projection (`--emit neo4j`) `--emit neo4j` projects the **same v2 envelope** into a Neo4j property graph. It is a *second -projection of the identical analysis* — JSON nodes and graph nodes carry the same `can:///…` +projection of the identical analysis* — JSON nodes and graph nodes carry the same `can:////…` ids, so the two join on one string and are never recomposed. The graph vocabulary is `Go`-prefixed labels (`:GoModule`, `:GoType`, `:GoCallable`, `:GoField`, `:GoBodyNode`, `:GoExternal`, plus the shared merge label `:GoSymbol` and marker `:GoCanNode`) and `GO_`-prefixed relationships @@ -492,7 +496,7 @@ There are two output paths, chosen by whether `--neo4j-uri` is set: | **Use for** | Review, diffing, loading on your own schedule, CI | A DB you already have running | Both paths are **scoped to one application**: every destructive statement is anchored on -`:GoCanNode` and the `can:///` id prefix, so it never touches another app (or a +`:GoCanNode` and the `can:////` id prefix, so it never touches another app (or a sibling-language graph) in the same database. The projection is **deterministic** — a second emit is byte-identical. @@ -602,7 +606,7 @@ MATCH (n:GoCanNode) RETURN labels(n)[0] AS label, count(*) AS n ORDER BY n DESC; MATCH ()-[r]->() RETURN type(r) AS rel, count(*) AS n ORDER BY n DESC; // Everything owned by one application (prefix seek). -MATCH (n:GoCanNode) WHERE n.id STARTS WITH 'can:///' RETURN count(n) AS app_nodes; +MATCH (n:GoCanNode) WHERE n.id STARTS WITH 'can:////' RETURN count(n) AS app_nodes; // Types ranked by method count (the "god struct" finder). MATCH (t:GoType) OPTIONAL MATCH (t)-[:GO_HAS_METHOD]->(m) @@ -645,8 +649,8 @@ methods above, including `pip install codeanalyzer-go`). See This release changes the output contract. If you consume `analysis.json` or the Neo4j graph, check the following. -- **Ids are app-outermost.** `can://go///…` is now `can:///go//…`. Library - callees are `can:///@external//`. +- **Ids are app-outermost.** `can://go///…` is now `can:////go//…`. Library + callees are `can:////@external//`. - **Body nodes carry `id` at every level.** Each body node has `id` = `@`, from `-a 1` on, not only at `-a 3`. - **L2 includes library calls.** The call graph now has edges to library (stdlib and dependency) @@ -781,3 +785,12 @@ The version is injected into the binary at build time via `-ldflags "-X main.ver ## License Apache 2.0 — see [LICENSE](./LICENSE). + +Application identity uses `--namespace` (default: resolved application name) and +`--app-name` (default: input directory basename). Both segments are always present, +for example `can://team/inventory` or `can://inventory/inventory`. Names are raw, +case-sensitive Unicode strings; reserved UTF-8 bytes are percent encoded. Empty, +whitespace-only, control-containing names, slashes, backslashes, `.` and `..` are +rejected. Explicit empty flags are errors. Outputs and graph anchors carry integer +`identity_version: 2`; regenerate legacy outputs from source. The graph writer +rejects incompatible selected anchors before any DDL or graph write. diff --git a/cmd/codeanalyzer/main.go b/cmd/codeanalyzer/main.go index 9e0a941..e855f31 100644 --- a/cmd/codeanalyzer/main.go +++ b/cmd/codeanalyzer/main.go @@ -6,6 +6,7 @@ package main import ( "fmt" + v2 "github.com/codellm-devkit/codeanalyzer-go/internal/schema/v2" "os" "path/filepath" "runtime" @@ -38,6 +39,7 @@ func rootCmd() *cobra.Command { format string emit string appName string + namespace string level int fieldDepth int strict bool @@ -142,16 +144,28 @@ via CLDK(language="go").analysis(project_path=...).`, jobs = runtime.NumCPU() } // application anchor: explicit flag > input dir base name. - if appName == "" { - appName = filepath.Base(filepath.Clean(inputPath)) + if !cmd.Flags().Changed("app-name") { + root, err := filepath.Abs(inputPath) + if err != nil { + return err + } + appName = filepath.Base(filepath.Clean(root)) } + var ns *string + if cmd.Flags().Changed("namespace") { + ns = &namespace + } + identity, err := v2.ResolveIdentity(appName, ns) + if err != nil { + return err + } opts := options.AnalysisOptions{ - InputPath: inputPath, - OutputDir: outputDir, - Format: format, - Emit: options.EmitTarget(emit), - AppName: appName, + InputPath: inputPath, + OutputDir: outputDir, + Format: format, + Emit: options.EmitTarget(emit), + AppName: appName, Namespace: ns, Level: options.AnalysisLevel(level), Strict: strict, LibraryModels: libraryModels, @@ -179,7 +193,7 @@ via CLDK(language="go").analysis(project_path=...).`, // JSON path emits) and project it. --neo4j-uri set → live Bolt push; // absent → a graph.cypher snapshot in the output dir. if emitTarget == options.EmitNeo4j { - payload := v2emit.Emit(app, opts.AppName, opts.InputPath, int(opts.Level), opts.AnalyzerVersion, core.KLimit(opts), analyzer.L3(), analyzer.L4()) + payload := v2emit.Emit(app, identity, opts.InputPath, int(opts.Level), opts.AnalyzerVersion, core.KLimit(opts), analyzer.L3(), analyzer.L4()) boltCfg := neo4j.BoltConfig{ URI: opts.Neo4jURI, User: opts.Neo4jUser, @@ -211,6 +225,7 @@ via CLDK(language="go").analysis(project_path=...).`, f.StringVarP(&outputDir, "output", "o", "", "Output directory for analysis.json (default: stdout)") f.StringVarP(&format, "format", "f", "json", "Output format: json|msgpack") f.StringVar(&emit, "emit", "json", "Output projection: json|neo4j|schema") + f.StringVar(&namespace, "namespace", "", "Identity namespace (default: application name)") f.StringVar(&appName, "app-name", "", "Application anchor name for can:// ids and Neo4j :Application (default: input dir name)") f.IntVarP(&level, "analysis-level", "a", 1, diff --git a/cmd/codeanalyzer/main_test.go b/cmd/codeanalyzer/main_test.go index 7da9999..e7f6813 100644 --- a/cmd/codeanalyzer/main_test.go +++ b/cmd/codeanalyzer/main_test.go @@ -278,8 +278,8 @@ func TestRootCmd_EmitsCanonicalV2(t *testing.T) { if v.Language != "go" { t.Errorf("language = %q, want go", v.Language) } - if v.Application.ID != "can://greeter" { - t.Errorf("application.id = %q, want can://greeter", v.Application.ID) + if v.Application.ID != "can://greeter/greeter" { + t.Errorf("application.id = %q, want can://greeter/greeter", v.Application.ID) } } @@ -779,3 +779,51 @@ func TestCLI_LibraryModelsBadFileErrors(t *testing.T) { } } } + +func TestNamespaceExplicitEmptyRejected(t *testing.T) { + for _, flag := range []string{"--namespace", "--app-name"} { + _, _, err := runCmd("--input", filepath.Join(cliTestdataDir(), "greeter"), flag, "", "--cache-dir", t.TempDir()) + if err == nil { + t.Fatalf("accepted empty %s", flag) + } + } +} +func TestNamespaceExplicitScope(t *testing.T) { + out, _, err := runCmd("--input", filepath.Join(cliTestdataDir(), "greeter"), "--namespace", "team space", "--app-name", "billing", "--cache-dir", t.TempDir()) + if err != nil { + t.Fatal(err) + } + var a struct { + IdentityVersion int `json:"identity_version"` + Application struct { + ID string `json:"id"` + Namespace string `json:"identity_namespace"` + Name string `json:"application_name"` + } + } + if err = json.Unmarshal([]byte(out), &a); err != nil { + t.Fatal(err) + } + if a.IdentityVersion != 2 || a.Application.ID != "can://team%20space/billing" || a.Application.Namespace != "team space" || a.Application.Name != "billing" { + t.Fatalf("%+v", a) + } +} + +func TestNamespaceDefaultUsesNormalizedInput(t *testing.T) { + t.Chdir(filepath.Join(cliTestdataDir(), "greeter")) + out, _, err := runCmd("--input", ".", "--cache-dir", t.TempDir()) + if err != nil { + t.Fatal(err) + } + var a struct { + Application struct { + ID string `json:"id"` + } + } + if err = json.Unmarshal([]byte(out), &a); err != nil { + t.Fatal(err) + } + if a.Application.ID != "can://greeter/greeter" { + t.Fatal(a.Application.ID) + } +} diff --git a/internal/core/analyzer.go b/internal/core/analyzer.go index 2d541bf..9618e27 100644 --- a/internal/core/analyzer.go +++ b/internal/core/analyzer.go @@ -15,9 +15,11 @@ package core import ( "bufio" + "crypto/sha256" "encoding/json" "errors" "fmt" + v2 "github.com/codellm-devkit/codeanalyzer-go/internal/schema/v2" "io" "io/fs" "math/rand/v2" @@ -67,13 +69,33 @@ func KLimit(opts options.AnalysisOptions) int { return 3 } -// New creates an Analyzer for the given options. +// resolveIdentity preserves the default input basename for programmatic callers. +func resolveIdentity(opts options.AnalysisOptions) (v2.Identity, error) { + name := opts.AppName + if name == "" { + root, err := filepath.Abs(opts.InputPath) + if err != nil { + return v2.Identity{}, err + } + name = filepath.Base(filepath.Clean(root)) + } + return v2.ResolveIdentity(name, opts.Namespace) +} + +// New creates an Analyzer with a cache isolated by canonical application identity. func New(opts options.AnalysisOptions) *Analyzer { + if identity, err := resolveIdentity(opts); err == nil && opts.CacheDir != "" { + key := sha256.Sum256([]byte(identity.ApplicationID)) + opts.CacheDir = filepath.Join(opts.CacheDir, fmt.Sprintf("identity-v2-%x", key)) + } return &Analyzer{opts: opts} } // Analyze runs the full analysis pipeline and returns a GoApplication. func (a *Analyzer) Analyze() (*schema.GoApplication, error) { + if _, err := resolveIdentity(a.opts); err != nil { + return nil, err + } // Resolve to absolute path so filepath.Rel works correctly in all cases. abs, err := filepath.Abs(a.opts.InputPath) if err != nil { @@ -292,7 +314,11 @@ func RenderJSON(app *schema.GoApplication, l3 map[dataflow.L3Key]dataflow.Result if err := checkFormat(opts); err != nil { return nil, err } - payload := v2emit.Emit(app, opts.AppName, opts.InputPath, int(opts.Level), opts.AnalyzerVersion, KLimit(opts), l3, l4) + identity, err := resolveIdentity(opts) + if err != nil { + return nil, err + } + payload := v2emit.Emit(app, identity, opts.InputPath, int(opts.Level), opts.AnalyzerVersion, KLimit(opts), l3, l4) return json.Marshal(payload) } @@ -302,8 +328,12 @@ func StreamJSON(w io.Writer, app *schema.GoApplication, l3 map[dataflow.L3Key]da if err := checkFormat(opts); err != nil { return err } + identity, err := resolveIdentity(opts) + if err != nil { + return err + } bw := bufio.NewWriterSize(w, 1<<20) - if err := v2emit.EmitTo(bw, app, opts.AppName, opts.InputPath, int(opts.Level), opts.AnalyzerVersion, KLimit(opts), l3, l4); err != nil { + if err := v2emit.EmitTo(bw, app, identity, opts.InputPath, int(opts.Level), opts.AnalyzerVersion, KLimit(opts), l3, l4); err != nil { return err } return bw.Flush() diff --git a/internal/core/analyzer_test.go b/internal/core/analyzer_test.go index a7198dd..52330ec 100644 --- a/internal/core/analyzer_test.go +++ b/internal/core/analyzer_test.go @@ -289,7 +289,7 @@ func TestCaching_CacheFileWritten(t *testing.T) { if _, err := core.New(opts).Analyze(); err != nil { t.Fatalf("Analyze: %v", err) } - if _, err := os.Stat(filepath.Join(cacheDir, "analysis_cache.json")); err != nil { + if _, err := os.Stat(identityCacheFile(t, cacheDir)); err != nil { t.Fatalf("analysis_cache.json not written to CacheDir: %v", err) } } @@ -306,7 +306,7 @@ func TestCaching_CacheContentsRoundTrip(t *testing.T) { if err != nil { t.Fatalf("Analyze: %v", err) } - data, err := os.ReadFile(filepath.Join(cacheDir, "analysis_cache.json")) + data, err := os.ReadFile(identityCacheFile(t, cacheDir)) if err != nil { t.Fatalf("reading analysis_cache.json: %v", err) } @@ -331,7 +331,7 @@ func TestCaching_EagerForcesRebuild(t *testing.T) { if _, err := core.New(opts).Analyze(); err != nil { t.Fatalf("first run: %v", err) } - cachePath := filepath.Join(cacheDir, "analysis_cache.json") + cachePath := identityCacheFile(t, cacheDir) info1, err := os.Stat(cachePath) if err != nil { t.Fatalf("cache not written after first run: %v", err) @@ -390,3 +390,38 @@ func keys[K comparable, V any](m map[K]V) []K { } return out } + +func identityCacheFile(t *testing.T, dir string) string { + t.Helper() + files, err := filepath.Glob(filepath.Join(dir, "identity-v2-*", "analysis_cache.json")) + if err != nil || len(files) != 1 { + t.Fatalf("identity cache files: %v %v", files, err) + } + return files[0] +} +func TestCaching_NamespaceIsolation(t *testing.T) { + dir := t.TempDir() + for _, ns := range []string{"team-a", "team-b"} { + opts := options.AnalysisOptions{InputPath: greeterDir(t), AppName: "greeter", Namespace: &ns, Level: options.LevelSymbolTable, SkipTests: true, CacheDir: dir} + if _, err := core.New(opts).Analyze(); err != nil { + t.Fatal(err) + } + } + files, err := filepath.Glob(filepath.Join(dir, "identity-v2-*", "analysis_cache.json")) + if err != nil || len(files) != 2 { + t.Fatalf("cache scopes: %v %v", files, err) + } +} + +func TestAnalyzeInvalidNamespaceReadOnly(t *testing.T) { + ns := "" + dir := t.TempDir() + opts := options.AnalysisOptions{InputPath: greeterDir(t), AppName: "greeter", Namespace: &ns, Level: options.LevelSymbolTable, CacheDir: dir} + if _, err := core.New(opts).Analyze(); err == nil { + t.Fatal("accepted empty namespace") + } + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 0 { + t.Fatalf("cache changed: %v %v", entries, err) + } +} diff --git a/internal/dataflow/run_test.go b/internal/dataflow/run_test.go index 619963f..6576041 100644 --- a/internal/dataflow/run_test.go +++ b/internal/dataflow/run_test.go @@ -3,6 +3,7 @@ package dataflow_test import ( "bytes" "encoding/json" + v2 "github.com/codellm-devkit/codeanalyzer-go/internal/schema/v2" "go/ast" "go/importer" "go/parser" @@ -50,7 +51,13 @@ func TestRun_JobsDeterminism(t *testing.T) { if len(l3) != len(units) { t.Errorf("-j %d: %d results for %d units", jobs, len(l3), len(units)) } - data, err := json.Marshal(v2emit.Emit(app, "chi", dir, 3, "det", 3, l3, nil)) + data, err := json.Marshal(v2emit.Emit(app, func() v2.Identity { + i, e := v2.ResolveIdentity("chi", nil) + if e != nil { + panic(e) + } + return i + }(), dir, 3, "det", 3, l3, nil)) if err != nil { t.Fatal(err) } @@ -409,7 +416,13 @@ func TestResult_ReleaseDropsASTs(t *testing.T) { app := &schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()} out := dataflow.Run(b.Fset(), b.L3Units(st), 3, 4) emit := func() []byte { - data, err := json.Marshal(v2emit.Emit(app, "dataflow", dir, 3, "rel", 3, out, nil)) + data, err := json.Marshal(v2emit.Emit(app, func() v2.Identity { + i, e := v2.ResolveIdentity("dataflow", nil) + if e != nil { + panic(e) + } + return i + }(), dir, 3, "rel", 3, out, nil)) if err != nil { t.Fatal(err) } diff --git a/internal/metamorphic/run.go b/internal/metamorphic/run.go index 8c96d98..cb4208b 100644 --- a/internal/metamorphic/run.go +++ b/internal/metamorphic/run.go @@ -8,6 +8,7 @@ package metamorphic import ( "fmt" + v2 "github.com/codellm-devkit/codeanalyzer-go/internal/schema/v2" "io" "path/filepath" "sort" @@ -78,7 +79,7 @@ func Analyze(dir string, level int, targets []string) (*Run, error) { // so a large app's tree never exists in memory at once. pr, pw := io.Pipe() go func() { - pw.CloseWithError(v2emit.EmitTo(pw, app, AppName, dir, level, "metamorphic", k, res, l4)) + pw.CloseWithError(v2emit.EmitTo(pw, app, func() v2.Identity { i, _ := v2.ResolveIdentity(AppName, nil); return i }(), dir, level, "metamorphic", k, res, l4)) }() v, err := Collect(pr) pr.CloseWithError(err) diff --git a/internal/neo4j/bolt.go b/internal/neo4j/bolt.go index 60d2916..201df06 100644 --- a/internal/neo4j/bolt.go +++ b/internal/neo4j/bolt.go @@ -41,7 +41,7 @@ import ( // is additive: a stale L4 row stays until an --eager push. // // A push NEVER deletes by default (#171): steps 4/6/8 run under --eager only. -// Every destructive statement is scoped on the can:/// id prefix (#173), +// Every destructive statement is scoped on the can://// id prefix (#173), // anchored on :GoCanNode so it seeks an index. // BoltConfig carries the connection + behavior knobs (populated by the facade @@ -62,6 +62,9 @@ type BoltConfig struct { // WriteBolt pushes rows into the live DB per BoltConfig. appID scopes every // destructive statement; it must be the application root id. func WriteBolt(ctx context.Context, rows GraphRows, appID string, cfg BoltConfig) error { + if err := validateIdentity(rows, appID); err != nil { + return err + } driver, err := neo4j.NewDriverWithContext(cfg.URI, neo4j.BasicAuth(cfg.User, cfg.Password, "")) if err != nil { return fmt.Errorf("neo4j driver: %w", err) @@ -87,9 +90,6 @@ type boltWriter struct { } func (w *boltWriter) run() error { - if err := w.ensureDDL(); err != nil { - return err - } // Stored-application gate: a producer/consumer version mismatch, or a // graph pushed at a lower level, forces a full re-upsert (it never @@ -101,6 +101,9 @@ func (w *boltWriter) run() error { return err } + if err := w.ensureDDL(); err != nil { + return err + } root, scoped := applicationPrefix(w.appID) if w.shouldEagerPurge(scoped) { if err := w.eagerPurge(root); err != nil { @@ -206,12 +209,16 @@ func (w *boltWriter) storedAppStale() (bool, error) { haveLevel, badLevel := false, false err := w.read(func(tx neo4j.ManagedTransaction) error { res, err := tx.Run(w.ctx, - "MATCH (a:GoApplication {id: $id}) RETURN a.schema_version AS v, a.max_level AS l", + "MATCH (a:GoApplication {id: $id}) RETURN a.schema_version AS v, a.max_level AS l, a.identity_version AS iv", map[string]any{"id": w.appID}) if err != nil { return err } if res.Next(w.ctx) { + iv, _ := res.Record().Get("iv") + if n, ok := iv.(int64); !ok || n != 2 { + return fmt.Errorf("incompatible stored identity_version: %v", iv) + } if v, ok := res.Record().Get("v"); ok && v != nil { stored, _ = v.(string) } diff --git a/internal/neo4j/bolt_session_test.go b/internal/neo4j/bolt_session_test.go index 1cc8d6f..3e51d31 100644 --- a/internal/neo4j/bolt_session_test.go +++ b/internal/neo4j/bolt_session_test.go @@ -35,12 +35,12 @@ func (consumedResult) Consume(context.Context) (neo4j.ResultSummary, error) { re // TRANSACTIONS inside an explicit transaction (what ExecuteWrite opens), so // the batched eager purge must go through the session's implicit Run path. func TestEagerPurge_RunsAsImplicitTransaction(t *testing.T) { - if q, _ := eagerPurgeQuery("can://app"); !strings.Contains(q, "IN TRANSACTIONS") { + if q, _ := eagerPurgeQuery("can://app/app"); !strings.Contains(q, "IN TRANSACTIONS") { t.Fatalf("eagerPurgeQuery is not batched: %q", q) } s := &recordingSession{} w := &boltWriter{ctx: context.Background(), session: s} - if err := w.eagerPurge("can://app"); err != nil { + if err := w.eagerPurge("can://app/app"); err != nil { t.Fatal(err) } if s.explicit != 0 { @@ -84,8 +84,10 @@ type fakeDB struct { level int64 // rawLevel, when non-nil, is the stored max_level returned verbatim // (any type), overriding level. - rawLevel any - writes []fakeStmt + missingIdentity bool + rawIdentity any + rawLevel any + writes []fakeStmt } type fakeStmt struct { @@ -121,7 +123,14 @@ func (tx fakeTx) Run(_ context.Context, q string, params map[string]any) (neo4j. if tx.db.rawLevel != nil { lvl = tx.db.rawLevel } - return &fakeResult{rec: &neo4j.Record{Keys: []string{"v", "l"}, Values: []any{SchemaVersion, lvl}}}, nil + identity := any(int64(2)) + if tx.db.missingIdentity { + identity = nil + } + if tx.db.rawIdentity != nil { + identity = tx.db.rawIdentity + } + return &fakeResult{rec: &neo4j.Record{Keys: []string{"v", "l", "iv"}, Values: []any{SchemaVersion, lvl, identity}}}, nil case strings.Contains(q, "m.content_hash"): h, ok := tx.db.hashes[params["id"].(string)] if !ok { @@ -386,3 +395,27 @@ func TestBolt_CleanPushClearsDegradedJSON(t *testing.T) { t.Errorf("degraded push: degraded_json = %v, want the JSON string", v) } } + +func TestIncompatibleIdentityReadOnly(t *testing.T) { + for _, iv := range []any{int64(1), "2", float64(2), true} { + db := &fakeDB{rawIdentity: iv} + w := &boltWriter{ctx: context.Background(), session: db, appID: "can://team/app"} + if err := w.run(); err == nil { + t.Errorf("accepted %v", iv) + } + if len(db.writes) != 0 { + t.Fatal("wrote before identity gate") + } + } +} + +func TestMissingStoredIdentityReadOnly(t *testing.T) { + db := &fakeDB{missingIdentity: true} + w := &boltWriter{ctx: context.Background(), session: db, appID: "can://team/app"} + if err := w.run(); err == nil { + t.Fatal("accepted legacy anchor") + } + if len(db.writes) != 0 { + t.Fatal("wrote before rejecting legacy anchor") + } +} diff --git a/internal/neo4j/bolt_test.go b/internal/neo4j/bolt_test.go index cd6972f..87065a9 100644 --- a/internal/neo4j/bolt_test.go +++ b/internal/neo4j/bolt_test.go @@ -14,9 +14,9 @@ import ( func TestPartitionNodes_SharedHaveNoModule(t *testing.T) { b := NewRowBuilder() - b.Node([]string{LabelModule, MarkerLabel}, "id", "can://app/go/x.go", Props{"content_hash": "h1"}, "x.go") - b.Node([]string{LabelCallable, SymbolLabel, MarkerLabel}, "id", "can://app/go/x.go/f", nil, "x.go") - b.Node([]string{SymbolLabel, LabelExternal, MarkerLabel}, "id", "can://app/@external/fmt", nil, "") // shared + b.Node([]string{LabelModule, MarkerLabel}, "id", "can://app/app/go/x.go", Props{"content_hash": "h1"}, "x.go") + b.Node([]string{LabelCallable, SymbolLabel, MarkerLabel}, "id", "can://app/app/go/x.go/f", nil, "x.go") + b.Node([]string{SymbolLabel, LabelExternal, MarkerLabel}, "id", "can://app/app/@external/fmt", nil, "") // shared rows := b.Finish() byModule, shared, l4 := partitionNodes(rows.Nodes) @@ -33,8 +33,8 @@ func TestPartitionNodes_SharedHaveNoModule(t *testing.T) { func TestModuleHash_ReadsModuleNode(t *testing.T) { nodes := []NodeRow{ - {Labels: []string{LabelCallable}, Value: "can://app/go/x.go/f"}, - {Labels: []string{LabelModule}, Value: "can://app/go/x.go", Props: Props{"content_hash": "abc123"}}, + {Labels: []string{LabelCallable}, Value: "can://app/app/go/x.go/f"}, + {Labels: []string{LabelModule}, Value: "can://app/app/go/x.go", Props: Props{"content_hash": "abc123"}}, } if got := moduleHash(nodes); got != "abc123" { t.Errorf("moduleHash = %q, want abc123", got) @@ -47,15 +47,15 @@ func TestModuleHash_ReadsModuleNode(t *testing.T) { func TestDestructiveQueries_AreScopedAndAnchored(t *testing.T) { // Both destructive queries MUST filter on :GoCanNode and bind the prefix as a // parameter — never an unqualified DETACH DELETE. - eq, ep := eagerPurgeQuery("can://app") + eq, ep := eagerPurgeQuery("can://app/app") assertScoped(t, "eagerPurge", eq, ep) - if ep["p"] != "can://app/" || ep["root"] != "can://app" { + if ep["p"] != "can://app/app/" || ep["root"] != "can://app/app" { t.Errorf("eagerPurge params not the app prefix+root: %v", ep) } - pq, pp := purgeModuleQuery("can://app/go/x.go") + pq, pp := purgeModuleQuery("can://app/app/go/x.go") assertScoped(t, "purgeModule", pq, pp) - if pp["p"] != "can://app/go/x.go/" || pp["m"] != "can://app/go/x.go" { + if pp["p"] != "can://app/app/go/x.go/" || pp["m"] != "can://app/app/go/x.go" { t.Errorf("purgeModule params not the module prefix+id: %v", pp) } } diff --git a/internal/neo4j/cypher.go b/internal/neo4j/cypher.go index 548886b..8370558 100644 --- a/internal/neo4j/cypher.go +++ b/internal/neo4j/cypher.go @@ -2,6 +2,7 @@ package neo4j import ( "fmt" + v2 "github.com/codellm-devkit/codeanalyzer-go/internal/schema/v2" "sort" "strings" ) @@ -13,7 +14,7 @@ import ( // // 1. constraints + indexes (IF NOT EXISTS) — first, so MERGE seeks an index; // 2. scoped wipe of this app's prior subgraph — anchored on :GoCanNode and the -// can:/// id prefix, with the root id matched separately (the root is +// can://// id prefix, with the root id matched separately (the root is // outside its own descendant prefix). Rows with no proper application root // emit NO wipe (refused visibly), so a hand-built bag can't delete anything; // 3. batched UNWIND … MERGE for nodes, grouped by full label set + key prop; @@ -29,7 +30,46 @@ const batchSize = 500 // RenderCypher renders the full snapshot script. appID is the application root // id used to scope the wipe; it is normally rows' application node value. -func RenderCypher(rows GraphRows, appID string) string { +func validateIdentity(rows GraphRows, appID string) error { + for _, n := range rows.Nodes { + if n.Value != appID { + continue + } + for _, label := range n.Labels { + if label != LabelApplication { + continue + } + version, ok := n.Props["identity_version"].(int) + if !ok || version != 2 { + return fmt.Errorf("incompatible identity_version") + } + name, ok := n.Props["application_name"].(string) + if !ok { + return fmt.Errorf("missing application_name") + } + ns, ok := n.Props["identity_namespace"].(string) + if !ok { + return fmt.Errorf("missing identity_namespace") + } + identity, err := v2.ResolveIdentity(name, &ns) + if err != nil { + return err + } + if identity.ApplicationID != appID { + return fmt.Errorf("application root does not match metadata") + } + return nil + } + } + return fmt.Errorf("missing application anchor") +} +func RenderCypher(rows GraphRows, appID string) (string, error) { + if err := validateIdentity(rows, appID); err != nil { + return "", err + } + return renderCypher(rows, appID), nil +} +func renderCypher(rows GraphRows, appID string) string { var b strings.Builder writeDDL(&b) @@ -66,7 +106,7 @@ func writeScopedWipe(b *strings.Builder, appID string) { } desc := descendantPrefix(root) b.WriteString("// ─── scoped wipe (this application only) ───\n") - // Descendants: everything under can:///. + // Descendants: everything under can:////. fmt.Fprintf(b, "MATCH (n:%s) WHERE n.id STARTS WITH %s DETACH DELETE n;\n", MarkerLabel, cypherString(desc)) // The root itself is outside its own descendant prefix. diff --git a/internal/neo4j/cypher_test.go b/internal/neo4j/cypher_test.go index 74140de..8e829db 100644 --- a/internal/neo4j/cypher_test.go +++ b/internal/neo4j/cypher_test.go @@ -1,6 +1,9 @@ package neo4j import ( + "context" + "os" + "path/filepath" "strings" "testing" ) @@ -13,7 +16,11 @@ func renderFixture(t *testing.T) string { t.Helper() a := buildMultipackageL2(t) rows := Project(a) - return RenderCypher(rows, a.Application.ID) + script, err := RenderCypher(rows, a.Application.ID) + if err != nil { + t.Fatal(err) + } + return script } func TestCypher_HasDDLWipeAndBatches(t *testing.T) { @@ -34,8 +41,14 @@ func TestCypher_HasDDLWipeAndBatches(t *testing.T) { func TestCypher_Deterministic(t *testing.T) { a := buildMultipackageL2(t) - s1 := RenderCypher(Project(a), a.Application.ID) - s2 := RenderCypher(Project(a), a.Application.ID) + s1, err := RenderCypher(Project(a), a.Application.ID) + if err != nil { + t.Fatal(err) + } + s2, err := RenderCypher(Project(a), a.Application.ID) + if err != nil { + t.Fatal(err) + } if s1 != s2 { t.Error("graph.cypher is not byte-identical across runs") } @@ -45,10 +58,10 @@ func TestCypher_WipeIsPrefixScopedAndAnchored(t *testing.T) { script := renderFixture(t) // The wipe must match on the :GoCanNode marker and the can:/// prefix, // never a bare label like (:GoModule) or the whole store. - if !strings.Contains(script, "MATCH (n:GoCanNode) WHERE n.id STARTS WITH 'can://multipackage/'") { + if !strings.Contains(script, "MATCH (n:GoCanNode) WHERE n.id STARTS WITH 'can://multipackage/multipackage/'") { t.Errorf("wipe not anchored on :GoCanNode + descendant prefix:\n%s", wipeBlock(script)) } - if !strings.Contains(script, "MATCH (n:GoCanNode) WHERE n.id = 'can://multipackage'") { + if !strings.Contains(script, "MATCH (n:GoCanNode) WHERE n.id = 'can://multipackage/multipackage'") { t.Errorf("wipe does not match the root id separately:\n%s", wipeBlock(script)) } // A destructive statement must never be unscoped. @@ -63,13 +76,9 @@ func TestCypher_NoRootEmitsNoWipe(t *testing.T) { // A hand-built bag with no bare application id must emit NO destructive // statement — the applicationPrefix refusal in action. b := NewRowBuilder() - b.Node([]string{LabelCallable, SymbolLabel}, "id", "can://app/go/x.go/f", nil, "x.go") - script := RenderCypher(b.Finish(), "can://app/go/x.go/f") // not a bare app id - if strings.Contains(script, "DETACH DELETE") { - t.Errorf("a bag with no bare application root must emit no wipe:\n%s", script) - } - if !strings.Contains(script, "scoped wipe SKIPPED") { - t.Error("the skipped wipe should be visible in a comment") + b.Node([]string{LabelCallable, SymbolLabel}, "id", "can://app/app/go/x.go/f", nil, "x.go") + if _, err := RenderCypher(b.Finish(), "can://app/app/go/x.go/f"); err == nil { + t.Fatal("accepted missing anchor") } } @@ -97,3 +106,28 @@ func wipeBlock(script string) string { } return script[start : start+end] } + +func TestCypherRejectsInvalidIdentity(t *testing.T) { + a := buildMultipackageL2(t) + a.IdentityVersion = 0 + if _, err := RenderCypher(Project(a), a.Application.ID); err == nil { + t.Fatal("accepted missing identity marker") + } +} + +func TestEmitNeo4jInvalidIdentityLeavesOutput(t *testing.T) { + a := buildMultipackageL2(t) + a.Application.IdentityVersion = 1 + dir := t.TempDir() + path := filepath.Join(dir, GraphFileName) + if err := os.WriteFile(path, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + if _, err := EmitNeo4j(context.Background(), a, dir, BoltConfig{}); err == nil { + t.Fatal("accepted mismatched app marker") + } + b, err := os.ReadFile(path) + if err != nil || string(b) != "original" { + t.Fatalf("output changed: %s %v", b, err) + } +} diff --git a/internal/neo4j/emit.go b/internal/neo4j/emit.go index e5c682a..c627b41 100644 --- a/internal/neo4j/emit.go +++ b/internal/neo4j/emit.go @@ -23,6 +23,9 @@ const GraphFileName = "graph.cypher" // a self-contained graph.cypher snapshot into outputDir (default: current dir). // It returns a short human-readable description of what was written. func EmitNeo4j(ctx context.Context, a *v2.Analysis, outputDir string, cfg BoltConfig) (string, error) { + if a == nil || a.IdentityVersion != 2 || a.Application.IdentityVersion != 2 { + return "", fmt.Errorf("incompatible identity_version") + } rows := Project(a) appID := a.Application.ID @@ -33,6 +36,10 @@ func EmitNeo4j(ctx context.Context, a *v2.Analysis, outputDir string, cfg BoltCo return fmt.Sprintf("pushed %d nodes, %d relationships to %s", len(rows.Nodes), len(rows.Edges), cfg.URI), nil } + script, err := RenderCypher(rows, appID) + if err != nil { + return "", err + } if outputDir == "" { outputDir = "." } @@ -40,7 +47,6 @@ func EmitNeo4j(ctx context.Context, a *v2.Analysis, outputDir string, cfg BoltCo return "", fmt.Errorf("create output dir %q: %w", outputDir, err) } path := filepath.Join(outputDir, GraphFileName) - script := RenderCypher(rows, appID) if err := os.WriteFile(path, []byte(script), 0o644); err != nil { return "", fmt.Errorf("write %s: %w", path, err) } diff --git a/internal/neo4j/project.go b/internal/neo4j/project.go index 9584338..78fde41 100644 --- a/internal/neo4j/project.go +++ b/internal/neo4j/project.go @@ -23,7 +23,7 @@ import ( // an id-keyed :GoExternal ghost — nothing dangles. // Project is the pure (tree) → GraphRows function. appID is analysis.Application.ID -// (the can:// root); it is read from the envelope, never recomposed. +// (the can:/// root); it is read from the envelope, never recomposed. func Project(a *v2.Analysis) GraphRows { p := &projector{ b: NewRowBuilder(), @@ -56,7 +56,7 @@ func (p *projector) projectApplication() { []string{LabelApplication, MarkerLabel}, "id", a.Application.ID, Props{ - "schema_version": a.SchemaVersion, + "identity_version": a.IdentityVersion, "application_name": a.Application.ApplicationName, "identity_namespace": a.Application.IdentityNamespace, "schema_version": a.SchemaVersion, "language": a.Language, "max_level": a.MaxLevel, "analyzer_name": a.Analyzer.Name, diff --git a/internal/neo4j/project_test.go b/internal/neo4j/project_test.go index cebe5f9..04a9a4b 100644 --- a/internal/neo4j/project_test.go +++ b/internal/neo4j/project_test.go @@ -39,7 +39,13 @@ func buildMultipackageL2(t *testing.T) *v2.Analysis { cg := semantic_analysis.NewCallGraphBuilder(dir, b.Fset(), b.Pkgs()) edges := cg.Build(st) app := &schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()} - return v2emit.Emit(app, "multipackage", dir, 2, "neo4j-gate", 0, nil, nil) + return v2emit.Emit(app, func() v2.Identity { + i, e := v2.ResolveIdentity("multipackage", nil) + if e != nil { + panic(e) + } + return i + }(), dir, 2, "neo4j-gate", 0, nil, nil) } func TestProject_NoDanglingEndpoints(t *testing.T) { @@ -240,10 +246,22 @@ func buildDataflowAt(t *testing.T, level int) *v2.Analysis { edges := cg.Build(st) app := &schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()} if level < 3 { - return v2emit.Emit(app, "dataflow", dir, level, "neo4j-gate", 3, nil, nil) + return v2emit.Emit(app, func() v2.Identity { + i, e := v2.ResolveIdentity("dataflow", nil) + if e != nil { + panic(e) + } + return i + }(), dir, level, "neo4j-gate", 3, nil, nil) } res := dataflow.Run(b.Fset(), b.L3Units(st), 3, 4) - return v2emit.Emit(app, "dataflow", dir, level, "neo4j-gate", 3, res, nil) + return v2emit.Emit(app, func() v2.Identity { + i, e := v2.ResolveIdentity("dataflow", nil) + if e != nil { + panic(e) + } + return i + }(), dir, level, "neo4j-gate", 3, res, nil) } // walkCallables visits every callable (methods and nested closures included). @@ -559,7 +577,13 @@ func buildDataflowL4(t *testing.T) *v2.Analysis { Externals: app.Externals, CallGraph: app.CallGraph, Units: units, L3: res, K: 3, Jobs: 4, }) l4.ApplyTo(app) - l4Analysis = v2emit.Emit(app, "dataflow", dir, 4, "neo4j-gate", 3, res, &l4) + l4Analysis = v2emit.Emit(app, func() v2.Identity { + i, e := v2.ResolveIdentity("dataflow", nil) + if e != nil { + panic(e) + } + return i + }(), dir, 4, "neo4j-gate", 3, res, &l4) }) if l4Analysis == nil { t.Fatal("L4 fixture build failed") diff --git a/internal/neo4j/rows.go b/internal/neo4j/rows.go index 5bdf384..fc96985 100644 --- a/internal/neo4j/rows.go +++ b/internal/neo4j/rows.go @@ -1,6 +1,7 @@ package neo4j import ( + "encoding/json" "fmt" "sort" "strings" @@ -67,13 +68,13 @@ type GraphRows struct { // ─── RowBuilder ────────────────────────────────────────────────────────────── // RowBuilder accumulates nodes and edges with in-memory MERGE semantics. Nodes -// dedup on (labels[0], value); edges are append-only (per-pair facts that must -// not duplicate need an explicit guard in the projector). Edges whose target +// dedup on canonical ID; exact edge facts dedup while preserving kind, site, +// key and properties. Edges whose target // may be external/library code are DEFERRED and kept at finish() only if the // target node was actually emitted — the edge-only-when-resolved rule, so an // unresolved target degrades to a source-node property instead of a dangling edge. type RowBuilder struct { - nodeIndex map[string]int // (labels[0] + "\x00" + value) -> index into nodes + nodeIndex map[string]int // canonical ID (label/value for non-can nodes) -> row nodes []NodeRow edges []EdgeRow deferred []EdgeRow @@ -84,7 +85,7 @@ func NewRowBuilder() *RowBuilder { return &RowBuilder{nodeIndex: make(map[string]int)} } -// Node upserts a node keyed on (labels[0], value). Re-seeing the same identity +// Node upserts a node keyed on canonical ID (label/value for non-can nodes). Re-seeing the same identity // MERGES props (last-write-wins) and UNIONs labels — a hot external symbol // collapses to one row. Returns a NodeRef edges can address it by. nil props // are pruned on the way in. @@ -93,6 +94,9 @@ func (b *RowBuilder) Node(labels []string, keyProp, value string, props Props, m panic("neo4j: Node requires at least one label (labels[0] is the MERGE label)") } k := labels[0] + "\x00" + value + if strings.HasPrefix(value, "can://") { + k = value + } if i, ok := b.nodeIndex[k]; ok { existing := &b.nodes[i] existing.Labels = unionLabels(existing.Labels, labels) @@ -119,7 +123,7 @@ func (b *RowBuilder) Node(labels []string, keyProp, value string, props Props, m } // Edge appends a relationship whose endpoints are known to exist this run. -// Append-only — not deduped. +// Exact duplicate facts are collapsed at Finish. func (b *RowBuilder) Edge(relType string, from, to NodeRef, props Props, key string) { b.edges = append(b.edges, EdgeRow{Type: relType, From: from, To: to, Props: prune(props), Key: key}) } @@ -145,7 +149,9 @@ func (b *RowBuilder) EdgeToSymbol(relType string, from NodeRef, targetLabel, tar func (b *RowBuilder) Finish() GraphRows { emitted := make(map[string]bool, len(b.nodes)) for _, n := range b.nodes { - emitted[n.Labels[0]+"\x00"+n.Value] = true + for _, label := range n.Labels { + emitted[label+"\x00"+n.Value] = true + } } for _, e := range b.deferred { if emitted[e.To.Label+"\x00"+e.To.Value] { @@ -153,6 +159,17 @@ func (b *RowBuilder) Finish() GraphRows { } } + seenEdges := map[string]bool{} + unique := b.edges[:0] + for _, e := range b.edges { + props, _ := json.Marshal(e.Props) + key := e.Type + "\x00" + e.From.Value + "\x00" + e.To.Value + "\x00" + e.Key + "\x00" + string(props) + if !seenEdges[key] { + seenEdges[key] = true + unique = append(unique, e) + } + } + b.edges = unique sort.Slice(b.nodes, func(i, j int) bool { if b.nodes[i].Labels[0] != b.nodes[j].Labels[0] { return b.nodes[i].Labels[0] < b.nodes[j].Labels[0] @@ -169,13 +186,14 @@ func (b *RowBuilder) Finish() GraphRows { // edgeSortKey is the total order for edges: type, then endpoints, then the _k // discriminant (so parallel edges per pair order stably). func edgeSortKey(e EdgeRow) string { - return strings.Join([]string{e.Type, e.From.Label, e.From.Value, e.To.Label, e.To.Value, e.Key}, "\x00") + props, _ := json.Marshal(e.Props) + return strings.Join([]string{e.Type, e.From.Label, e.From.Value, e.To.Label, e.To.Value, e.Key, string(props)}, "\x00") } // ─── scoping helpers ────────────────────────────────────────────────────────── // applicationPrefix returns the bare application id used to scope destructive -// statements. It REFUSES anything that is not a bare can:// id (an +// statements. It REFUSES anything that is not a canonical can:/// id (an // empty or deeper id would silently widen or narrow the blast radius): it // returns ("", false) so a hand-built GraphRows with no proper application root // gets NO destructive statement. The app segment is the outermost, so one @@ -186,8 +204,9 @@ func applicationPrefix(appID string) (string, bool) { return "", false } rest := strings.TrimPrefix(appID, scheme) - // A bare app id is exactly — one non-empty segment, no deeper. - if rest == "" || strings.Contains(rest, "/") { + // A bare app id is exactly two non-empty encoded segments, no deeper. + parts := strings.Split(rest, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", false } return appID, true diff --git a/internal/neo4j/rows_test.go b/internal/neo4j/rows_test.go index 707ea15..4266406 100644 --- a/internal/neo4j/rows_test.go +++ b/internal/neo4j/rows_test.go @@ -11,7 +11,7 @@ import ( func TestRowBuilder_NodeMergeDedupsAndUnionsLabels(t *testing.T) { b := NewRowBuilder() - id := "can://app/go/x.go/T" + id := "can://app/app/go/x.go/T" b.Node([]string{SymbolLabel, LabelType}, "id", id, Props{"kind": "struct"}, "x.go") // Re-see the same identity with an extra label + an extra prop. b.Node([]string{SymbolLabel, MarkerLabel}, "id", id, Props{"signature": "T"}, "x.go") @@ -34,7 +34,7 @@ func TestRowBuilder_NodeMergeDedupsAndUnionsLabels(t *testing.T) { func TestRowBuilder_PropLastWriteWins(t *testing.T) { b := NewRowBuilder() - id := "can://app/go/x.go/T" + id := "can://app/app/go/x.go/T" b.Node([]string{LabelType}, "id", id, Props{"kind": "struct"}, "") b.Node([]string{LabelType}, "id", id, Props{"kind": "interface"}, "") rows := b.Finish() @@ -45,9 +45,9 @@ func TestRowBuilder_PropLastWriteWins(t *testing.T) { func TestRowBuilder_DeferredEdgeDroppedWhenTargetAbsent(t *testing.T) { b := NewRowBuilder() - src := b.Node([]string{SymbolLabel, LabelType}, "id", "can://app/go/x.go/T", nil, "x.go") + src := b.Node([]string{SymbolLabel, LabelType}, "id", "can://app/app/go/x.go/T", nil, "x.go") // Target never emitted as a node. - b.EdgeToSymbol(RelEmbeds, src, SymbolLabel, "id", "can://app/go/y.go/Missing", nil, "") + b.EdgeToSymbol(RelEmbeds, src, SymbolLabel, "id", "can://app/app/go/y.go/Missing", nil, "") rows := b.Finish() if len(rows.Edges) != 0 { t.Errorf("deferred edge to an unemitted target must be dropped; got %d edges", len(rows.Edges)) @@ -56,8 +56,8 @@ func TestRowBuilder_DeferredEdgeDroppedWhenTargetAbsent(t *testing.T) { func TestRowBuilder_DeferredEdgeKeptWhenTargetEmitted(t *testing.T) { b := NewRowBuilder() - src := b.Node([]string{SymbolLabel, LabelType}, "id", "can://app/go/x.go/T", nil, "x.go") - target := "can://app/go/y.go/Base" + src := b.Node([]string{SymbolLabel, LabelType}, "id", "can://app/app/go/x.go/T", nil, "x.go") + target := "can://app/app/go/y.go/Base" b.Node([]string{SymbolLabel, LabelType}, "id", target, nil, "y.go") b.EdgeToSymbol(RelEmbeds, src, SymbolLabel, "id", target, nil, "") rows := b.Finish() @@ -100,15 +100,15 @@ func TestPrune_AllEmptyReturnsNil(t *testing.T) { func TestApplicationPrefix_RefusesNonBareID(t *testing.T) { cases := map[string]bool{ - "can://app": true, // bare — accepted - "can://my-app": true, // - "": false, // empty - "can://": false, // missing app segment - "can://app/go/x.go": false, // too deep (a module id) - "can://app/go/x.go/T": false, // deeper still - "go/app": false, // no scheme - "can://app/": false, // trailing empty segment - "can://app//": false, // empty segments + "can://app/app": true, // bare — accepted + "can://my-app/my-app": true, // + "": false, // empty + "can://": false, // missing app segment + "can://app/app/go/x.go": false, // too deep (a module id) + "can://app/app/go/x.go/T": false, // deeper still + "go/app": false, // no scheme + "can://app/app/": false, // trailing empty segment + "can://app/app//": false, // empty segments } for id, wantOK := range cases { _, ok := applicationPrefix(id) @@ -119,8 +119,8 @@ func TestApplicationPrefix_RefusesNonBareID(t *testing.T) { } func TestDescendantPrefix_AppendsSlash(t *testing.T) { - if got := descendantPrefix("can://app"); got != "can://app/" { - t.Errorf("descendantPrefix = %q, want can://app/", got) + if got := descendantPrefix("can://app/app"); got != "can://app/app/" { + t.Errorf("descendantPrefix = %q, want can://app/app/", got) } } @@ -131,7 +131,7 @@ func TestFinish_IsOrderStable(t *testing.T) { ref := func(b *RowBuilder, id string) NodeRef { return b.Node([]string{SymbolLabel, LabelCallable}, "id", id, nil, "") } - const A, B, C = "can://app/go/a.go/A", "can://app/go/b.go/B", "can://app/go/c.go/C" + const A, B, C = "can://app/app/go/a.go/A", "can://app/app/go/b.go/B", "can://app/app/go/c.go/C" build := func(reverse bool) GraphRows { b := NewRowBuilder() @@ -193,13 +193,40 @@ func edgeKeys(g GraphRows) []string { } func TestApplicationPrefix_AcceptsBareAppOnly(t *testing.T) { - if _, ok := applicationPrefix("can://shop"); !ok { + if _, ok := applicationPrefix("can://shop/shop"); !ok { t.Error("bare can://shop should be accepted") } - if _, ok := applicationPrefix("can://shop/go/a.go"); ok { + if _, ok := applicationPrefix("can://shop/shop/go/a.go"); ok { t.Error("module id should be rejected") } if _, ok := applicationPrefix("can://"); ok { t.Error("can:// should be rejected") } } + +func TestCanonicalDedup(t *testing.T) { + b := NewRowBuilder() + a := b.Node([]string{LabelCallable}, "id", "can://team/app/go/a", Props{"kind": "function"}, "") + b.Node([]string{SymbolLabel}, "id", a.Value, Props{"kind": "function"}, "") + b.Edge("CALLS", a, a, Props{"site": "1"}, "") + b.Edge("CALLS", a, a, Props{"site": "1"}, "") + b.Edge("CALLS", a, a, Props{"site": "2"}, "") + r := b.Finish() + if len(r.Nodes) != 1 || len(r.Edges) != 2 { + t.Fatalf("nodes %d edges %d", len(r.Nodes), len(r.Edges)) + } +} + +func TestCanonicalEdgeFactOrder(t *testing.T) { + build := func(sites []string) GraphRows { + b := NewRowBuilder() + n := b.Node([]string{LabelCallable}, "id", "can://team/app/go/a", nil, "") + for _, site := range sites { + b.Edge(RelCalls, n, n, Props{"site": site}, "") + } + return b.Finish() + } + if !reflect.DeepEqual(build([]string{"a", "b"}), build([]string{"b", "a"})) { + t.Fatal("site order changes canonical graph") + } +} diff --git a/internal/neo4j/schema.go b/internal/neo4j/schema.go index 928e1b4..f9acac0 100644 --- a/internal/neo4j/schema.go +++ b/internal/neo4j/schema.go @@ -185,7 +185,7 @@ type RelSpec struct { var Nodes = []NodeSpec{ { Label: LabelApplication, MergeLabel: LabelApplication, Key: "id", CanNode: true, - Props: []string{"schema_version", "language", "max_level", "analyzer_name", "analyzer_version", "degraded_json"}, + Props: []string{"identity_version", "application_name", "identity_namespace", "schema_version", "language", "max_level", "analyzer_name", "analyzer_version", "degraded_json"}, }, { Label: LabelModule, MergeLabel: LabelModule, Key: "id", CanNode: true, diff --git a/internal/options/options.go b/internal/options/options.go index 01a1af8..13f0af9 100644 --- a/internal/options/options.go +++ b/internal/options/options.go @@ -37,9 +37,11 @@ type AnalysisOptions struct { Format string // Emit selects the output projection: json (default), neo4j, or schema. Emit EmitTarget - // AppName anchors application identity (can:// and the Neo4j + // AppName anchors application identity (can:/// and the Neo4j // :Application node). Defaults to the input directory's base name. AppName string + // Namespace is presence-aware: nil defaults to the resolved application name. + Namespace *string // AnalysisLevel controls symbol-table-only (1), + call graph (2), // + intraprocedural dataflow (3), or + the interprocedural SDG (4). Level AnalysisLevel diff --git a/internal/schema/v2/ids.go b/internal/schema/v2/ids.go index 70e6ac5..e2e0788 100644 --- a/internal/schema/v2/ids.go +++ b/internal/schema/v2/ids.go @@ -1,6 +1,11 @@ package v2 -import "strings" +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" +) // Group A vocabulary: the `can://` durable-id grammar (>= callable) and the // `@line:col` ordinal-id grammar (< callable), plus span construction. These @@ -8,14 +13,14 @@ import "strings" // the deferred L3/L4 train (which must not re-decide id or span shape). // // Durable id grammar (the app segment is outermost so a polyglot app shares one -// `can://` prefix across languages; the language follows it): +// `can:///` prefix across languages; the language follows it): // -// can:////// -// can://myapp/go/src/util.go/Hasher/Hash(string)uint64 +// can://///// +// can://myapp/myapp/go/src/util.go/Hasher/Hash(string)uint64 // // External (out-of-project) symbols are language-neutral: // -// can:///@external// +// can:////@external// // // Ordinal id grammar (statements and synthetic vertices, addressed WITHIN their // callable): @@ -32,10 +37,49 @@ const ( ordinalSep = "@" ) -// AppID builds the application id: can://. The language is not part of it -// (it follows the app in module ids) so sibling analyzers share the prefix. -func AppID(app string) string { - return scheme + app +// Identity holds validated raw names and their canonical root. +type Identity struct { + Namespace string + ApplicationName string + ApplicationID string +} + +// ResolveIdentity validates raw names, defaulting an omitted namespace to appName. +func ResolveIdentity(appName string, namespace *string) (Identity, error) { + ns := appName + if namespace != nil { + ns = *namespace + } + for _, s := range []string{appName, ns} { + if !utf8.ValidString(s) || strings.TrimSpace(s) == "" || s == "." || s == ".." || strings.ContainsAny(s, "/\\") { + return Identity{}, fmt.Errorf("invalid application identity segment %q", s) + } + for _, r := range s { + if unicode.IsControl(r) { + return Identity{}, fmt.Errorf("control character in identity") + } + } + } + return Identity{Namespace: ns, ApplicationName: appName, ApplicationID: AppID(appName, ns)}, nil +} +func encodeSegment(s string) string { + const hex = "0123456789ABCDEF" + var b strings.Builder + for _, c := range []byte(s) { + if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("-._~", rune(c)) { + b.WriteByte(c) + } else { + b.WriteByte('%') + b.WriteByte(hex[c>>4]) + b.WriteByte(hex[c&15]) + } + } + return b.String() +} + +// AppID encodes resolved, validated names into a canonical application root. +func AppID(app, namespace string) string { + return scheme + encodeSegment(namespace) + "/" + encodeSegment(app) } // ModuleID builds a module id from the application id and the module's file diff --git a/internal/schema/v2/ids_test.go b/internal/schema/v2/ids_test.go index 4b23e72..72e4ee3 100644 --- a/internal/schema/v2/ids_test.go +++ b/internal/schema/v2/ids_test.go @@ -3,22 +3,22 @@ package v2 import "testing" func TestAppID(t *testing.T) { - if got, want := AppID("myapp"), "can://myapp"; got != want { + if got, want := AppID("myapp", "myapp"), "can://myapp/myapp"; got != want { t.Errorf("AppID = %q, want %q", got, want) } } func TestDurableIDChain(t *testing.T) { // Reproduces the keystone worked example, segment by segment. - app := AppID("myapp") + app := AppID("myapp", "myapp") mod := ModuleID(app, "src/util.go") typ := TypeID(mod, "Hasher") call := CallableID(typ, "Hash(string)uint64") cases := []struct{ got, want string }{ - {mod, "can://myapp/go/src/util.go"}, - {typ, "can://myapp/go/src/util.go/Hasher"}, - {call, "can://myapp/go/src/util.go/Hasher/Hash(string)uint64"}, + {mod, "can://myapp/myapp/go/src/util.go"}, + {typ, "can://myapp/myapp/go/src/util.go/Hasher"}, + {call, "can://myapp/myapp/go/src/util.go/Hasher/Hash(string)uint64"}, } for _, c := range cases { if c.got != c.want { @@ -28,27 +28,27 @@ func TestDurableIDChain(t *testing.T) { } func TestModuleIDNormalizesPath(t *testing.T) { - app := AppID("myapp") + app := AppID("myapp", "myapp") // Backslashes become forward slashes; a leading slash is trimmed. - if got, want := ModuleID(app, "\\pkg\\a.go"), "can://myapp/go/pkg/a.go"; got != want { + if got, want := ModuleID(app, "\\pkg\\a.go"), "can://myapp/myapp/go/pkg/a.go"; got != want { t.Errorf("ModuleID = %q, want %q", got, want) } - if got, want := ModuleID(app, "/pkg/a.go"), "can://myapp/go/pkg/a.go"; got != want { + if got, want := ModuleID(app, "/pkg/a.go"), "can://myapp/myapp/go/pkg/a.go"; got != want { t.Errorf("ModuleID = %q, want %q", got, want) } } func TestModuleLevelFunctionParent(t *testing.T) { // A module-level function hangs off the module id, not a type. - mod := ModuleID(AppID("myapp"), "src/util.go") + mod := ModuleID(AppID("myapp", "myapp"), "src/util.go") fn := CallableID(mod, "New64()") - if want := "can://myapp/go/src/util.go/New64()"; fn != want { + if want := "can://myapp/myapp/go/src/util.go/New64()"; fn != want { t.Errorf("CallableID = %q, want %q", fn, want) } } func TestOrdinalAndTagIDs(t *testing.T) { - call := "can://myapp/go/src/util.go/Hasher/Hash(string)uint64" + call := "can://myapp/myapp/go/src/util.go/Hasher/Hash(string)uint64" if got, want := OrdinalID(call, 15, 2), call+"@15:2"; got != want { t.Errorf("OrdinalID = %q, want %q", got, want) } @@ -93,11 +93,11 @@ func TestItoa(t *testing.T) { } func TestIDs_AppOutermost(t *testing.T) { - app := AppID("shop") + app := AppID("shop", "shop") cases := []struct{ got, want string }{ - {app, "can://shop"}, - {ModuleID(app, "pkg/a.go"), "can://shop/go/pkg/a.go"}, - {ExternalID(app, "database/sql", "DB.Query"), "can://shop/@external/database/sql/DB.Query"}, + {app, "can://shop/shop"}, + {ModuleID(app, "pkg/a.go"), "can://shop/shop/go/pkg/a.go"}, + {ExternalID(app, "database/sql", "DB.Query"), "can://shop/shop/@external/database/sql/DB.Query"}, {GlobalOrdinal("X/F", "@entry"), "X/F@entry"}, {GlobalOrdinal("X/F", "12:3"), "X/F@12:3"}, } diff --git a/internal/schema/v2/namespace_test.go b/internal/schema/v2/namespace_test.go new file mode 100644 index 0000000..bfcfa7b --- /dev/null +++ b/internal/schema/v2/namespace_test.go @@ -0,0 +1,62 @@ +package v2 + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +func TestResolveIdentityFixtures(t *testing.T) { + b, e := os.ReadFile("testdata/namespaces.json") + if e != nil { + t.Fatal(e) + } + var f struct { + Invalid []struct { + Value string + Reason string + } `json:"invalid"` + Roots []struct { + ApplicationName string `json:"application_name"` + Namespace *string `json:"namespace"` + ExpectedRoot string `json:"expected_root"` + } + } + if e = json.Unmarshal(b, &f); e != nil { + t.Fatal(e) + } + for _, r := range f.Invalid { + if strings.Contains(r.Reason, "surrogate") { + continue + } + if _, err := ResolveIdentity(r.Value, nil); err == nil { + t.Errorf("accepted fixture %q (%s)", r.Value, r.Reason) + } + } + for _, r := range f.Roots { + i, e := ResolveIdentity(r.ApplicationName, r.Namespace) + if e != nil || i.ApplicationID != r.ExpectedRoot { + t.Fatalf("%+v: %+v %v", r, i, e) + } + } +} +func TestNamespaceExplicitEmptyRejected(t *testing.T) { + for _, s := range []string{"", " ", "\u00a0", "a/b", "a\\b", ".", "..", "a\n", string([]byte{255})} { + if _, e := ResolveIdentity("app", &s); e == nil { + t.Errorf("accepted %q", s) + } + if _, e := ResolveIdentity(s, nil); e == nil { + t.Errorf("accepted app %q", s) + } + } + s := "AZaz09-._~!()*'" + i, e := ResolveIdentity(s, nil) + if e != nil || i.ApplicationID != "can://AZaz09-._~%21%28%29%2A%27/AZaz09-._~%21%28%29%2A%27" { + t.Fatalf("%+v %v", i, e) + } + s = "\u00a0x\u00a0" + if _, e = ResolveIdentity(s, nil); e != nil { + t.Fatal(e) + } +} diff --git a/internal/schema/v2/schema.go b/internal/schema/v2/schema.go index 8042056..800c694 100644 --- a/internal/schema/v2/schema.go +++ b/internal/schema/v2/schema.go @@ -27,7 +27,7 @@ import "encoding/json" // SchemaVersion is the canonical schema major this package targets. const SchemaVersion = "2.0.0" -// Language is the analyzer's language tag in the manifest and `can:////…`. +// Language is the analyzer's language tag in the manifest and `can://///…`. const Language = "go" // ─── Positioning ─────────────────────────────────────────────────────────── @@ -51,12 +51,13 @@ type Span struct { // rather than sniffing for keys. k_limit (the field-path depth bound of the // L3 and L4 access paths) is present only at max_level >= 3. type Analysis struct { - SchemaVersion string `json:"schema_version"` - Language string `json:"language"` - MaxLevel int `json:"max_level"` - KLimit int `json:"k_limit,omitempty"` - Analyzer AnalyzerTag `json:"analyzer"` - Application Application `json:"application"` + IdentityVersion int `json:"identity_version"` + SchemaVersion string `json:"schema_version"` + Language string `json:"language"` + MaxLevel int `json:"max_level"` + KLimit int `json:"k_limit,omitempty"` + Analyzer AnalyzerTag `json:"analyzer"` + Application Application `json:"application"` // Degraded records the L4 degradation markers (spec §4.15); absent when // the run degraded nothing and below -a 4. Degraded []Degraded `json:"degraded,omitempty"` @@ -80,7 +81,10 @@ type AnalyzerTag struct { // Application is the root node of the containment tree. type Application struct { - // ID is `can://` — the app segment is outermost, so a polyglot + IdentityVersion int `json:"identity_version"` + ApplicationName string `json:"application_name"` + IdentityNamespace string `json:"identity_namespace"` + // ID is `can:///` — the app segment is outermost, so a polyglot // app shares one prefix across languages. is the --app-name value (default: input dir base name). ID string `json:"id"` Kind string `json:"kind"` // always "application" @@ -90,7 +94,7 @@ type Application struct { // at L1. CallGraph []Edge `json:"call_graph"` // ExternalSymbols holds the out-of-project (library) callables the call graph - // reaches, keyed by their `can:///@external//` id. + // reaches, keyed by their `can:////@external//` id. // Absent when the analysis saw no library call. ExternalSymbols map[string]ExternalSymbol `json:"external_symbols,omitempty"` // ParamIn and ParamOut are the L4 interprocedural parameter edges between diff --git a/internal/syntactic_analysis/v2emit/determinism_freshbuild_test.go b/internal/syntactic_analysis/v2emit/determinism_freshbuild_test.go index f89fb6a..b4b7d6a 100644 --- a/internal/syntactic_analysis/v2emit/determinism_freshbuild_test.go +++ b/internal/syntactic_analysis/v2emit/determinism_freshbuild_test.go @@ -64,7 +64,7 @@ func buildChiL2Fresh(t *testing.T, dir string) *v2.Analysis { cg := semantic_analysis.NewCallGraphBuilder(dir, b.Fset(), b.Pkgs()) edges := cg.Build(st) app := &schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()} - return Emit(app, "chi", dir, 2, "gate-test", 0, nil, nil) + return Emit(app, testIdentity("chi"), dir, 2, "gate-test", 0, nil, nil) } // TestDeterminism_FreshBuildsByteIdentical locks that independent from-disk diff --git a/internal/syntactic_analysis/v2emit/determinism_test.go b/internal/syntactic_analysis/v2emit/determinism_test.go index e87e9cf..b6d2313 100644 --- a/internal/syntactic_analysis/v2emit/determinism_test.go +++ b/internal/syntactic_analysis/v2emit/determinism_test.go @@ -46,7 +46,7 @@ func TestDeterminism_CallGraphStableAcrossEmits(t *testing.T) { // edges slice without a stable sort, repeated marshals still match (same // slice), so to actually exercise order-independence we marshal a shuffled // copy of the edge slice too. - first := mustMarshalCallGraph(t, Emit(app, "multipackage", dir, 2, "det-test", 0, nil, nil)) + first := mustMarshalCallGraph(t, Emit(app, testIdentity("multipackage"), dir, 2, "det-test", 0, nil, nil)) // Reverse the input edge order and re-emit: a correct emitter sorts, so the // serialized call_graph must be unchanged. @@ -55,7 +55,7 @@ func TestDeterminism_CallGraphStableAcrossEmits(t *testing.T) { rev[len(edges)-1-i] = e } appRev := &schema.GoApplication{SymbolTable: st, CallGraph: rev} - second := mustMarshalCallGraph(t, Emit(appRev, "multipackage", dir, 2, "det-test", 0, nil, nil)) + second := mustMarshalCallGraph(t, Emit(appRev, testIdentity("multipackage"), dir, 2, "det-test", 0, nil, nil)) if first != second { t.Errorf("call_graph not order-independent: emitCallGraph must sort edges to a total order\nfirst=%s\nsecond=%s", first, second) @@ -100,7 +100,7 @@ func TestEncode_ByteIdenticalToMarshal(t *testing.T) { apps := map[int]*schema.GoApplication{1: {SymbolTable: st, CallGraph: []schema.GoCallEdge{}}} check := func(level int, app *schema.GoApplication, l3 map[dataflow.L3Key]dataflow.Result) { t.Run(fmt.Sprintf("%s/a%d", name, level), func(t *testing.T) { - tree := Emit(app, name, dir, level, "enc-test", 3, l3, nil) + tree := Emit(app, testIdentity(name), dir, level, "enc-test", 3, l3, nil) want, err := json.Marshal(tree) if err != nil { t.Fatal(err) @@ -113,7 +113,7 @@ func TestEncode_ByteIdenticalToMarshal(t *testing.T) { t.Errorf("Encode differs from json.Marshal at byte %d (len %d vs %d)", firstDiff(enc.Bytes(), want), enc.Len(), len(want)) } var lazy bytes.Buffer - if err := EmitTo(&lazy, app, name, dir, level, "enc-test", 3, l3, nil); err != nil { + if err := EmitTo(&lazy, app, testIdentity(name), dir, level, "enc-test", 3, l3, nil); err != nil { t.Fatal(err) } if !bytes.Equal(lazy.Bytes(), want) { @@ -149,7 +149,7 @@ func TestEncode_L4EnvelopeFieldsByteIdentical(t *testing.T) { Analyzer: v2.AnalyzerTag{Name: "codeanalyzer-go", Version: "t"}, Degraded: []v2.Degraded{{Code: "ssa_build", Detail: `has "symbol_table":{} & `, Packages: []string{"p"}, Callables: []string{"c"}}}, Application: v2.Application{ - ID: "can://app", Kind: "application", + ID: "can://app/app", Kind: "application", SymbolTable: map[string]v2.Module{ "a.go": {ID: "m", Functions: map[string]v2.Callable{"f": { ID: "f", Summary: []v2.SummaryEdge{{Src: "a", Dst: "b"}}, diff --git a/internal/syntactic_analysis/v2emit/emit.go b/internal/syntactic_analysis/v2emit/emit.go index 8ff0b1e..3f0279e 100644 --- a/internal/syntactic_analysis/v2emit/emit.go +++ b/internal/syntactic_analysis/v2emit/emit.go @@ -19,7 +19,7 @@ import ( "github.com/codellm-devkit/codeanalyzer-go/internal/sdg" ) -// Emit transforms the v1 GoApplication into the v2 Analysis payload. appName is +// Emit transforms the v1 GoApplication into the v2 Analysis payload. identity is // the application anchor (--app-name); projectDir is the absolute input root, // used to read each module's source for span slicing. maxLevel records how // deeply the tree was populated (1 = symbol table, 2 = + call_graph, 3 = + @@ -34,8 +34,8 @@ import ( // This is a pure re-serialization: every fact comes from the v1 model and the // L3/L4 side tables, except span byte offsets, which are derived from source // via lineIndex. -func Emit(app *schema.GoApplication, appName, projectDir string, maxLevel int, analyzerVersion string, kLimit int, l3 map[dataflow.L3Key]dataflow.Result, l4 *sdg.Result) *v2.Analysis { - e := newEmitter(app, appName, projectDir, maxLevel, l3, l4) +func Emit(app *schema.GoApplication, identity v2.Identity, projectDir string, maxLevel int, analyzerVersion string, kLimit int, l3 map[dataflow.L3Key]dataflow.Result, l4 *sdg.Result) *v2.Analysis { + e := newEmitter(app, identity, projectDir, maxLevel, l3, l4) out := e.envelope(app, maxLevel, analyzerVersion, kLimit) out.Application.SymbolTable = make(map[string]v2.Module, len(app.SymbolTable)) for _, relPath := range sortedFileKeys(app.SymbolTable) { @@ -54,8 +54,8 @@ func Emit(app *schema.GoApplication, appName, projectDir string, maxLevel int, a // memory is the envelope plus the largest module, not the document. The L4 // param edges follow the modules (they need every callable's id) and are // minted and written one at a time. -func EmitTo(w io.Writer, app *schema.GoApplication, appName, projectDir string, maxLevel int, analyzerVersion string, kLimit int, l3 map[dataflow.L3Key]dataflow.Result, l4 *sdg.Result) error { - e := newEmitter(app, appName, projectDir, maxLevel, l3, l4) +func EmitTo(w io.Writer, app *schema.GoApplication, identity v2.Identity, projectDir string, maxLevel int, analyzerVersion string, kLimit int, l3 map[dataflow.L3Key]dataflow.Result, l4 *sdg.Result) error { + e := newEmitter(app, identity, projectDir, maxLevel, l3, l4) out := e.envelope(app, maxLevel, analyzerVersion, kLimit) return encode(w, out, func(yield func(string, v2.Module) bool) { for _, relPath := range sortedFileKeys(app.SymbolTable) { @@ -221,19 +221,20 @@ func writeEntry(w io.Writer, key string, m v2.Module, first bool) error { // Schema decisions → callable.source_file); the cache lets emitCallable reach // any file's index without re-reading it per callable. type emitter struct { - appID string - idx sigIndex - srcs *sourceCache - flow l3Overlay - sdg *l4Overlay // nil below L4 + identity v2.Identity + appID string + idx sigIndex + srcs *sourceCache + flow l3Overlay + sdg *l4Overlay // nil below L4 } -func newEmitter(app *schema.GoApplication, appName, projectDir string, maxLevel int, l3 map[dataflow.L3Key]dataflow.Result, l4 *sdg.Result) *emitter { - appID := v2.AppID(appName) +func newEmitter(app *schema.GoApplication, identity v2.Identity, projectDir string, maxLevel int, l3 map[dataflow.L3Key]dataflow.Result, l4 *sdg.Result) *emitter { + appID := identity.ApplicationID idx := buildSigIndex(appID, app.SymbolTable) idx.externals = externalIDs(appID, app.Externals) return &emitter{ - appID: appID, idx: idx, srcs: newSourceCache(projectDir), + identity: identity, appID: appID, idx: idx, srcs: newSourceCache(projectDir), flow: newL3Overlay(maxLevel, l3), sdg: newL4Overlay(maxLevel, l4, idx), } } @@ -242,12 +243,12 @@ func newEmitter(app *schema.GoApplication, appName, projectDir string, maxLevel // call graph and the external symbols. func (e *emitter) envelope(app *schema.GoApplication, maxLevel int, analyzerVersion string, kLimit int) *v2.Analysis { out := &v2.Analysis{ - SchemaVersion: v2.SchemaVersion, - Language: v2.Language, - MaxLevel: maxLevel, - Analyzer: v2.AnalyzerTag{Name: "codeanalyzer-go", Version: analyzerVersion}, + IdentityVersion: 2, SchemaVersion: v2.SchemaVersion, + Language: v2.Language, + MaxLevel: maxLevel, + Analyzer: v2.AnalyzerTag{Name: "codeanalyzer-go", Version: analyzerVersion}, Application: v2.Application{ - ID: e.appID, + IdentityVersion: 2, ApplicationName: e.identity.ApplicationName, IdentityNamespace: e.identity.Namespace, ID: e.appID, Kind: "application", CallGraph: emitCallGraph(app.CallGraph, e.idx), }, diff --git a/internal/syntactic_analysis/v2emit/emit_l4_test.go b/internal/syntactic_analysis/v2emit/emit_l4_test.go index cdf0d91..445ac9b 100644 --- a/internal/syntactic_analysis/v2emit/emit_l4_test.go +++ b/internal/syntactic_analysis/v2emit/emit_l4_test.go @@ -40,7 +40,7 @@ func TestCompareOrdinal_MatchesMintedIds(t *testing.T) { // the clash is warned once, naming both ids. func TestL4Overlay_DuplicateKeyKeepsSmallerID(t *testing.T) { key := dataflow.L3Key{Path: "a.go", Signature: "example.com/a.F"} - small, big := "can://app/go/a.go/example.com/a.F", "can://app/go/b.go/example.com/a.F" + small, big := "can://app/app/go/a.go/example.com/a.F", "can://app/app/go/b.go/example.com/a.F" var warned []string prev := warnf warnf = func(format string, args ...any) { warned = append(warned, fmt.Sprintf(format, args...)) } diff --git a/internal/syntactic_analysis/v2emit/emit_test.go b/internal/syntactic_analysis/v2emit/emit_test.go index 87fe4e2..9bc1dc3 100644 --- a/internal/syntactic_analysis/v2emit/emit_test.go +++ b/internal/syntactic_analysis/v2emit/emit_test.go @@ -37,7 +37,7 @@ func buildGreeter(t *testing.T) (*schema.GoApplication, string) { func TestEmit_Envelope(t *testing.T) { app, dir := buildGreeter(t) - out := Emit(app, "greeter", dir, 1, "test", 0, nil, nil) + out := Emit(app, testIdentity("greeter"), dir, 1, "test", 0, nil, nil) if out.SchemaVersion != "2.0.0" { t.Errorf("schema_version = %q, want 2.0.0", out.SchemaVersion) @@ -48,8 +48,8 @@ func TestEmit_Envelope(t *testing.T) { if out.MaxLevel != 1 { t.Errorf("max_level = %d, want 1", out.MaxLevel) } - if out.Application.ID != "can://greeter" { - t.Errorf("application.id = %q, want can://greeter", out.Application.ID) + if out.Application.ID != "can://greeter/greeter" { + t.Errorf("application.id = %q, want can://greeter/greeter", out.Application.ID) } if out.Application.Kind != "application" { t.Errorf("application.kind = %q, want application", out.Application.Kind) @@ -61,7 +61,7 @@ func TestEmit_Envelope(t *testing.T) { func TestEmit_ModuleSourceAndIDs(t *testing.T) { app, dir := buildGreeter(t) - out := Emit(app, "greeter", dir, 1, "test", 0, nil, nil) + out := Emit(app, testIdentity("greeter"), dir, 1, "test", 0, nil, nil) mod, ok := out.Application.SymbolTable["main.go"] if !ok { @@ -70,8 +70,8 @@ func TestEmit_ModuleSourceAndIDs(t *testing.T) { if mod.Kind != "module" { t.Errorf("module.kind = %q, want module", mod.Kind) } - if mod.ID != "can://greeter/go/main.go" { - t.Errorf("module.id = %q, want can://greeter/go/main.go", mod.ID) + if mod.ID != "can://greeter/greeter/go/main.go" { + t.Errorf("module.id = %q, want can://greeter/greeter/go/main.go", mod.ID) } if !strings.HasPrefix(mod.Source, "package main") { t.Errorf("module.source should hold the file text; got prefix %q", head(mod.Source, 20)) @@ -84,7 +84,7 @@ func TestEmit_ModuleSourceAndIDs(t *testing.T) { func TestEmit_CallableBodyAndSpanSlice(t *testing.T) { app, dir := buildGreeter(t) - out := Emit(app, "greeter", dir, 1, "test", 0, nil, nil) + out := Emit(app, testIdentity("greeter"), dir, 1, "test", 0, nil, nil) mod := out.Application.SymbolTable["main.go"] var main v2.Callable @@ -123,7 +123,7 @@ func TestEmit_CallableBodyAndSpanSlice(t *testing.T) { func TestEmit_CalleeSerializesAsNull(t *testing.T) { app, dir := buildGreeter(t) - out := Emit(app, "greeter", dir, 1, "test", 0, nil, nil) + out := Emit(app, testIdentity("greeter"), dir, 1, "test", 0, nil, nil) data, err := json.Marshal(out) if err != nil { t.Fatalf("marshal failed: %v", err) @@ -144,7 +144,7 @@ func TestEmit_CalleeSerializesAsNull(t *testing.T) { func TestEmit_TypeKindAndMethods(t *testing.T) { app, dir := buildGreeter(t) - out := Emit(app, "greeter", dir, 1, "test", 0, nil, nil) + out := Emit(app, testIdentity("greeter"), dir, 1, "test", 0, nil, nil) // Find the Greeter type wherever it lives; assert struct kind + methods. var found bool @@ -183,3 +183,11 @@ func keys[V any](m map[string]V) []string { } return out } + +func testIdentity(name string) v2.Identity { + i, e := v2.ResolveIdentity(name, nil) + if e != nil { + panic(e) + } + return i +} diff --git a/internal/syntactic_analysis/v2emit/gate_l2_inits_test.go b/internal/syntactic_analysis/v2emit/gate_l2_inits_test.go index 287cd81..3953e2a 100644 --- a/internal/syntactic_analysis/v2emit/gate_l2_inits_test.go +++ b/internal/syntactic_analysis/v2emit/gate_l2_inits_test.go @@ -37,7 +37,7 @@ func buildDataflowL2Fresh(t *testing.T) *v2.Analysis { cg := semantic_analysis.NewCallGraphBuilder(dir, b.Fset(), b.Pkgs()) edges := cg.Build(st) app := &schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()} - return Emit(app, "dataflow", dir, 2, "gate-test", 0, nil, nil) + return Emit(app, testIdentity("dataflow"), dir, 2, "gate-test", 0, nil, nil) } // TestGateL2_EveryInitKeepsItsEdges: each file's init has its own edge to the diff --git a/internal/syntactic_analysis/v2emit/gate_l2_test.go b/internal/syntactic_analysis/v2emit/gate_l2_test.go index 347d3db..d402ed6 100644 --- a/internal/syntactic_analysis/v2emit/gate_l2_test.go +++ b/internal/syntactic_analysis/v2emit/gate_l2_test.go @@ -42,7 +42,7 @@ func buildMultipackageL2(t *testing.T) *v2.Analysis { cg := semantic_analysis.NewCallGraphBuilder(dir, b.Fset(), b.Pkgs()) edges := cg.Build(st) // mutates st in place: backfills callee_signature app := &schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()} - return Emit(app, "multipackage", dir, 2, "gate-test", 0, nil, nil) + return Emit(app, testIdentity("multipackage"), dir, 2, "gate-test", 0, nil, nil) } // allCallableIDs collects every callable/type id in the emitted tree. @@ -110,8 +110,8 @@ func TestGateL2_NamedEdgePresent(t *testing.T) { out := buildMultipackageL2(t) const ( - src = "can://multipackage/go/worker/worker.go/example.com/multipackage/worker.Worker/example.com/multipackage/worker.Worker.Run" - dst = "can://multipackage/go/worker/worker.go/example.com/multipackage/worker.Worker/example.com/multipackage/worker.Worker.execute" + src = "can://multipackage/multipackage/go/worker/worker.go/example.com/multipackage/worker.Worker/example.com/multipackage/worker.Worker.Run" + dst = "can://multipackage/multipackage/go/worker/worker.go/example.com/multipackage/worker.Worker/example.com/multipackage/worker.Worker.execute" ) found := false for _, e := range out.Application.CallGraph { @@ -133,8 +133,8 @@ func TestGateL2_CrossPackageEdge(t *testing.T) { out := buildMultipackageL2(t) const ( - src = "can://multipackage/go/main.go/example.com/multipackage.main" - dst = "can://multipackage/go/server/server.go/example.com/multipackage/server.New" + src = "can://multipackage/multipackage/go/main.go/example.com/multipackage.main" + dst = "can://multipackage/multipackage/go/server/server.go/example.com/multipackage/server.New" ) found := false for _, e := range out.Application.CallGraph { @@ -189,12 +189,12 @@ func TestGateL2_SupersetOfL1(t *testing.T) { t.Fatalf("symbol table build failed: %v", err) } // L1 snapshot BEFORE the resolver mutates call sites. - l1 := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: nil}, "multipackage", dir, 1, "gate-test", 0, nil, nil) + l1 := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: nil}, testIdentity("multipackage"), dir, 1, "gate-test", 0, nil, nil) // Now run the resolver (mutates st) and emit L2. cg := semantic_analysis.NewCallGraphBuilder(dir, b.Fset(), b.Pkgs()) edges := cg.Build(st) - l2 := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: edges}, "multipackage", dir, 2, "gate-test", 0, nil, nil) + l2 := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: edges}, testIdentity("multipackage"), dir, 2, "gate-test", 0, nil, nil) if len(l1.Application.SymbolTable) != len(l2.Application.SymbolTable) { t.Fatalf("module count changed L1->L2: %d vs %d", @@ -410,7 +410,7 @@ func emitTemp(t *testing.T, src string) *v2.Analysis { cg := semantic_analysis.NewCallGraphBuilder(dir, b.Fset(), b.Pkgs()) edges := cg.Build(st) app := &schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()} - return Emit(app, "p", dir, 2, "gate-test", 0, nil, nil) + return Emit(app, testIdentity("p"), dir, 2, "gate-test", 0, nil, nil) } // TestGateL2_ExternalParamsHaveNoSpan locks that external parameters serialize @@ -442,7 +442,7 @@ func TestGateL2_GenericLibraryMethodUsesDeclaredSignature(t *testing.T) { } cg := semantic_analysis.NewCallGraphBuilder(dir, b.Fset(), b.Pkgs()) edges := cg.Build(st) - out := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()}, "dataflow", dir, 2, "gate-test", 0, nil, nil) + out := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()}, testIdentity("dataflow"), dir, 2, "gate-test", 0, nil, nil) ext := v2.ExternalID(out.Application.ID, "sync/atomic", "Pointer.Store") sym, ok := out.Application.ExternalSymbols[ext] if !ok { @@ -466,7 +466,7 @@ func TestGateL2_IncrementalModeKeepsProjectCalleesInternal(t *testing.T) { } cg := semantic_analysis.NewCallGraphBuilder(dir, b.Fset(), b.Pkgs()) edges := cg.Build(st) - out := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()}, "multipackage", dir, 2, "gate-test", 0, nil, nil) + out := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()}, testIdentity("multipackage"), dir, 2, "gate-test", 0, nil, nil) const projectModule = "example.com/multipackage" for id, sym := range out.Application.ExternalSymbols { @@ -492,7 +492,7 @@ func TestGateL2_VendoredCalleeIsExternal(t *testing.T) { } cg := semantic_analysis.NewCallGraphBuilder(dir, b.Fset(), b.Pkgs()) edges := cg.Build(st) - out := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()}, "dataflow", dir, 2, "gate-test", 0, nil, nil) + out := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()}, testIdentity("dataflow"), dir, 2, "gate-test", 0, nil, nil) ext := v2.ExternalID(out.Application.ID, "example.com/vlib", "F") sym, ok := out.Application.ExternalSymbols[ext] @@ -564,7 +564,7 @@ func TestGateL2_ExternalUnnamedParamsArePositional(t *testing.T) { } cg := semantic_analysis.NewCallGraphBuilder(dir, b.Fset(), b.Pkgs()) edges := cg.Build(st) - out := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()}, "dataflow", dir, 2, "gate-test", 0, nil, nil) + out := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()}, testIdentity("dataflow"), dir, 2, "gate-test", 0, nil, nil) for sig, want := range map[string][]string{"G": {"_0", "_1"}, "H": {"_0", "n"}} { ext := v2.ExternalID(out.Application.ID, "example.com/vlib", sig) @@ -593,7 +593,7 @@ func emitDirL2(t *testing.T, dir, app string) *v2.Analysis { } cg := semantic_analysis.NewCallGraphBuilder(dir, b.Fset(), b.Pkgs()) edges := cg.Build(st) - return Emit(&schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()}, app, dir, 2, "gate-test", 0, nil, nil) + return Emit(&schema.GoApplication{SymbolTable: st, CallGraph: edges, Externals: cg.Externals()}, testIdentity(app), dir, 2, "gate-test", 0, nil, nil) } // assertNoDanglingEdges fails when an edge endpoint is neither a callable id diff --git a/internal/syntactic_analysis/v2emit/gate_l3_test.go b/internal/syntactic_analysis/v2emit/gate_l3_test.go index eaf31f6..a9f17fc 100644 --- a/internal/syntactic_analysis/v2emit/gate_l3_test.go +++ b/internal/syntactic_analysis/v2emit/gate_l3_test.go @@ -69,8 +69,8 @@ func buildL3(t *testing.T, name string) *l3Fixture { res := dataflow.Run(b.Fset(), b.L3Units(st), 3, 4) fx := &l3Fixture{ st: st, - l2: Emit(app, name, dir, 2, "gate-test", 3, nil, nil), - l3: Emit(app, name, dir, 3, "gate-test", 3, res, nil), + l2: Emit(app, testIdentity(name), dir, 2, "gate-test", 3, nil, nil), + l3: Emit(app, testIdentity(name), dir, 3, "gate-test", 3, res, nil), res: res, } l3Cache[name] = fx diff --git a/internal/syntactic_analysis/v2emit/gate_l4_done_test.go b/internal/syntactic_analysis/v2emit/gate_l4_done_test.go index d3b4319..6803719 100644 --- a/internal/syntactic_analysis/v2emit/gate_l4_done_test.go +++ b/internal/syntactic_analysis/v2emit/gate_l4_done_test.go @@ -120,8 +120,8 @@ func TestGateL4_HTTPToSQLPath(t *testing.T) { t.Fatalf("httpsql degraded: %+v", a.Degraded) } app := a.Application.ID - if app != "can://httpsql" { - t.Fatalf("application id %s, want can://httpsql", app) + if app != "can://httpsql/httpsql" { + t.Fatalf("application id %s, want can://httpsql/httpsql", app) } idx := bodyIndex(a) vertex := func(owner, kind, of string) string { @@ -144,7 +144,7 @@ func TestGateL4_HTTPToSQLPath(t *testing.T) { method := func(typ, m string) string { return v2.CallableID(v2.TypeID(mod, pkg+typ), pkg+typ+"."+m) } sink := vertex(v2.ExternalID(app, "database/sql", "DB.Query"), "formal_in", "query") - if sink != "can://httpsql/@external/database/sql/DB.Query@formal_in:1" { + if sink != "can://httpsql/httpsql/@external/database/sql/DB.Query@formal_in:1" { t.Fatalf("sink vertex %s", sink) } find := method("sqlRepo", "Find") @@ -232,7 +232,7 @@ func buildHTTPSQLWithModels(t *testing.T, models sdg.Models) *v2.Analysis { Units: units, L3: res, K: 3, Jobs: 4, Models: models, }) l4.ApplyTo(app) - return Emit(app, "httpsql", dir, 4, "gate-test", 3, res, &l4) + return Emit(app, testIdentity("httpsql"), dir, 4, "gate-test", 3, res, &l4) } // TestGateL4_HTTPToSQLSanitizerIsSDKSemantics pins what L4 does — and diff --git a/internal/syntactic_analysis/v2emit/gate_l4_test.go b/internal/syntactic_analysis/v2emit/gate_l4_test.go index 42b4891..582b2bc 100644 --- a/internal/syntactic_analysis/v2emit/gate_l4_test.go +++ b/internal/syntactic_analysis/v2emit/gate_l4_test.go @@ -43,11 +43,16 @@ var ( // buildL4 runs the -a 4 pipeline over testdata/ once and emits both the // L3 and the L4 payload from the same resolved symbol table. The app name is // name's last element (cango's default: the input directory's base name). -func buildL4(t *testing.T, name string) *l4Fixture { +func buildL4(t *testing.T, name string) *l4Fixture { return buildL4Identity(t, name, nil) } +func buildL4Identity(t *testing.T, name string, identity *v2.Identity) *l4Fixture { t.Helper() l4Mu.Lock() defer l4Mu.Unlock() - if fx, ok := l4Cache[name]; ok { + key := name + if identity != nil { + key += "|" + identity.ApplicationID + } + if fx, ok := l4Cache[key]; ok { return fx } _, thisFile, _, _ := runtime.Caller(0) @@ -66,7 +71,11 @@ func buildL4(t *testing.T, name string) *l4Fixture { units := b.L3Units(st) res := dataflow.RunWithSideTables(b.Fset(), units, 3, 4) appName := filepath.Base(dir) - fx := &l4Fixture{st: st, l3: Emit(app, appName, dir, 3, "gate-test", 3, res, nil), res: res, dir: dir} + resolved := testIdentity(appName) + if identity != nil { + resolved = *identity + } + fx := &l4Fixture{st: st, l3: Emit(app, resolved, dir, 3, "gate-test", 3, res, nil), res: res, dir: dir} l4 := sdg.Run(sdg.Input{ Fset: b.Fset(), Pkgs: b.Pkgs(), ProjectDir: dir, SymbolTable: st, Externals: app.Externals, CallGraph: app.CallGraph, @@ -74,8 +83,8 @@ func buildL4(t *testing.T, name string) *l4Fixture { }) l4.ApplyTo(app) fx.sdg = &l4 - fx.l4 = Emit(app, appName, dir, 4, "gate-test", 3, res, &l4) - l4Cache[name] = fx + fx.l4 = Emit(app, resolved, dir, 4, "gate-test", 3, res, &l4) + l4Cache[key] = fx return fx } @@ -789,10 +798,10 @@ func buildL4Jobs(t *testing.T, dir, name string, jobs int) []byte { l4.ApplyTo(app) dataflow.ReleaseAll(res) var buf bytes.Buffer - if err := EmitTo(&buf, app, name, dir, 4, "det", 3, res, &l4); err != nil { + if err := EmitTo(&buf, app, testIdentity(name), dir, 4, "det", 3, res, &l4); err != nil { t.Fatal(err) } - want, err := json.Marshal(Emit(app, name, dir, 4, "det", 3, res, &l4)) + want, err := json.Marshal(Emit(app, testIdentity(name), dir, 4, "det", 3, res, &l4)) if err != nil { t.Fatal(err) } @@ -846,7 +855,7 @@ func TestEmitL4_UnresolvedRefIsDroppedAndWarned(t *testing.T) { warnf = func(format string, args ...any) { warned = append(warned, fmt.Sprintf(format, args...)) } defer func() { warnf = prev }() app := &schema.GoApplication{SymbolTable: fx.st, Externals: map[string]schema.GoExternalCallee{}} - out := Emit(app, "dataflow", fx.dir, 4, "t", 3, fx.res, &res) + out := Emit(app, testIdentity("dataflow"), fx.dir, 4, "t", 3, fx.res, &res) for _, e := range out.Application.ParamIn { if strings.Contains(e.Src, "nowhere") || strings.Contains(e.Dst, "nowhere") { t.Errorf("dangling param_in emitted: %+v", e) @@ -920,3 +929,49 @@ func TestSupersetL4_DetectsEveryViolation(t *testing.T) { } } } + +func TestNamespaceNestedReferences(t *testing.T) { + ns := "team space" + identity, err := v2.ResolveIdentity("billing", &ns) + if err != nil { + t.Fatal(err) + } + a := buildL4Identity(t, "dataflow/httpsql", &identity).l4 + b, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } + var raw any + if err = json.Unmarshal(b, &raw); err != nil { + t.Fatal(err) + } + count := 0 + var walk func(any) + check := func(s string) { + if strings.HasPrefix(s, "can://") { + count++ + if s != identity.ApplicationID && !strings.HasPrefix(s, identity.ApplicationID+"/") { + t.Errorf("foreign root: %s", s) + } + } + } + walk = func(v any) { + switch x := v.(type) { + case string: + check(x) + case map[string]any: + for k, v := range x { + check(k) + walk(v) + } + case []any: + for _, v := range x { + walk(v) + } + } + } + walk(raw) + if count < 20 || len(a.Application.ExternalSymbols) == 0 || len(a.Application.ParamIn) == 0 { + t.Fatalf("missing nested coverage: ids=%d externals=%d params=%d", count, len(a.Application.ExternalSymbols), len(a.Application.ParamIn)) + } +} diff --git a/internal/syntactic_analysis/v2emit/gate_test.go b/internal/syntactic_analysis/v2emit/gate_test.go index 4679a60..41822be 100644 --- a/internal/syntactic_analysis/v2emit/gate_test.go +++ b/internal/syntactic_analysis/v2emit/gate_test.go @@ -58,7 +58,7 @@ func buildMultipackageV2(t *testing.T) *v2.Analysis { t.Fatalf("symbol table build failed: %v", err) } app := &schema.GoApplication{SymbolTable: st, CallGraph: []schema.GoCallEdge{}} - return Emit(app, "multipackage", dir, 1, "gate-test", 0, nil, nil) + return Emit(app, testIdentity("multipackage"), dir, 1, "gate-test", 0, nil, nil) } // mustModule fetches a module by relative path or fails. @@ -87,7 +87,7 @@ func TestGateL1_ModuleSet(t *testing.T) { if mod.Kind != "module" { t.Errorf("%s: kind = %q, want module", rel, mod.Kind) } - wantID := "can://multipackage/go/" + rel + wantID := "can://multipackage/multipackage/go/" + rel if mod.ID != wantID { t.Errorf("%s: id = %q, want %q", rel, mod.ID, wantID) } @@ -301,8 +301,8 @@ func TestGateL1_Envelope(t *testing.T) { if out.MaxLevel != 1 { t.Errorf("max_level = %d, want 1", out.MaxLevel) } - if out.Application.ID != "can://multipackage" { - t.Errorf("application.id = %q, want can://multipackage", out.Application.ID) + if out.Application.ID != "can://multipackage/multipackage" { + t.Errorf("application.id = %q, want can://multipackage/multipackage", out.Application.ID) } // L1: no call_graph edges yet. if len(out.Application.CallGraph) != 0 { @@ -322,7 +322,7 @@ func TestGateL1_SupersetOfV1(t *testing.T) { t.Fatalf("symbol table build failed: %v", err) } app := &schema.GoApplication{SymbolTable: st, CallGraph: []schema.GoCallEdge{}} - out := Emit(app, "multipackage", dir, 1, "gate-test", 0, nil, nil) + out := Emit(app, testIdentity("multipackage"), dir, 1, "gate-test", 0, nil, nil) // Every v1 file, type, method and function reappears in v2. for rel, v1file := range st { @@ -387,7 +387,7 @@ func buildDataflowL1V2(t *testing.T) *v2.Analysis { t.Fatalf("symbol table build failed: %v", err) } app := &schema.GoApplication{SymbolTable: st, CallGraph: []schema.GoCallEdge{}} - return Emit(app, "dataflow", dir, 1, "gate-test", 0, nil, nil) + return Emit(app, testIdentity("dataflow"), dir, 1, "gate-test", 0, nil, nil) } func TestGateL1_TypeKindsAliasAndDefined(t *testing.T) { @@ -478,7 +478,7 @@ func buildDataflowL2V2(t *testing.T) *v2.Analysis { cg := semantic_analysis.NewCallGraphBuilder(dir, b.Fset(), b.Pkgs()) edges := cg.Build(st) app := &schema.GoApplication{SymbolTable: st, CallGraph: edges} - return Emit(app, "dataflow", dir, 2, "gate-test", 0, nil, nil) + return Emit(app, testIdentity("dataflow"), dir, 2, "gate-test", 0, nil, nil) } // TestGateL2_CoPositionedCallsResolveSeparately: in s.A().B() the outer call @@ -599,7 +599,7 @@ func TestGateL2_GoStatementArgumentCalls(t *testing.T) { if w.callee == "" { continue } - want := "can://dataflow/go/flow/flow.go/" + pkg + w.callee + want := "can://dataflow/dataflow/go/flow/flow.go/" + pkg + w.callee if n.Callee == nil || *n.Callee != want { t.Errorf("GoArgs %s callee = %v, want %s", w.key, n.Callee, want) } @@ -638,7 +638,7 @@ func TestGate_LineDirectiveSpansSliceToText(t *testing.T) { if err != nil { t.Fatalf("symbol table build failed: %v", err) } - out := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: []schema.GoCallEdge{}}, "dataflow", dir, 1, "gate-test", 0, nil, nil) + out := Emit(&schema.GoApplication{SymbolTable: st, CallGraph: []schema.GoCallEdge{}}, testIdentity("dataflow"), dir, 1, "gate-test", 0, nil, nil) const rel = "linedir/linedir.go" mod := mustModule(t, out, rel) calls := 0 From f62c3ec5df679858add19694c3c37d88c8037e30 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 30 Oct 2026 15:45:32 -0400 Subject: [PATCH 3/5] fix: validate output scope and preserve graph edge facts --- README.md | 6 +++ internal/core/analyzer.go | 3 ++ internal/core/analyzer_test.go | 37 ++++++++++++++++ internal/neo4j/bolt.go | 6 +-- internal/neo4j/bolt_session_test.go | 65 +++++++++++++++++++++++++++++ internal/neo4j/cypher.go | 12 +++--- internal/neo4j/ddl.go | 42 ++++++++++--------- internal/neo4j/rows.go | 9 ++++ internal/neo4j/schema.go | 11 +++-- internal/neo4j/schema_test.go | 7 ++++ 10 files changed, 166 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 8481ee6..0d61c82 100644 --- a/README.md +++ b/README.md @@ -794,3 +794,9 @@ whitespace-only, control-containing names, slashes, backslashes, `.` and `..` ar rejected. Explicit empty flags are errors. Outputs and graph anchors carry integer `identity_version: 2`; regenerate legacy outputs from source. The graph writer rejects incompatible selected anchors before any DDL or graph write. + +Graph relationships retain existing `_k` values and additionally use private +`_fact` identity (declared as `relationship_fact_property` by `--emit schema`). +Both snapshot and Bolt writers derive it from SHA-256 of the canonical JSON of +the existing pruned relationship properties. Equal endpoint/type/key facts with +different site, context, or evidence properties therefore remain distinct. diff --git a/internal/core/analyzer.go b/internal/core/analyzer.go index 9618e27..a82ac2c 100644 --- a/internal/core/analyzer.go +++ b/internal/core/analyzer.go @@ -343,6 +343,9 @@ func StreamJSON(w io.Writer, app *schema.GoApplication, l3 map[dataflow.L3Key]da // when outputDir is empty. The file is written beside its destination and // renamed into place, so a failed run never leaves a truncated analysis.json. func WriteOutput(app *schema.GoApplication, l3 map[dataflow.L3Key]dataflow.Result, l4 *sdg.Result, opts options.AnalysisOptions) error { + if _, err := resolveIdentity(opts); err != nil { + return err + } if err := checkFormat(opts); err != nil { return err } diff --git a/internal/core/analyzer_test.go b/internal/core/analyzer_test.go index 52330ec..8df7513 100644 --- a/internal/core/analyzer_test.go +++ b/internal/core/analyzer_test.go @@ -425,3 +425,40 @@ func TestAnalyzeInvalidNamespaceReadOnly(t *testing.T) { t.Fatalf("cache changed: %v %v", entries, err) } } + +func TestWriteOutputInvalidIdentityPreservesFilesystem(t *testing.T) { + for _, existing := range []bool{false, true} { + t.Run(fmt.Sprint(existing), func(t *testing.T) { + root := t.TempDir() + dst := filepath.Join(root, "missing", "output") + if existing { + if err := os.MkdirAll(dst, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dst, "analysis.json"), []byte("protected"), 0600); err != nil { + t.Fatal(err) + } + } + ns := "" + opts := options.AnalysisOptions{InputPath: greeterDir(t), AppName: "greeter", Namespace: &ns, OutputDir: dst} + if err := core.WriteOutput(&schema.GoApplication{}, nil, nil, opts); err == nil { + t.Fatal("accepted invalid identity") + } + if !existing { + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 0 { + t.Fatalf("created destination: %v %v", entries, err) + } + } else { + entries, err := os.ReadDir(dst) + if err != nil || len(entries) != 1 { + t.Fatalf("changed destination: %v %v", entries, err) + } + b, err := os.ReadFile(filepath.Join(dst, "analysis.json")) + if err != nil || string(b) != "protected" { + t.Fatalf("changed output: %q %v", b, err) + } + } + }) + } +} diff --git a/internal/neo4j/bolt.go b/internal/neo4j/bolt.go index 201df06..baaf0e2 100644 --- a/internal/neo4j/bolt.go +++ b/internal/neo4j/bolt.go @@ -392,9 +392,9 @@ func (w *boltWriter) upsertEdges(edges []EdgeRow) error { keyed := e0.Key != "" var mergeClause string if keyed { - mergeClause = fmt.Sprintf("MERGE (a)-[r:%s {_k: row.k}]->(b)", e0.Type) + mergeClause = fmt.Sprintf("MERGE (a)-[r:%s {_k: row.k, %s: row.f}]->(b)", e0.Type, RelationshipFactProperty) } else { - mergeClause = fmt.Sprintf("MERGE (a)-[r:%s]->(b)", e0.Type) + mergeClause = fmt.Sprintf("MERGE (a)-[r:%s {%s: row.f}]->(b)", e0.Type, RelationshipFactProperty) } q := fmt.Sprintf("UNWIND $rows AS row MATCH (a:%s {%s: row.a}) MATCH (b:%s {%s: row.b}) %s SET r += row.p", e0.From.Label, e0.From.KeyProp, e0.To.Label, e0.To.KeyProp, mergeClause) @@ -466,7 +466,7 @@ func nodeParams(g []NodeRow) []map[string]any { func edgeParams(g []EdgeRow, keyed bool) []map[string]any { out := make([]map[string]any, len(g)) for i, e := range g { - m := map[string]any{"a": e.From.Value, "b": e.To.Value, "p": propsToAny(e.Props)} + m := map[string]any{"a": e.From.Value, "b": e.To.Value, "p": propsToAny(e.Props), "f": edgeFactID(e)} if keyed { m["k"] = e.Key } diff --git a/internal/neo4j/bolt_session_test.go b/internal/neo4j/bolt_session_test.go index 3e51d31..db37f0c 100644 --- a/internal/neo4j/bolt_session_test.go +++ b/internal/neo4j/bolt_session_test.go @@ -2,6 +2,7 @@ package neo4j import ( "context" + "reflect" "strings" "testing" @@ -419,3 +420,67 @@ func TestMissingStoredIdentityReadOnly(t *testing.T) { t.Fatal("wrote before rejecting legacy anchor") } } + +func TestWritersPreserveDistinctEdgeFacts(t *testing.T) { + for _, key := range []string{"", "original-key"} { + for _, field := range []string{"site", "context"} { + t.Run(key+"/"+field, func(t *testing.T) { + build := func(reverse bool) GraphRows { + b := NewRowBuilder() + n := b.Node([]string{LabelCallable}, "id", "can://team/app/go/a", nil, "") + sites := []string{"one", "two"} + if reverse { + sites[0], sites[1] = sites[1], sites[0] + } + for _, site := range sites { + props := Props{"site": "shared", "context": "shared"} + props[field] = site + b.Edge(RelCalls, n, n, props, key) + } + return b.Finish() + } + var scripts []string + var batches [][]map[string]any + for _, reverse := range []bool{false, true} { + rows := build(reverse) + var script strings.Builder + writeEdgeBatches(&script, rows.Edges) + scripts = append(scripts, script.String()) + db := &fakeDB{} + w := &boltWriter{ctx: context.Background(), session: db} + if err := w.upsertEdges(rows.Edges); err != nil { + t.Fatal(err) + } + if len(db.writes) != 1 { + t.Fatalf("writes %d", len(db.writes)) + } + q := db.writes[0].q + if !strings.Contains(q, "_fact: row.f") || !strings.Contains(script.String(), "_fact: row.f") { + t.Fatalf("fact identity missing:\n%s\n%s", q, script.String()) + } + batch := db.writes[0].params["rows"].([]map[string]any) + batches = append(batches, batch) + if len(batch) != 2 || batch[0]["f"] == nil || batch[0]["f"] == batch[1]["f"] { + t.Fatalf("collapsed facts: %+v", batch) + } + for _, row := range batch { + f, ok := row["f"].(string) + if !ok || !strings.Contains(script.String(), "f: "+cypherString(f)) { + t.Fatalf("writer identity differs: %+v", row) + } + if key != "" { + if row["k"] != key || !strings.Contains(q, "_k: row.k") || !strings.Contains(script.String(), "k: "+cypherString(key)) { + t.Fatal("original key lost") + } + } else if _, ok := row["k"]; ok { + t.Fatal("invented original key") + } + } + } + if scripts[0] != scripts[1] || !reflect.DeepEqual(batches[0], batches[1]) { + t.Fatal("input order changes writer identities") + } + }) + } + } +} diff --git a/internal/neo4j/cypher.go b/internal/neo4j/cypher.go index 8370558..3b883b3 100644 --- a/internal/neo4j/cypher.go +++ b/internal/neo4j/cypher.go @@ -179,20 +179,20 @@ func writeEdgeBatches(b *strings.Builder, edges []EdgeRow) { sep = "" } if keyed { - fmt.Fprintf(b, " {a: %s, b: %s, k: %s, p: %s}%s\n", - cypherString(e.From.Value), cypherString(e.To.Value), cypherString(e.Key), cypherMap(e.Props), sep) + fmt.Fprintf(b, " {a: %s, b: %s, k: %s, f: %s, p: %s}%s\n", + cypherString(e.From.Value), cypherString(e.To.Value), cypherString(e.Key), cypherString(edgeFactID(e)), cypherMap(e.Props), sep) } else { - fmt.Fprintf(b, " {a: %s, b: %s, p: %s}%s\n", - cypherString(e.From.Value), cypherString(e.To.Value), cypherMap(e.Props), sep) + fmt.Fprintf(b, " {a: %s, b: %s, f: %s, p: %s}%s\n", + cypherString(e.From.Value), cypherString(e.To.Value), cypherString(edgeFactID(e)), cypherMap(e.Props), sep) } } b.WriteString("] AS row\n") fmt.Fprintf(b, "MATCH (a:%s {%s: row.a})\n", e0.From.Label, e0.From.KeyProp) fmt.Fprintf(b, "MATCH (b:%s {%s: row.b})\n", e0.To.Label, e0.To.KeyProp) if keyed { - fmt.Fprintf(b, "MERGE (a)-[r:%s {_k: row.k}]->(b)\n", e0.Type) + fmt.Fprintf(b, "MERGE (a)-[r:%s {_k: row.k, %s: row.f}]->(b)\n", e0.Type, RelationshipFactProperty) } else { - fmt.Fprintf(b, "MERGE (a)-[r:%s]->(b)\n", e0.Type) + fmt.Fprintf(b, "MERGE (a)-[r:%s {%s: row.f}]->(b)\n", e0.Type, RelationshipFactProperty) } b.WriteString("SET r += row.p;\n") } diff --git a/internal/neo4j/ddl.go b/internal/neo4j/ddl.go index eb1e30c..b35632a 100644 --- a/internal/neo4j/ddl.go +++ b/internal/neo4j/ddl.go @@ -85,16 +85,17 @@ func constraintName(label, prop string) string { // --emit schema to schema.neo4j.json. It is the graph-side sibling of the // keystone JSON schema and what the SDK's Neo4j backend reconstructs against. type SchemaDocument struct { - SchemaVersion string `json:"schema_version"` - Language string `json:"language"` - NodePrefix string `json:"node_prefix"` - RelPrefix string `json:"rel_prefix"` - MarkerLabel string `json:"marker_label"` - SymbolLabel string `json:"symbol_label"` - Nodes []NodeDoc `json:"nodes"` - Relationships []RelDoc `json:"relationships"` - Constraints []Constraint `json:"constraints"` - Indexes []Index `json:"indexes"` + RelationshipFactProperty string `json:"relationship_fact_property"` + SchemaVersion string `json:"schema_version"` + Language string `json:"language"` + NodePrefix string `json:"node_prefix"` + RelPrefix string `json:"rel_prefix"` + MarkerLabel string `json:"marker_label"` + SymbolLabel string `json:"symbol_label"` + Nodes []NodeDoc `json:"nodes"` + Relationships []RelDoc `json:"relationships"` + Constraints []Constraint `json:"constraints"` + Indexes []Index `json:"indexes"` } // NodeDoc / RelDoc are the serialized (JSON-stable) views of NodeSpec / RelSpec. @@ -128,16 +129,17 @@ func BuildSchemaDocument() SchemaDocument { rels = append(rels, RelDoc(r)) } return SchemaDocument{ - SchemaVersion: SchemaVersion, - Language: v2.Language, - NodePrefix: NodePrefix, - RelPrefix: RelPrefix, - MarkerLabel: MarkerLabel, - SymbolLabel: SymbolLabel, - Nodes: nodes, - Relationships: rels, - Constraints: UniquenessConstraints(), - Indexes: Indexes(), + RelationshipFactProperty: RelationshipFactProperty, + SchemaVersion: SchemaVersion, + Language: v2.Language, + NodePrefix: NodePrefix, + RelPrefix: RelPrefix, + MarkerLabel: MarkerLabel, + SymbolLabel: SymbolLabel, + Nodes: nodes, + Relationships: rels, + Constraints: UniquenessConstraints(), + Indexes: Indexes(), } } diff --git a/internal/neo4j/rows.go b/internal/neo4j/rows.go index fc96985..ea0ad8b 100644 --- a/internal/neo4j/rows.go +++ b/internal/neo4j/rows.go @@ -1,6 +1,7 @@ package neo4j import ( + "crypto/sha256" "encoding/json" "fmt" "sort" @@ -335,3 +336,11 @@ func unionLabels(a, b []string) []string { } return out } + +// edgeFactID retains distinct site/context/evidence facts through relationship +// MERGE. Endpoints, type and original Key remain separate identity components. +// JSON orders map keys, so equivalent property bags produce the same digest. +func edgeFactID(e EdgeRow) string { + data, _ := json.Marshal(prune(e.Props)) + return fmt.Sprintf("%x", sha256.Sum256(data)) +} diff --git a/internal/neo4j/schema.go b/internal/neo4j/schema.go index f9acac0..18853b4 100644 --- a/internal/neo4j/schema.go +++ b/internal/neo4j/schema.go @@ -35,6 +35,11 @@ import v2 "github.com/codellm-devkit/codeanalyzer-go/internal/schema/v2" // moves in lockstep with the JSON schema major (v2.SchemaVersion). The // additive-MINOR rule is suspended for the 2.0.0 line (parity with the sibling // ruling); consumers gate on the analyzer-version floor instead. +// RelationshipFactProperty is the private SHA-256 discriminator every writer +// includes in relationship MERGE, in addition to endpoints/type and any _k. +// It hashes the canonical JSON of the existing pruned fact properties. +const RelationshipFactProperty = "_fact" + const SchemaVersion = v2.SchemaVersion // "2.0.0" // ─── namespacing (parity: Python PY_/Py, TS TS_/TS) ───────────────────────── @@ -164,13 +169,13 @@ type RelSpec struct { // conformance test to reject an edge wired to an undeclared endpoint. From []string To []string - // Props are the property names the edge may carry (excluding the _k - // discriminant, which is Discriminant). + // Props are fact property names, excluding writer identity properties: + // _k (Discriminant) and the private RelationshipFactProperty. Props []string // Discriminant names the _k key when parallel edges per (endpoint pair) are // legitimate (e.g. "kind" for GO_CFG_NEXT). A composite is written // "a|b" and means _k = a + "|" + strings.Join(b, ","), b being an array - // prop (GO_DDG: "var|prov"). Empty = plain endpoint-pair MERGE. + // prop (GO_DDG: "var|prov"). Empty omits _k; all relationships also MERGE on RelationshipFactProperty. Discriminant string // Deferred marks an edge whose target may be library/external code not // present in the graph: it is emitted via the RowBuilder's edge-only-when- diff --git a/internal/neo4j/schema_test.go b/internal/neo4j/schema_test.go index 4069038..a7c295b 100644 --- a/internal/neo4j/schema_test.go +++ b/internal/neo4j/schema_test.go @@ -323,3 +323,10 @@ func TestContract_L4RelsCarryVar(t *testing.T) { } } } + +func TestSchemaDeclaresRelationshipFactIdentity(t *testing.T) { + doc := BuildSchemaDocument() + if doc.RelationshipFactProperty != "_fact" { + t.Fatalf("private fact identity property: %q", doc.RelationshipFactProperty) + } +} From f66a589c1e2f4dc0ed373f4811a0dbe8f22b3d13 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Sat, 31 Oct 2026 00:14:32 -0400 Subject: [PATCH 4/5] Reject contradictory stored application owners before graph writes --- internal/neo4j/bolt.go | 12 +++++- internal/neo4j/bolt_session_test.go | 60 ++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/internal/neo4j/bolt.go b/internal/neo4j/bolt.go index baaf0e2..da5b001 100644 --- a/internal/neo4j/bolt.go +++ b/internal/neo4j/bolt.go @@ -3,6 +3,7 @@ package neo4j import ( "context" "fmt" + v2 "github.com/codellm-devkit/codeanalyzer-go/internal/schema/v2" "sort" "github.com/neo4j/neo4j-go-driver/v5/neo4j" @@ -209,7 +210,7 @@ func (w *boltWriter) storedAppStale() (bool, error) { haveLevel, badLevel := false, false err := w.read(func(tx neo4j.ManagedTransaction) error { res, err := tx.Run(w.ctx, - "MATCH (a:GoApplication {id: $id}) RETURN a.schema_version AS v, a.max_level AS l, a.identity_version AS iv", + "MATCH (a:GoApplication {id: $id}) RETURN a.schema_version AS v, a.max_level AS l, a.identity_version AS iv, a.application_name AS an, a.identity_namespace AS ns", map[string]any{"id": w.appID}) if err != nil { return err @@ -219,6 +220,15 @@ func (w *boltWriter) storedAppStale() (bool, error) { if n, ok := iv.(int64); !ok || n != 2 { return fmt.Errorf("incompatible stored identity_version: %v", iv) } + nameValue, _ := res.Record().Get("an") + namespaceValue, _ := res.Record().Get("ns") + name, nameOK := nameValue.(string) + namespace, namespaceOK := namespaceValue.(string) + identity, identityErr := v2.ResolveIdentity(name, &namespace) + if !nameOK || !namespaceOK || identityErr != nil || identity.ApplicationID != w.appID { + return fmt.Errorf("incompatible stored application identity metadata") + } + if v, ok := res.Record().Get("v"); ok && v != nil { stored, _ = v.(string) } diff --git a/internal/neo4j/bolt_session_test.go b/internal/neo4j/bolt_session_test.go index db37f0c..545d35d 100644 --- a/internal/neo4j/bolt_session_test.go +++ b/internal/neo4j/bolt_session_test.go @@ -2,6 +2,7 @@ package neo4j import ( "context" + "net/url" "reflect" "strings" "testing" @@ -85,10 +86,15 @@ type fakeDB struct { level int64 // rawLevel, when non-nil, is the stored max_level returned verbatim // (any type), overriding level. - missingIdentity bool - rawIdentity any - rawLevel any - writes []fakeStmt + missingIdentity bool + missingAnchor bool + missingName bool + missingNamespace bool + rawIdentity any + rawName any + rawNamespace any + rawLevel any + writes []fakeStmt } type fakeStmt struct { @@ -117,6 +123,9 @@ func (s *fakeDB) ExecuteRead(ctx context.Context, work neo4j.ManagedTransactionW func (tx fakeTx) Run(_ context.Context, q string, params map[string]any) (neo4j.ResultWithContext, error) { switch { case strings.Contains(q, "a.schema_version"): + if tx.db.missingAnchor { + return &fakeResult{}, nil + } var lvl any if tx.db.level != 0 { lvl = tx.db.level @@ -131,7 +140,24 @@ func (tx fakeTx) Run(_ context.Context, q string, params map[string]any) (neo4j. if tx.db.rawIdentity != nil { identity = tx.db.rawIdentity } - return &fakeResult{rec: &neo4j.Record{Keys: []string{"v", "l", "iv"}, Values: []any{SchemaVersion, lvl, identity}}}, nil + parts := strings.Split(strings.TrimPrefix(params["id"].(string), "can://"), "/") + ns, _ := url.PathUnescape(parts[0]) + name, _ := url.PathUnescape(parts[1]) + var rawName any = name + var rawNamespace any = ns + if tx.db.missingName { + rawName = nil + } + if tx.db.missingNamespace { + rawNamespace = nil + } + if tx.db.rawName != nil { + rawName = tx.db.rawName + } + if tx.db.rawNamespace != nil { + rawNamespace = tx.db.rawNamespace + } + return &fakeResult{rec: &neo4j.Record{Keys: []string{"v", "l", "iv", "an", "ns"}, Values: []any{SchemaVersion, lvl, identity, rawName, rawNamespace}}}, nil case strings.Contains(q, "m.content_hash"): h, ok := tx.db.hashes[params["id"].(string)] if !ok { @@ -484,3 +510,27 @@ func TestWritersPreserveDistinctEdgeFacts(t *testing.T) { } } } + +func TestStoredOwnerMismatchReadOnly(t *testing.T) { + for _, db := range []*fakeDB{{missingName: true}, {missingNamespace: true}, {rawName: "other"}, {rawNamespace: "other"}, {rawName: 42}, {rawNamespace: 42}} { + w := &boltWriter{ctx: context.Background(), session: db, appID: "can://team/app"} + if err := w.run(); err == nil { + t.Fatal("accepted contradictory stored owner") + } + if len(db.writes) != 0 { + t.Fatal("wrote before rejecting stored owner") + } + } +} + +func TestStoredOwnerAllowsFreshAndMatchingAnchor(t *testing.T) { + for _, db := range []*fakeDB{{missingAnchor: true}, {}} { + w := &boltWriter{ctx: context.Background(), session: db, appID: "can://team/app"} + if _, err := w.storedAppStale(); err != nil { + t.Fatal(err) + } + if len(db.writes) != 0 { + t.Fatal("identity probe mutated graph") + } + } +} From 409f76c076de4fde2e3b361db923c497f131703c Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Sat, 31 Oct 2026 08:58:46 -0400 Subject: [PATCH 5/5] chore: prepare 0.3.0 release --- CHANGELOG.md | 9 +++++++++ cmd/codeanalyzer/main.go | 2 +- cmd/codeanalyzer/main_test.go | 4 ++-- packaging/python/build_wheels.sh | 2 +- packaging/python/src/codeanalyzer_go/__init__.py | 2 +- 5 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7fc44b7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## [0.3.0] - 2026-10-31 + +- Add `--namespace` and namespace-qualified application IDs. +- Preserve distinct relationship facts and validate stored ownership before writes. +- Isolate caches and graph scopes by canonical application identity. + +Regenerate existing analyses and graphs from source for the new ID roots. diff --git a/cmd/codeanalyzer/main.go b/cmd/codeanalyzer/main.go index e855f31..d2b5379 100644 --- a/cmd/codeanalyzer/main.go +++ b/cmd/codeanalyzer/main.go @@ -23,7 +23,7 @@ import ( // version is the analyzer version. It defaults to a dev value and is overridden // at release time via -ldflags "-X main.version=" (see packaging/python/build_wheels.sh), // keeping the binary, the PyPI wheel, and the git tag in lockstep. -var version = "0.1.0" +var version = "0.3.0" func main() { if err := rootCmd().Execute(); err != nil { diff --git a/cmd/codeanalyzer/main_test.go b/cmd/codeanalyzer/main_test.go index e7f6813..966f28e 100644 --- a/cmd/codeanalyzer/main_test.go +++ b/cmd/codeanalyzer/main_test.go @@ -624,11 +624,11 @@ func l4CypherRows(cy string) map[string]int { for _, stmt := range strings.Split(cy, ";\n") { rows := strings.Count(stmt, "\n {") for _, rel := range []string{"GO_PARAM_IN", "GO_PARAM_OUT", "GO_SUMMARY"} { - if strings.Contains(stmt, "MERGE (a)-[r:"+rel+"]->(b)") { + if strings.Contains(stmt, "MERGE (a)-[r:"+rel+" {_fact: row.f}]->(b)") { n[rel] += rows } } - if strings.Contains(stmt, "-[r:GO_DDG {_k: row.k}]->") { + if strings.Contains(stmt, "-[r:GO_DDG {_k: row.k, _fact: row.f}]->") { n["GO_DDG reaching-defs"] += strings.Count(stmt, "|reaching-defs'") } if strings.Contains(stmt, "MERGE (n:GoBodyNode") { diff --git a/packaging/python/build_wheels.sh b/packaging/python/build_wheels.sh index 1a7d8b0..02ea167 100755 --- a/packaging/python/build_wheels.sh +++ b/packaging/python/build_wheels.sh @@ -28,7 +28,7 @@ REPO_ROOT="$(cd "$HERE/../.." && pwd)" # codeanalyzer-go repo root (has # tag); the literal is only a local-dev fallback. It is written into __init__.py # (hatch's single source of truth for the wheel version) and injected into the # binary via -ldflags so `cango --version` matches the wheel version. -PKG_VERSION="${PKG_VERSION:-0.1.0}" +PKG_VERSION="${PKG_VERSION:-0.3.0}" # The wheel filename carries the PEP 440-normalized version, not the raw tag # version: a SemVer pre-release like 0.1.0-rc.1 is built as 0.1.0rc1, and # 0.0.0-dev as 0.0.0.dev0. Normalize the same way so the retag step below finds diff --git a/packaging/python/src/codeanalyzer_go/__init__.py b/packaging/python/src/codeanalyzer_go/__init__.py index 650bef1..81e10ba 100644 --- a/packaging/python/src/codeanalyzer_go/__init__.py +++ b/packaging/python/src/codeanalyzer_go/__init__.py @@ -17,7 +17,7 @@ from importlib import resources from pathlib import Path -__version__ = "0.1.0" +__version__ = "0.3.0" __all__ = ["bin_path", "__version__"]