diff --git a/cmd/build_leyline_coverage_test.go b/cmd/build_leyline_coverage_test.go index da5e541..2d40797 100644 --- a/cmd/build_leyline_coverage_test.go +++ b/cmd/build_leyline_coverage_test.go @@ -15,7 +15,8 @@ import ( // seedCoverageFixture builds a synthetic leyline-parse db containing _ast // rows ONLY for Go, plus a source dir holding both a .go and a .sql file — // the exact hollow-projection shape the review of mache-73b885 reproduced -// live (leyline v0.7.5 has no sql grammar, so sql schema dirs project +// live (as of ley-line-open v0.8.0 only CUE lacks a grammar; sql/java/etc. +// all parse now, so cue is the sole hollow-projection language) // empty with zero warnings). func seedCoverageFixture(t *testing.T) (*sql.DB, string) { t.Helper() @@ -107,28 +108,29 @@ func TestRunBuildViaLeylineSchema_UnparseableLanguageErrors(t *testing.T) { defer saved.restore() src := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(src, "util.sql"), - []byte("CREATE TABLE users (id INTEGER PRIMARY KEY);\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(src, "conf.cue"), + []byte("package conf\n\nx: 1\n"), 0o644)) output := filepath.Join(t.TempDir(), "out.db") - // The ORIGINAL #524 repro: the sql PRESET ref. Preset schemas carry - // no Language hints, so this exercises the preset-derived language - // stamp, not the hint collector. - schemaPath = "sql" + // cue is the SOLE language ley-line-open v0.8.0 has no grammar for + // (no tree-sitter-0.26 cue grammar exists) — the last hollow-projection + // case after sql/java/etc. gained grammars. Preset ref (no Language + // hints) exercises the preset-derived language stamp. + schemaPath = "cue" err := runBuildViaLeyline(src, output, true /* explicit backend */) require.Error(t, err, "preset-ref hollow projection must not build silently on the explicit backend") - assert.Contains(t, err.Error(), "sql", "error must name the unparseable language") + assert.Contains(t, err.Error(), "cue", "error must name the unparseable language") assert.Contains(t, err.Error(), "--backend=tree-sitter", "error must point at the working escape hatch") // Same via an explicit Language-hinted schema FILE (the hint collector). work := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(work, "schema.json"), - []byte(`{"version":"v1alpha1","nodes":[{"name":"tables","selector":"(create_table_statement) @t","language":"sql"}]}`), 0o644)) + []byte(`{"version":"v1alpha1","nodes":[{"name":"fields","selector":"(field) @f","language":"cue"}]}`), 0o644)) t.Chdir(work) schemaPath = "schema.json" err = runBuildViaLeyline(src, output, true /* explicit backend */) require.Error(t, err, "hint-based hollow projection must not build silently on the explicit backend") - assert.Contains(t, err.Error(), "sql") + assert.Contains(t, err.Error(), "cue") // Auto path: warns and builds (advisory), does not error. require.NoError(t, runBuildViaLeyline(src, output, false /* auto */), diff --git a/cmd/serve_find_smells_views_test.go b/cmd/serve_find_smells_views_test.go index 7d85e00..8f82be8 100644 --- a/cmd/serve_find_smells_views_test.go +++ b/cmd/serve_find_smells_views_test.go @@ -167,6 +167,62 @@ func TestEnsureCanonicalViews_ReferrerFromContainerNodeID(t *testing.T) { assert.Equal(t, "f.go/call_expression_top", topReferrer, "empty container_node_id falls back to node_id") } +// TestEnsureCanonicalViews_QualifierFromNodeRefs pins the node_refs.qualifier +// passthrough (ley-line-open v0.8.0, mache-dcb808): the mention arm surfaces +// the ref's qualifier (receiver/selector text) instead of the pre-v0.8.0 +// hardcoded ”. Legacy dbs without the column degrade to ”. +func TestEnsureCanonicalViews_QualifierFromNodeRefs(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "qual.db") + db, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + defer func() { _ = db.Close() }() + + _, err = db.Exec(` + CREATE TABLE node_defs (token TEXT, node_id TEXT); + CREATE TABLE node_refs (token TEXT, node_id TEXT, qualifier TEXT); + INSERT INTO node_refs VALUES + ('Println', 'f.go/call_0', 'fmt'), + ('Method', 'f.go/call_1', 'obj'), + ('bareCall','f.go/call_2', NULL); + `) + require.NoError(t, err) + + qg := &sqlDBQuerier{db: db} + require.NoError(t, ensureCanonicalViews(qg)) + + var q string + require.NoError(t, db.QueryRow(`SELECT qualifier FROM v_refs WHERE token='Println'`).Scan(&q)) + assert.Equal(t, "fmt", q, "mention arm must surface node_refs.qualifier") + require.NoError(t, db.QueryRow(`SELECT qualifier FROM v_refs WHERE token='Method'`).Scan(&q)) + assert.Equal(t, "obj", q) + require.NoError(t, db.QueryRow(`SELECT qualifier FROM v_refs WHERE token='bareCall'`).Scan(&q)) + assert.Equal(t, "", q, "NULL qualifier degrades to empty string") +} + +// TestEnsureCanonicalViews_QualifierAbsentDegradesToEmpty pins the legacy +// path: a node_refs WITHOUT a qualifier column must still build v_refs with +// an empty qualifier (no error). +func TestEnsureCanonicalViews_QualifierAbsentDegradesToEmpty(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "noqual.db") + db, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + defer func() { _ = db.Close() }() + + _, err = db.Exec(` + CREATE TABLE node_defs (token TEXT, node_id TEXT); + CREATE TABLE node_refs (token TEXT, node_id TEXT); + INSERT INTO node_refs VALUES ('x', 'f.go/call_0'); + `) + require.NoError(t, err) + + qg := &sqlDBQuerier{db: db} + require.NoError(t, ensureCanonicalViews(qg), "missing qualifier column must not error") + + var q string + require.NoError(t, db.QueryRow(`SELECT qualifier FROM v_refs WHERE token='x'`).Scan(&q)) + assert.Equal(t, "", q) +} + // TestEnsureCanonicalViews_ReferrerFallsBackWithoutContainer pins the // legacy/tree-sitter shape: node_refs WITHOUT a container_node_id column → // referrer_node_id is node_id (the mache-schema caller construct), unchanged. diff --git a/cmd/smell_refs_views.go b/cmd/smell_refs_views.go index 78ae12d..317c073 100644 --- a/cmd/smell_refs_views.go +++ b/cmd/smell_refs_views.go @@ -106,6 +106,21 @@ func ensureCanonicalViews(qg refsQuerier) error { return fmt.Errorf("probe node_refs.container_node_id: %w", err) } + // node_refs.qualifier (ley-line-open v0.8.0, bead ley-line-open-4dde42): + // the receiver/selector text of a call site — the bare-token row of a + // dual-emit pair carries its qualifier (the `Println` row carries `fmt`; + // `obj.Method()` carries `obj`; '' for bare calls). Before v0.8.0 the + // mention arm had no qualifier source, so it emitted a constant '' and + // only capnp binding rows carried one. Surfacing it lets package-scoped + // rules GROUP BY structurally (fan_out_skew) and resolve stdlib-vs-local + // callees through _imports (fatal_call rung-1, mache-4dc205). Probe and + // prefer it; degrades to '' on legacy dbs — tree-sitter/mache-schema + // behavior is unchanged. + hasRefsQualifier, err := tableHasColumn(qg, "node_refs", "qualifier") + if err != nil { + return fmt.Errorf("probe node_refs.qualifier: %w", err) + } + defsHashExpr := "NULL" if hasDefsNodeHash { defsHashExpr = "node_hash" @@ -142,13 +157,17 @@ func ensureCanonicalViews(qg refsQuerier) error { if hasRefsContainer { refsReferrerExpr = "COALESCE(NULLIF(container_node_id, ''), node_id)" } + refsQualExpr := "''" + if hasRefsQualifier { + refsQualExpr = "COALESCE(qualifier, '')" + } refsBody := `SELECT ` + refsReferrerExpr + ` AS referrer_node_id, token, NULL AS target_node_id, NULL AS ref_uri, NULL AS ref_line, 'mention' AS fidelity, - '' AS qualifier, + ` + refsQualExpr + ` AS qualifier, ` + refsHashExpr + ` AS node_hash FROM node_refs` // Binding-fidelity rows come from the per-connection diff --git a/docs/smell-baseline.json b/docs/smell-baseline.json index 81bd150..a8368b1 100644 --- a/docs/smell-baseline.json +++ b/docs/smell-baseline.json @@ -11,6 +11,16 @@ "source_id": "internal/ingest/benchmark_test.go", "count": 1 }, + { + "rule_id": "dead_code", + "source_id": "internal/treesitter/elixir/scanner.c", + "count": 7 + }, + { + "rule_id": "dead_code", + "source_id": "internal/treesitter/elixir/tree_sitter/parser.h", + "count": 16 + }, { "rule_id": "dead_code", "source_id": "mount/mount.go", @@ -469,7 +479,7 @@ { "rule_id": "duplicate_code", "source_id": "internal/writeback/validate_test.go", - "count": 6 + "count": 2 }, { "rule_id": "duplicate_code", @@ -601,11 +611,21 @@ "source_id": "cmd/serve_test.go", "count": 10 }, + { + "rule_id": "duplicate_definitions", + "source_id": "cmd/testdata/preset_fixtures/bash/build.sh", + "count": 1 + }, { "rule_id": "duplicate_definitions", "source_id": "cmd/testdata/preset_fixtures/rust/lib.rs", "count": 2 }, + { + "rule_id": "duplicate_definitions", + "source_id": "cmd/testdata/preset_fixtures/sql/schema.sql", + "count": 1 + }, { "rule_id": "duplicate_definitions", "source_id": "cmd/untested_function_leyline_test.go", @@ -616,6 +636,11 @@ "source_id": "cmd/utils.go", "count": 1 }, + { + "rule_id": "duplicate_definitions", + "source_id": "examples/testdata/sql_sample.sql", + "count": 2 + }, { "rule_id": "duplicate_definitions", "source_id": "internal/graph/graph.go", @@ -696,6 +721,11 @@ "source_id": "internal/testfixtures/registry.go", "count": 3 }, + { + "rule_id": "duplicate_definitions", + "source_id": "internal/treesitter/elixir/tree_sitter/parser.h", + "count": 4 + }, { "rule_id": "duplicate_definitions", "source_id": "internal/vfs/types.go", @@ -716,6 +746,16 @@ "source_id": "mount/mount.go", "count": 2 }, + { + "rule_id": "duplicate_definitions", + "source_id": "scripts/arena.sh", + "count": 2 + }, + { + "rule_id": "duplicate_definitions", + "source_id": "scripts/leyline-integration-test.sh", + "count": 1 + }, { "rule_id": "duplicate_definitions", "source_id": "testdata/snapshots/medium-rust-rosary/crates/bdr/src/accrete.rs", @@ -944,7 +984,7 @@ { "rule_id": "duplicate_definitions", "source_id": "testdata/snapshots/medium-rust-rosary/src/observation/mod.rs", - "count": 4 + "count": 5 }, { "rule_id": "duplicate_definitions", @@ -1146,6 +1186,26 @@ "source_id": "testdata/snapshots/medium-rust-rosary/tests/cli_integration.rs", "count": 1 }, + { + "rule_id": "duplicate_definitions", + "source_id": "tests/llm_refactor_challenge.sh", + "count": 1 + }, + { + "rule_id": "duplicate_definitions", + "source_id": "tests/test_data/sql/test.sql", + "count": 2 + }, + { + "rule_id": "duplicate_definitions", + "source_id": "tests/torture_test.sh", + "count": 1 + }, + { + "rule_id": "duplicate_definitions", + "source_id": "tests/verify_git_projection.sh", + "count": 1 + }, { "rule_id": "duplicate_definitions", "source_id": "tools/coverage-gate/main.go", @@ -1186,6 +1246,11 @@ "source_id": "tools/notion-fetch/main.go", "count": 1 }, + { + "rule_id": "fan_out_skew", + "source_id": "cmd/agent.go", + "count": 1 + }, { "rule_id": "fan_out_skew", "source_id": "cmd/build.go", @@ -1199,7 +1264,7 @@ { "rule_id": "fan_out_skew", "source_id": "cmd/cache_oci.go", - "count": 2 + "count": 4 }, { "rule_id": "fan_out_skew", @@ -1214,7 +1279,7 @@ { "rule_id": "fan_out_skew", "source_id": "cmd/mount_control.go", - "count": 2 + "count": 1 }, { "rule_id": "fan_out_skew", @@ -1229,7 +1294,7 @@ { "rule_id": "fan_out_skew", "source_id": "cmd/serve.go", - "count": 2 + "count": 3 }, { "rule_id": "fan_out_skew", @@ -1286,6 +1351,11 @@ "source_id": "cmd/serve_handlers.go", "count": 1 }, + { + "rule_id": "fan_out_skew", + "source_id": "cmd/serve_hosted.go", + "count": 1 + }, { "rule_id": "fan_out_skew", "source_id": "cmd/serve_impact.go", @@ -1299,7 +1369,7 @@ { "rule_id": "fan_out_skew", "source_id": "cmd/serve_registry.go", - "count": 3 + "count": 2 }, { "rule_id": "fan_out_skew", @@ -1313,12 +1383,12 @@ }, { "rule_id": "fan_out_skew", - "source_id": "internal/control/control.go", + "source_id": "internal/graph/arena_writer.go", "count": 1 }, { "rule_id": "fan_out_skew", - "source_id": "internal/graph/arena_writer.go", + "source_id": "internal/graph/community.go", "count": 1 }, { @@ -1329,7 +1399,7 @@ { "rule_id": "fan_out_skew", "source_id": "internal/graph/memstore_refsdb.go", - "count": 2 + "count": 1 }, { "rule_id": "fan_out_skew", @@ -1401,6 +1471,11 @@ "source_id": "internal/ingest/sitter_walker.go", "count": 4 }, + { + "rule_id": "fan_out_skew", + "source_id": "internal/ingest/sqlite_writer.go", + "count": 1 + }, { "rule_id": "fan_out_skew", "source_id": "internal/lattice/greedy.go", @@ -1421,16 +1496,6 @@ "source_id": "internal/leyline/trigger.go", "count": 1 }, - { - "rule_id": "fan_out_skew", - "source_id": "internal/linter/linter.go", - "count": 1 - }, - { - "rule_id": "fan_out_skew", - "source_id": "internal/lsp/binding_log.go", - "count": 1 - }, { "rule_id": "fan_out_skew", "source_id": "internal/materialize/json.go", @@ -1464,18 +1529,28 @@ { "rule_id": "fan_out_skew", "source_id": "testdata/snapshots/medium-rust-rosary/src/acp.rs", - "count": 1 + "count": 2 }, { "rule_id": "fan_out_skew", "source_id": "testdata/snapshots/medium-rust-rosary/src/dispatch/mod.rs", - "count": 2 + "count": 3 + }, + { + "rule_id": "fan_out_skew", + "source_id": "testdata/snapshots/medium-rust-rosary/src/dispatch/providers.rs", + "count": 1 }, { "rule_id": "fan_out_skew", "source_id": "testdata/snapshots/medium-rust-rosary/src/dolt/mod.rs", "count": 1 }, + { + "rule_id": "fan_out_skew", + "source_id": "testdata/snapshots/medium-rust-rosary/src/github_mirror.rs", + "count": 1 + }, { "rule_id": "fan_out_skew", "source_id": "testdata/snapshots/medium-rust-rosary/src/linear.rs", @@ -1489,13 +1564,18 @@ { "rule_id": "fan_out_skew", "source_id": "testdata/snapshots/medium-rust-rosary/src/reconcile/mod.rs", - "count": 2 + "count": 3 }, { "rule_id": "fan_out_skew", "source_id": "testdata/snapshots/medium-rust-rosary/src/reconcile/orchestration.rs", "count": 1 }, + { + "rule_id": "fan_out_skew", + "source_id": "testdata/snapshots/medium-rust-rosary/src/reconcile/threading.rs", + "count": 1 + }, { "rule_id": "fan_out_skew", "source_id": "testdata/snapshots/medium-rust-rosary/src/reconcile/triage.rs", @@ -1514,17 +1594,22 @@ { "rule_id": "fan_out_skew", "source_id": "testdata/snapshots/medium-rust-rosary/src/serve/handlers.rs", - "count": 4 + "count": 7 }, { "rule_id": "fan_out_skew", "source_id": "testdata/snapshots/medium-rust-rosary/src/serve/ipc.rs", - "count": 2 + "count": 3 }, { "rule_id": "fan_out_skew", "source_id": "testdata/snapshots/medium-rust-rosary/src/serve/mod.rs", - "count": 2 + "count": 4 + }, + { + "rule_id": "fan_out_skew", + "source_id": "testdata/snapshots/medium-rust-rosary/src/serve/webhook.rs", + "count": 1 }, { "rule_id": "fan_out_skew", @@ -1536,6 +1621,11 @@ "source_id": "testdata/snapshots/medium-rust-rosary/src/sync.rs", "count": 1 }, + { + "rule_id": "fan_out_skew", + "source_id": "testdata/snapshots/medium-rust-rosary/tests/cli_integration.rs", + "count": 1 + }, { "rule_id": "fan_out_skew", "source_id": "tools/coverage-gate/main.go", @@ -1566,6 +1656,11 @@ "source_id": "tools/notion-fetch/main.go", "count": 1 }, + { + "rule_id": "fan_out_skew", + "source_id": "tools/server-json-gen/main.go", + "count": 1 + }, { "rule_id": "god_file", "source_id": "cmd/serve_registry.go", @@ -2246,16 +2341,6 @@ "source_id": "internal/leyline/version_check.go", "count": 1 }, - { - "rule_id": "untested_function", - "source_id": "internal/linter/linter.go", - "count": 1 - }, - { - "rule_id": "untested_function", - "source_id": "internal/writeback/validate.go", - "count": 1 - }, { "rule_id": "untested_function", "source_id": "mount/mount.go", diff --git a/internal/leyline/binary_pin.go b/internal/leyline/binary_pin.go index 51d0552..04c542f 100644 --- a/internal/leyline/binary_pin.go +++ b/internal/leyline/binary_pin.go @@ -26,10 +26,10 @@ import ( // gh release view --repo agentic-research/ley-line-open --json assets \ // --jq '.assets[]|select(.name|startswith("leyline-"))|"\(.name) \(.digest)"' var leylinePinnedSHA256 = map[string]string{ - "darwin-amd64": "54b21072d3c0e362304b93f82065d21451619751d9b66ad4291de0a64b1a5f3e", - "darwin-arm64": "c20ae5ed2b89b7693463240fcba25285c019502b3f29d0476ed3b3430e6430cb", - "linux-amd64": "34cc851f744f3f2662d89473be1bc6a175b9777747e2922fd3efd88292b9fcb2", - "linux-arm64": "c48b64041a68744d66274cd18d5b0996d0e4db07c51af8430c8b31fe7ddffe79", + "darwin-amd64": "f5fb211ff35b863273cd1cf2c1ed911a9724d53d444aa4f0f90180e263fb5734", + "darwin-arm64": "78e47b7abf70c29e42a31a937ff84f8f0c20af1625727818a133f6cea9418d83", + "linux-amd64": "d5510a540d2ed26bf8d861899f07cac62a78778e4ef0dd00d9da3133d8b5162f", + "linux-arm64": "03ca51e453193ad27020dbc30c811da1ccc2a1f12ef980eb9a7cb6f8b07d876d", } // leylineVersionMatchesPin runs ` --version` and reports whether the diff --git a/internal/leyline/socket.go b/internal/leyline/socket.go index 8c9cf19..447a106 100644 --- a/internal/leyline/socket.go +++ b/internal/leyline/socket.go @@ -795,15 +795,18 @@ func (c *SocketClient) Prioritize(files []string) error { // column (closed κ vocabulary — lets dead_code filter WHERE canonical_kind IN // ('function','method','type'), collapsing its leyline over-report). All // additive — mache does not consume the new columns / CFG types yet (that is -// the LLO-only smell-gate consolidation, mache-608a3c), and the v0.7.2–v0.7.5 +// the LLO-only smell-gate consolidation, mache-608a3c), and the v0.7.2–v0.8.0 // leyline-schema Go submodule tags are unpublished (clients/go/leyline-schema -// stops at v0.7.1), so the schema-client pin remains at v0.7.1. The -// version-parity gate (mache-b8af69) enforces MAJOR.MINOR agreement only — -// 0.7 == 0.7 — so this is in contract. Earlier context: v0.7.0 raised compat_min -// to 0.6.0 and added source_blobs / capnp_blobs / _ast_pointer, the unified -// daemon.sheaf.invalidate topic, and cross-language def/ref extraction -// (ley-line-open-caf423); v0.7.1 was a sheaf-correctness patch (δ⁰ -// orientation-invariance, cascade fixed-point). +// stops at v0.7.1) BECAUSE THE WIRE HASN'T CHANGED — wire_format_major=1 with +// compat floor 0.6.0 has held from v0.7.0 through v0.8.0, so the v0.7.1 schema +// client still decodes v0.8.0's wire. The version-parity gate (mache-b8af69) +// checks the schema pin sits in [leylineSchemaCompatFloor, binary], not that +// it equals the binary minor (that only held while everything was 0.7.x). +// Earlier context: v0.7.0 raised compat_min to 0.6.0 and added source_blobs / +// capnp_blobs / _ast_pointer, the unified daemon.sheaf.invalidate topic, and +// cross-language def/ref extraction (ley-line-open-caf423); v0.7.1 was a +// sheaf-correctness patch; v0.8.0 added validate coverage (27/28), +// node_refs.qualifier, and extraction epochs — all additive, wire unchanged. // // The version-check (leylineVersionMatchesPin) is EXACT major.minor.patch — // LLO patch releases have changed the emitted _ast schema (0.7.4 added @@ -811,16 +814,30 @@ func (c *SocketClient) Prioritize(files []string) error { // float (mache-608a3c). v0.7.5 ships all four leyline-- daemon // binaries (darwin/linux × amd64/arm64). // -// BUMP THIS to the latest published ley-line-open release with binary assets; -// bump the go.mod leyline-schema pin too whenever the WIRE format changes (i.e. -// wire_format_major or the major.minor moves — a patch-only daemon bump does not). +// BUMP THIS to the latest published ley-line-open release with binary assets. +// The go.mod leyline-schema pin only needs bumping when the WIRE format +// changes (a new schema module tag is published) — a binary-version bump with +// unchanged wire (like v0.8.0) does NOT require a schema bump; the parity gate +// enforces the [floor, binary] range that makes this safe. // // Doubles as this build's leyline schema-client version for the startup // wire-compat handshake (VerifyReachableDaemonVersion, mache-8kif): mache // queries the daemon's leyline_version op and refuses on a structural -// mismatch. Its major.minor is kept in lockstep with the go.mod leyline-schema -// pin by the version-parity gate (mache-b8af69). -const leylineBinaryVersion = "v0.7.8" +// mismatch. +const leylineBinaryVersion = "v0.8.0" + +// leylineSchemaCompatFloor is the OLDEST leyline-schema Go client version +// whose wire format the pinned binary still accepts (ley-line-open's +// compat_min_schema_version). The schema Go module +// (clients/go/leyline-schema, go.mod pinned) is tagged SEPARATELY from the +// binary and only re-cut when the capnp wire types change — so it lags the +// binary version legitimately (v0.8.0 binary, but the wire has been +// wire_format_major=1 / floor 0.6.0 since well before, so the newest schema +// tag is still v0.7.1). The parity gate asserts the go.mod schema pin sits in +// [floor, binary], NOT that it equals the binary's minor (which only held by +// coincidence while everything was in the 0.7.x line). BUMP this when +// ley-line-open raises compat_min_schema_version. (mache-b8af69 / mache-dcb808) +const leylineSchemaCompatFloor = "v0.6.0" // leylineReleaseURLTemplate is the GitHub releases URL for the public // ley-line-open repository. The earlier URL pointed at the private diff --git a/internal/leyline/version_parity_test.go b/internal/leyline/version_parity_test.go index 00f59df..1ca25a5 100644 --- a/internal/leyline/version_parity_test.go +++ b/internal/leyline/version_parity_test.go @@ -17,14 +17,17 @@ import ( // (github.com/agentic-research/ley-line-open/clients/go/leyline-schema) // - the leylineBinaryVersion const (the daemon binary mache downloads) // -// Per socket.go's design note, the *patch* may float (a daemon-only LSP fix -// ships a binary with no wire change), but the MAJOR.MINOR must match because -// that is the wire/schema format the Go client decodes. Today only a comment -// and a human keeping two pins in lockstep enforce that — the exact setup -// that produced the stale-cached-0.4.1 skew (mache-0acdf6). This test makes -// the invariant executable: a minor/major bump to one without the other fails -// CI. The dynamic runtime handshake is the complementary mache-8kif (blocked -// on LLO shipping a daemon `version` op). +// The invariant (mache-b8af69): the go.mod leyline-schema client must decode +// the wire the pinned binary speaks. The binary and the schema Go module are +// tagged on DIFFERENT cadences — the binary bumps every release, the schema +// module (clients/go/leyline-schema) only when the capnp wire types change — +// so equal-minor is NOT the contract (it held only by coincidence while +// everything was 0.7.x, and v0.8.0 broke that: binary v0.8.0, newest schema +// tag v0.7.1, wire unchanged). The real contract is a RANGE: the schema pin +// must sit in [leylineSchemaCompatFloor, binary]. Below the floor the wire is +// incompatible; above the binary the client expects types the daemon doesn't +// emit. This makes the invariant executable — a schema/binary pair that falls +// outside the range fails CI. Complementary runtime handshake: mache-8kif. func TestLeylineSchemaBinaryVersionParity(t *testing.T) { root := repoRoot(t) modBytes, err := os.ReadFile(filepath.Join(root, "go.mod")) @@ -32,20 +35,50 @@ func TestLeylineSchemaBinaryVersionParity(t *testing.T) { // The require line: " vMAJOR.MINOR.PATCH[...]". Pseudo-versions // still lead with vMAJOR.MINOR.PATCH, so the base captures fine. - schemaRE := regexp.MustCompile(`(?m)^\s*github\.com/agentic-research/ley-line-open/clients/go/leyline-schema\s+v(\d+)\.(\d+)\.\d+`) + schemaRE := regexp.MustCompile(`(?m)^\s*github\.com/agentic-research/ley-line-open/clients/go/leyline-schema\s+(v\d+\.\d+\.\d+)`) sm := schemaRE.FindStringSubmatch(string(modBytes)) require.NotNil(t, sm, "leyline-schema require line not found in go.mod") - schemaMM := sm[1] + "." + sm[2] + schemaVer := sm[1] - binRE := regexp.MustCompile(`^v(\d+)\.(\d+)\.\d+`) - bm := binRE.FindStringSubmatch(leylineBinaryVersion) - require.NotNil(t, bm, "leylineBinaryVersion %q is not a vMAJOR.MINOR.PATCH tag", leylineBinaryVersion) - binMM := bm[1] + "." + bm[2] + // schema pin must be >= the binary's wire compat floor … + assert.GreaterOrEqual(t, compareSemver(schemaVer, leylineSchemaCompatFloor), 0, + "leyline-schema go.mod pin (%s) is below the binary's wire compat floor (%s) — "+ + "the client can't decode %s's wire (mache-b8af69; bump the schema pin or the floor)", + schemaVer, leylineSchemaCompatFloor, leylineBinaryVersion) - assert.Equal(t, schemaMM, binMM, - "leyline-schema go.mod pin (v%s.x) and leylineBinaryVersion const (%s) must agree on major.minor — "+ - "that's the wire format. Patch may float; a minor/major bump must update BOTH (mache-b8af69; see socket.go's leylineBinaryVersion note).", - schemaMM, leylineBinaryVersion) + // … and <= the binary version (the client must not expect newer types + // than the daemon emits). + assert.LessOrEqual(t, compareSemver(schemaVer, leylineBinaryVersion), 0, + "leyline-schema go.mod pin (%s) is newer than the pinned binary (%s) — "+ + "the client expects wire types the daemon may not emit (mache-b8af69)", + schemaVer, leylineBinaryVersion) +} + +// schemaInBinaryRange is the pure predicate the parity gate asserts: a schema +// client version is compatible iff floor <= schema <= binary. Extracted so the +// boundary semantics are pinned with synthetic values, independent of the live +// go.mod pin and consts. +func schemaInBinaryRange(schema, floor, binary string) bool { + return compareSemver(schema, floor) >= 0 && compareSemver(schema, binary) <= 0 +} + +func TestSchemaInBinaryRange_Boundaries(t *testing.T) { + const floor, binary = "v0.6.0", "v0.8.0" + cases := []struct { + schema string + want bool + why string + }{ + {"v0.7.1", true, "the real v0.8.0 case: wire unchanged, schema lags the binary minor"}, + {"v0.6.0", true, "exactly at the floor is compatible"}, + {"v0.8.0", true, "exactly at the binary is compatible"}, + {"v0.5.9", false, "below the floor: wire incompatible"}, + {"v0.8.1", false, "above the binary: client expects types the daemon may not emit"}, + } + for _, c := range cases { + assert.Equalf(t, c.want, schemaInBinaryRange(c.schema, floor, binary), + "schema=%s floor=%s binary=%s — %s", c.schema, floor, binary, c.why) + } } // repoRoot walks up from the test's working directory to the module root diff --git a/internal/writeback/validate.go b/internal/writeback/validate.go index 19d85d0..3b06d1f 100644 --- a/internal/writeback/validate.go +++ b/internal/writeback/validate.go @@ -9,14 +9,15 @@ package writeback // see that function's doc for the latency profile (sub-ms with a live daemon, // a one-off spawn cost on the first write otherwise). // -// Language coverage CHANGED with the migration: the daemon validates the -// extension keys in leylineValidateLangs below, and HCL/Terraform validates -// IN-PROCESS via hclsyntax (hclwrite.Format is a token formatter, NOT a -// validator — it mangles broken input, which is why the in-process check -// exists). Every other extension the old in-process grammar set covered -// (.sql/.yaml/.md/.toml/.json plus the C-family/JVM/scripting set) passes -// through UNVALIDATED — same contract as an unknown extension — until -// leyline grows the grammars (ley-line-open-e5addb). +// Language coverage: since ley-line-open v0.8.0 the daemon validates EVERY +// mache registry language except cue (no tree-sitter-0.26 cue grammar), so +// daemonValidates gates on "recognized source extension, not cue, not HCL". +// HCL/Terraform validates IN-PROCESS via hclsyntax (hclwrite.Format is a +// token formatter, NOT a validator — it mangles broken input; the daemon +// also validates HCL now, but the in-process path is faster and needs no +// daemon). Only cue and unrecognized extensions pass through unvalidated. +// The daemon identifies the language from the path, so mache keeps no +// per-extension language-id map (the old one missed .cc/.cxx/.mjs aliases). import ( "fmt" @@ -26,6 +27,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/agentic-research/mache/internal/lang" "github.com/agentic-research/mache/internal/leyline" ) @@ -41,39 +43,30 @@ func (e *ValidationError) Error() string { return fmt.Sprintf("%s:%d:%d: %s", e.FilePath, e.Line+1, e.Column+1, e.Message) } -// leylineValidateLangs is the set of extension keys the pinned leyline -// daemon's validate op accepts (ley-line-open rs/ll-open/fs/src/validate.rs -// language_for_extension). The key doubles as the wire `language` value. -var leylineValidateLangs = map[string]bool{ - "go": true, - "py": true, - "js": true, - "ts": true, - "tsx": true, - "rs": true, - "ex": true, - "exs": true, -} - -// langKeyForPath maps a file path to the leyline validate language key, or "" -// when the extension is not validated (pass-through). -func langKeyForPath(filePath string) string { - ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") - if leylineValidateLangs[ext] { - return ext +// daemonValidates reports whether filePath is syntax-validated by the leyline +// daemon (as opposed to in-process HCL, or pass-through). Since ley-line-open +// v0.8.0 the daemon validates every registry language EXCEPT cue (no +// tree-sitter-0.26 cue grammar exists), so the gate is "recognized source +// extension, not cue, not HCL" — the language itself is identified by the +// daemon from the path, so mache never maintains a per-extension language-id +// map (the old map missed C++'s .cc/.cxx/.hpp aliases, JS's .mjs, etc.). +func daemonValidates(filePath string) bool { + l := lang.ForPath(filePath) + if l == nil { + return false // unrecognized extension → pass through + } + if l.Name == "cue" { + return false // no tree-sitter-0.26 cue grammar; daemon rejects it } - return "" + return !isHCLPath(filePath) // HCL validates in-process (below) } // SupportedPath reports whether filePath's extension is syntax-validated on -// write-back — by the leyline daemon (Go/Python/JS/TS/TSX/Rust/Elixir) or -// in-process (HCL/Terraform via hclsyntax). Everything else — including -// .sql/.yaml/.md/.toml/.json and the C-family/JVM/scripting extensions the -// old in-process grammar set covered — passes through UNVALIDATED and -// splices as written; there is no structural check for those until leyline -// grows the grammars. +// write-back — by the leyline daemon (every registry language except cue, +// since ley-line-open v0.8.0) or in-process (HCL/Terraform via hclsyntax). +// Only cue and unrecognized extensions pass through unvalidated. func SupportedPath(filePath string) bool { - return isHCLPath(filePath) || langKeyForPath(filePath) != "" + return isHCLPath(filePath) || daemonValidates(filePath) } // Validate checks content for syntax errors via the leyline daemon and @@ -111,13 +104,15 @@ func validateRemote(content []byte, filePath string, wantAST bool) (*leyline.AST if isHCLPath(filePath) { return nil, validateHCL(content, filePath) } - key := langKeyForPath(filePath) - if key == "" { - return nil, nil // not validated — pass through + if !daemonValidates(filePath) { + return nil, nil // cue / unrecognized extension — pass through } - emit := wantAST && key == "go" + // emit_ast is Go-only (the sole language mache has AST lint rules for). + // language="" → the daemon infers it from the path, which handles every + // extension alias (.cc/.cxx/.mjs/...) without a per-extension map. + emit := wantAST && lang.ForPath(filePath).Name == "go" - res, err := leyline.ValidateContent(content, key, filePath, emit) + res, err := leyline.ValidateContent(content, "", filePath, emit) if err != nil { return nil, fmt.Errorf("validate %s: %w", filePath, err) } @@ -202,11 +197,10 @@ func ASTErrors(content []byte, filePath string) []ValidationError { if isHCLPath(filePath) { return hclErrors(content, filePath) } - key := langKeyForPath(filePath) - if key == "" { + if !daemonValidates(filePath) { return nil } - res, err := leyline.ValidateContent(content, key, filePath, false) + res, err := leyline.ValidateContent(content, "", filePath, false) if err != nil || res.OK { return nil } diff --git a/internal/writeback/validate_test.go b/internal/writeback/validate_test.go index 1cb1e1b..938cf26 100644 --- a/internal/writeback/validate_test.go +++ b/internal/writeback/validate_test.go @@ -55,8 +55,10 @@ func TestValidate_ValidGo_FakeDaemon(t *testing.T) { require.NotNil(t, gotReq, "daemon must be consulted for .go content") assert.Equal(t, "validate", gotReq["op"]) - assert.Equal(t, "go", gotReq["language"], "language key must be sent (it wins over path)") - assert.Equal(t, "test.go", gotReq["path"]) + assert.Equal(t, "test.go", gotReq["path"], + "path drives the daemon's language inference (no per-extension language-id map)") + _, hasLang := gotReq["language"] + assert.False(t, hasLang, "language must be omitted — the daemon infers it from the path") assert.Equal(t, "package main\n", gotReq["content"]) _, hasEmit := gotReq["emit_ast"] assert.False(t, hasEmit, "plain Validate must not request emit_ast") @@ -99,7 +101,8 @@ func TestValidate_DaemonWithoutErrorsKey_IsHardError(t *testing.T) { require.Error(t, err) var ve *ValidationError assert.False(t, errors.As(err, &ve), "daemon-too-old must not be a ValidationError") - assert.Contains(t, err.Error(), "v0.7.8") + assert.Contains(t, err.Error(), "errors", "must name the missing errors field") + assert.Contains(t, err.Error(), "daemon too old", "must diagnose the daemon as too old") } func TestValidate_DaemonErrorEnvelope_IsHardError(t *testing.T) { @@ -188,7 +191,8 @@ func TestValidateWithAST_EmitRequestedButMissing_IsHardError(t *testing.T) { _, err := ValidateWithAST([]byte("package main\n"), "test.go") require.Error(t, err) - assert.Contains(t, err.Error(), "v0.7.8") + assert.Contains(t, err.Error(), "ast", "must name the missing AST payload") + assert.Contains(t, err.Error(), "daemon too old", "must diagnose the daemon as too old") } func TestValidateWithAST_NonGoValidatedLanguage_NoEmit(t *testing.T) { @@ -207,13 +211,19 @@ func TestValidateWithAST_NonGoValidatedLanguage_NoEmit(t *testing.T) { assert.Nil(t, ast) require.NotNil(t, gotReq) _, hasEmit := gotReq["emit_ast"] - assert.False(t, hasEmit) - assert.Equal(t, "py", gotReq["language"]) + assert.False(t, hasEmit, "non-Go validated language must not request emit_ast") + assert.Equal(t, "test.py", gotReq["path"]) + _, hasLang := gotReq["language"] + assert.False(t, hasLang, "language inferred from path") } func TestValidateWithAST_PassThroughExtension_NilNil(t *testing.T) { + // .xyz is not a recognized source extension → pass through, daemon + // never contacted (dead socket would error if dialed). Note .md/.sql/ + // .yaml are VALIDATED since v0.8.0 — only cue + unknown extensions + // pass through now. useDeadSocket(t) - ast, err := ValidateWithAST([]byte("whatever"), "notes.md") + ast, err := ValidateWithAST([]byte("whatever"), "notes.xyz") assert.NoError(t, err) assert.Nil(t, ast) } @@ -273,11 +283,13 @@ func TestSupportedPath(t *testing.T) { {"MAIN.GO", true}, // extension matching is case-insensitive {"infra.tf", true}, // HCL validates in-process via hclsyntax {"mod.hcl", true}, - {"conf.yaml", false}, - {"query.sql", false}, - {"README.md", false}, - {"data.json", false}, - {"no_extension", false}, + {"conf.yaml", true}, // v0.8.0: daemon validates all registry langs + {"query.sql", true}, // except cue + {"README.md", true}, + {"data.json", false}, // .json has no tree-sitter Language (data, not source) + {"foo.cue", false}, // the ONE unvalidated registry language + {"no_extension", false}, // unrecognized → pass through + {"pic.png", false}, } for _, tc := range tests { t.Run(tc.path, func(t *testing.T) { diff --git a/validate/validate.go b/validate/validate.go index 3192d6d..f66bbc2 100644 --- a/validate/validate.go +++ b/validate/validate.go @@ -2,16 +2,14 @@ // // This is the public API for mache's AST validation. Since mache-73b885 it is // backed by the pinned leyline daemon's `validate` op (ley-line-open >= -// v0.7.8) instead of in-process tree-sitter, and supports Go, Python, -// JavaScript, TypeScript/TSX, Rust, and Elixir; HCL/Terraform validates -// in-process via hclsyntax. EVERY other extension passes through with no -// error — including .sql/.yaml/.md/.toml/.json AND the ~25 further -// extensions the old in-process grammar set covered (.c/.cpp/.java/.rb/ -// .php/.kt/.lua/.sh/...). Content in those languages splices as written, -// with no structural check, until leyline grows the grammars. A leyline -// daemon is acquired lazily on first use (see internal/leyline -// ValidateContent for the latency profile); daemon-acquisition failures -// surface as ordinary errors, never as false "valid" results. +// v0.8.0) instead of in-process tree-sitter. As of v0.8.0 the daemon +// validates EVERY mache registry language except CUE (no tree-sitter-0.26 +// grammar exists); HCL/Terraform validates in-process via hclsyntax (a fast, +// offline path — the daemon covers it too). Only .cue and unrecognized +// extensions pass through with no error. A leyline daemon is acquired lazily +// on first use (see internal/leyline ValidateContent for the latency +// profile); daemon-acquisition failures surface as ordinary errors, never as +// false "valid" results. // // Usage: // diff --git a/validate/validate_test.go b/validate/validate_test.go index 5f1bfc0..c0774b3 100644 --- a/validate/validate_test.go +++ b/validate/validate_test.go @@ -129,9 +129,10 @@ func TestContentErrors_BrokenHCLPopulatesSlice(t *testing.T) { } func TestSupportedExtension(t *testing.T) { - // Since mache-73b885 the supported set is the leyline daemon's validate - // language set — .tf/.hcl, .sql, .yaml, .md, .toml (covered by the old - // in-process grammar set) are now pass-through, .ex/.exs are new. + // Since ley-line-open v0.8.0 the daemon validates every mache registry + // language except cue; HCL/Terraform validates in-process. Only cue, + // .json (data, no tree-sitter Language), and unrecognized extensions + // pass through. tests := []struct { path string want bool @@ -142,9 +143,13 @@ func TestSupportedExtension(t *testing.T) { {"comp.tsx", true}, {"lib.rs", true}, {"app.ex", true}, - {"infra.tf", true}, // HCL validates in-process via hclsyntax (mache-73b885) - {"README.md", false}, // was true pre-mache-73b885 - {"data.json", false}, + {"infra.tf", true}, // HCL validates in-process via hclsyntax + {"README.md", true}, // v0.8.0: markdown validated + {"query.sql", true}, + {"conf.yaml", true}, + {"Main.java", true}, // C-family/JVM now validated too + {"foo.cue", false}, // the ONE unvalidated registry language + {"data.json", false}, // data, not a tree-sitter language {"no_extension", false}, } for _, tc := range tests {