diff --git a/.grafema/guarantees.yaml b/.grafema/guarantees.yaml index eb7e4b370..134d56cfd 100644 --- a/.grafema/guarantees.yaml +++ b/.grafema/guarantees.yaml @@ -595,3 +595,28 @@ guarantees: check: datalog rule: 'violation(F) :- node(F, "STATE_FIELD"), edge(_, F, "READS_STATE_TRUTHY"), edge(_, F, "WRITES_STATE_TRUTHY"), \+ edge(_, F, "WRITES_STATE_FALSY"), \+ edge(_, F, "WRITES_STATE_DYNAMIC").' severity: warning + + # ============================================================ + # CONNASCENCE OF VALUE (RFD-75) + # ============================================================ + + - name: value-lockstep + description: > + Any two version:ref nodes that refer to the same version:coord must carry + the same value. Two refs to one coord with different values is + connascence-of-value drift (e.g. a cli package's optionalDeps pinned to a + stale @grafema/x version while another package pins the current one, or a + package.version that has drifted from the git tag / CHANGELOG top entry). + value/locus/coord ride the version:ref metadata JSON, so the rule reads + `value` via the derive-engine `node_attr` builtin (NOT `attr`, which only + sees top-level node columns and would yield zero rows). R and R2 are bound + by the REFERS_TO generator legs before node_attr (a point probe with no + generator mode) runs. Generalizes verbatim to feature-flag / enum-case / + API-version DRY connascence — coord/ref/REFERS_TO is value-agnostic. + check: datalog + severity: error + rule: >- + violation(R) :- edge(R, C, "REFERS_TO"), edge(R2, C, "REFERS_TO"), + neq(R, R2), + node_attr(R, "value", V1), node_attr(R2, "value", V2), + neq(V1, V2). diff --git a/packages/cli/src/commands/analyzeAction.ts b/packages/cli/src/commands/analyzeAction.ts index a9ffbd84f..4d16c763f 100644 --- a/packages/cli/src/commands/analyzeAction.ts +++ b/packages/cli/src/commands/analyzeAction.ts @@ -28,6 +28,7 @@ import { enrichBehaviors } from '@grafema/util/enrichers/behaviorEnricher'; import { enrichContracts } from '@grafema/util/enrichers/contractEnricher'; import { enrichLibraryCallbacks } from '@grafema/util/enrichers/libraryCallbackEnricher'; import { enrichPackageApis } from '@grafema/util/enrichers/packageApiEnricher'; +import { enrichVersionRefs } from '@grafema/util/enrichers/versionRefEnricher'; import { enrichMcpToolDefinitions } from '@grafema/util/enrichers/mcpToolDefinitionEnricher'; import { enrichSpecedContracts } from '@grafema/util/enrichers/specedContractEnricher'; import { commanderExtractor } from '@grafema/util/enrichers/extractors/commanderExtractor'; @@ -527,6 +528,23 @@ export async function analyzeAction(path: string, options: { service?: string; e debug(`Package API enricher skipped: ${err instanceof Error ? err.message : String(err)}`); } + // Version-ref enricher (RFD-75) — emit a `version:ref` node per repo- + // static version locus (package.json version, @grafema/* dependency pins, + // git tag, CHANGELOG top entry). The version_coords_nodes / + // version_refs_edges derive packs then mint `version:coord` + REFERS_TO, + // and the `value-lockstep` guarantee trips on connascence-of-value drift. + try { + const client = (backend as unknown as { client: Parameters[0] }).client; + if (client) { + const result = await enrichVersionRefs(client, projectPath); + if (result.nodesCreated > 0) { + info(` Version refs: ${result.nodesCreated} version:ref nodes across ${result.lociScanned} loci (pkg.version=${result.packageVersionRefs}, deps=${result.dependencyRefs}, gitTag=${result.gitTagRefs}, changelog=${result.changelogRefs})`); + } + } + } catch (err) { + debug(`Version-ref enricher skipped: ${err instanceof Error ? err.message : String(err)}`); + } + // Generate manifest after successful analysis try { const manifestPath = await generateManifest(backend, projectPath, grafemaDir, debug); diff --git a/packages/grafema-orchestrator/src/main.rs b/packages/grafema-orchestrator/src/main.rs index da1cb9582..071e54a61 100644 --- a/packages/grafema-orchestrator/src/main.rs +++ b/packages/grafema-orchestrator/src/main.rs @@ -169,6 +169,13 @@ const STDLIB_RULE_PACKS: &[&str] = &[ // consume analyzer EDB only. "@stdlib/js_entrypoint_features_nodes", "@stdlib/js_entrypoint_features_edges", + // RFD-75 connascence-of-value vertical: version_coords_nodes MINTS the + // version:coord nodes (one per distinct coord) the version_refs_edges pack + // joins as committed EDB to derive REFERS_TO (strict nodes→edges order — the + // js_builtins two-pack split). Both consume the enricher-minted version:ref + // EDB only. + "@stdlib/version_coords_nodes", + "@stdlib/version_refs_edges", ]; @@ -618,6 +625,8 @@ fn pack_owned_slice(pack: &str) -> &'static str { "@stdlib/js_http_routes_edges" => "js http:route EXPOSES + HANDLES", "@stdlib/js_entrypoint_features_nodes" => "js cli:command/mcp:tool/vscode:command FEATURE nodes (commander/mcp-sdk/vscode)", "@stdlib/js_entrypoint_features_edges" => "js cli:command/mcp:tool/vscode:command EXPOSES + HANDLES", + "@stdlib/version_coords_nodes" => "version:coord nodes (RFD-75 connascence)", + "@stdlib/version_refs_edges" => "version:ref → version:coord REFERS_TO (RFD-75 connascence)", _ => "(unregistered pack)", } } diff --git a/packages/rfdb-server/src/derive/stdlib.rs b/packages/rfdb-server/src/derive/stdlib.rs index 3c4c83812..06005980f 100644 --- a/packages/rfdb-server/src/derive/stdlib.rs +++ b/packages/rfdb-server/src/derive/stdlib.rs @@ -470,6 +470,26 @@ pub const JS_ENTRYPOINT_FEATURES_EDGES_DL: &str = concat!( include_str!("stdlib/js_entrypoint_features_edges.dl"), ); +// ── RFD-75 connascence-of-value vertical (version:ref → version:coord) ────── + +/// Node half of the RFD-75 version-consistency vertical: mints one +/// `version:coord` node per DISTINCT `coord` carried by the `version:ref` nodes +/// the versionRefEnricher committed (package.json version + @grafema/* dep pins +/// + git tag + CHANGELOG top). sid = `version:coord::`; coord rides the +/// node's own metadata (meta(coord)) so the edge pack reads it back via +/// node_attr for the ref→coord join. Pure positive joins, no negation ⇒ +/// incrementally maintainable. PRODUCER of the version:coord nodes the edge +/// pack joins — strictly before `version_refs_edges`. +pub const VERSION_COORDS_NODES_DL: &str = include_str!("stdlib/version_coords_nodes.dl"); + +/// Edge half of the RFD-75 vertical: REFERS_TO (version:ref → version:coord), +/// joining the committed version:ref nodes against the committed version:coord +/// nodes BY COORD NAME (node_attr equality on both sides). MUST run after +/// `version_coords_nodes` (committed-EDB endpoint join). REFERS_TO is shared +/// vocabulary (packages/types RefersToEdge) ⇒ mode = "additive". Pure positive +/// joins, no negation ⇒ incrementally maintainable. +pub const VERSION_REFS_EDGES_DL: &str = include_str!("stdlib/version_refs_edges.dl"); + // ── Java packs (the java-resolve migration, lang-spec-java.md) ───────────── /// Java import resolution — replaces `ImportResolution.hs` of `java-resolve`: @@ -696,6 +716,14 @@ pub const STDLIB_PACKS: &[(&str, &str)] = &[ // so they sit at the registry tail with js_http_routes. ("js_entrypoint_features_nodes", JS_ENTRYPOINT_FEATURES_NODES_DL), ("js_entrypoint_features_edges", JS_ENTRYPOINT_FEATURES_EDGES_DL), + // RFD-75 connascence-of-value vertical: version_coords_nodes MINTS the + // version:coord nodes (one per distinct coord) that version_refs_edges joins + // as committed EDB to derive REFERS_TO (strict nodes→edges order — the + // js_builtins two-pack split). Consume the enricher-minted version:ref EDB + // only and produce nothing any earlier pack reads, so they sit at the + // registry tail. + ("version_coords_nodes", VERSION_COORDS_NODES_DL), + ("version_refs_edges", VERSION_REFS_EDGES_DL), ]; /// Look up a bundled pack by its wire name (the `` in `"@stdlib/"`). @@ -825,6 +853,37 @@ mod tests { }); } + /// RFD-75 — a `version:ref` node (as the versionRefEnricher commits it): + /// value/locus/coord ride the metadata JSON (read via node_attr), `name` is + /// the value label. `sid` is the test handle (the enricher's real id scheme + /// is `::version:ref::`; the helper id is arbitrary-but-stable). + fn ref_node( + v: &mut FixtureStorageView, + sid: &str, + value: &str, + locus: &str, + coord: &str, + file: &str, + ) { + named_node(v, sid, value, "version:ref", file); + v.put_node_metadata( + id_of(sid), + &serde_json::json!({ "value": value, "locus": locus, "coord": coord }).to_string(), + ); + } + + /// RFD-75 — a `version:coord` node (as version_coords_nodes commits it): + /// sid = `version:coord::`, `coord` rides the metadata JSON so the + /// edge pack reads it back via node_attr. + fn coord_node(v: &mut FixtureStorageView, coord: &str) { + let sid = format!("version:coord::{coord}"); + named_node(v, &sid, coord, "version:coord", coord); + v.put_node_metadata( + id_of(&sid), + &serde_json::json!({ "coord": coord }).to_string(), + ); + } + /// The bundled method-call rule pack reproduces the plugin's two resolution strategies /// on a fixture, and ONLY those: /// - instance_of: c1 ("kb.queryNodes") → PA → REF → VAR —INSTANCE_OF→ KB —HAS_METHOD→ m1, @@ -1666,6 +1725,8 @@ mod tests { "js_http_routes_edges", "js_entrypoint_features_nodes", "js_entrypoint_features_edges", + "version_coords_nodes", + "version_refs_edges", ], "canonical run order: Wave-1 resolver packs → Wave-1b packs \ (rust_cross_methods_ctor after rust_calls — the CALLS EDB seam; the \ @@ -1743,6 +1804,11 @@ mod tests { stdlib_pack("js_entrypoint_features_edges"), Some(JS_ENTRYPOINT_FEATURES_EDGES_DL) ); + assert_eq!( + stdlib_pack("version_coords_nodes"), + Some(VERSION_COORDS_NODES_DL) + ); + assert_eq!(stdlib_pack("version_refs_edges"), Some(VERSION_REFS_EDGES_DL)); assert_eq!(stdlib_pack("nope"), None, "unknown pack name resolves to None"); } @@ -6991,4 +7057,258 @@ mod tests { ); } } + + /// RFD-75 — version_coords_nodes mints one `version:coord` node per DISTINCT + /// coord carried by the committed version:ref nodes' metadata, dedups + /// multiple refs → one coord, and projects `coord` into the minted node's + /// own metadata via meta(coord). + #[test] + fn version_coords_nodes_mints_one_per_distinct_coord() { + let mut v = FixtureStorageView::new(1); + + // Three refs to the "release" coord (package.version, git.tag, changelog) + // + two refs to the "@grafema/x" coord (two packages pin it). Five refs, + // two distinct coords. + ref_node(&mut v, "r_pkg", "0.4.0", "package.version", "release", "package.json"); + ref_node(&mut v, "r_tag", "0.4.0", "git.tag", "release", ""); + ref_node(&mut v, "r_chg", "0.4.0", "changelog", "release", "CHANGELOG.md"); + ref_node( + &mut v, + "r_cli_x", + "0.4.0", + "optionalDeps[@grafema/x]", + "@grafema/x", + "packages/cli/package.json", + ); + ref_node( + &mut v, + "r_meta_x", + "0.3.29", + "optionalDeps[@grafema/x]", + "@grafema/x", + "packages/meta/package.json", + ); + + let (eval, _specs, node_specs) = evaluate_with_materialize( + &v, + VERSION_COORDS_NODES_DL, + Stats::default(), + EvalLimits::none(), + EventLog::discard(), + ) + .expect("version_coords_nodes.dl evaluates"); + + // One vcoord fact per DISTINCT coord (sid, name, file, coord) — refs to + // the same coord collapse to one row (engine set semantics). + let minted: BTreeSet<(String, String, String, String)> = eval + .facts("vcoord") + .into_iter() + .map(|r| (r[0].as_str(), r[1].as_str(), r[2].as_str(), r[3].as_str())) + .collect(); + assert_eq!( + minted, + BTreeSet::from([ + ( + "version:coord::release".to_string(), + "release".to_string(), + "release".to_string(), + "release".to_string() + ), + ( + "version:coord::@grafema/x".to_string(), + "@grafema/x".to_string(), + "@grafema/x".to_string(), + "@grafema/x".to_string() + ), + ]), + "one version:coord per distinct coord; multiple refs → one coord dedup" + ); + + // Spec: exactly one node-materialized head, exclusive, meta(coord). + assert_eq!(node_specs.len(), 1, "exactly one node-materialized head"); + let ns = &node_specs[0]; + assert_eq!(ns.predicate, "vcoord"); + assert_eq!(ns.node_type, "version:coord"); + assert!(!ns.additive, "exclusive (provenance-scoped) node ownership"); + assert_eq!(ns.meta, vec!["coord".to_string()]); + } + + /// RFD-75 — version_refs_edges joins the committed version:ref nodes against + /// the committed version:coord nodes BY COORD NAME and derives REFERS_TO + /// (ref → coord). Every ref of a coord links to that coord; refs of distinct + /// coords never cross-link. + #[test] + fn version_refs_edges_joins_refs_to_coords_by_name() { + let mut v = FixtureStorageView::new(1); + + // version:ref EDB (enricher-committed). + ref_node(&mut v, "r_pkg", "0.4.0", "package.version", "release", "package.json"); + ref_node(&mut v, "r_tag", "0.4.0", "git.tag", "release", ""); + ref_node( + &mut v, + "r_cli_x", + "0.4.0", + "optionalDeps[@grafema/x]", + "@grafema/x", + "packages/cli/package.json", + ); + + // version:coord EDB (version_coords_nodes-committed; coord rides metadata). + coord_node(&mut v, "release"); + coord_node(&mut v, "@grafema/x"); + + let (eval, specs, _node_specs) = evaluate_with_materialize( + &v, + VERSION_REFS_EDGES_DL, + Stats::default(), + EvalLimits::none(), + EventLog::discard(), + ) + .expect("version_refs_edges.dl evaluates"); + + let edges: BTreeSet<(u128, u128)> = eval + .facts("refers_to") + .into_iter() + .map(|r| (r[0].as_id().expect("src id"), r[1].as_id().expect("dst id"))) + .collect(); + assert_eq!( + edges, + BTreeSet::from([ + (id_of("r_pkg"), id_of("version:coord::release")), + (id_of("r_tag"), id_of("version:coord::release")), + (id_of("r_cli_x"), id_of("version:coord::@grafema/x")), + ]), + "each ref REFERS_TO its own coord; distinct coords never cross-link" + ); + + let spec = specs + .iter() + .find(|s| s.edge_type == "REFERS_TO") + .expect("REFERS_TO spec"); + assert!(spec.additive, "REFERS_TO is shared vocabulary — additive"); + } + + /// RFD-75 — the `value-lockstep` guarantee TRIPS on connascence drift (two + /// refs to one coord with different values) and PASSES on lockstep. An + /// externally-ingested `binary.` ref (the ops release-hook locus — + /// out of the enricher's scope) participates with NO rule change: the graph + /// side is uniform, a binary ref REFERS_TO the same coord as package.version. + #[test] + fn value_lockstep_guarantee_trips_on_drift_and_includes_binary_ref() { + // The guarantee rule, verbatim from .grafema/guarantees.yaml. + const VALUE_LOCKSTEP: &str = r#" +violation(R) :- edge(R, C, "REFERS_TO"), edge(R2, C, "REFERS_TO"), + neq(R, R2), + node_attr(R, "value", V1), node_attr(R2, "value", V2), + neq(V1, V2). +"#; + + // ── Drift case: cli pins @grafema/x = 0.3.29, meta pins 0.4.0; both + // REFERS_TO coord @grafema/x. Plus a matched "release" pair (pkg+tag + // both 0.4.0) that must NOT trip. Plus a binary ref on "release" + // that DOES drift (binary 0.3.29 vs package.version 0.4.0). + let mut v = FixtureStorageView::new(1); + + ref_node( + &mut v, + "r_cli_x", + "0.3.29", + "optionalDeps[@grafema/x]", + "@grafema/x", + "packages/cli/package.json", + ); + ref_node( + &mut v, + "r_meta_x", + "0.4.0", + "optionalDeps[@grafema/x]", + "@grafema/x", + "packages/meta/package.json", + ); + ref_node(&mut v, "r_pkg", "0.4.0", "package.version", "release", "package.json"); + ref_node(&mut v, "r_tag", "0.4.0", "git.tag", "release", ""); + // Externally-ingested binary ref (ops hook) — drifts on the release coord. + ref_node(&mut v, "r_bin", "0.3.29", "binary.grafema", "release", ""); + + coord_node(&mut v, "@grafema/x"); + coord_node(&mut v, "release"); + + // Committed REFERS_TO edges (as version_refs_edges would produce). + edge(&mut v, "r_cli_x", "version:coord::@grafema/x", "REFERS_TO"); + edge(&mut v, "r_meta_x", "version:coord::@grafema/x", "REFERS_TO"); + edge(&mut v, "r_pkg", "version:coord::release", "REFERS_TO"); + edge(&mut v, "r_tag", "version:coord::release", "REFERS_TO"); + edge(&mut v, "r_bin", "version:coord::release", "REFERS_TO"); + + let eval = evaluate( + &v, + VALUE_LOCKSTEP, + Stats::default(), + EvalLimits::none(), + EventLog::discard(), + ) + .expect("value-lockstep rule evaluates"); + + let violators: BTreeSet = eval + .facts("violation") + .into_iter() + .map(|r| r[0].as_id().expect("violation id")) + .collect(); + // Drift trips for BOTH refs of each drifting pair (R bound to either side). + // @grafema/x pair: r_cli_x, r_meta_x. release: r_bin drifts vs r_pkg/r_tag, + // so r_bin, r_pkg, r_tag all appear (each is some R with a differing R2). + assert!( + violators.contains(&id_of("r_cli_x")) && violators.contains(&id_of("r_meta_x")), + "@grafema/x drift (0.3.29 vs 0.4.0) trips value-lockstep" + ); + assert!( + violators.contains(&id_of("r_bin")), + "externally-ingested binary ref participates — drift vs package.version trips" + ); + assert!( + !violators.is_empty(), + "the guarantee TRIPS on drift (not silently passing)" + ); + + // ── Lockstep case: every ref of each coord agrees → NO violation. ────── + let mut v2 = FixtureStorageView::new(1); + ref_node( + &mut v2, + "r_cli_x", + "0.4.0", + "optionalDeps[@grafema/x]", + "@grafema/x", + "packages/cli/package.json", + ); + ref_node( + &mut v2, + "r_meta_x", + "0.4.0", + "optionalDeps[@grafema/x]", + "@grafema/x", + "packages/meta/package.json", + ); + ref_node(&mut v2, "r_pkg", "0.4.0", "package.version", "release", "package.json"); + ref_node(&mut v2, "r_bin", "0.4.0", "binary.grafema", "release", ""); + coord_node(&mut v2, "@grafema/x"); + coord_node(&mut v2, "release"); + edge(&mut v2, "r_cli_x", "version:coord::@grafema/x", "REFERS_TO"); + edge(&mut v2, "r_meta_x", "version:coord::@grafema/x", "REFERS_TO"); + edge(&mut v2, "r_pkg", "version:coord::release", "REFERS_TO"); + edge(&mut v2, "r_bin", "version:coord::release", "REFERS_TO"); + + let eval2 = evaluate( + &v2, + VALUE_LOCKSTEP, + Stats::default(), + EvalLimits::none(), + EventLog::discard(), + ) + .expect("value-lockstep rule evaluates (lockstep)"); + assert_eq!( + eval2.facts("violation").len(), + 0, + "lockstep (all refs of each coord agree) PASSES — no violation" + ); + } } diff --git a/packages/rfdb-server/src/derive/stdlib/version_coords_nodes.dl b/packages/rfdb-server/src/derive/stdlib/version_coords_nodes.dl new file mode 100644 index 000000000..723e5af02 --- /dev/null +++ b/packages/rfdb-server/src/derive/stdlib/version_coords_nodes.dl @@ -0,0 +1,47 @@ +% version_coords_nodes.dl — node half of the RFD-75 connascence-of-value +% vertical. Mints one `version:coord` node per DISTINCT coord value carried by +% the `version:ref` nodes the versionRefEnricher committed (package.json +% version + @grafema/* dependency pins + git tag + CHANGELOG top entry). +% +% The `version:coord` node is the logical thing that must agree: coord +% "release" (shared by package.version / git.tag / changelog) and one coord per +% tracked dependency name. The PAIRED edge pack version_refs_edges.dl then joins +% these committed coord nodes as EDB and derives REFERS_TO (ref → coord) — the +% js_http_routes two-pack split (node pack strictly before edge pack; the edge +% endpoint must be a node-ID column, and a same-run-minted node's id exists only +% as the BLAKE3 of the sid string this pack builds). +% +% Unlike js_http_routes, the version:ref NODES are NOT minted here — the +% enricher mints them directly (RFD plan §1: version sources are filesystem +% reads of package.json/git/CHANGELOG, not graph-derivable from analyzer EDB). +% This pack only derives the coord nodes + (in the edge pack) the REFERS_TO +% edge. +% +% IDENTITY: sid = "version:coord::" + Coord (deterministic, one per distinct +% coord — the engine's set semantics dedup multiple refs → same coord, since +% the head's only varying column besides Sid is Coord). Coord is projected into +% the node's OWN metadata via meta(coord) so the edge pack can read it back with +% node_attr(Coord_node, "coord", Coord) — node_attr is a point probe (no +% generator mode, builtin.rs NODE_ATTR_MODES = [B,B,F]/[B,B,B]), so the coord +% value MUST live in the coord node's metadata for the join. +% +% mode = "exclusive" is PROVENANCE-SCOPED: only the version:coord nodes stamped +% with THIS rule's _source are owned — a coord no longer referenced by any ref +% retracts on the next run; foreign producers' nodes are never touched. +% +% MAINTAIN ENVELOPE: pure positive joins, NO negation ⇒ incrementally +% maintainable (unlike js_http_routes, which is scratch-only due to negation). +% +% ORDERING (PRODUCER): consumes only the committed version:ref EDB; produces the +% version:coord nodes version_refs_edges joins. MUST run strictly before it. + +% Head shape (the @materialize_node contract, materialize.rs §node section): +% col0 = SemanticId, col1 = Name, col2 = File, col3.. = meta(...) columns. +% Here Name = File = Coord (a coord node has no source file; the coord string +% doubles as a human-facing label). meta(coord) projects col3 into the metadata +% JSON so node_attr(Coord_node, "coord", Coord) reads it back in the edge pack. +@materialize_node(node_type = "version:coord", mode = "exclusive", meta(coord)) +vcoord(Sid, Coord, Coord, Coord) :- + node(R, "version:ref"), + node_attr(R, "coord", Coord), + concat("version:coord::", Coord, Sid). diff --git a/packages/rfdb-server/src/derive/stdlib/version_refs_edges.dl b/packages/rfdb-server/src/derive/stdlib/version_refs_edges.dl new file mode 100644 index 000000000..21143ed09 --- /dev/null +++ b/packages/rfdb-server/src/derive/stdlib/version_refs_edges.dl @@ -0,0 +1,52 @@ +% version_refs_edges.dl — edge half of the RFD-75 connascence-of-value +% vertical. Joins the committed version:ref nodes (enricher-minted) against the +% committed version:coord nodes (version_coords_nodes-minted) BY COORD NAME and +% derives the REFERS_TO edge (ref → coord). +% +% MUST be declared AFTER version_coords_nodes in the pack runner: the minted +% version:coord nodes are joined here as COMMITTED storage EDB (the +% js_http_routes two-pack split — a @materialize edge endpoint must be a node-ID +% column, and a same-run-minted node's id exists only as the BLAKE3 of the sid +% string the nodes pack builds). +% +% PLANNING NOTE: node_attr is a POINT PROBE with NO generator mode (builtin.rs +% NODE_ATTR_MODES = [B,B,F]/[B,B,B]). So: +% - R must be bound by node(R, "version:ref") (a generator) BEFORE +% node_attr(R, "coord", Coord) runs as a reader; +% - Coord_node must be bound by node(Coord_node, "version:coord") BEFORE its +% node_attr(Coord_node, "coord", Coord) runs. +% The greedy planner leads with the small node generators, then the readers +% bind/check Coord — every leg gets a supported mode. +% +% mode = "additive": REFERS_TO is shared vocabulary registered in +% packages/types/src/edges.ts (RefersToEdge); additive matches the +% http:route ROUTES_TO precedent and is the safe default for shared vocab. +% Additive edges do not retract — a REFERS_TO edge whose coord node was retracted +% by the (exclusive) nodes pack dangles until the next writer cleanup, the known +% additive-vocabulary class. +% +% MAINTAIN ENVELOPE: pure positive joins, NO negation ⇒ incrementally +% maintainable. +% +% ORDERING (CONSUMER): strictly after version_coords_nodes (committed-EDB +% endpoint join), enforced by both pack registries. + +% Each side is its OWN derived relation, each LED by a node(...) generator (so +% node_attr runs as a reader on a bound id). The two relations are then joined +% on the shared Coord variable — a real equi-join, never a cross-join. (A single +% rule with both node(...) generators is rejected by the planner's +% GuardRejected: the second node(...) shares no bound variable with the +% preceding body — the join key Coord is only a metadata field, and node_attr +% has no generator mode to bridge two un-linked generators in one body.) +ref_coord(R, Coord) :- + node(R, "version:ref"), + node_attr(R, "coord", Coord). + +coord_node(CN, Coord) :- + node(CN, "version:coord"), + node_attr(CN, "coord", Coord). + +@materialize(edge_type = "REFERS_TO", mode = "additive") +refers_to(R, CN) :- + ref_coord(R, Coord), + coord_node(CN, Coord). diff --git a/packages/types/src/edges.ts b/packages/types/src/edges.ts index 332bb3c81..7366f6b47 100644 --- a/packages/types/src/edges.ts +++ b/packages/types/src/edges.ts @@ -84,6 +84,9 @@ export const EDGE_TYPE = { EXPOSES: 'EXPOSES', RESPONDS_WITH: 'RESPONDS_WITH', + // Connascence / version-consistency (RFD-75) + REFERS_TO: 'REFERS_TO', // version:ref -> version:coord + // Events/Sockets LISTENS_TO: 'LISTENS_TO', EMITS_EVENT: 'EMITS_EVENT', @@ -222,6 +225,12 @@ export interface RouteEdge extends EdgeRecord { path?: string; } +// version:ref -> version:coord (RFD-75 connascence). Derived by the +// version_refs_edges pack; carries no metadata of its own. +export interface RefersToEdge extends EdgeRecord { + type: 'REFERS_TO'; +} + /** * Edge from LOOP to iterated collection (for-in/for-of loops) * Source: LOOP node diff --git a/packages/types/src/nodes.ts b/packages/types/src/nodes.ts index 72119bb74..1f36eeeff 100644 --- a/packages/types/src/nodes.ts +++ b/packages/types/src/nodes.ts @@ -100,6 +100,10 @@ export const NAMESPACED_TYPE = { // Grafema internal (self-describing pipeline) GRAFEMA_PLUGIN: 'grafema:plugin', + + // Connascence / version-consistency (RFD-75) + VERSION_REF: 'version:ref', + VERSION_COORD: 'version:coord', } as const; export type NamespacedNodeType = typeof NAMESPACED_TYPE[keyof typeof NAMESPACED_TYPE]; @@ -290,6 +294,26 @@ export interface HttpRouteNodeRecord extends BaseNodeRecord { handler?: string; } +// version:ref — one physical occurrence of a version value (RFD-75). +// value/locus/coord are carried in the node's metadata JSON (read by the +// `value-lockstep` guarantee via `node_attr`), NOT as RFDB top-level columns — +// these fields document the metadata shape for TS consumers, exactly like +// HttpRouteNodeRecord's method/path which ride node metadata. +export interface VersionRefNodeRecord extends BaseNodeRecord { + type: 'version:ref'; + value: string; // the version string, e.g. "0.4.0" + locus: string; // "package.version" | "optionalDeps[]" | "git.tag" | "changelog" | "binary." + coord: string; // the logical coord this ref must agree with: "release" or "" +} + +// version:coord — the logical thing that must agree (RFD-75). Minted by the +// version_coords_nodes derive pack (one node per distinct coord). `coord` rides +// the node metadata so the edge pack can join ref→coord via `node_attr`. +export interface VersionCoordNodeRecord extends BaseNodeRecord { + type: 'version:coord'; + coord: string; +} + // Database query node export interface DbQueryNodeRecord extends BaseNodeRecord { type: 'db:query'; diff --git a/packages/util/src/enrichers/versionRefEnricher.ts b/packages/util/src/enrichers/versionRefEnricher.ts new file mode 100644 index 000000000..1dc220e6b --- /dev/null +++ b/packages/util/src/enrichers/versionRefEnricher.ts @@ -0,0 +1,308 @@ +/** + * versionRefEnricher — RFD-75 connascence-of-value vertical, node producer. + * + * Emits one `version:ref` node per physical occurrence of a version value that + * MUST agree with the others that share its logical coordinate (`coord`): + * + * - every `package.json` `version` → locus "package.version", coord "release" + * - every `@grafema/*` dep/peer/optionalDep pin + * → locus "optionalDeps[]" / "deps[]" / "peerDeps[]", + * coord = the dependency name + * - the git tag (`git describe --tags`) → locus "git.tag", coord "release" + * - the CHANGELOG top entry (first `## [x.y.z]`) + * → locus "changelog", coord "release" + * + * The paired derive packs (version_coords_nodes.dl + version_refs_edges.dl) + * mint the `version:coord` node per distinct coord and derive the REFERS_TO + * edge (ref → coord). The `value-lockstep` guarantee then trips when two refs + * to one coord carry different `value`s — connascence-of-value drift (e.g. cli + * optionalDeps pinned to a stale version vs the package's own version). + * + * Each node carries `value` / `locus` / `coord` in its `metadata` JSON so the + * lockstep rule reads them via the derive engine's `node_attr` builtin + * (value/locus/coord are NOT RFDB top-level columns — `attr` would yield zero + * rows; see the RFD-75 guarantee notes and skill rfdb-batchhandle-deletes- + * existing-nodes for why we write via direct addNodes, never BatchHandle). + * + * Like packageApiEnricher, this writes via direct addNodes (chunked) — NEVER + * BatchHandle.commit, which would delete pre-existing nodes whose `file` field + * appears in changedFiles. Node ids are deterministic + * (`::version:ref::`) so RFDB dedupes on re-upsert. + * + * SCOPE: repo-static loci only. The binary `--version` ref (an ops release + * hook) is OUT OF SCOPE here — but the graph side is uniform: a future + * `binary.` ref REFERS_TO the same coord and the lockstep rule needs no + * change to catch a binary-vs-package mismatch. + */ + +import type { RFDBClient } from '@grafema/rfdb-client'; +import type { WireNode } from '@grafema/types'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +export interface VersionRefEnrichResult { + /** Number of `version:ref` nodes created (or re-upserted). */ + nodesCreated: number; + /** Distinct loci (package.json files + git tag + changelog) scanned. */ + lociScanned: number; + /** `package.json` `version` refs emitted. */ + packageVersionRefs: number; + /** `@grafema/*` dependency-pin refs emitted. */ + dependencyRefs: number; + /** Whether the git tag ref was emitted (1) or not (0 — no tag / not a repo). */ + gitTagRefs: number; + /** Whether the CHANGELOG top-entry ref was emitted. */ + changelogRefs: number; +} + +export interface VersionRefEnrichOptions { + /** Maximum nodes to flush per addNodes round-trip. */ + flushBatchSize?: number; + /** Dependency-name prefixes whose pins become coord refs. Defaults to the + * internal `@grafema/` scope — the version-lockstep set the RFD targets. + * An entry pins a ref for EVERY dependency whose name starts with it. */ + trackedDepPrefixes?: readonly string[]; +} + +const DEFAULT_FLUSH_BATCH_SIZE = 200; +const DEFAULT_TRACKED_DEP_PREFIXES: readonly string[] = ['@grafema/']; + +/** The "release" coordinate: package.version / git.tag / changelog all agree on it. */ +const RELEASE_COORD = 'release'; + +interface PackageJson { + name?: unknown; + version?: unknown; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; +} + +/** A dependency section → the locus prefix it contributes. */ +const DEP_SECTIONS: ReadonlyArray<{ key: keyof PackageJson; locusKind: string }> = [ + { key: 'dependencies', locusKind: 'deps' }, + { key: 'optionalDependencies', locusKind: 'optionalDeps' }, + { key: 'peerDependencies', locusKind: 'peerDeps' }, +]; + +/** + * Scan the repo's static version loci and emit `version:ref` nodes. + * + * Idempotent: node ids are deterministic (`::version:ref::`) and + * the metadata blob is stable, so RFDB dedupes on re-upsert. + * + * @param projectPath the repo root — used to find package.json files, the git + * tag, and the CHANGELOG. (The graph stores logical paths; version sources + * are filesystem reads, so we need the real root — they are not derivable + * from analyzer EDB, which is exactly why the enricher mints these nodes.) + */ +export async function enrichVersionRefs( + client: RFDBClient, + projectPath: string, + options?: VersionRefEnrichOptions, +): Promise { + const result: VersionRefEnrichResult = { + nodesCreated: 0, + lociScanned: 0, + packageVersionRefs: 0, + dependencyRefs: 0, + gitTagRefs: 0, + changelogRefs: 0, + }; + + const flushSize = options?.flushBatchSize ?? DEFAULT_FLUSH_BATCH_SIZE; + const trackedPrefixes = options?.trackedDepPrefixes ?? DEFAULT_TRACKED_DEP_PREFIXES; + + const newNodes: WireNode[] = []; + const seenNodeIds = new Set(); + + const pushRef = (file: string, value: string, locus: string, coord: string): void => { + const id = makeRefNodeId(file, locus); + if (seenNodeIds.has(id)) return; + seenNodeIds.add(id); + newNodes.push({ + id, + nodeType: 'version:ref' as never, + // Human-facing label parallels http:route using its path as the name. + name: value, + file, + exported: false, + metadata: JSON.stringify({ value, locus, coord }), + }); + result.nodesCreated++; + }; + + // ── package.json loci: own version + tracked dependency pins ────────────── + for (const pkgJsonPath of discoverPackageJsonFiles(projectPath)) { + const parsed = readJsonFile(pkgJsonPath); + if (!parsed) continue; + result.lociScanned++; + // Store the path relative to the repo root so the node `file` is stable and + // matches the logical-path convention the rest of the graph uses. + const relFile = relativeTo(projectPath, pkgJsonPath); + + if (typeof parsed.version === 'string' && parsed.version.length > 0) { + pushRef(relFile, parsed.version, 'package.version', RELEASE_COORD); + result.packageVersionRefs++; + } + + for (const { key, locusKind } of DEP_SECTIONS) { + const section = parsed[key]; + if (!section || typeof section !== 'object') continue; + for (const [depName, depVersion] of Object.entries(section as Record)) { + if (typeof depVersion !== 'string' || depVersion.length === 0) continue; + if (!trackedPrefixes.some(p => depName.startsWith(p))) continue; + // coord = the dependency name: every pin of @grafema/x across the repo + // (and @grafema/x's own package.version, whose coord is "release", NOT + // the dep name — distinct coords) must agree among the pins. + pushRef(relFile, depVersion, `${locusKind}[${depName}]`, depName); + result.dependencyRefs++; + } + } + } + + // ── git tag locus ───────────────────────────────────────────────────────── + const gitTag = readGitTag(projectPath); + if (gitTag) { + result.lociScanned++; + pushRef('', gitTag, 'git.tag', RELEASE_COORD); + result.gitTagRefs = 1; + } + + // ── CHANGELOG top-entry locus ───────────────────────────────────────────── + const changelog = readChangelogTopVersion(projectPath); + if (changelog) { + result.lociScanned++; + pushRef(changelog.file, changelog.value, 'changelog', RELEASE_COORD); + result.changelogRefs = 1; + } + + for (let i = 0; i < newNodes.length; i += flushSize) { + await client.addNodes(newNodes.slice(i, i + flushSize)); + } + + return result; +} + +/** --------------------------------------------------------------------------- + * Source discovery / parsing + * ------------------------------------------------------------------------ */ + +/** + * Find every package.json under the repo root: the root manifest plus each + * direct child of `packages/` (the Grafema monorepo layout). We deliberately do + * NOT recurse into node_modules or nested package dirs — those are vendored or + * out-of-scope for the repo's own version lockstep. + */ +function discoverPackageJsonFiles(projectPath: string): string[] { + const out: string[] = []; + const seen = new Set(); + const push = (p: string): void => { + if (!seen.has(p) && existsSync(p)) { + seen.add(p); + out.push(p); + } + }; + + push(join(projectPath, 'package.json')); + + const packagesDir = join(projectPath, 'packages'); + if (existsSync(packagesDir) && isDirectory(packagesDir)) { + for (const entry of readdirSync(packagesDir)) { + if (entry === 'node_modules') continue; + const childDir = join(packagesDir, entry); + if (!isDirectory(childDir)) continue; + push(join(childDir, 'package.json')); + } + } + + return out.sort(); +} + +function isDirectory(p: string): boolean { + try { + return statSync(p).isDirectory(); + } catch { + return false; + } +} + +function readJsonFile(path: string): PackageJson | null { + try { + return JSON.parse(readFileSync(path, 'utf8')) as PackageJson; + } catch { + return null; + } +} + +/** + * Read the repo's current git tag via `git describe --tags --abbrev=0` (the + * most recent tag, exact or ancestor). Returns null when not a git repo, no + * tags exist, or git is unavailable — git.tag is then simply not a locus. + * + * We strip a leading `v` / `rfdb-v` / `binaries-v` prefix so the value compares + * as a bare semver against package.version (the release.sh tag convention). + */ +function readGitTag(projectPath: string): string | null { + try { + const raw = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { + cwd: projectPath, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + if (!raw) return null; + return stripTagPrefix(raw); + } catch { + return null; + } +} + +/** Strip the known release tag prefixes, leaving a bare semver. */ +function stripTagPrefix(tag: string): string { + const m = tag.match(/^(?:rfdb-v|binaries-v|v)(.+)$/); + return m ? m[1] : tag; +} + +/** + * Read the top version of the repo's CHANGELOG — the first `## [x.y.z]` (or + * `## x.y.z`) heading. Returns null when no CHANGELOG exists or no heading + * matches. + */ +function readChangelogTopVersion( + projectPath: string, +): { file: string; value: string } | null { + const candidates = ['CHANGELOG.md', 'CHANGELOG']; + for (const name of candidates) { + const path = join(projectPath, name); + if (!existsSync(path)) continue; + let text: string; + try { + text = readFileSync(path, 'utf8'); + } catch { + continue; + } + // First `## [x.y.z]` or `## x.y.z` heading. The semver is captured; an + // `[Unreleased]` / non-version heading is skipped (no semver match). + const re = /^##\s*\[?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\]?/m; + const m = text.match(re); + if (m) return { file: name, value: m[1] }; + } + return null; +} + +/** --------------------------------------------------------------------------- + * Helpers + * ------------------------------------------------------------------------ */ + +function makeRefNodeId(file: string, locus: string): string { + return `${file || ''}::version:ref::${locus}`; +} + +/** Render `absPath` relative to `root` (logical-path convention), with a + * forward-slash separator. Falls back to the abs path if it is not under root. */ +function relativeTo(root: string, absPath: string): string { + const normRoot = root.endsWith('/') ? root : `${root}/`; + return absPath.startsWith(normRoot) ? absPath.slice(normRoot.length) : absPath; +}