diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dbda4d..d8a5fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [0.4.4] - Unreleased + +### Fixed + +- Reconciled the strict `@workspacejson/spec` validator with the VR-639/640 + contract corrections on a single release branch. +- Added the internal-only `@workspacejson/cli` normalization shim; it is marked + `private` and is not part of the public release. + ## [0.4.2] - 2026-07-16 ### Changed diff --git a/packages/agents-audit/CHANGELOG.md b/packages/agents-audit/CHANGELOG.md index 240b5a8..ef96af3 100644 --- a/packages/agents-audit/CHANGELOG.md +++ b/packages/agents-audit/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.4.4] - Unreleased + +### Patch Changes + +- Updated dependencies: `@workspacejson/rules@0.4.4` and `@workspacejson/spec@0.4.4`. + ## [0.4.3] - 2026-07-17 ### Patch Changes diff --git a/packages/agents-audit/package.json b/packages/agents-audit/package.json index a04d44c..46cbe8a 100644 --- a/packages/agents-audit/package.json +++ b/packages/agents-audit/package.json @@ -1,6 +1,6 @@ { "name": "agents-audit", - "version": "0.4.3", + "version": "0.4.4", "description": "Audit tool for AGENTS.md hygiene - reads .agents/workspace.json for richer findings", "license": "Apache-2.0", "author": "workspace-json contributors", diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..7fa2902 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,40 @@ +# @workspacejson/cli + +Joins dbt models to [workspace.json](https://www.workspacejson.dev) behavioral +intelligence (fragility, co-change, modification history) by +**repository-root-relative POSIX path**. + +## The problem it solves + +dbt's `manifest.json` reports `original_file_path` relative to the **dbt project +root**. A workspace.json `fileIndex` is keyed relative to the **git repository +root** (see `@workspacejson/spec`, VR-640). When the dbt project is nested in a +subdirectory — `dbt/` under the repo root, the common real-world layout — the two +path representations differ by exactly that prefix, and a naive join silently +returns **zero rows** (no error). This was reproduced empirically in the HAC-75 +probe: 5/5 match at the repo root, 5/5 miss when nested. + +## The fix (the normalization shim) + +``` +projectPrefix = relative(gitRoot, dbtProjectDir) // "dbt" when nested, "" at root +joinKey = projectPrefix ? `${projectPrefix}/${original_file_path}` : original_file_path +``` + +`dbtProjectDir` is wherever `dbt_project.yml` lives. Real repos hold more than one +dbt project, so `findDbtProjects()` enumerates **all** of them rather than +assuming a single knowable path. + +## Usage + +```bash +workspacejson --git-root . --manifest dbt/target/manifest.json --workspace-json .agents/workspace.json +``` + +Exits non-zero if any dbt project produces zero joined rows. + +## Status + +MVP. The path-normalization shim and join are implemented and tested (including +the HAC-75 nested-repo case, red-first). Consumes `@workspacejson/spec` as a +workspace sibling for the `fileIndex` key contract. diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..41361ff --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,50 @@ +{ + "name": "@workspacejson/cli", + "version": "0.0.1", + "private": true, + "description": "CLI that joins dbt models to workspace.json behavioral intelligence by repository-root-relative path (the DataHub / HAC-75 normalization shim).", + "license": "Apache-2.0", + "author": "workspace-json contributors", + "homepage": "https://www.workspacejson.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/workspace-json/agents-audit.git", + "directory": "packages/cli" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=20.0.0" + }, + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "bin": { + "workspacejson": "./dist/cli.js" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsup src/index.ts src/cli.ts --format esm --dts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@workspacejson/spec": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.4.0", + "tsup": "^8.0.0", + "vitest": "^1.6.0" + } +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000..15eb267 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { extractModels, findDbtProjects, type DbtManifest } from "./dbt.js"; +import { computeProjectPrefix, canonical } from "./normalize.js"; +import { joinModels, type FileIndex } from "./join.js"; + +interface Args { + gitRoot: string; + manifest: string; + workspaceJson: string; +} + +function parseArgs(argv: string[]): Args { + const map = new Map(); + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a?.startsWith("--")) map.set(a.slice(2), argv[++i] ?? ""); + } + const gitRoot = resolve(map.get("git-root") ?? process.cwd()); + const manifest = resolve(map.get("manifest") ?? "target/manifest.json"); + const workspaceJson = resolve(map.get("workspace-json") ?? ".agents/workspace.json"); + return { gitRoot, manifest, workspaceJson }; +} + +function readJson(path: string): T { + return JSON.parse(readFileSync(path, "utf8")) as T; +} + +/** + * Join dbt models (manifest.json) to workspace.json behavioral intelligence, + * normalizing dbt's project-relative paths to repo-root-relative keys (VR-640). + * Exits non-zero if any dbt project produces zero joined rows — the silent + * zero-row failure HAC-75 was built to surface. + */ +export function run(args: Args): number { + const manifest = readJson(args.manifest); + const models = extractModels(manifest); + const workspace = readJson<{ generated?: { fileIndex?: FileIndex }; fileIndex?: FileIndex }>( + args.workspaceJson, + ); + const fileIndex: FileIndex = workspace.generated?.fileIndex ?? workspace.fileIndex ?? {}; + + // The dbt project these models belong to is the manifest's grandparent dir + // (/target/manifest.json -> ). `projects` is the full multi-project + // enumeration (VR-640 guard), reported so a repo with several dbt projects is + // visibly not being treated as one. + const projects = findDbtProjects(args.gitRoot); + const dbtProjectDir = dirname(dirname(args.manifest)); + const prefix = computeProjectPrefix(args.gitRoot, dbtProjectDir); + + if (prefix === null) { + console.error(`dbt project ${dbtProjectDir} is not inside git root ${args.gitRoot}`); + return 2; + } + + const result = joinModels(models, prefix, fileIndex); + console.log(`git root: ${args.gitRoot}`); + console.log(`dbt project: ${canonical(dbtProjectDir)} (prefix: "${prefix}")`); + console.log(`projects found: ${projects.length}`); + console.log(`join: ${result.matched}/${result.total} models matched fileIndex`); + for (const row of result.rows) { + console.log(` [${row.matched ? "hit " : "MISS"}] ${row.originalFilePath} -> ${row.normalizedKey}`); + } + return result.total > 0 && result.matched === 0 ? 1 : 0; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(run(parseArgs(process.argv.slice(2)))); +} diff --git a/packages/cli/src/dbt.ts b/packages/cli/src/dbt.ts new file mode 100644 index 0000000..581c907 --- /dev/null +++ b/packages/cli/src/dbt.ts @@ -0,0 +1,56 @@ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; + +import type { DbtModel } from "./join.js"; + +interface ManifestNode { + resource_type?: string; + unique_id?: string; + original_file_path?: string; +} + +export interface DbtManifest { + nodes?: Record; +} + +/** Extract model nodes (resource_type === "model") from a parsed manifest.json. */ +export function extractModels(manifest: DbtManifest): DbtModel[] { + const models: DbtModel[] = []; + for (const node of Object.values(manifest.nodes ?? {})) { + if (node.resource_type === "model" && node.original_file_path) { + models.push({ + uniqueId: node.unique_id ?? node.original_file_path, + originalFilePath: node.original_file_path, + }); + } + } + return models; +} + +const DEFAULT_IGNORE = new Set(["node_modules", ".git", "target", "dbt_packages", "dist"]); + +/** + * Enumerate every dbt project under `root` — a directory containing a + * `dbt_project.yml`. VR-640's multi-project guard: real repos hold more than one + * dbt project, so the shim must never assume a single knowable project dir. + */ +export function findDbtProjects(root: string, ignore: Set = DEFAULT_IGNORE): string[] { + const found: string[] = []; + const walk = (dir: string): void => { + let entries: ReturnType; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; // unreadable dir — skip, don't crash the sweep + } + for (const entry of entries) { + if (entry.isFile() && entry.name === "dbt_project.yml") { + found.push(dir); + } else if (entry.isDirectory() && !ignore.has(entry.name)) { + walk(join(dir, entry.name)); + } + } + }; + walk(root); + return found.sort(); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..0afc89a --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,5 @@ +export { toPosix, canonical, computeProjectPrefix, normalizeModelPath } from "./normalize.js"; +export { joinModels } from "./join.js"; +export type { DbtModel, FileIndex, JoinRow, JoinResult } from "./join.js"; +export { extractModels, findDbtProjects } from "./dbt.js"; +export type { DbtManifest } from "./dbt.js"; diff --git a/packages/cli/src/join.ts b/packages/cli/src/join.ts new file mode 100644 index 0000000..412692b --- /dev/null +++ b/packages/cli/src/join.ts @@ -0,0 +1,55 @@ +import type { FileIndexEntry } from "@workspacejson/spec"; + +import { normalizeModelPath } from "./normalize.js"; + +/** A dbt model as read from manifest.json. */ +export interface DbtModel { + uniqueId: string; + /** `original_file_path` — relative to the dbt project root. */ + originalFilePath: string; +} + +/** + * workspace.json behavioral intelligence, keyed by repository-root-relative + * POSIX path (per @workspacejson/spec fileIndex, VR-640). Values follow the + * spec's own FileIndexEntry contract — the join only needs key presence. + */ +export type FileIndex = Record; + +export interface JoinRow { + uniqueId: string; + originalFilePath: string; + /** The normalized repo-root-relative key used for lookup. */ + normalizedKey: string; + matched: boolean; +} + +export interface JoinResult { + rows: JoinRow[]; + matched: number; + total: number; +} + +/** + * Join dbt models to a workspace.json fileIndex. Each model's project-relative + * `original_file_path` is normalized to a repo-root-relative key via + * `projectPrefix`, then looked up by membership. Pass an empty `projectPrefix` + * to join without normalization (the naive path — correct only when the dbt + * project sits at the git root). + */ +export function joinModels( + models: DbtModel[], + projectPrefix: string, + fileIndex: FileIndex, +): JoinResult { + const rows = models.map((m): JoinRow => { + const normalizedKey = normalizeModelPath(projectPrefix, m.originalFilePath); + return { + uniqueId: m.uniqueId, + originalFilePath: m.originalFilePath, + normalizedKey, + matched: Object.prototype.hasOwnProperty.call(fileIndex, normalizedKey), + }; + }); + return { rows, matched: rows.filter((r) => r.matched).length, total: rows.length }; +} diff --git a/packages/cli/src/normalize.ts b/packages/cli/src/normalize.ts new file mode 100644 index 0000000..1efc061 --- /dev/null +++ b/packages/cli/src/normalize.ts @@ -0,0 +1,39 @@ +import { relative, sep } from "node:path"; + +/** Convert an OS-native path to canonical POSIX (forward slashes). No-op on POSIX. */ +export function toPosix(p: string): string { + return sep === "\\" ? p.replace(/\\/g, "/") : p; +} + +/** Canonical form: POSIX separators, no leading "./", no trailing slash. */ +export function canonical(p: string): string { + return toPosix(p) + .replace(/^\.\//, "") + .replace(/\/+$/, ""); +} + +/** + * The repository-root-relative POSIX prefix from the git root to a dbt project + * directory. Empty string when the dbt project IS the git root (the control + * layout where dbt's `original_file_path` already matches the workspace.json key). + * + * Returns null when `dbtProjectDir` is not inside `gitRoot` (relative path + * escapes upward) — a caller that gets null must not attempt the join, since no + * repo-root-relative key can be derived. + */ +export function computeProjectPrefix(gitRoot: string, dbtProjectDir: string): string | null { + const rel = canonical(relative(gitRoot, dbtProjectDir)); + if (rel === ".." || rel.startsWith("../")) return null; + return rel; +} + +/** + * Normalize a dbt `original_file_path` (relative to the dbt project root) into + * the canonical workspace.json fileIndex key: repository-root-relative POSIX + * (VR-640). This is the entire DataHub join fix — prepend the project prefix so + * a nested dbt project's model paths line up with git-root-relative keys. + */ +export function normalizeModelPath(projectPrefix: string, originalFilePath: string): string { + const rel = canonical(originalFilePath); + return projectPrefix ? `${projectPrefix}/${rel}` : rel; +} diff --git a/packages/cli/test/find-projects.test.ts b/packages/cli/test/find-projects.test.ts new file mode 100644 index 0000000..588ec74 --- /dev/null +++ b/packages/cli/test/find-projects.test.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { findDbtProjects } from "../src/index.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const MULTI = resolve(here, "fixtures/multi"); + +describe("VR-640 multi-project guard: findDbtProjects", () => { + it("enumerates every dbt_project.yml, not just the first", () => { + const projects = findDbtProjects(MULTI).map((p) => p.slice(MULTI.length + 1)); + expect(projects).toEqual(["analytics", "sub/warehouse"]); + }); + + it("returns an empty list for a tree with no dbt projects", () => { + expect(findDbtProjects(resolve(here, "fixtures/multi/analytics/models"))).toEqual([]); + }); +}); diff --git a/packages/cli/test/fixtures/multi/analytics/dbt_project.yml b/packages/cli/test/fixtures/multi/analytics/dbt_project.yml new file mode 100644 index 0000000..4aa2ffc --- /dev/null +++ b/packages/cli/test/fixtures/multi/analytics/dbt_project.yml @@ -0,0 +1,3 @@ +name: analytics +version: "1.0.0" +profile: analytics diff --git a/packages/cli/test/fixtures/multi/analytics/models/customers.sql b/packages/cli/test/fixtures/multi/analytics/models/customers.sql new file mode 100644 index 0000000..43258a7 --- /dev/null +++ b/packages/cli/test/fixtures/multi/analytics/models/customers.sql @@ -0,0 +1 @@ +select 1 as id diff --git a/packages/cli/test/fixtures/multi/sub/warehouse/dbt_project.yml b/packages/cli/test/fixtures/multi/sub/warehouse/dbt_project.yml new file mode 100644 index 0000000..82b16d8 --- /dev/null +++ b/packages/cli/test/fixtures/multi/sub/warehouse/dbt_project.yml @@ -0,0 +1,3 @@ +name: warehouse +version: "1.0.0" +profile: warehouse diff --git a/packages/cli/test/join.nested.test.ts b/packages/cli/test/join.nested.test.ts new file mode 100644 index 0000000..b9c16d6 --- /dev/null +++ b/packages/cli/test/join.nested.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { computeProjectPrefix, joinModels, type DbtModel, type FileIndex } from "../src/index.js"; + +// ─── Fixtures from the HAC-75 join probe ───────────────────────────────────── +// jaffle_shop_duckdb's five models. `originalFilePath` is exactly what dbt's +// manifest.json reports (relative to the dbt project root) — identical whether +// the project sits at the repo root or nested under dbt/. +const MODELS: DbtModel[] = [ + { uniqueId: "model.jaffle_shop.customers", originalFilePath: "models/customers.sql" }, + { uniqueId: "model.jaffle_shop.orders", originalFilePath: "models/orders.sql" }, + { uniqueId: "model.jaffle_shop.stg_customers", originalFilePath: "models/staging/stg_customers.sql" }, + { uniqueId: "model.jaffle_shop.stg_payments", originalFilePath: "models/staging/stg_payments.sql" }, + { uniqueId: "model.jaffle_shop.stg_orders", originalFilePath: "models/staging/stg_orders.sql" }, +]; + +// workspace.json fileIndex as the REAL Vreko emitter produced it on the nested +// repo (git-root-relative keys, observed in the HAC-75 probe): dbt/ prefix. +const NESTED_FILE_INDEX: FileIndex = { + "dbt/models/customers.sql": {}, + "dbt/models/orders.sql": {}, + "dbt/models/staging/stg_customers.sql": {}, + "dbt/models/staging/stg_payments.sql": {}, + "dbt/models/staging/stg_orders.sql": {}, +}; + +// Control layout: dbt project AT the git root, so keys carry no prefix. +const ROOT_FILE_INDEX: FileIndex = { + "models/customers.sql": {}, + "models/orders.sql": {}, + "models/staging/stg_customers.sql": {}, + "models/staging/stg_payments.sql": {}, + "models/staging/stg_orders.sql": {}, +}; + +describe("DataHub join — nested dbt project (HAC-75 treatment)", () => { + it("RED: naive join (original_file_path used directly) matches 0/5", () => { + // No normalization — the failure the probe reproduced. This is the guard: + // if the shim were a no-op, the join silently returns zero rows. + const naive = joinModels(MODELS, "", NESTED_FILE_INDEX); + expect(naive.matched).toBe(0); + expect(naive.rows.every((r) => !r.matched)).toBe(true); + }); + + it("GREEN: shim join (repo-root-relative normalization) matches 5/5", () => { + const prefix = computeProjectPrefix("/repo", "/repo/dbt"); + expect(prefix).toBe("dbt"); + const joined = joinModels(MODELS, prefix as string, NESTED_FILE_INDEX); + expect(joined.matched).toBe(5); + expect(joined.rows.every((r) => r.matched)).toBe(true); + expect(joined.rows[0]?.normalizedKey).toBe("dbt/models/customers.sql"); + }); + + it("control: dbt project at git root joins 5/5 with an empty prefix", () => { + const prefix = computeProjectPrefix("/repo", "/repo"); + expect(prefix).toBe(""); + const joined = joinModels(MODELS, prefix as string, ROOT_FILE_INDEX); + expect(joined.matched).toBe(5); + }); + + it("rejects a dbt project outside the git root (no derivable key)", () => { + expect(computeProjectPrefix("/repo/dbt", "/repo")).toBeNull(); + }); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..149104e --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": false + }, + "include": [ + "src/**/*.ts", + "../../types/**/*.d.ts" + ], + "exclude": [ + "src/**/*.test.ts", + "test/**", + "src/**/__tests__/**" + ] +} diff --git a/packages/rules/CHANGELOG.md b/packages/rules/CHANGELOG.md index f6ce1ae..99af58d 100644 --- a/packages/rules/CHANGELOG.md +++ b/packages/rules/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.4.4] - Unreleased + +### Patch Changes + +- Updated dependency: `@workspacejson/spec@0.4.4`. + ## [0.4.3] - 2026-07-17 ### Patch Changes diff --git a/packages/rules/package.json b/packages/rules/package.json index 5a3a3c6..4517ae3 100644 --- a/packages/rules/package.json +++ b/packages/rules/package.json @@ -1,6 +1,6 @@ { "name": "@workspacejson/rules", - "version": "0.4.3", + "version": "0.4.4", "description": "Rule engine for auditing AGENTS.md hygiene using .agents/workspace.json", "license": "Apache-2.0", "author": "workspace-json contributors", diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index 4d33b45..975a6f4 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [0.4.4] - Unreleased + +### Fixed + +- Reconciled the strict packaged-schema validator with the VR-639/640 contract + fixes that had diverged across earlier release branches. +- Documented `generated.fileIndex` keys as repository-root-relative POSIX paths. +- Typed and documented `generated.coChange[].files` as an unordered two-file set, + rather than a positional tuple. +- Corrected the exported runtime `version` to match the package manifest. + +### Compatibility note + +- `npx @workspacejson/spec validate ` resolves because npm selects this + package's sole `workspacejson-spec` bin. This release deliberately does not + reserve the generic `spec` bin alias. If a second bin is added in the future, + re-evaluate that npm single-bin fallback dependency as part of that change. + ## [0.4.3] - 2026-07-17 ### Patch Changes diff --git a/packages/spec/README.md b/packages/spec/README.md index 2bea4c7..0acc383 100644 --- a/packages/spec/README.md +++ b/packages/spec/README.md @@ -26,7 +26,7 @@ pnpm add @workspacejson/spec ```ts import { validate, validateV4, validateLegacy, version } from '@workspacejson/spec'; -console.log(version); // '0.4.2' +console.log(version); // '0.4.4' validate(doc); // true if doc is a valid v0.3 or v0.4 document validateV4(doc); // true if doc is a valid v0.4 document diff --git a/packages/spec/package.json b/packages/spec/package.json index 4fdf5b1..0bdd9cd 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@workspacejson/spec", - "version": "0.4.3", + "version": "0.4.4", "description": "JSON Schema and TypeScript types for workspace.json", "license": "Apache-2.0", "author": "workspace-json contributors", diff --git a/packages/spec/schema/v1.json b/packages/spec/schema/v1.json index 4123d8b..4ef0119 100644 --- a/packages/spec/schema/v1.json +++ b/packages/spec/schema/v1.json @@ -57,7 +57,7 @@ }, "fileIndex": { "type": "object", - "description": "Per-file behavioral intelligence keyed by relative path. Read by Buildomator.", + "description": "Per-file behavioral intelligence keyed by repository-root-relative POSIX path (forward slashes, no leading \"./\", no drive letters).", "additionalProperties": { "type": "object", "properties": { @@ -74,7 +74,7 @@ "type": "object", "required": ["files", "rate", "occurrences", "generated"], "properties": { - "files": { "type": "array", "items": { "type": "string" }, "minItems": 2, "maxItems": 2 }, + "files": { "type": "array", "items": { "type": "string" }, "minItems": 2, "maxItems": 2, "description": "Unordered pair (set semantics — position is NOT meaningful; join by membership, not index). Each entry is a repository-root-relative POSIX path." }, "rate": { "type": "number", "minimum": 0, "maximum": 1 }, "occurrences": { "type": "integer", "minimum": 0 }, "generated": { "type": "boolean" } @@ -89,7 +89,7 @@ "type": "object", "required": ["file", "changeCount", "revertCount", "revertRate", "fragilityScore", "excluded"], "properties": { - "file": { "type": "string" }, + "file": { "type": "string", "description": "Repository-root-relative POSIX path (forward slashes, no leading \"./\")." }, "changeCount": { "type": "integer", "minimum": 0 }, "revertCount": { "type": "integer", "minimum": 0 }, "revertRate": { "type": "number", "minimum": 0, "maximum": 1 }, diff --git a/packages/spec/src/index.test.ts b/packages/spec/src/index.test.ts index 4e06cc5..4e260ea 100644 --- a/packages/spec/src/index.test.ts +++ b/packages/spec/src/index.test.ts @@ -5,6 +5,7 @@ import Ajv from 'ajv'; import { describe, expect, it } from 'vitest'; import { validate, validateLegacy, validateV4, version, workspaceJsonSchema } from './index.js'; import { compileSchemaValidator } from './validator.js'; +import type { CoChangeEntry } from './index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCHEMA_JSON_PATH = resolve(__dirname, '../schema/v1.json'); @@ -55,8 +56,8 @@ describe('draft-2020-12 validator', () => { }); describe('version', () => { - it('is 0.4.2', () => { - expect(version).toBe('0.4.2'); + it('is 0.4.4', () => { + expect(version).toBe('0.4.4'); }); }); @@ -207,3 +208,104 @@ describe('schema structural invariants', () => { expect((workspaceJsonSchema as Record)['additionalProperties']).toBe(false); }); }); + +// ─── VR-640: fileIndex key format pinned to repo-root-relative POSIX ────────── +// The DataHub join (HAC-75 probe) silently produced zero rows because the spec +// said "relative path" without an anchor. The canonical form must be stated and +// kept in sync across both schema mirrors so the CLI shim normalizes toward a +// blessed target rather than an assumed one. +describe('VR-640: canonical key format is repository-root-relative POSIX', () => { + const gen = (s: Record) => + ((s['properties'] as Record>)['generated']?.['properties'] ?? + {}) as Record>; + + it("schema/v1.json fileIndex description pins the anchor (not just 'relative path')", () => { + const json = JSON.parse(readFileSync(SCHEMA_JSON_PATH, 'utf8')) as Record; + const desc = gen(json)['fileIndex']?.['description'] as string; + expect(desc).toContain('repository-root-relative POSIX path'); + }); + + it('schema.ts fileIndex description matches schema/v1.json (no split-brain on the anchor)', () => { + const json = JSON.parse(readFileSync(SCHEMA_JSON_PATH, 'utf8')) as Record; + const jsonDesc = gen(json)['fileIndex']?.['description']; + const tsDesc = gen(workspaceJsonSchema as unknown as Record)['fileIndex']?.[ + 'description' + ]; + expect(tsDesc).toBe(jsonDesc); + }); + + it('fragility.file is documented as repo-root-relative POSIX in both mirrors', () => { + const json = JSON.parse(readFileSync(SCHEMA_JSON_PATH, 'utf8')) as Record; + const jsonFile = (gen(json)['fragility']?.['items'] as Record>>) + ?.['properties']?.['file']?.['description'] as string; + expect(jsonFile).toContain('Repository-root-relative POSIX path'); + }); +}); + +// ─── VR-639: coChange.files is a set, not a positional tuple ────────────────── +// types.ts said [string, string] (positional) while the schema said min/max-2 +// array (set). A CLI that treated files[0] as canonical would silently mis-join. +// The contract is now set semantics — order must never affect the join. +describe('VR-639: coChange.files has set semantics (order-independent join)', () => { + const minimalV4 = { + manual: {}, + generated: { + specVersion: '0.4' as const, + generatedAt: '2026-05-22T00:00:00Z', + by: { name: 'test', version: '0.1.0' }, + frameworkManifest: [], + fileIndex: {}, + coChange: [] as Array<{ files: string[]; rate: number; occurrences: number; generated: boolean }>, + fragility: [], + }, + agents: {}, + health: { intelligenceState: 'OBSERVING', observationCount: 0, confidence: 0 }, + }; + + const withPair = (files: string[]) => ({ + ...minimalV4, + generated: { ...minimalV4.generated, coChange: [{ files, rate: 0.5, occurrences: 10, generated: false }] }, + }); + + it('validateV4 accepts a coChange pair in either ordering', () => { + expect(validateV4(withPair(['a.sql', 'b.sql']))).toBe(true); + expect(validateV4(withPair(['b.sql', 'a.sql']))).toBe(true); + }); + + it('the co-change join (set membership) resolves identically under reversed pair order', () => { + // Models the CLI join: find co-change partners of a target file by membership, + // never by index. This is the assertion VR-639 requires "at the join level". + const partnersOf = (doc: ReturnType, target: string) => + doc.generated.coChange + .filter((e) => e.files.includes(target)) + .flatMap((e) => e.files.filter((f) => f !== target)); + + const forward = partnersOf(withPair(['a.sql', 'b.sql']), 'a.sql'); + const reversed = partnersOf(withPair(['b.sql', 'a.sql']), 'a.sql'); + expect(forward).toEqual(['b.sql']); + expect(reversed).toEqual(['b.sql']); // reversing the stored pair changes nothing + }); + + it('a co-change pair equals its reverse as a set (position carries no meaning)', () => { + // Behavioral lock for set semantics, enforced at runtime (tsconfig excludes + // *.test.ts from tsc, so a compile-time tuple assertion here would never run). + // The type-level guarantee lives in types.ts `files: string[]`, which tsc DOES + // compile and which binds any consumer (the CLI) against indexing files[0]. + const stored: CoChangeEntry['files'] = ['a.sql', 'b.sql']; + const emittedInReverse: CoChangeEntry['files'] = ['b.sql', 'a.sql']; + expect(new Set(stored)).toEqual(new Set(emittedInReverse)); + }); + + it('schema still constrains the pair to exactly two entries in both mirrors', () => { + const json = JSON.parse(readFileSync(SCHEMA_JSON_PATH, 'utf8')) as Record; + const filesSchema = ( + ( + (json['properties'] as Record>>>)[ + 'generated' + ]['properties']['coChange']['items'] as Record>> + )['properties']['files'] + ) as Record; + expect(filesSchema['minItems']).toBe(2); + expect(filesSchema['maxItems']).toBe(2); + }); +}); diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 3cdae50..ef459b4 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -30,7 +30,7 @@ const packagedSchema = JSON.parse( ) as object; const validateSchema = compileSchemaValidator(packagedSchema); -export const version = '0.4.2'; +export const version = '0.4.4'; export function validate(data: unknown): data is WorkspaceJsonV3 | WorkspaceJsonV4 { return validateSchema(data); diff --git a/packages/spec/src/schema.ts b/packages/spec/src/schema.ts index b988946..3be7750 100644 --- a/packages/spec/src/schema.ts +++ b/packages/spec/src/schema.ts @@ -58,7 +58,7 @@ export const workspaceJsonSchema = { fileIndex: { type: 'object', description: - 'Per-file behavioral intelligence keyed by relative path. Read by Buildomator.', + 'Per-file behavioral intelligence keyed by repository-root-relative POSIX path (forward slashes, no leading "./", no drive letters).', additionalProperties: { type: 'object', properties: { @@ -75,7 +75,14 @@ export const workspaceJsonSchema = { type: 'object', required: ['files', 'rate', 'occurrences', 'generated'] as const, properties: { - files: { type: 'array', items: { type: 'string' }, minItems: 2, maxItems: 2 }, + files: { + type: 'array', + items: { type: 'string' }, + minItems: 2, + maxItems: 2, + description: + 'Unordered pair (set semantics — position is NOT meaningful; join by membership, not index). Each entry is a repository-root-relative POSIX path.', + }, rate: { type: 'number', minimum: 0, maximum: 1 }, occurrences: { type: 'integer', minimum: 0 }, generated: { type: 'boolean' }, @@ -90,7 +97,7 @@ export const workspaceJsonSchema = { type: 'object', required: ['file', 'changeCount', 'revertCount', 'revertRate', 'fragilityScore', 'excluded'] as const, properties: { - file: { type: 'string' }, + file: { type: 'string', description: 'Repository-root-relative POSIX path (forward slashes, no leading "./").' }, changeCount: { type: 'integer', minimum: 0 }, revertCount: { type: 'integer', minimum: 0 }, revertRate: { type: 'number', minimum: 0, maximum: 1 }, diff --git a/packages/spec/src/types.ts b/packages/spec/src/types.ts index e0756bf..92f6b28 100644 --- a/packages/spec/src/types.ts +++ b/packages/spec/src/types.ts @@ -91,7 +91,14 @@ export interface WorkspaceJsonV3 { } export interface CoChangeEntry { - files: [string, string]; + /** + * The co-changing pair. **Set semantics: exactly two entries, order NOT + * meaningful** — `files[0]` carries no positional significance and consumers + * must join by set membership, not index. Each entry is a + * repository-root-relative POSIX path (forward slashes, no leading "./"). + * Invariant: length === 2 (mirrors schema minItems/maxItems: 2). + */ + files: string[]; rate: number; occurrences: number; /** true = tooling-coupled pair (e.g. lockfile + package.json); consumers skip these */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f481dd1..894ffbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,22 @@ importers: specifier: ^1.6.0 version: 1.6.1(@types/node@22.19.17) + packages/cli: + dependencies: + '@workspacejson/spec': + specifier: workspace:* + version: link:../spec + devDependencies: + tsup: + specifier: ^8.0.0 + version: 8.5.1(postcss@8.5.14)(typescript@5.9.3) + typescript: + specifier: ^5.4.0 + version: 5.9.3 + vitest: + specifier: ^1.6.0 + version: 1.6.1(@types/node@22.19.17) + packages/rules: dependencies: '@workspacejson/spec': diff --git a/types/ambient.d.ts b/types/ambient.d.ts index 723f9c4..7557921 100644 --- a/types/ambient.d.ts +++ b/types/ambient.d.ts @@ -20,8 +20,15 @@ declare const process: { }; declare module 'node:fs' { + interface Dirent { + name: string; + isDirectory(): boolean; + isFile(): boolean; + } + export function existsSync(path: string): boolean; export function readFileSync(path: string, encoding: string): string; + export function readdirSync(path: string, options: { withFileTypes: true }): Dirent[]; } declare module 'node:child_process' { @@ -44,7 +51,10 @@ declare module 'node:fs/promises' { declare module 'node:path' { export function basename(path: string): string; export function dirname(path: string): string; + export function join(...paths: string[]): string; + export function relative(from: string, to: string): string; export function resolve(...paths: string[]): string; + export const sep: string; } declare module 'node:module' {