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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/agents-audit/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/agents-audit/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
@@ -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.
50 changes: 50 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
72 changes: 72 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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<T>(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<DbtManifest>(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
// (<proj>/target/manifest.json -> <proj>). `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))));
}
56 changes: 56 additions & 0 deletions packages/cli/src/dbt.ts
Original file line number Diff line number Diff line change
@@ -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<string, ManifestNode>;
}

/** 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<string> = DEFAULT_IGNORE): string[] {
const found: string[] = [];
const walk = (dir: string): void => {
let entries: ReturnType<typeof readdirSync>;
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();
}
5 changes: 5 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
55 changes: 55 additions & 0 deletions packages/cli/src/join.ts
Original file line number Diff line number Diff line change
@@ -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<string, FileIndexEntry>;

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 };
}
39 changes: 39 additions & 0 deletions packages/cli/src/normalize.ts
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 20 additions & 0 deletions packages/cli/test/find-projects.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
3 changes: 3 additions & 0 deletions packages/cli/test/fixtures/multi/analytics/dbt_project.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: analytics
version: "1.0.0"
profile: analytics
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
select 1 as id
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: warehouse
version: "1.0.0"
profile: warehouse
Loading
Loading