From 93805a167eac01289a993699fc51c80002038d90 Mon Sep 17 00:00:00 2001 From: Qwynn Marcelle Date: Sun, 26 Jul 2026 00:14:46 -0400 Subject: [PATCH 1/3] feat(arch): land the ratified neutral package architecture (META-247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Architecture B, ratified in META-236. Behavior-preserving: the public surface of `agents-audit` is unchanged and both parity harnesses are green. packages/cli/ @workspacejson/cli, bin `workspacejson` src/producer/ generation, scanning, reconciliation src/commands/ command routing and UX packages/agents-audit-compat/ agents-audit, bin `agents-audit` (frozen bridge) packages/datahub-adapter/ private, MIGRATION STAGING ONLY Module moves are git renames, so history follows the code: agents-audit/src/generate.ts -> cli/src/producer/generate.ts agents-audit/src/internal/fs.ts -> cli/src/producer/fs.ts agents-audit/src/internal/config.ts -> split: detectCiProvider to the producer, DEFAULT_AUDIT_CONFIG stays in compat packages/cli/** -> packages/datahub-adapter/** One implementation, two binaries `agents-audit generate` delegates to the same `runGenerate` the neutral CLI uses, so the two commands cannot drift during the compatibility window. The caller supplies its own `producer` identity and `commandName`, which is what keeps `agents-audit` output byte-identical. Three defects the move exposed, each fixed rather than worked around: 1. `generated.by` hardcoded `'agents-audit'` inside what is now the neutral producer. `by` is provenance — it must record which producer actually ran — so it became a caller-supplied identity defaulting to this package. compat passes its historical value, so its artifacts are unchanged. 2. Refusal messages hardcoded `agents-audit generate ...`, which would have told a `workspacejson` user to run a different tool. Command name is now parameterized; compat's messages are byte-identical. 3. `generate.ts` resolved its own package.json by fixed relative path. Source depth (`src/producer/`) no longer matches bundled depth (`dist/`), so the path was correct for tests and wrong for consumers. It now walks up to find the manifest, which is depth-independent. Also fixed: scripts/verify-package-tarball.mjs left the packed manifest as the raw string `tar` returns, so every assertion indexing into it — bin targets, pinned dependencies — read `undefined` and passed vacuously. Parsing it makes those checks real for the first time. Guards - `local-dependency` now distinguishes intra-repository workspace links (legal, rewritten by pnpm at pack time) from cross-repository ones (never resolvable). The old rule would have rejected compat depending on the neutral package. - New `neutral-producer-purity` rejects DataHub, dbt or vendor content in packages/cli by filename or content. Its first red test failed — `\bdatahub\b` does not match `joinDataHubUrn` — so the rule now matches substrings and checks filenames. - Red tests 15 -> 19, all passing including baseline. packages/datahub-adapter is staging, not architecture It is a DataHub *consumer* adapter and belongs to workspacejson/datahub-agent. It is renamed for accuracy, kept private, excluded from the durable package map, and the neutral CLI does not depend on it. Its 35/35 harness travels with it. Verification - typecheck 3/3, build 3/3 - tests 80 (32 neutral CLI + 42 compat + 6 adapter); was 74 - architecture guard pass; red tests 19/19 - pack verification pass for both public packages, incl. a real generate smoke test resolving the unpublished sibling from disk - META-240 producer parity 29/29 — all 9 exports identical, producer stamp preserved, generated artifacts identical - META-240 DataHub parity 35/35, with two documented intentional differences: the package rename and surrendering the `workspacejson` bin Nothing published. @workspacejson/cli has public metadata but is not on npm; agents-audit@0.4.4 on the registry is untouched; no publish authority moved. --- .github/workflows/ci.yml | 15 +- AGENTS.md | 7 +- OWNERSHIP.md | 92 +++++++--- README.md | 123 ++++++------- migration/parity-agents-audit-pack.sh | 2 +- migration/parity-agents-audit-runtime.sh | 12 +- migration/parity-agents-audit-runtime.txt | 4 +- migration/parity-datahub-shim.mjs | 17 +- migration/parity-datahub-shim.txt | 4 +- package.json | 3 +- .../CHANGELOG.md | 0 .../LICENSE | 0 .../README.md | 0 .../package.json | 3 +- .../scripts/add-shebang.js | 0 .../scripts/add-shebang.test.ts | 0 .../src/audit.integration.test.ts | 0 .../src/audit.ts | 2 +- .../src/cli-helpers.test.ts | 0 .../src/cli-helpers.ts | 0 .../src/cli.integration.test.ts | 97 +++------- .../src/cli.ts | 52 ++---- .../src/index.test.ts | 0 packages/agents-audit-compat/src/index.ts | 12 ++ .../src/internal/config.ts | 17 ++ .../src/navigator.test.ts | 0 .../src/navigator.ts | 0 .../src/package-metadata.test.ts | 14 +- .../src/presenter.test.ts | 0 .../src/presenter.ts | 0 .../src/reporter.test.ts | 0 .../src/reporter.ts | 0 .../tsconfig.json | 0 packages/agents-audit/src/index.ts | 6 - packages/cli/LICENSE | 12 ++ packages/cli/README.md | 76 +++++--- packages/cli/package.json | 48 +++-- packages/cli/scripts/add-shebang.js | 22 +++ packages/cli/src/cli.ts | 112 ++++++------ packages/cli/src/commands/generate.test.ts | 168 ++++++++++++++++++ packages/cli/src/commands/generate.ts | 80 +++++++++ packages/cli/src/index.ts | 22 ++- .../internal => cli/src/producer}/config.ts | 14 +- .../src/internal => cli/src/producer}/fs.ts | 0 .../producer}/generate.integration.test.ts | 21 ++- .../src => cli/src/producer}/generate.ts | 72 ++++++-- .../producer}/producer-conformance.test.ts | 0 .../src/spec-contract-visibility.test.ts | 0 packages/cli/tsconfig.json | 9 +- packages/datahub-adapter/README.md | 40 +++++ packages/datahub-adapter/package.json | 48 +++++ packages/datahub-adapter/src/cli.ts | 72 ++++++++ packages/{cli => datahub-adapter}/src/dbt.ts | 0 packages/datahub-adapter/src/index.ts | 5 + packages/{cli => datahub-adapter}/src/join.ts | 0 .../{cli => datahub-adapter}/src/normalize.ts | 0 .../test/find-projects.test.ts | 0 .../fixtures/multi/analytics/dbt_project.yml | 0 .../multi/analytics/models/customers.sql | 0 .../multi/sub/warehouse/dbt_project.yml | 0 .../test/join.nested.test.ts | 0 packages/datahub-adapter/tsconfig.json | 19 ++ pnpm-lock.yaml | 41 ++++- scripts/check-architecture.mjs | 67 +++++-- scripts/check-architecture.test.mjs | 39 +++- scripts/verify-package-tarball.mjs | 53 +++++- 66 files changed, 1128 insertions(+), 394 deletions(-) rename packages/{agents-audit => agents-audit-compat}/CHANGELOG.md (100%) rename packages/{agents-audit => agents-audit-compat}/LICENSE (100%) rename packages/{agents-audit => agents-audit-compat}/README.md (100%) rename packages/{agents-audit => agents-audit-compat}/package.json (87%) rename packages/{agents-audit => agents-audit-compat}/scripts/add-shebang.js (100%) rename packages/{agents-audit => agents-audit-compat}/scripts/add-shebang.test.ts (100%) rename packages/{agents-audit => agents-audit-compat}/src/audit.integration.test.ts (100%) rename packages/{agents-audit => agents-audit-compat}/src/audit.ts (98%) rename packages/{agents-audit => agents-audit-compat}/src/cli-helpers.test.ts (100%) rename packages/{agents-audit => agents-audit-compat}/src/cli-helpers.ts (100%) rename packages/{agents-audit => agents-audit-compat}/src/cli.integration.test.ts (64%) rename packages/{agents-audit => agents-audit-compat}/src/cli.ts (73%) rename packages/{agents-audit => agents-audit-compat}/src/index.test.ts (100%) create mode 100644 packages/agents-audit-compat/src/index.ts create mode 100644 packages/agents-audit-compat/src/internal/config.ts rename packages/{agents-audit => agents-audit-compat}/src/navigator.test.ts (100%) rename packages/{agents-audit => agents-audit-compat}/src/navigator.ts (100%) rename packages/{agents-audit => agents-audit-compat}/src/package-metadata.test.ts (83%) rename packages/{agents-audit => agents-audit-compat}/src/presenter.test.ts (100%) rename packages/{agents-audit => agents-audit-compat}/src/presenter.ts (100%) rename packages/{agents-audit => agents-audit-compat}/src/reporter.test.ts (100%) rename packages/{agents-audit => agents-audit-compat}/src/reporter.ts (100%) rename packages/{agents-audit => agents-audit-compat}/tsconfig.json (100%) delete mode 100644 packages/agents-audit/src/index.ts create mode 100644 packages/cli/LICENSE create mode 100644 packages/cli/scripts/add-shebang.js create mode 100644 packages/cli/src/commands/generate.test.ts create mode 100644 packages/cli/src/commands/generate.ts rename packages/{agents-audit/src/internal => cli/src/producer}/config.ts (58%) rename packages/{agents-audit/src/internal => cli/src/producer}/fs.ts (100%) rename packages/{agents-audit/src => cli/src/producer}/generate.integration.test.ts (79%) rename packages/{agents-audit/src => cli/src/producer}/generate.ts (69%) rename packages/{agents-audit/src => cli/src/producer}/producer-conformance.test.ts (100%) rename packages/{agents-audit => cli}/src/spec-contract-visibility.test.ts (100%) create mode 100644 packages/datahub-adapter/README.md create mode 100644 packages/datahub-adapter/package.json create mode 100644 packages/datahub-adapter/src/cli.ts rename packages/{cli => datahub-adapter}/src/dbt.ts (100%) create mode 100644 packages/datahub-adapter/src/index.ts rename packages/{cli => datahub-adapter}/src/join.ts (100%) rename packages/{cli => datahub-adapter}/src/normalize.ts (100%) rename packages/{cli => datahub-adapter}/test/find-projects.test.ts (100%) rename packages/{cli => datahub-adapter}/test/fixtures/multi/analytics/dbt_project.yml (100%) rename packages/{cli => datahub-adapter}/test/fixtures/multi/analytics/models/customers.sql (100%) rename packages/{cli => datahub-adapter}/test/fixtures/multi/sub/warehouse/dbt_project.yml (100%) rename packages/{cli => datahub-adapter}/test/join.nested.test.ts (100%) create mode 100644 packages/datahub-adapter/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67c8011..0b66846 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,11 +43,16 @@ jobs: - name: Verify package tarballs run: pnpm run release:verify-packs - # This repository owns and exports agents-audit only. @workspacejson/spec - # and @workspacejson/rules are consumed as released dependencies and are - # verified by workspacejson/standard, not here. + # This repository owns @workspacejson/cli and agents-audit. + # @workspacejson/spec and @workspacejson/rules are consumed as released + # dependencies and are verified by workspacejson/standard, not here. - name: Validate package exports - run: node -e "import('agents-audit')" + run: | + node -e "import('@workspacejson/cli')" + node -e "import('agents-audit')" + + - name: Run the neutral producer on this repo + run: node packages/cli/dist/cli.js generate --check || true - name: Run agents-audit on this repo - run: node packages/agents-audit/dist/cli.js scan . --fail-on error + run: node packages/agents-audit-compat/dist/cli.js scan . --fail-on error diff --git a/AGENTS.md b/AGENTS.md index 4e82a61..448e508 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,9 @@ # Workspace Notes -- This repository holds two distinct CLI packages. `./packages/agents-audit/` is the published `agents-audit` package and contains the real workspace.json generator (`./packages/agents-audit/src/generate.ts`). `./packages/cli/` is the private `@workspacejson/cli` DataHub/dbt join shim and is not the generator. -- Keep package entry points aligned with `./packages/agents-audit/src/index.ts` and `./packages/cli/src/index.ts`. -- Review changes against `./packages/agents-audit/src/cli.ts` before release. +- This repository holds three packages. `./packages/cli/` is `@workspacejson/cli`, the neutral workspace.json producer — generation lives in `./packages/cli/src/producer/` and command routing in `./packages/cli/src/commands/`. `./packages/agents-audit-compat/` is the published `agents-audit` compatibility package. `./packages/datahub-adapter/` is a private DataHub/dbt adapter staged here pending extraction to `workspacejson/datahub-agent`. +- Keep package entry points aligned with `./packages/cli/src/index.ts` and `./packages/agents-audit-compat/src/index.ts`. +- `agents-audit` is a frozen compatibility bridge. Do not add features to it; both binaries route through `./packages/cli/src/commands/generate.ts` so they cannot drift. - `@workspacejson/spec` and `@workspacejson/rules` are consumed as released packages from `workspacejson/standard`; never vendor, copy or workspace-link them here. - Keep workspace metadata in `./CHANGELOG.md` and `./README.md` current. - Workspace layout is defined in `./pnpm-workspace.yaml`; repository boundaries are defined in `./OWNERSHIP.md` and enforced by `./scripts/check-architecture.mjs`. +- Compatibility is gated by the parity harnesses in `./migration/`. Run them before changing anything `agents-audit` exposes. diff --git a/OWNERSHIP.md b/OWNERSHIP.md index cc2d257..4b2834d 100644 --- a/OWNERSHIP.md +++ b/OWNERSHIP.md @@ -1,47 +1,88 @@ # Ownership — `workspacejson/cli` -Machine-checked by `scripts/check-architecture.mjs`, run in CI. This document -states the intent; the script is what enforces it. +Machine-checked by `scripts/check-architecture.mjs`, run in CI with red tests in +`scripts/check-architecture.test.mjs`. This document states the intent; the +script is what enforces it. + +## Packages + +| Directory | Package | Distribution | Role | +| -- | -- | -- | -- | +| `packages/cli/` | `@workspacejson/cli` | public, **not yet published** | the neutral workspace.json producer and its `workspacejson` binary | +| `packages/agents-audit-compat/` | `agents-audit` | public, published `0.4.4` | frozen compatibility bridge for the historical `agents-audit` command and API | +| `packages/datahub-adapter/` | `@workspacejson/datahub-adapter` | **private, never published** | **migration staging only** — see below | ## Owns -* Producer and audit CLI implementation (`packages/agents-audit/`) -* Repository scanning, deterministic generation and reconciliation of - `.agents/workspace.json` -* Manual-evidence preservation, drift detection and atomic-write behavior -* The DataHub/dbt adapter package (`packages/cli/`) +* The neutral producer: deterministic generation, repository scanning, + reconciliation, manual-evidence preservation, atomic writes, drift detection + (`packages/cli/src/producer/`) +* Public command routing and CLI UX (`packages/cli/src/commands/`) +* The historical `agents-audit` audit behavior and its compatibility surface * CLI package distribution and the CLI release workflow ## Consumes * Released public contracts from `workspacejson/standard`: - `@workspacejson/spec`, `@workspacejson/rules` — pinned to registry-backed + `@workspacejson/spec`, `@workspacejson/rules` — pinned to exact registry versions, never workspace links or sibling checkouts ## Must never define -* The normative schema or specification text — that is `workspacejson/standard` -* MCP, Codex, VS Code or other editor/host integration behavior — that is +* The normative schema or specification text — `workspacejson/standard` +* MCP, Codex, VS Code or other editor/host integration behavior — `workspacejson/integrations` -* Site content as a source of truth — that is `workspacejson/site` +* Site content as a source of truth — `workspacejson/site` +* **DataHub-specific consumption, joining, orchestration or evaluation** — + `workspacejson/datahub-agent` * Private product behavior of any kind +## `packages/datahub-adapter/` is staging, not architecture + +This package is **not durable CLI architecture**. It is the DataHub/dbt adapter +migrated intact from the `agents-audit` monorepo, parked here only because +META-240 had to preserve it somewhere while its permanent owner was decided. + +It is a **consumer** adapter — it reads an existing `.agents/workspace.json` and +joins dbt models against `generated.fileIndex`. That is DataHub consumer logic, +not neutral producer logic, and it belongs to `workspacejson/datahub-agent`. + +Until it is extracted: + +* it stays `private: true` and is never published; +* the neutral CLI **must not depend on it** — the dependency direction is + one-way and the guard enforces it; +* it is not polished, documented or advertised as a CLI-owned package; +* it does not appear in the durable CLI package map. + +Its 35/35 parity harness (`migration/parity-datahub-shim.mjs`) travels with it +and must pass against the DataHub-owned candidate after extraction. + +**Do not confuse this adapter with a hypothetical `workspacejson signals +datahub` producer surface.** They run in opposite directions: this adapter +consumes the artifact; a signals surface would produce DataHub-specific +evidence. No such producer surface is admitted, and none may be added without an +explicit ADR-002 Gate A ruling. + ## Dependency direction ```text workspacejson/standard ↓ -workspacejson/cli workspacejson/integrations - \ / +workspacejson/cli ──→ agents-audit (compat) + ↑ + │ (consumes released contracts and the public CLI interface) +workspacejson/integrations workspacejson/datahub-agent + \ / workspacejson/site ``` -This repository depends **downstream only**. It must never be imported by -`workspacejson/standard`. +Within this repository: `agents-audit` depends on `@workspacejson/cli`. Never +the reverse. `@workspacejson/datahub-adapter` depends on neither. ## Clean-room boundary -No code in this repository may import, copy, require or assume: +No code here may import, copy, require or assume: ```text @marcelle-labs/* @@ -49,22 +90,23 @@ private Vreko source workspace.vreko.json ``` -Proprietary repositories may consume released Apache-2.0 `@workspacejson/*` -packages. The reverse direction is prohibited. +Additionally, `packages/cli/` must contain **no** vendor- or host-specific +content at all — no DataHub, dbt or Vreko logic, by filename or by content. +Guard: `neutral-producer-purity`. ## Publish authority | Package | Publishable from here | Current authority | | -- | -- | -- | -| `agents-audit` | Metadata says yes; **workflow disabled** | `workspace-json/agents-audit` until META-243 | -| `@workspacejson/cli` | **No** — `private: true`, must not be published | none (unpublished by design) | -| `@workspacejson/spec` | **Never** — not owned here | `workspacejson/standard` | -| `@workspacejson/rules` | **Never** — not owned here | `workspacejson/standard` | +| `agents-audit` | metadata says yes; **workflow disabled** | `workspace-json/agents-audit` until META-243 | +| `@workspacejson/cli` | metadata says yes; **workflow disabled, never published** | none yet — META-243 | +| `@workspacejson/datahub-adapter` | **No** — private, and leaving this repository | none | +| `@workspacejson/spec`, `@workspacejson/rules` | **Never** — not owned here | `workspacejson/standard` | -This repository holds no publish-capable secret. Authority transfer is -META-243's job, not this repository's. +This repository holds no publish-capable secret. ## Migration source and provenance Extracted from `workspace-json/agents-audit@e47eb1b8556c4f361db9a78190a2f36b400756e8` -on 2026-07-26 under META-240. See [`migration/PROVENANCE.md`](./migration/PROVENANCE.md). +on 2026-07-26 under META-240; restructured to the ratified architecture under +META-247. See [`migration/PROVENANCE.md`](./migration/PROVENANCE.md). diff --git a/README.md b/README.md index daef913..f710e1e 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,70 @@ # workspacejson/cli -CLI distribution for the **workspace.json** standard: repository scanning, -deterministic generation and reconciliation of `.agents/workspace.json`, and -CLI-side adapters. +CLI distribution for the **workspace.json** standard: repository scanning and +deterministic generation of `.agents/workspace.json`. This repository owns the *producer implementation and its executables*. It does -not own the specification. The normative schema, rules and contracts live in -[`workspacejson/standard`](https://github.com/workspacejson/standard); this -repository consumes them as released packages. +not own the specification — the normative schema, rules and contracts live in +[`workspacejson/standard`](https://github.com/workspacejson/standard) and are +consumed here as released packages. -## The generator command today +> **Status: pre-release.** The architecture below landed in META-247 and is the +> ratified target shape, but nothing here is published yet and the public +> documentation is deliberately unfinished. The working command today is +> `npx agents-audit generate`. -```bash -npx agents-audit generate -``` - -That is the real, published, working command for producing -`.agents/workspace.json`. It is implemented in -[`packages/agents-audit/`](./packages/agents-audit/) and published to npm as -[`agents-audit`](https://www.npmjs.com/package/agents-audit). +## Packages -```bash -agents-audit scan . # audit AGENTS.md hygiene -agents-audit generate # write .agents/workspace.json -agents-audit generate --dry-run # print the projection, write nothing -agents-audit generate --check # non-writing drift gate for CI -agents-audit generate --force # recover from an invalid existing artifact -``` +| Directory | Package | Published? | Role | +| -- | -- | -- | -- | +| [`packages/cli/`](./packages/cli/) | `@workspacejson/cli` | **No — not yet on npm** | the neutral producer and its `workspacejson` binary | +| [`packages/agents-audit-compat/`](./packages/agents-audit-compat/) | `agents-audit` | **Yes — `0.4.4`** | frozen compatibility bridge; preserves the historical command and API | -## This repository contains two different CLI packages +`packages/datahub-adapter/` also exists but is **not part of this repository's +architecture** — it is a private DataHub/dbt adapter staged here pending +extraction to `workspacejson/datahub-agent`, which owns DataHub consumption. See +[`OWNERSHIP.md`](./OWNERSHIP.md). -They are **not** the same tool, and one of them is not installable. +## Generating the artifact -| Directory | Package | Published? | What it actually does | -| -- | -- | -- | -- | -| [`packages/agents-audit/`](./packages/agents-audit/) | `agents-audit` | **Yes — public, `0.4.4`** | `AGENTS.md` audit **and the current workspace.json producer**. Binary: `agents-audit`. | -| [`packages/cli/`](./packages/cli/) | `@workspacejson/cli` | **No — `private: true`, not on npm** | A DataHub/dbt adapter: normalizes dbt model paths to repository-root-relative keys and joins them against an existing `generated.fileIndex`. Binary declared as `workspacejson`, but the package is not distributed. | +Today, the command that works is the compatibility one: -### `packages/cli` is not the generator +```bash +npx agents-audit generate +``` -The directory being named `cli` and the package being named -`@workspacejson/cli` is misleading, and it has misled before. It contains no -generation logic. It reads a `.agents/workspace.json` that something else -already produced. If you are looking for the code that *writes* the artifact, -it is `packages/agents-audit/src/generate.ts`. +Once `@workspacejson/cli` is published, the neutral equivalent is: -### Do not advertise `@workspacejson/cli` as installable +```bash +workspacejson generate # write .agents/workspace.json +workspacejson generate --dry-run # print the projection, write nothing +workspacejson generate --check # non-writing drift gate for CI +workspacejson generate --force # recover from an invalid existing artifact +``` -`npm install @workspacejson/cli` does not work and is expected not to work — -the package is `private: true` and returns `E404` from the registry. Any -documentation, extension text or integration guide that tells a user to install -it is wrong. +Both routes run **the same implementation** — `agents-audit` delegates to +`@workspacejson/cli`, so the two binaries cannot drift apart during the +compatibility window. -## The future neutral CLI identity is undecided +`agents-audit` additionally keeps its audit commands (`scan`, `version`) and all +nine of its historical public exports. -Whether a neutral producer package appears, whether `agents-audit` becomes a -compatibility bridge or stays a distinct audit product, and what happens to the -private DataHub shim are **open questions**, tracked in -[META-236](https://linear.app/marcelle-labs/issue/META-236). Nothing in this -repository should be read as having settled them. Until META-236 is ratified, -the answer to "what does a cold user run?" is `npx agents-audit generate`. +## Compatibility guarantee -## Ownership boundaries +`agents-audit` is a **frozen bridge**: its package name, binary, commands, exit +codes, output and exported API are unchanged, and it gets no new features. The +guarantee is enforced by executable parity harnesses in +[`migration/`](./migration/), which run real packed candidates from before and +after each change: -| Repository | Owns | -| -- | -- | -| [`workspacejson/standard`](https://github.com/workspacejson/standard) | specification, JSON Schema, rules, ADRs, conformance fixtures — **contract authority** | -| **`workspacejson/cli`** (this repo) | producer/audit CLI implementation, repository scanning, generation, CLI distribution | -| [`workspacejson/integrations`](https://github.com/workspacejson/integrations) | MCP, Codex, VS Code, host adapters | -| [`workspacejson/site`](https://github.com/workspacejson/site) | `workspacejson.dev` presentation and documentation assembly | +```bash +migration/parity-agents-audit-runtime.sh # 29/29 producer behavior +migration/parity-datahub-shim.mjs # 35/35 adapter behavior +``` -This repository contains **no** normative schema copy, **no** host-integration -implementation, and **no** site implementation. See [`OWNERSHIP.md`](./OWNERSHIP.md). +Removing the compatibility package is gated on downstream consumers — Buildomator, +the VS Code extension, MCP installers and documentation — moving to the neutral +command first. ## Development @@ -80,26 +73,28 @@ pnpm install pnpm typecheck pnpm build pnpm test -pnpm run check:architecture # clean-room and repository-boundary guards -pnpm run release:verify-packs # packed-tarball verification for agents-audit +pnpm run check:architecture # boundary and clean-room guards +node scripts/check-architecture.test.mjs # deliberate-violation red tests +pnpm run release:verify-packs # packed-tarball verification ``` Requires Node.js >= 20. ## Publishing -**Publishing from this repository is currently disabled.** The release workflow -is non-authoritative and cannot publish: it holds no npm credential and exits -before any publish step. `workspace-json/agents-audit` remains the sole publisher -of `agents-audit` until the coordinated authority cutover in -[META-243](https://linear.app/marcelle-labs/issue/META-243). +**Publishing from this repository is disabled.** The release workflow has no +enabled trigger, holds no npm credential and contains no publish step. +`workspace-json/agents-audit` remains the sole publisher of `agents-audit` until +the coordinated authority cutover in META-243. + +`@workspacejson/cli` has never been published. Do not document +`npm install @workspacejson/cli` as if it works. ## Provenance Extracted with full history from `workspace-json/agents-audit@e47eb1b8556c4f361db9a78190a2f36b400756e8`. -See [`migration/PROVENANCE.md`](./migration/PROVENANCE.md) for the extraction -command, old→new commit mapping, included/excluded paths and rollback procedure. +See [`migration/PROVENANCE.md`](./migration/PROVENANCE.md). ## License diff --git a/migration/parity-agents-audit-pack.sh b/migration/parity-agents-audit-pack.sh index a55f0b5..d9aaad1 100644 --- a/migration/parity-agents-audit-pack.sh +++ b/migration/parity-agents-audit-pack.sh @@ -4,7 +4,7 @@ set -uo pipefail SCRATCH="/private/tmp/claude-502/-Users-user1-dev-cli/ed967700-e9b4-4202-b983-6faf9cee9f6d/scratchpad" OLD="$SCRATCH/source-agents-audit/packages/agents-audit" -NEW="$SCRATCH/cli-extract/packages/agents-audit" +NEW="$SCRATCH/cli-extract/packages/agents-audit-compat" OUT="$SCRATCH/parity" mkdir -p "$OUT/old" "$OUT/new" diff --git a/migration/parity-agents-audit-runtime.sh b/migration/parity-agents-audit-runtime.sh index 622a377..ee49c4d 100644 --- a/migration/parity-agents-audit-runtime.sh +++ b/migration/parity-agents-audit-runtime.sh @@ -13,16 +13,22 @@ RUN="$OUT/runtime"; rm -rf "$RUN"; mkdir -p "$RUN" PASS=0; FAIL=0 declare -a FAILED -install_side () { # $1=side $2=tarball +install_side () { # $1=side $2=tarball $3...=extra sibling tarballs local dir="$RUN/$1-install" + local tarball="$2"; shift 2 mkdir -p "$dir" printf '{"private":true,"type":"module"}' > "$dir/package.json" - (cd "$dir" && npm install --ignore-scripts --no-package-lock "$2" >/dev/null 2>&1) || { echo "install failed for $1"; exit 1; } + # META-247: `agents-audit` now depends on @workspacejson/cli, which is + # deliberately unpublished until the authority cutover. The new side installs + # that sibling from a packed tarball so the harness measures behavior rather + # than failing on a package that simply is not released yet. + (cd "$dir" && npm install --ignore-scripts --no-package-lock "$@" "$tarball" >/dev/null 2>&1) \ + || { echo "install failed for $1"; exit 1; } echo "$dir" } OLD_DIR=$(install_side old "$OUT/oldpnpm/agents-audit-0.4.4.tgz") -NEW_DIR=$(install_side new "$OUT/newpnpm/agents-audit-0.4.4.tgz") +NEW_DIR=$(install_side new "$OUT/newpnpm/agents-audit-0.4.4.tgz" "$OUT/newpnpm/workspacejson-cli-0.1.0.tgz") # Build one canonical fixture repo, then clone it per invocation so old and new # always see byte-identical input. diff --git a/migration/parity-agents-audit-runtime.txt b/migration/parity-agents-audit-runtime.txt index 60222cf..aabb4c6 100644 --- a/migration/parity-agents-audit-runtime.txt +++ b/migration/parity-agents-audit-runtime.txt @@ -28,8 +28,8 @@ --- assertions on perturbation side effects --- PASS [old] manual.fragileFiles survived regeneration verbatim PASS [new] manual.fragileFiles survived regeneration verbatim - PASS [old] --force moved invalid artifact aside (workspace.json.invalid.2026-07-26T03-02-48-807Z) - PASS [new] --force moved invalid artifact aside (workspace.json.invalid.2026-07-26T03-02-49-116Z) + PASS [old] --force moved invalid artifact aside (workspace.json.invalid.2026-07-26T04-12-47-304Z) + PASS [new] --force moved invalid artifact aside (workspace.json.invalid.2026-07-26T04-12-47-648Z) PASS [old] --dry-run wrote nothing PASS [old] --check wrote nothing PASS [new] --dry-run wrote nothing diff --git a/migration/parity-datahub-shim.mjs b/migration/parity-datahub-shim.mjs index 2c9ced3..7b0c065 100644 --- a/migration/parity-datahub-shim.mjs +++ b/migration/parity-datahub-shim.mjs @@ -13,7 +13,7 @@ import { spawnSync } from "node:child_process"; const SCRATCH = "/private/tmp/claude-502/-Users-user1-dev-cli/ed967700-e9b4-4202-b983-6faf9cee9f6d/scratchpad"; const SIDES = { old: join(SCRATCH, "source-agents-audit/packages/cli"), - new: join(SCRATCH, "cli-extract/packages/cli"), + new: join(SCRATCH, "cli-extract/packages/datahub-adapter"), }; let pass = 0, fail = 0; @@ -35,16 +35,23 @@ const manifests = {}; for (const [side, dir] of Object.entries(SIDES)) { manifests[side] = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); } -check("name unchanged: @workspacejson/cli", - manifests.old.name === "@workspacejson/cli" && manifests.new.name === "@workspacejson/cli", +// META-247 INTENTIONAL DIFFERENCE: the package was renamed so the neutral CLI +// could take the `@workspacejson/cli` name. The rename is the ratified change; +// everything below still asserts that BEHAVIOR is untouched. +check("renamed to an accurate identity, old name released for the neutral CLI", + manifests.old.name === "@workspacejson/cli" && manifests.new.name === "@workspacejson/datahub-adapter", `old=${manifests.old.name} new=${manifests.new.name}`); check("version unchanged: 0.0.1", manifests.old.version === manifests.new.version && manifests.new.version === "0.0.1"); check("STILL PRIVATE (private:true) — must never be published", manifests.old.private === true && manifests.new.private === true, `old=${manifests.old.private} new=${manifests.new.private}`); -check("bin unchanged: workspacejson -> ./dist/cli.js", - equalish(manifests.old.bin, manifests.new.bin) && manifests.new.bin.workspacejson === "./dist/cli.js"); +// META-247 INTENTIONAL DIFFERENCE: the `workspacejson` bin now belongs to the +// neutral CLI, so this private adapter had to surrender it or collide. +check("bin surrendered `workspacejson` to the neutral CLI", + manifests.old.bin.workspacejson === "./dist/cli.js" + && manifests.new.bin["workspacejson-datahub-adapter"] === "./dist/cli.js" + && manifests.new.bin.workspacejson === undefined); check("exports/main/types unchanged", equalish(manifests.old.exports, manifests.new.exports) && manifests.old.main === manifests.new.main diff --git a/migration/parity-datahub-shim.txt b/migration/parity-datahub-shim.txt index f0d2a4b..ab7bec2 100644 --- a/migration/parity-datahub-shim.txt +++ b/migration/parity-datahub-shim.txt @@ -1,10 +1,10 @@ ============================================================== 1. PACKAGE IDENTITY AND PRIVACY ============================================================== - PASS name unchanged: @workspacejson/cli + PASS renamed to an accurate identity, old name released for the neutral CLI PASS version unchanged: 0.0.1 PASS STILL PRIVATE (private:true) — must never be published - PASS bin unchanged: workspacejson -> ./dist/cli.js + PASS bin surrendered `workspacejson` to the neutral CLI PASS exports/main/types unchanged PASS declares NO generate command (it is not the producer) PASS does not depend on agents-audit or @workspacejson/rules diff --git a/package.json b/package.json index 5f9a6fb..b5cde04 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "url": "https://github.com/workspacejson/cli/issues" }, "dependencies": { + "@workspacejson/cli": "workspace:*", "agents-audit": "workspace:*" }, "scripts": { @@ -19,7 +20,7 @@ "test": "pnpm -r test", "typecheck": "pnpm -r typecheck", "check:architecture": "node scripts/check-architecture.mjs", - "release:verify-packs": "pnpm --filter agents-audit exec node ../../scripts/verify-package-tarball.mjs", + "release:verify-packs": "pnpm --filter @workspacejson/cli exec node ../../scripts/verify-package-tarball.mjs && pnpm --filter agents-audit exec node ../../scripts/verify-package-tarball.mjs", "release:verify-published": "node scripts/verify-published.mjs" }, "devDependencies": { diff --git a/packages/agents-audit/CHANGELOG.md b/packages/agents-audit-compat/CHANGELOG.md similarity index 100% rename from packages/agents-audit/CHANGELOG.md rename to packages/agents-audit-compat/CHANGELOG.md diff --git a/packages/agents-audit/LICENSE b/packages/agents-audit-compat/LICENSE similarity index 100% rename from packages/agents-audit/LICENSE rename to packages/agents-audit-compat/LICENSE diff --git a/packages/agents-audit/README.md b/packages/agents-audit-compat/README.md similarity index 100% rename from packages/agents-audit/README.md rename to packages/agents-audit-compat/README.md diff --git a/packages/agents-audit/package.json b/packages/agents-audit-compat/package.json similarity index 87% rename from packages/agents-audit/package.json rename to packages/agents-audit-compat/package.json index f455882..f4a9163 100644 --- a/packages/agents-audit/package.json +++ b/packages/agents-audit-compat/package.json @@ -1,7 +1,7 @@ { "name": "agents-audit", "version": "0.4.4", - "description": "Audit tool for AGENTS.md hygiene - reads .agents/workspace.json for richer findings", + "description": "Compatibility package for the workspace.json producer and AGENTS.md audit. Generation is implemented by @workspacejson/cli; this package preserves the historical `agents-audit` command and API surface.", "license": "Apache-2.0", "author": "workspace-json contributors", "homepage": "https://www.workspacejson.dev/audit/", @@ -64,6 +64,7 @@ "prepublishOnly": "pnpm build && pnpm test && node ../../scripts/verify-package-tarball.mjs" }, "dependencies": { + "@workspacejson/cli": "workspace:*", "@inquirer/core": "^9.0.0", "@workspacejson/rules": "0.4.4", "@workspacejson/spec": "0.4.4", diff --git a/packages/agents-audit/scripts/add-shebang.js b/packages/agents-audit-compat/scripts/add-shebang.js similarity index 100% rename from packages/agents-audit/scripts/add-shebang.js rename to packages/agents-audit-compat/scripts/add-shebang.js diff --git a/packages/agents-audit/scripts/add-shebang.test.ts b/packages/agents-audit-compat/scripts/add-shebang.test.ts similarity index 100% rename from packages/agents-audit/scripts/add-shebang.test.ts rename to packages/agents-audit-compat/scripts/add-shebang.test.ts diff --git a/packages/agents-audit/src/audit.integration.test.ts b/packages/agents-audit-compat/src/audit.integration.test.ts similarity index 100% rename from packages/agents-audit/src/audit.integration.test.ts rename to packages/agents-audit-compat/src/audit.integration.test.ts diff --git a/packages/agents-audit/src/audit.ts b/packages/agents-audit-compat/src/audit.ts similarity index 98% rename from packages/agents-audit/src/audit.ts rename to packages/agents-audit-compat/src/audit.ts index 5c710a2..7b863bc 100644 --- a/packages/agents-audit/src/audit.ts +++ b/packages/agents-audit-compat/src/audit.ts @@ -5,7 +5,7 @@ import type { WorkspaceJson } from '@workspacejson/spec'; import { AgentsMdParser, RepoScanner, RuleEngine, WorkspaceJsonValidator, computeHygieneScore, conventionMismatch, frameworkDrift, missingFileReference, patternZeroMatch, sectionStaleness } from '@workspacejson/rules'; import type { AuditConfig, AuditResult, ParsedAgentsMd, RepoState, RuleContext } from '@workspacejson/rules'; import { DEFAULT_AUDIT_CONFIG, detectCiProvider } from './internal/config.js'; -import { findAgentsMdPath, readTextOrEmpty } from './internal/fs.js'; +import { findAgentsMdPath, readTextOrEmpty } from '@workspacejson/cli'; function buildLegacyContext( agentsMd: ParsedAgentsMd, diff --git a/packages/agents-audit/src/cli-helpers.test.ts b/packages/agents-audit-compat/src/cli-helpers.test.ts similarity index 100% rename from packages/agents-audit/src/cli-helpers.test.ts rename to packages/agents-audit-compat/src/cli-helpers.test.ts diff --git a/packages/agents-audit/src/cli-helpers.ts b/packages/agents-audit-compat/src/cli-helpers.ts similarity index 100% rename from packages/agents-audit/src/cli-helpers.ts rename to packages/agents-audit-compat/src/cli-helpers.ts diff --git a/packages/agents-audit/src/cli.integration.test.ts b/packages/agents-audit-compat/src/cli.integration.test.ts similarity index 64% rename from packages/agents-audit/src/cli.integration.test.ts rename to packages/agents-audit-compat/src/cli.integration.test.ts index ad78450..10927ca 100644 --- a/packages/agents-audit/src/cli.integration.test.ts +++ b/packages/agents-audit-compat/src/cli.integration.test.ts @@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({ renderFindingsTable: vi.fn(), renderScoreCard: vi.fn(), renderVrekoUpsell: vi.fn(), - generateWorkspaceJson: vi.fn(), + runGenerate: vi.fn(), ora: vi.fn(() => ({ start: () => ({ stop: vi.fn() }), })), @@ -23,7 +23,7 @@ vi.mock('./presenter.js', () => ({ renderScoreCard: mocks.renderScoreCard, renderVrekoUpsell: mocks.renderVrekoUpsell, })); -vi.mock('./generate.js', () => ({ generateWorkspaceJson: mocks.generateWorkspaceJson })); +vi.mock('@workspacejson/cli', () => ({ runGenerate: mocks.runGenerate })); vi.mock('ora', () => ({ default: mocks.ora })); import { runCli } from './cli.js'; @@ -71,88 +71,43 @@ describe('CLI integration', () => { expect(mocks.runAudit).toHaveBeenCalledWith(resolve(repoRoot), expect.any(Object)); }); - it('uses --check as a non-writing drift gate for generate', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - mocks.generateWorkspaceJson.mockResolvedValueOnce({ - path: '/repo/.agents/workspace.json', - written: false, - skipped: false, - drift: true, - preservedManual: true, - content: {}, - }); - - const exitCode = await runCli(['node', 'agents-audit', 'generate', '/repo', '--check']); - - expect(exitCode).toBe(1); - const generateCalls = (mocks.generateWorkspaceJson as unknown as { mock: { calls: unknown[][] } }).mock.calls; - const errorCalls = (errorSpy as unknown as { mock: { calls: unknown[][] } }).mock.calls; - expect(generateCalls[0]?.[0]).toBe('/repo'); - expect(generateCalls[0]?.[2]).toEqual({ check: true, dryRun: false, force: false }); - expect(errorCalls.flat().join(' ')).toContain('manual evidence is untouched'); - errorSpy.mockRestore(); - }); - - it('reports a current generated projection without writing', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - mocks.generateWorkspaceJson.mockResolvedValueOnce({ - path: '/repo/.agents/workspace.json', - written: false, - skipped: true, - drift: false, - preservedManual: true, - content: {}, - }); + // META-247: `generate` output behavior moved to @workspacejson/cli along with + // its implementation, and is tested there (src/commands/generate.test.ts). + // What this package is still responsible for is delegating correctly — with + // its own historical command name and provenance stamp — so the tests here + // assert the contract at that seam. End-to-end proof that the observable + // behavior is unchanged is the META-240 parity harness, which runs real + // packed candidates rather than mocks. + it('delegates generate to the neutral producer with the historical identity', async () => { + mocks.runGenerate.mockResolvedValueOnce(0); const exitCode = await runCli(['node', 'agents-audit', 'generate', '/repo', '--check']); expect(exitCode).toBe(0); - expect(logSpy).toHaveBeenCalledWith('Generated sections are current at /repo/.agents/workspace.json'); - logSpy.mockRestore(); + const calls = (mocks.runGenerate as unknown as { mock: { calls: unknown[][] } }).mock.calls; + expect(calls[0]?.[0]).toBe('/repo'); + expect(calls[0]?.[1]).toMatchObject({ check: true }); + expect(calls[0]?.[2]).toMatchObject({ + commandName: 'agents-audit', + producer: { name: 'agents-audit' }, + }); }); - it('still fails the drift gate when --check is combined with --dry-run', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - mocks.generateWorkspaceJson.mockResolvedValueOnce({ - path: '/repo/.agents/workspace.json', - written: false, - skipped: false, - drift: true, - preservedManual: true, - content: { staged: true }, - }); + it('propagates the generate exit code unchanged', async () => { + mocks.runGenerate.mockResolvedValueOnce(1); - const exitCode = await runCli(['node', 'agents-audit', 'generate', '/repo', '--check', '--dry-run']); + const exitCode = await runCli(['node', 'agents-audit', 'generate', '/repo', '--check']); expect(exitCode).toBe(1); - const errorCalls = (errorSpy as unknown as { mock: { calls: unknown[][] } }).mock.calls; - expect(errorCalls.flat().join(' ')).toContain('manual evidence is untouched'); - expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ staged: true }, null, 2)); - errorSpy.mockRestore(); - logSpy.mockRestore(); }); - it('surfaces the relocated invalid file when --force recovers a fresh generate', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - mocks.generateWorkspaceJson.mockResolvedValueOnce({ - path: '/repo/.agents/workspace.json', - written: true, - skipped: false, - drift: true, - preservedManual: false, - invalidFileMoved: '/repo/.agents/workspace.json.invalid.2026-01-01T00-00-00-000Z', - content: {}, - }); + it('forwards --dry-run and --force through to the producer', async () => { + mocks.runGenerate.mockResolvedValueOnce(0); - const exitCode = await runCli(['node', 'agents-audit', 'generate', '/repo', '--force']); + await runCli(['node', 'agents-audit', 'generate', '/repo', '--dry-run', '--force']); - expect(exitCode).toBe(0); - const logCalls = (logSpy as unknown as { mock: { calls: unknown[][] } }).mock.calls; - const logs = logCalls.flat().join(' '); - expect(logs).toContain('Generated /repo/.agents/workspace.json'); - expect(logs).toContain('/repo/.agents/workspace.json.invalid.2026-01-01T00-00-00-000Z'); - logSpy.mockRestore(); + const calls = (mocks.runGenerate as unknown as { mock: { calls: unknown[][] } }).mock.calls; + expect(calls[0]?.[1]).toMatchObject({ dryRun: true, force: true }); }); it('treats --dir as an invalid option', async () => { diff --git a/packages/agents-audit/src/cli.ts b/packages/agents-audit-compat/src/cli.ts similarity index 73% rename from packages/agents-audit/src/cli.ts rename to packages/agents-audit-compat/src/cli.ts index 9736dd2..19e16f4 100644 --- a/packages/agents-audit/src/cli.ts +++ b/packages/agents-audit-compat/src/cli.ts @@ -8,7 +8,7 @@ import ora from 'ora'; import pc from 'picocolors'; import { runAudit } from './audit.js'; import { getExitCode, isActionable, loadConfig } from './cli-helpers.js'; -import { generateWorkspaceJson } from './generate.js'; +import { runGenerate } from '@workspacejson/cli'; import { renderFindingsTable, renderScoreCard, renderVrekoUpsell } from './presenter.js'; import { startInteractiveNavigation } from './navigator.js'; import { saveReport } from './reporter.js'; @@ -96,46 +96,18 @@ export async function runCli(argv: string[] = process.argv): Promise { .action(async (path: string, options: { dryRun?: boolean; check?: boolean; force?: boolean; config?: string }) => { const repoRoot = resolve(path); const { config, warning } = loadConfig(options.config, repoRoot); - const spinner = ora({ text: 'Scanning repository...', color: 'green' }).start(); - try { - if (warning) { - console.error(`agents-audit config warning: ${warning}`); - } - - const result = await generateWorkspaceJson(repoRoot, config, { - dryRun: options.dryRun === true, - check: options.check === true, - force: options.force === true, - }); - spinner.stop(); - - if (options.check) { - if (result.drift) { - console.error(`Generated sections are stale at ${result.path}; manual evidence is untouched. Run: agents-audit generate ${path}`); - exitCode = 1; - } else { - console.log(`Generated sections are current at ${result.path}`); - } - if (options.dryRun) { - console.log(JSON.stringify(result.content, null, 2)); - } - } else if (options.dryRun) { - console.log(JSON.stringify(result.content, null, 2)); - } else if (result.skipped) { - console.log(`Generated sections already current at ${result.path}; manual evidence preserved`); - } else if (result.invalidFileMoved) { - console.log(`Generated ${result.path}`); - console.log(pc.yellow(` Previous file was invalid and has been moved aside: ${result.invalidFileMoved}`)); - console.log(pc.yellow(' Manual evidence from the previous file was not recovered (it could not be parsed/validated).')); - } else { - console.log(`Generated ${result.path}`); - } - } catch (error) { - spinner.stop(); - console.error('agents-audit generate failed:', error instanceof Error ? error.message : error); - exitCode = 1; - } + // Delegated to @workspacejson/cli so `agents-audit generate` and + // `workspacejson generate` cannot drift apart. `producer` keeps this + // package's historical provenance stamp in `generated.by`, and + // `commandName` keeps every message and remediation hint byte-identical + // to what this command has always printed (META-247). + exitCode = await runGenerate(path, options, { + config, + configWarning: warning, + producer: { name: 'agents-audit', version }, + commandName: 'agents-audit', + }); }); program diff --git a/packages/agents-audit/src/index.test.ts b/packages/agents-audit-compat/src/index.test.ts similarity index 100% rename from packages/agents-audit/src/index.test.ts rename to packages/agents-audit-compat/src/index.test.ts diff --git a/packages/agents-audit-compat/src/index.ts b/packages/agents-audit-compat/src/index.ts new file mode 100644 index 0000000..eeb882d --- /dev/null +++ b/packages/agents-audit-compat/src/index.ts @@ -0,0 +1,12 @@ +// agents-audit public API — UNCHANGED SURFACE. +// +// This package is a frozen compatibility bridge (META-236). Generation now +// lives in @workspacejson/cli; `generateWorkspaceJson` and `detectCiProvider` +// are re-exported from there so every historical import path keeps working. +// All nine exports below were importable from agents-audit before META-247 and +// must remain so. +export { runAudit, DEFAULT_AUDIT_CONFIG } from './audit.js'; +export { generateWorkspaceJson, detectCiProvider } from '@workspacejson/cli'; +export { renderFindingsTable, renderScoreCard, renderVrekoUpsell } from './presenter.js'; +export { startInteractiveNavigation } from './navigator.js'; +export { saveReport } from './reporter.js'; diff --git a/packages/agents-audit-compat/src/internal/config.ts b/packages/agents-audit-compat/src/internal/config.ts new file mode 100644 index 0000000..036f59a --- /dev/null +++ b/packages/agents-audit-compat/src/internal/config.ts @@ -0,0 +1,17 @@ +import type { AuditConfig } from '@workspacejson/rules'; + +// `detectCiProvider` is a neutral producer primitive and now lives in +// @workspacejson/cli. It is re-exported here because it is part of this +// package's historical public API and must remain importable from +// `agents-audit` (META-247). +export { detectCiProvider } from '@workspacejson/cli'; + +export const DEFAULT_AUDIT_CONFIG: AuditConfig = { + stalenessThresholdDays: 60, + highActivityCommitCount: 20, + conventionMismatchPrecisionMode: true, + failOn: null, + save: false, + reportDir: '.agents/audit-history', + ignore: [], +}; diff --git a/packages/agents-audit/src/navigator.test.ts b/packages/agents-audit-compat/src/navigator.test.ts similarity index 100% rename from packages/agents-audit/src/navigator.test.ts rename to packages/agents-audit-compat/src/navigator.test.ts diff --git a/packages/agents-audit/src/navigator.ts b/packages/agents-audit-compat/src/navigator.ts similarity index 100% rename from packages/agents-audit/src/navigator.ts rename to packages/agents-audit-compat/src/navigator.ts diff --git a/packages/agents-audit/src/package-metadata.test.ts b/packages/agents-audit-compat/src/package-metadata.test.ts similarity index 83% rename from packages/agents-audit/src/package-metadata.test.ts rename to packages/agents-audit-compat/src/package-metadata.test.ts index 42e4717..1474436 100644 --- a/packages/agents-audit/src/package-metadata.test.ts +++ b/packages/agents-audit-compat/src/package-metadata.test.ts @@ -17,7 +17,7 @@ function readPackageJson(relativePath: string): Record { describe('package metadata', () => { it('keeps the CLI package mature and executable', () => { - const pkg = readPackageJson('packages/agents-audit/package.json'); + const pkg = readPackageJson('packages/agents-audit-compat/package.json'); expect(pkg.name).toBe('agents-audit'); expect((pkg.bin as { [key: string]: string } | undefined)?.['agents-audit']).toBe('./dist/cli.js'); expect((pkg.publishConfig as { access?: string } | undefined)?.access).toBe('public'); @@ -36,14 +36,14 @@ describe('package metadata', () => { }); it('points package metadata at the new owning repository', () => { - const pkg = readPackageJson('packages/agents-audit/package.json'); + const pkg = readPackageJson('packages/agents-audit-compat/package.json'); const repository = pkg.repository as { url?: string } | undefined; expect(repository?.url).toBe('git+https://github.com/workspacejson/cli.git'); expect((pkg.bugs as { url?: string } | undefined)?.url).toBe('https://github.com/workspacejson/cli/issues'); }); it('pins standard-owned dependencies to exact published versions', () => { - const pkg = readPackageJson('packages/agents-audit/package.json'); + const pkg = readPackageJson('packages/agents-audit-compat/package.json'); const dependencies = pkg.dependencies as Record; for (const name of ['@workspacejson/spec', '@workspacejson/rules']) { const range = dependencies[name]; @@ -54,13 +54,13 @@ describe('package metadata', () => { } }); - it('keeps the DataHub shim private and separate from the producer', () => { - const pkg = readPackageJson('packages/cli/package.json'); + it('keeps the DataHub adapter private and separate from the producer', () => { + const pkg = readPackageJson('packages/datahub-adapter/package.json'); // The two CLI packages must not converge during migration. META-236 owns // any future decision about this package's identity; until then it is a // private dbt/DataHub adapter and must never be published. - expect(pkg.name).toBe('@workspacejson/cli'); + expect(pkg.name).toBe('@workspacejson/datahub-adapter'); expect(pkg.private).toBe(true); - expect((pkg.bin as { [key: string]: string } | undefined)?.workspacejson).toBe('./dist/cli.js'); + expect((pkg.bin as { [key: string]: string } | undefined)?.['workspacejson-datahub-adapter']).toBe('./dist/cli.js'); }); }); diff --git a/packages/agents-audit/src/presenter.test.ts b/packages/agents-audit-compat/src/presenter.test.ts similarity index 100% rename from packages/agents-audit/src/presenter.test.ts rename to packages/agents-audit-compat/src/presenter.test.ts diff --git a/packages/agents-audit/src/presenter.ts b/packages/agents-audit-compat/src/presenter.ts similarity index 100% rename from packages/agents-audit/src/presenter.ts rename to packages/agents-audit-compat/src/presenter.ts diff --git a/packages/agents-audit/src/reporter.test.ts b/packages/agents-audit-compat/src/reporter.test.ts similarity index 100% rename from packages/agents-audit/src/reporter.test.ts rename to packages/agents-audit-compat/src/reporter.test.ts diff --git a/packages/agents-audit/src/reporter.ts b/packages/agents-audit-compat/src/reporter.ts similarity index 100% rename from packages/agents-audit/src/reporter.ts rename to packages/agents-audit-compat/src/reporter.ts diff --git a/packages/agents-audit/tsconfig.json b/packages/agents-audit-compat/tsconfig.json similarity index 100% rename from packages/agents-audit/tsconfig.json rename to packages/agents-audit-compat/tsconfig.json diff --git a/packages/agents-audit/src/index.ts b/packages/agents-audit/src/index.ts deleted file mode 100644 index 71d88ea..0000000 --- a/packages/agents-audit/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { runAudit, DEFAULT_AUDIT_CONFIG } from './audit.js'; -export { generateWorkspaceJson } from './generate.js'; -export { detectCiProvider } from './internal/config.js'; -export { renderFindingsTable, renderScoreCard, renderVrekoUpsell } from './presenter.js'; -export { startInteractiveNavigation } from './navigator.js'; -export { saveReport } from './reporter.js'; diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..0d74b41 --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,12 @@ +Apache License 2.0 + +Copyright (c) 2026 workspace-json contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may obtain a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/packages/cli/README.md b/packages/cli/README.md index 7fa2902..fc9e59b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,40 +1,60 @@ # @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 **workspace.json producer**. Scans a repository and generates +`.agents/workspace.json` deterministically, preserving human-authored `manual` +evidence across regenerations. -## The problem it solves +> **Not yet published.** This package is not on npm. Until the coordinated +> publish-authority cutover, the working public command is +> `npx agents-audit generate`, which runs this same producer implementation. +> Do not document `npm install @workspacejson/cli` as if it works. -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. +## Commands -## The fix (the normalization shim) - -``` -projectPrefix = relative(gitRoot, dbtProjectDir) // "dbt" when nested, "" at root -joinKey = projectPrefix ? `${projectPrefix}/${original_file_path}` : original_file_path +```bash +workspacejson generate # write .agents/workspace.json +workspacejson generate --dry-run # print the projection, write nothing +workspacejson generate --check # non-writing drift gate for CI +workspacejson generate --force # recover from an invalid existing artifact ``` -`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. +## Behavior + +- **Manual evidence is preserved verbatim.** Regeneration replaces + producer-owned sections only; anything under `manual` survives untouched. +- **Writes are atomic** — a temporary file is renamed into place, so a crash + never leaves a half-written artifact. +- **Invalid artifacts are refused, not overwritten.** If an existing + `.agents/workspace.json` cannot be parsed or fails validation, `generate` + exits non-zero rather than destroying evidence it cannot read. `--force` + moves the invalid file aside as `workspace.json.invalid.` instead + of deleting it. +- **Unchanged material output does not create drift.** The producer compares a + stable projection of generated content that excludes volatile timestamps, so + re-running on an unchanged repository is a no-op and `--check` stays usable as + a CI gate. + +## Library use + +```ts +import { generateWorkspaceJson } from '@workspacejson/cli'; + +const result = await generateWorkspaceJson(process.cwd()); +console.log(result.path, result.written, result.drift); +``` -## Usage +`generateWorkspaceJson(repoRoot, config?, options?)` accepts +`{ dryRun, check, force, producer }`. The `producer` identity is written to +`generated.by` and defaults to this package — `agents-audit` passes its own +identity so its historical artifacts keep stamping the same provenance. -```bash -workspacejson --git-root . --manifest dbt/target/manifest.json --workspace-json .agents/workspace.json -``` +## Contracts -Exits non-zero if any dbt project produces zero joined rows. +Schema, types and validation come from +[`workspacejson/standard`](https://github.com/workspacejson/standard) as +released packages (`@workspacejson/spec`, `@workspacejson/rules`). This package +implements the contract; it does not define it. -## Status +## License -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. +Apache-2.0. diff --git a/packages/cli/package.json b/packages/cli/package.json index 24242c8..ab8cbbb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,23 +1,40 @@ { "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).", + "version": "0.1.0", + "description": "The workspace.json producer — scans a repository and generates .agents/workspace.json deterministically, preserving human-authored manual evidence.", "license": "Apache-2.0", - "author": "workspace-json contributors", - "homepage": "https://www.workspacejson.dev", + "author": "workspace.json contributors", + "homepage": "https://workspacejson.dev", "repository": { "type": "git", "url": "git+https://github.com/workspacejson/cli.git", "directory": "packages/cli" }, + "bugs": { + "url": "https://github.com/workspacejson/cli/issues" + }, "publishConfig": { "access": "public" }, + "keywords": [ + "workspace.json", + "ai-agents", + "ai-coding-agents", + "codebase-intelligence", + "open-standard", + "json-schema", + "developer-tools", + "aaif", + "producer", + "cli" + ], "engines": { "node": ">=20.0.0" }, "type": "module", + "bin": { + "workspacejson": "./dist/cli.js" + }, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -27,25 +44,30 @@ "types": "./dist/index.d.ts" } }, - "bin": { - "workspacejson": "./dist/cli.js" - }, "files": [ "dist", - "README.md" + "README.md", + "LICENSE", + "CHANGELOG.md" ], "scripts": { - "build": "tsup src/index.ts src/cli.ts --format esm --dts", + "build": "tsup src/cli.ts src/index.ts --format esm --dts && node scripts/add-shebang.js", "test": "vitest run", - "typecheck": "tsc --noEmit" + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "prepublishOnly": "pnpm build && pnpm test && node ../../scripts/verify-package-tarball.mjs" }, "dependencies": { - "@workspacejson/spec": "0.4.4" + "@workspacejson/rules": "0.4.4", + "@workspacejson/spec": "0.4.4", + "commander": "^12.1.0", + "ora": "^8.0.1", + "picocolors": "^1.0.1" }, "devDependencies": { "@types/node": "22.19.17", - "tsup": "^8.0.0", "typescript": "^5.4.0", + "tsup": "^8.0.0", "vitest": "^1.6.0" } } diff --git a/packages/cli/scripts/add-shebang.js b/packages/cli/scripts/add-shebang.js new file mode 100644 index 0000000..0fe3f92 --- /dev/null +++ b/packages/cli/scripts/add-shebang.js @@ -0,0 +1,22 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const NODE_SHEBANG = '#!/usr/bin/env node\n'; + +export async function addShebang(target) { + const current = await readFile(target, 'utf8'); + if (current.startsWith(NODE_SHEBANG)) return; + await writeFile(target, `${NODE_SHEBANG}${current}`, 'utf8'); +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]); + +if (isMain) { + const target = resolve(process.cwd(), 'dist/cli.js'); + + addShebang(target).catch((error) => { + console.error(`Failed to add Node shebang to ${target}:`, error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 15eb267..f455988 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,72 +1,64 @@ #!/usr/bin/env node -import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { Command } from 'commander'; +import { runGenerate } from './commands/generate.js'; +import { DEFAULT_PRODUCER_CONFIG } from './producer/config.js'; -import { extractModels, findDbtProjects, type DbtManifest } from "./dbt.js"; -import { computeProjectPrefix, canonical } from "./normalize.js"; -import { joinModels, type FileIndex } from "./join.js"; +const require = createRequire(import.meta.url); +const { version } = require('../package.json') as { version: string }; -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 }; -} +export async function runCli(argv: string[] = process.argv): Promise { + let exitCode = 0; + const program = new Command(); -function readJson(path: string): T { - return JSON.parse(readFileSync(path, "utf8")) as T; -} + program + .name('workspacejson') + .description('Generate .agents/workspace.json — the workspace.json producer') + .version(version) + .exitOverride(); -/** - * 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 ?? {}; + program + .command('generate', { isDefault: true }) + .description('Generate .agents/workspace.json from a repository scan') + .argument('[path]', 'Repository root to scan', '.') + .option('--dry-run', 'Print the workspace.json that would be written without writing it') + .option('--check', 'Exit non-zero when producer-owned sections are missing, invalid, or stale without writing') + .option('--force', 'Move an invalid existing artifact aside before writing a fresh generated artifact') + .action(async (path: string, options: { dryRun?: boolean; check?: boolean; force?: boolean }) => { + // Config-file support is deliberately absent for now. `agents-audit` + // reads `.agentsauditrc`, which is an audit-shaped name the neutral + // producer should not inherit by default. Naming a neutral config file is + // a public-surface decision and belongs with the OSS polish issue, not + // with this structural change (META-247). + exitCode = await runGenerate(path, options, { + config: DEFAULT_PRODUCER_CONFIG, + commandName: 'workspacejson', + }); + }); - // 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); + program + .command('version') + .description('Print version information') + .action(() => { + console.log(`workspacejson v${version}`); + console.log('https://workspacejson.dev'); + }); - if (prefix === null) { - console.error(`dbt project ${dbtProjectDir} is not inside git root ${args.gitRoot}`); - return 2; + try { + await program.parseAsync(argv); + } catch (error) { + exitCode = typeof error === 'object' && error && 'exitCode' in error ? Number((error as { exitCode?: number }).exitCode) || 1 : 1; } - 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; + return exitCode; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - process.exit(run(parseArgs(process.argv.slice(2)))); +// npm exposes package bins through node_modules/.bin symlinks. Resolve both +// paths before comparing them so the executable runs whether invoked directly +// or through npx/npm exec. +if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { + const exitCode = await runCli(process.argv); + process.exit(exitCode); } diff --git a/packages/cli/src/commands/generate.test.ts b/packages/cli/src/commands/generate.test.ts new file mode 100644 index 0000000..016890f --- /dev/null +++ b/packages/cli/src/commands/generate.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + generateWorkspaceJson: vi.fn(), + ora: vi.fn(() => ({ + start: () => ({ stop: vi.fn() }), + })), +})); + +vi.mock('../producer/generate.js', () => ({ generateWorkspaceJson: mocks.generateWorkspaceJson })); +vi.mock('ora', () => ({ default: mocks.ora })); + +import { runGenerate } from './generate.js'; +import { DEFAULT_PRODUCER_CONFIG } from '../producer/config.js'; + +/** + * Output behavior of the `generate` command. + * + * These cases moved here from `agents-audit`'s CLI integration suite in + * META-247, because the implementation moved. They are parameterized by + * `commandName` so the same assertions cover both binaries — which is the point + * of routing both through one implementation. + * + * End-to-end proof that `agents-audit generate` still behaves identically lives + * in the META-240 parity harness, which runs real packed candidates. + */ +describe('runGenerate', () => { + const context = (commandName: string) => ({ + config: DEFAULT_PRODUCER_CONFIG, + commandName, + ...(commandName === 'agents-audit' + ? { producer: { name: 'agents-audit', version: '0.4.4' } } + : {}), + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses --check as a non-writing drift gate', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mocks.generateWorkspaceJson.mockResolvedValueOnce({ + path: '/repo/.agents/workspace.json', + written: false, + skipped: false, + drift: true, + preservedManual: true, + content: {}, + }); + + const exitCode = await runGenerate('/repo', { check: true }, context('agents-audit')); + + expect(exitCode).toBe(1); + const generateCalls = (mocks.generateWorkspaceJson as unknown as { mock: { calls: unknown[][] } }).mock.calls; + expect(generateCalls[0]?.[0]).toBe('/repo'); + expect(generateCalls[0]?.[2]).toMatchObject({ check: true, dryRun: false, force: false }); + const errorCalls = (errorSpy as unknown as { mock: { calls: unknown[][] } }).mock.calls; + expect(errorCalls.flat().join(' ')).toContain('manual evidence is untouched'); + errorSpy.mockRestore(); + }); + + it('reports a current generated projection without writing', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mocks.generateWorkspaceJson.mockResolvedValueOnce({ + path: '/repo/.agents/workspace.json', + written: false, + skipped: true, + drift: false, + preservedManual: true, + content: {}, + }); + + const exitCode = await runGenerate('/repo', { check: true }, context('agents-audit')); + + expect(exitCode).toBe(0); + expect(logSpy).toHaveBeenCalledWith('Generated sections are current at /repo/.agents/workspace.json'); + logSpy.mockRestore(); + }); + + it('still fails the drift gate when --check is combined with --dry-run', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mocks.generateWorkspaceJson.mockResolvedValueOnce({ + path: '/repo/.agents/workspace.json', + written: false, + skipped: false, + drift: true, + preservedManual: true, + content: { staged: true }, + }); + + const exitCode = await runGenerate('/repo', { check: true, dryRun: true }, context('agents-audit')); + + expect(exitCode).toBe(1); + const errorCalls = (errorSpy as unknown as { mock: { calls: unknown[][] } }).mock.calls; + expect(errorCalls.flat().join(' ')).toContain('manual evidence is untouched'); + expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ staged: true }, null, 2)); + errorSpy.mockRestore(); + logSpy.mockRestore(); + }); + + it('surfaces the relocated invalid file when --force recovers a fresh generate', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mocks.generateWorkspaceJson.mockResolvedValueOnce({ + path: '/repo/.agents/workspace.json', + written: true, + skipped: false, + drift: true, + preservedManual: false, + invalidFileMoved: '/repo/.agents/workspace.json.invalid.2026-01-01T00-00-00-000Z', + content: {}, + }); + + const exitCode = await runGenerate('/repo', { force: true }, context('agents-audit')); + + expect(exitCode).toBe(0); + const logCalls = (logSpy as unknown as { mock: { calls: unknown[][] } }).mock.calls; + const logs = logCalls.flat().join(' '); + expect(logs).toContain('Generated /repo/.agents/workspace.json'); + expect(logs).toContain('/repo/.agents/workspace.json.invalid.2026-01-01T00-00-00-000Z'); + logSpy.mockRestore(); + }); + + it('names the invoking command in the drift remediation hint', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mocks.generateWorkspaceJson.mockResolvedValue({ + path: '/repo/.agents/workspace.json', + written: false, + skipped: false, + drift: true, + preservedManual: true, + content: {}, + }); + + await runGenerate('/repo', { check: true }, context('agents-audit')); + expect(((errorSpy as unknown as { mock: { calls: unknown[][] } }).mock.calls).flat().join(' ')) + .toContain('Run: agents-audit generate /repo'); + + errorSpy.mockClear(); + + await runGenerate('/repo', { check: true }, context('workspacejson')); + expect(((errorSpy as unknown as { mock: { calls: unknown[][] } }).mock.calls).flat().join(' ')) + .toContain('Run: workspacejson generate /repo'); + + errorSpy.mockRestore(); + }); + + it('passes the caller producer identity through to the generator', async () => { + mocks.generateWorkspaceJson.mockResolvedValueOnce({ + path: '/repo/.agents/workspace.json', + written: true, + skipped: false, + drift: true, + preservedManual: false, + content: {}, + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runGenerate('/repo', {}, context('agents-audit')); + + const generateCalls = (mocks.generateWorkspaceJson as unknown as { mock: { calls: unknown[][] } }).mock.calls; + expect(generateCalls[0]?.[2]).toMatchObject({ + commandName: 'agents-audit', + producer: { name: 'agents-audit', version: '0.4.4' }, + }); + logSpy.mockRestore(); + }); +}); diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts new file mode 100644 index 0000000..25ecfa8 --- /dev/null +++ b/packages/cli/src/commands/generate.ts @@ -0,0 +1,80 @@ +import { resolve } from 'node:path'; +import ora from 'ora'; +import pc from 'picocolors'; +import { generateWorkspaceJson } from '../producer/generate.js'; +import type { ProducerConfig } from '../producer/config.js'; +import type { ProducerIdentity } from '../producer/generate.js'; + +export interface GenerateCommandOptions { + dryRun?: boolean; + check?: boolean; + force?: boolean; +} + +/** + * The `generate` command, extracted verbatim from the historical + * `agents-audit generate` action (META-247). + * + * `agents-audit` calls this same function so the two commands cannot drift: + * one implementation, two entry points. `producer` and `commandName` are the + * only things the caller varies, so the compatibility package can keep its + * historical provenance stamp and its historical remediation hint. + */ +export async function runGenerate( + path: string, + options: GenerateCommandOptions, + context: { + config: ProducerConfig; + configWarning?: string | undefined; + producer?: ProducerIdentity | undefined; + commandName: string; + }, +): Promise { + const repoRoot = resolve(path); + const spinner = ora({ text: 'Scanning repository...', color: 'green' }).start(); + + try { + if (context.configWarning) { + console.error(`${context.commandName} config warning: ${context.configWarning}`); + } + + const result = await generateWorkspaceJson(repoRoot, context.config, { + dryRun: options.dryRun === true, + check: options.check === true, + force: options.force === true, + commandName: context.commandName, + ...(context.producer === undefined ? {} : { producer: context.producer }), + }); + spinner.stop(); + + if (options.check) { + if (result.drift) { + console.error(`Generated sections are stale at ${result.path}; manual evidence is untouched. Run: ${context.commandName} generate ${path}`); + if (options.dryRun) { + console.log(JSON.stringify(result.content, null, 2)); + } + return 1; + } + console.log(`Generated sections are current at ${result.path}`); + if (options.dryRun) { + console.log(JSON.stringify(result.content, null, 2)); + } + } else if (options.dryRun) { + console.log(JSON.stringify(result.content, null, 2)); + } else if (result.skipped) { + console.log(`Generated sections already current at ${result.path}; manual evidence preserved`); + } else if (result.invalidFileMoved) { + console.log(`Generated ${result.path}`); + console.log(pc.yellow(` Previous file was invalid and has been moved aside: ${result.invalidFileMoved}`)); + console.log(pc.yellow(' Manual evidence from the previous file was not recovered (it could not be parsed/validated).')); + } else { + console.log(`Generated ${result.path}`); + } + + return 0; + } catch (error) { + spinner.stop(); + console.error(`${context.commandName} generate failed:`, error instanceof Error ? error.message : error); + return 1; + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0afc89a..220d0df 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,17 @@ -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"; +export { + generateWorkspaceJson, + writeWorkspaceAtomically, + GenerateRefusalError, + THIS_PRODUCER, +} from './producer/generate.js'; +export type { GenerateResult, ProducerIdentity } from './producer/generate.js'; +export { DEFAULT_PRODUCER_CONFIG, detectCiProvider } from './producer/config.js'; +export type { ProducerConfig } from './producer/config.js'; +export { findAgentsMdPath, readTextOrEmpty } from './producer/fs.js'; + +// The `generate` command implementation is deliberately public. `agents-audit` +// invokes it so that both binaries route through exactly one implementation +// and their output cannot drift apart during the compatibility window +// (META-247). +export { runGenerate } from './commands/generate.js'; +export type { GenerateCommandOptions } from './commands/generate.js'; diff --git a/packages/agents-audit/src/internal/config.ts b/packages/cli/src/producer/config.ts similarity index 58% rename from packages/agents-audit/src/internal/config.ts rename to packages/cli/src/producer/config.ts index 0ca605a..0f66244 100644 --- a/packages/agents-audit/src/internal/config.ts +++ b/packages/cli/src/producer/config.ts @@ -1,6 +1,18 @@ import type { AuditConfig } from '@workspacejson/rules'; -export const DEFAULT_AUDIT_CONFIG: AuditConfig = { +/** + * Producer configuration. + * + * Structurally this is still `@workspacejson/rules`' `AuditConfig`, because the + * generator consumes that package's rule engine to compute `generated.hygiene`. + * That dependency is on a standard-owned package, not on the audit product, so + * it is the sanctioned direction — but the *name* is audit-shaped, which is a + * historical artifact. Aliasing it here keeps the neutral producer's own + * vocabulary neutral without forking a standard-owned type (META-236). + */ +export type ProducerConfig = AuditConfig; + +export const DEFAULT_PRODUCER_CONFIG: ProducerConfig = { stalenessThresholdDays: 60, highActivityCommitCount: 20, conventionMismatchPrecisionMode: true, diff --git a/packages/agents-audit/src/internal/fs.ts b/packages/cli/src/producer/fs.ts similarity index 100% rename from packages/agents-audit/src/internal/fs.ts rename to packages/cli/src/producer/fs.ts diff --git a/packages/agents-audit/src/generate.integration.test.ts b/packages/cli/src/producer/generate.integration.test.ts similarity index 79% rename from packages/agents-audit/src/generate.integration.test.ts rename to packages/cli/src/producer/generate.integration.test.ts index 066c9c9..874b074 100644 --- a/packages/agents-audit/src/generate.integration.test.ts +++ b/packages/cli/src/producer/generate.integration.test.ts @@ -45,17 +45,34 @@ describe('generateWorkspaceJson — v0.3 conformance', () => { expect(result.content.generated.specVersion).toBe('0.3'); }); - it('sets generated.by with name and version', async () => { + it('sets generated.by to this producer by default', async () => { const repoRoot = tmpDir(); await mkdir(repoRoot, { recursive: true }); const result = await generateWorkspaceJson(repoRoot, {}, { dryRun: true }); - expect(result.content.generated.by.name).toBe('agents-audit'); + // Before META-247 this asserted 'agents-audit', because the producer lived + // inside that package. `generated.by` is provenance — it records which + // producer actually ran — so the neutral producer stamps itself. + expect(result.content.generated.by.name).toBe('@workspacejson/cli'); expect(typeof result.content.generated.by.version).toBe('string'); expect(result.content.generated.by.version.length).toBeGreaterThan(0); }); + it('lets a caller override generated.by, which is how agents-audit keeps its historical stamp', async () => { + const repoRoot = tmpDir(); + await mkdir(repoRoot, { recursive: true }); + + const result = await generateWorkspaceJson(repoRoot, {}, { + dryRun: true, + producer: { name: 'agents-audit', version: '0.4.4' }, + }); + + // This is the exact call `agents-audit generate` makes. Its artifacts must + // remain byte-identical to what it produced before the producer moved. + expect(result.content.generated.by).toEqual({ name: 'agents-audit', version: '0.4.4' }); + }); + it('sets generated.frameworkManifest as an array', async () => { const repoRoot = tmpDir(); await mkdir(repoRoot, { recursive: true }); diff --git a/packages/agents-audit/src/generate.ts b/packages/cli/src/producer/generate.ts similarity index 69% rename from packages/agents-audit/src/generate.ts rename to packages/cli/src/producer/generate.ts index 92516f8..316a3cc 100644 --- a/packages/agents-audit/src/generate.ts +++ b/packages/cli/src/producer/generate.ts @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'; import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import type { WorkspaceJsonV3 } from '@workspacejson/spec'; import { AgentsMdParser, @@ -16,12 +17,50 @@ import { sectionStaleness, conventionMismatch, } from '@workspacejson/rules'; -import type { AuditConfig, RuleContext } from '@workspacejson/rules'; -import { DEFAULT_AUDIT_CONFIG, detectCiProvider } from './internal/config.js'; -import { findAgentsMdPath, readTextOrEmpty } from './internal/fs.js'; +import type { RuleContext } from '@workspacejson/rules'; +import { DEFAULT_PRODUCER_CONFIG, detectCiProvider, type ProducerConfig } from './config.js'; +import { findAgentsMdPath, readTextOrEmpty } from './fs.js'; const _require = createRequire(import.meta.url); -const { version: pkgVersion } = _require('../package.json') as { version: string }; + +// Resolve this package's own manifest by walking up from the current module +// rather than by a fixed relative path. +// +// A fixed path is depth-dependent, and source depth does not match bundled +// depth: this module lives at `src/producer/generate.ts` but tsup emits it into +// `dist/`, so `../../package.json` is correct for the source tree and wrong for +// the build output. That mismatch is invisible to this package's own tests +// (which run against source) and only fails for a consumer importing `dist`. +function readOwnManifest(): { name: string; version: string } { + let directory = dirname(fileURLToPath(import.meta.url)); + for (let depth = 0; depth < 10; depth += 1) { + const candidate = resolve(directory, 'package.json'); + if (existsSync(candidate)) { + return _require(candidate) as { name: string; version: string }; + } + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + throw new Error('Unable to locate the producer package manifest from ' + import.meta.url); +} + +const { name: pkgName, version: pkgVersion } = readOwnManifest(); + +/** + * Identity written to `generated.by` — the provenance of *which producer ran*. + * + * It defaults to this package. `agents-audit` passes its own identity so that + * `agents-audit generate` keeps stamping artifacts exactly as it always has, + * which is what its compatibility guarantee requires (META-247). `by` is + * excluded from the material projection, so this never affects drift detection. + */ +export interface ProducerIdentity { + name: string; + version: string; +} + +export const THIS_PRODUCER: ProducerIdentity = { name: pkgName, version: pkgVersion }; export interface GenerateResult { path: string; @@ -55,12 +94,17 @@ function isMateriallyCurrent(existing: WorkspaceJsonV3, next: WorkspaceJsonV3): ); } -function invalidArtifactMessage(outputPath: string, errors: string[], check: boolean): string { +// Refusal messages name the command the user actually ran. Before META-247 +// these hardcoded `agents-audit`, which was correct when that was the only +// binary — in the neutral producer it would tell a `workspacejson` user to run +// a different tool. The caller supplies its own name; `agents-audit` passes its +// historical one so its messages stay byte-identical. +function invalidArtifactMessage(outputPath: string, errors: string[], check: boolean, commandName: string): string { const detail = errors.length > 0 ? `\nValidation errors:\n${errors.map((error) => ` - ${error}`).join('\n')}` : ''; if (check) { - return `agents-audit generate --check: ${outputPath} is invalid.\nGenerated sections are not current; manual evidence is untouched.${detail}`; + return `${commandName} generate --check: ${outputPath} is invalid.\nGenerated sections are not current; manual evidence is untouched.${detail}`; } - return `agents-audit generate: refusing to overwrite ${outputPath}\nThe existing file is invalid and may contain hand-authored manual evidence.${detail}\nTo recover while preserving the invalid file:\n agents-audit generate . --force`; + return `${commandName} generate: refusing to overwrite ${outputPath}\nThe existing file is invalid and may contain hand-authored manual evidence.${detail}\nTo recover while preserving the invalid file:\n ${commandName} generate . --force`; } async function moveInvalidArtifact(outputPath: string): Promise { @@ -83,11 +127,13 @@ export async function writeWorkspaceAtomically(outputPath: string, content: Work export async function generateWorkspaceJson( repoRoot: string, - config: Partial = {}, - options: { dryRun?: boolean; check?: boolean; force?: boolean } = {}, + config: Partial = {}, + options: { dryRun?: boolean; check?: boolean; force?: boolean; producer?: ProducerIdentity; commandName?: string } = {}, ): Promise { const resolvedRoot = resolve(repoRoot); - const fullConfig: AuditConfig = { ...DEFAULT_AUDIT_CONFIG, ...config }; + const fullConfig: ProducerConfig = { ...DEFAULT_PRODUCER_CONFIG, ...config }; + const producer: ProducerIdentity = options.producer ?? THIS_PRODUCER; + const commandName = options.commandName ?? 'workspacejson'; const scanner = new RepoScanner(); const parser = new AgentsMdParser(); @@ -145,7 +191,7 @@ export async function generateWorkspaceJson( try { parsed = JSON.parse(await readFile(outputPath, 'utf8')); } catch { - const message = invalidArtifactMessage(outputPath, ['JSON could not be parsed'], options.check === true); + const message = invalidArtifactMessage(outputPath, ['JSON could not be parsed'], options.check === true, commandName); if (!options.force || options.dryRun || options.check) throw new GenerateRefusalError(message); invalidFileMoved = await moveInvalidArtifact(outputPath); } @@ -153,7 +199,7 @@ export async function generateWorkspaceJson( if (parsed !== undefined) { const validation = new WorkspaceJsonValidator().validate(parsed); if (!validation.valid) { - const message = invalidArtifactMessage(outputPath, validation.errors, options.check === true); + const message = invalidArtifactMessage(outputPath, validation.errors, options.check === true, commandName); if (!options.force || options.dryRun || options.check) throw new GenerateRefusalError(message); invalidFileMoved = await moveInvalidArtifact(outputPath); } else { @@ -166,7 +212,7 @@ export async function generateWorkspaceJson( generated: { specVersion: '0.3', generatedAt: now, - by: { name: 'agents-audit', version: pkgVersion }, + by: { name: producer.name, version: producer.version }, frameworkManifest: agentsMd.frameworkTokens.map((name) => ({ name, confidence: 0.5 })), fileIndex: {}, topology: { diff --git a/packages/agents-audit/src/producer-conformance.test.ts b/packages/cli/src/producer/producer-conformance.test.ts similarity index 100% rename from packages/agents-audit/src/producer-conformance.test.ts rename to packages/cli/src/producer/producer-conformance.test.ts diff --git a/packages/agents-audit/src/spec-contract-visibility.test.ts b/packages/cli/src/spec-contract-visibility.test.ts similarity index 100% rename from packages/agents-audit/src/spec-contract-visibility.test.ts rename to packages/cli/src/spec-contract-visibility.test.ts diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 149104e..ff242d6 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -2,18 +2,11 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src", "declaration": true, - "declarationMap": false, - "emitDeclarationOnly": false + "noEmit": false }, "include": [ "src/**/*.ts", "../../types/**/*.d.ts" - ], - "exclude": [ - "src/**/*.test.ts", - "test/**", - "src/**/__tests__/**" ] } diff --git a/packages/datahub-adapter/README.md b/packages/datahub-adapter/README.md new file mode 100644 index 0000000..7fa2902 --- /dev/null +++ b/packages/datahub-adapter/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/datahub-adapter/package.json b/packages/datahub-adapter/package.json new file mode 100644 index 0000000..1129cc7 --- /dev/null +++ b/packages/datahub-adapter/package.json @@ -0,0 +1,48 @@ +{ + "name": "@workspacejson/datahub-adapter", + "version": "0.0.1", + "private": true, + "description": "Private adapter that joins dbt models to workspace.json behavioral intelligence by repository-root-relative path (the DataHub / HAC-75 normalization shim). Not the workspace.json producer.", + "license": "Apache-2.0", + "author": "workspace-json contributors", + "homepage": "https://www.workspacejson.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/workspacejson/cli.git", + "directory": "packages/datahub-adapter" + }, + "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-datahub-adapter": "./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": "0.4.4" + }, + "devDependencies": { + "@types/node": "22.19.17", + "tsup": "^8.0.0", + "typescript": "^5.4.0", + "vitest": "^1.6.0" + } +} diff --git a/packages/datahub-adapter/src/cli.ts b/packages/datahub-adapter/src/cli.ts new file mode 100644 index 0000000..15eb267 --- /dev/null +++ b/packages/datahub-adapter/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/datahub-adapter/src/dbt.ts similarity index 100% rename from packages/cli/src/dbt.ts rename to packages/datahub-adapter/src/dbt.ts diff --git a/packages/datahub-adapter/src/index.ts b/packages/datahub-adapter/src/index.ts new file mode 100644 index 0000000..0afc89a --- /dev/null +++ b/packages/datahub-adapter/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/datahub-adapter/src/join.ts similarity index 100% rename from packages/cli/src/join.ts rename to packages/datahub-adapter/src/join.ts diff --git a/packages/cli/src/normalize.ts b/packages/datahub-adapter/src/normalize.ts similarity index 100% rename from packages/cli/src/normalize.ts rename to packages/datahub-adapter/src/normalize.ts diff --git a/packages/cli/test/find-projects.test.ts b/packages/datahub-adapter/test/find-projects.test.ts similarity index 100% rename from packages/cli/test/find-projects.test.ts rename to packages/datahub-adapter/test/find-projects.test.ts diff --git a/packages/cli/test/fixtures/multi/analytics/dbt_project.yml b/packages/datahub-adapter/test/fixtures/multi/analytics/dbt_project.yml similarity index 100% rename from packages/cli/test/fixtures/multi/analytics/dbt_project.yml rename to packages/datahub-adapter/test/fixtures/multi/analytics/dbt_project.yml diff --git a/packages/cli/test/fixtures/multi/analytics/models/customers.sql b/packages/datahub-adapter/test/fixtures/multi/analytics/models/customers.sql similarity index 100% rename from packages/cli/test/fixtures/multi/analytics/models/customers.sql rename to packages/datahub-adapter/test/fixtures/multi/analytics/models/customers.sql diff --git a/packages/cli/test/fixtures/multi/sub/warehouse/dbt_project.yml b/packages/datahub-adapter/test/fixtures/multi/sub/warehouse/dbt_project.yml similarity index 100% rename from packages/cli/test/fixtures/multi/sub/warehouse/dbt_project.yml rename to packages/datahub-adapter/test/fixtures/multi/sub/warehouse/dbt_project.yml diff --git a/packages/cli/test/join.nested.test.ts b/packages/datahub-adapter/test/join.nested.test.ts similarity index 100% rename from packages/cli/test/join.nested.test.ts rename to packages/datahub-adapter/test/join.nested.test.ts diff --git a/packages/datahub-adapter/tsconfig.json b/packages/datahub-adapter/tsconfig.json new file mode 100644 index 0000000..149104e --- /dev/null +++ b/packages/datahub-adapter/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/pnpm-lock.yaml b/pnpm-lock.yaml index c68c381..627b3a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,19 +8,25 @@ importers: .: dependencies: + '@workspacejson/cli': + specifier: workspace:* + version: link:packages/cli agents-audit: specifier: workspace:* - version: link:packages/agents-audit + version: link:packages/agents-audit-compat devDependencies: '@changesets/cli': specifier: ^2.31.0 version: 2.31.1(@types/node@22.19.17) - packages/agents-audit: + packages/agents-audit-compat: dependencies: '@inquirer/core': specifier: ^9.0.0 version: 9.2.1 + '@workspacejson/cli': + specifier: workspace:* + version: link:../cli '@workspacejson/rules': specifier: 0.4.4 version: 0.4.4 @@ -63,6 +69,37 @@ importers: version: 1.6.1(@types/node@22.19.17) packages/cli: + dependencies: + '@workspacejson/rules': + specifier: 0.4.4 + version: 0.4.4 + '@workspacejson/spec': + specifier: 0.4.4 + version: 0.4.4 + commander: + specifier: ^12.1.0 + version: 12.1.0 + ora: + specifier: ^8.0.1 + version: 8.2.0 + picocolors: + specifier: ^1.0.1 + version: 1.1.1 + devDependencies: + '@types/node': + specifier: 22.19.17 + version: 22.19.17 + tsup: + specifier: ^8.0.0 + version: 8.5.1(postcss@8.5.23)(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/datahub-adapter: dependencies: '@workspacejson/spec': specifier: 0.4.4 diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs index e715761..1e40984 100644 --- a/scripts/check-architecture.mjs +++ b/scripts/check-architecture.mjs @@ -140,6 +140,40 @@ for (const { path, owner } of FOREIGN_DIRECTORIES) { if (existsSync(full)) report("repository-boundary", full, `owned by ${owner}, must not exist here`); } +// --------------------------------------------------------------------------- +// 3b. The neutral producer stays neutral: no vendor-specific or host-specific +// consumer logic inside packages/cli. +// +// The migrated DataHub/dbt adapter is staged in this repository pending +// extraction to workspacejson/datahub-agent, which owns DataHub consumption. +// It must not leak into the neutral producer on its way out, and no future +// vendor adapter may take its place there. +// --------------------------------------------------------------------------- +// Substring matching, not word-boundary matching. A red test caught the +// difference: `\bdatahub\b` does not match `joinDataHubUrn`, which is exactly +// the shape vendor logic arrives in. The file NAME is checked too, since +// `producer/datahub.ts` is a violation regardless of what it contains. +const NEUTRALITY_PATTERNS = [ + { pattern: /dbt[_-]?project|\bdbt\b/i, detail: "dbt-specific logic" }, + { pattern: /datahub/i, detail: "DataHub-specific logic" }, + { pattern: /vreko/i, detail: "vendor-specific (Vreko) content" }, +]; + +const neutralPackage = join(repoRoot, "packages", "cli"); +if (existsSync(neutralPackage)) { + for (const file of sourceFiles) { + if (!file.startsWith(neutralPackage + "/")) continue; + if (SELF_REFERENTIAL.has(file)) continue; + const subject = `${relative(repoRoot, file)}\n${stripComments(readFileSync(file, "utf8"))}`; + for (const { pattern, detail } of NEUTRALITY_PATTERNS) { + if (pattern.test(subject)) { + report("neutral-producer-purity", file, + `${detail} must not live in the neutral producer; DataHub consumption is owned by workspacejson/datahub-agent`); + } + } + } +} + // --------------------------------------------------------------------------- // 4. Package manifests: no local links, standard deps pinned, shim stays private. // --------------------------------------------------------------------------- @@ -156,19 +190,32 @@ if (existsSync(packagesDirectory)) { } } +// Names of packages defined inside THIS repository. A `workspace:` range that +// points at one of these is a normal monorepo link that pnpm rewrites to a real +// version at pack time. A `workspace:` range pointing anywhere else means a +// cross-repository link, which cannot resolve for a consumer and is exactly +// what the split was meant to eliminate. The original rule conflated the two +// (META-247). +const localPackageNames = new Set( + manifests + .map((p) => { + try { return JSON.parse(readFileSync(p, "utf8")).name; } catch { return undefined; } + }) + .filter(Boolean), +); + for (const manifestPath of manifests) { const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - const isRoot = manifestPath === join(repoRoot, "package.json"); for (const field of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) { for (const [name, range] of Object.entries(manifest[field] ?? {})) { if (typeof range !== "string") continue; - // The private root manifest may workspace-link its own local packages; - // a published package manifest may not, and no manifest may point at a - // sibling checkout on disk. - const isInternalWorkspaceLink = isRoot && range.startsWith("workspace:"); + // `workspace:` is allowed only for packages this repository defines. + // `file:`, `link:`, `portal:` and relative paths are never allowed — + // they point outside the dependency graph a consumer can resolve. + const isInternalWorkspaceLink = range.startsWith("workspace:") && localPackageNames.has(name); if (LOCAL_LINK.test(range) && !isInternalWorkspaceLink) { - report("local-dependency", manifestPath, `${field}.${name}=${JSON.stringify(range)} is a local/sibling link and cannot resolve for a consumer`); + report("local-dependency", manifestPath, `${field}.${name}=${JSON.stringify(range)} is a local/sibling link to a package this repository does not define; it cannot resolve for a consumer`); } if (STANDARD_OWNED.has(name) && !EXACT_VERSION.test(range)) { report("unpinned-standard-dependency", manifestPath, `${field}.${name}=${JSON.stringify(range)} must be an exact published version`); @@ -176,8 +223,8 @@ for (const manifestPath of manifests) { } } - if (manifest.name === "@workspacejson/cli" && manifest.private !== true) { - report("private-package-publication", manifestPath, "@workspacejson/cli must remain private:true until META-236 ratifies its identity; publishing it is prohibited"); + if (manifest.name === "@workspacejson/datahub-adapter" && manifest.private !== true) { + report("private-package-publication", manifestPath, "@workspacejson/datahub-adapter must remain private:true; it is staged here pending extraction to workspacejson/datahub-agent and must never be published from this repository"); } if (STANDARD_OWNED.has(manifest.name)) { @@ -201,8 +248,8 @@ if (existsSync(workflowsDirectory)) { report("foreign-publish", file, `publishing workflow references ${owned}, which is published by workspacejson/standard`); } } - if (content.includes("@workspacejson/cli")) { - report("private-package-publication", file, "publishing workflow references the private @workspacejson/cli"); + if (content.includes("@workspacejson/datahub-adapter")) { + report("private-package-publication", file, "publishing workflow references the private @workspacejson/datahub-adapter"); } } } diff --git a/scripts/check-architecture.test.mjs b/scripts/check-architecture.test.mjs index b88257f..9897f6d 100644 --- a/scripts/check-architecture.test.mjs +++ b/scripts/check-architecture.test.mjs @@ -61,6 +61,31 @@ const cases = [ mutate: (root) => write(join(root, "types/rogue.d.ts"), `declare module "@workspacejson/rules" {\n export type Finding = unknown;\n}\n`), }, + { + name: "neutral-producer-purity: DataHub logic inside the neutral producer", + expect: "neutral-producer-purity", + mutate: (root) => write(join(root, "packages/cli/src/producer/datahub.ts"), + `export function joinDataHubUrn(urn: string): string {\n return urn;\n}\n`), + }, + { + name: "neutral-producer-purity: dbt logic inside the neutral producer", + expect: "neutral-producer-purity", + mutate: (root) => write(join(root, "packages/cli/src/producer/dbt.ts"), + `export const MANIFEST = "dbt_project.yml";\n`), + }, + { + name: "neutral-producer-purity: vendor content inside the neutral producer", + expect: "neutral-producer-purity", + mutate: (root) => write(join(root, "packages/cli/src/producer/upsell.ts"), + `export const PROMO = "Vreko generates it automatically";\n`), + }, + { + name: "local-dependency: workspace link to a package this repo does not define", + expect: "local-dependency", + mutate: (root) => patchJson(join(root, "packages/cli/package.json"), (m) => { + m.dependencies["@workspacejson/rules"] = "workspace:*"; + }), + }, { name: "repository-boundary: host-integration code in the CLI repo", expect: "repository-boundary", @@ -75,28 +100,28 @@ const cases = [ { name: "local-dependency: committed sibling-checkout path", expect: "local-dependency", - mutate: (root) => patchJson(join(root, "packages/agents-audit/package.json"), (m) => { + mutate: (root) => patchJson(join(root, "packages/agents-audit-compat/package.json"), (m) => { m.dependencies["@workspacejson/spec"] = "file:../../../standard/packages/spec"; }), }, { name: "local-dependency: workspace link in a published package", expect: "local-dependency", - mutate: (root) => patchJson(join(root, "packages/agents-audit/package.json"), (m) => { + mutate: (root) => patchJson(join(root, "packages/agents-audit-compat/package.json"), (m) => { m.dependencies["@workspacejson/rules"] = "workspace:*"; }), }, { name: "unpinned-standard-dependency: floating range on a standard package", expect: "unpinned-standard-dependency", - mutate: (root) => patchJson(join(root, "packages/agents-audit/package.json"), (m) => { + mutate: (root) => patchJson(join(root, "packages/agents-audit-compat/package.json"), (m) => { m.dependencies["@workspacejson/spec"] = "^0.4.4"; }), }, { - name: "private-package-publication: @workspacejson/cli loses private:true", + name: "private-package-publication: @workspacejson/datahub-adapter loses private:true", expect: "private-package-publication", - mutate: (root) => patchJson(join(root, "packages/cli/package.json"), (m) => { + mutate: (root) => patchJson(join(root, "packages/datahub-adapter/package.json"), (m) => { delete m.private; }), }, @@ -107,10 +132,10 @@ const cases = [ `name: Rogue\non: workflow_dispatch\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - run: npm publish --workspace @workspacejson/spec\n`), }, { - name: "private-package-publication: workflow publishing the private shim", + name: "private-package-publication: workflow publishing the private DataHub adapter", expect: "private-package-publication", mutate: (root) => write(join(root, ".github/workflows/rogue.yml"), - `name: Rogue\non: workflow_dispatch\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - run: npm publish --workspace @workspacejson/cli\n`), + `name: Rogue\non: workflow_dispatch\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - run: npm publish --workspace @workspacejson/datahub-adapter\n`), }, ]; diff --git a/scripts/verify-package-tarball.mjs b/scripts/verify-package-tarball.mjs index 4cc8db7..5578f9e 100644 --- a/scripts/verify-package-tarball.mjs +++ b/scripts/verify-package-tarball.mjs @@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; +import { basename, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; const packageDirectory = process.cwd(); @@ -33,7 +33,11 @@ try { throw new Error(`${packer} pack did not create ${tarballName}.`); } - const manifest = tar("-xOf", tarballPath, "package/package.json"); + // META-247: this was previously left as the raw string `tar` returns, so every + // assertion that indexed into it (`manifest.bin`, `manifest.dependencies`) + // silently read `undefined` and passed vacuously. Parsing it makes the + // packed-manifest checks real for the first time. + const manifest = JSON.parse(tar("-xOf", tarballPath, "package/package.json")); // Release evidence, 2026-07-16: this verifier's first run found that archive // listings do not guarantee directory entries. Normalize once so every runtime // asset assertion checks the archive's contents, not a packer formatting detail. @@ -41,7 +45,9 @@ try { assertNoWorkspaceProtocol(manifest, "package"); assertStandardDependenciesArePinned(manifest); assertRuntimeFiles(manifest, files); - if (packageName === "agents-audit") assertAgentsAuditBinGenerates(tarballPath); + if (packageName === "agents-audit" || packageName === "@workspacejson/cli") { + assertBinGenerates(tarballPath, manifest); + } console.log(`Verified ${basename(tarballPath)} with ${packer}: packed manifest and runtime files are release-safe.`); } finally { rmSync(tarballPath, { force: true }); @@ -106,8 +112,37 @@ function normalizeArchivePath(file) { return file.replace(/^\.\//, "").replaceAll("\\", "/").replace(/\/{2,}/g, "/"); } -function assertAgentsAuditBinGenerates(tarballPath) { - const smokeDirectory = mkdtempSync(join(tmpdir(), "agents-audit-pack-")); +// Packs a workspace sibling this tarball depends on but which is not yet on the +// registry, so the smoke install can resolve it. `agents-audit` depends on +// @workspacejson/cli, which is deliberately unpublished until the authority +// cutover (META-243) — without this the smoke test would fail on a package that +// is simply not released yet, rather than on a real defect. +function packUnpublishedSiblings(manifest, destinationDirectory) { + const packagesRoot = resolve(packageDirectory, ".."); + const tarballs = []; + for (const dependency of Object.keys(manifest.dependencies ?? {})) { + for (const entry of readdirSync(packagesRoot)) { + const siblingManifestPath = join(packagesRoot, entry, "package.json"); + if (!existsSync(siblingManifestPath)) continue; + const sibling = JSON.parse(readFileSync(siblingManifestPath, "utf8")); + if (sibling.name !== dependency) continue; + const siblingTarball = `${sibling.name.replace(/^@/, "").replaceAll("/", "-")}-${sibling.version}.tgz`; + const packArgs = packer === "npm" + ? ["pack", "--ignore-scripts", "--pack-destination", destinationDirectory] + : ["pack", "--pack-destination", destinationDirectory]; + const packed = spawnSync(packer, packArgs, { cwd: join(packagesRoot, entry), encoding: "utf8" }); + process.stdout.write(packed.stdout); + process.stderr.write(packed.stderr); + if (packed.status !== 0) throw new Error(`${packer} pack failed for sibling ${sibling.name}.`); + console.log(`Resolved unpublished workspace sibling ${sibling.name}@${sibling.version} from disk.`); + tarballs.push(join(destinationDirectory, siblingTarball)); + } + } + return tarballs; +} + +function assertBinGenerates(tarballPath, manifest) { + const smokeDirectory = mkdtempSync(join(tmpdir(), "workspacejson-pack-")); try { writeFileSync(join(smokeDirectory, "package.json"), JSON.stringify({ private: true })); // Migration note (META-240): the monorepo version of this smoke test packed @@ -122,12 +157,14 @@ function assertAgentsAuditBinGenerates(tarballPath) { // can point WORKSPACEJSON_STANDARD_TARBALLS at a directory of packed // standard candidates to test against bytes that are not published yet. const candidateTarballs = standardCandidateTarballs(); - run("npm", ["install", "--ignore-scripts", "--no-package-lock", ...candidateTarballs, tarballPath], smokeDirectory); - run("npx", ["--no-install", "agents-audit", "generate"], smokeDirectory); + const siblingTarballs = packUnpublishedSiblings(manifest, smokeDirectory); + run("npm", ["install", "--ignore-scripts", "--no-package-lock", ...candidateTarballs, ...siblingTarballs, tarballPath], smokeDirectory); + const [binName] = Object.keys(manifest.bin ?? {}); + run("npx", ["--no-install", binName, "generate"], smokeDirectory); const artifact = join(smokeDirectory, ".agents", "workspace.json"); if (!existsSync(artifact)) { - throw new Error("Packed agents-audit bin exited without creating .agents/workspace.json."); + throw new Error(`Packed ${packageName} bin exited without creating .agents/workspace.json.`); } JSON.parse(readFileSync(artifact, "utf8")); } finally { From d15864de901cbe10021f3ba4c6007433772e529d Mon Sep 17 00:00:00 2001 From: Qwynn Marcelle Date: Sun, 26 Jul 2026 00:17:28 -0400 Subject: [PATCH 2/3] feat(compat): replace the vendor upsell with a neutral producer notice (META-236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit META-236 ratified that vendor promotion never enters the neutral package and is removed or made opt-in in the compatibility package. Deleting the notice outright would have thrown away a genuinely useful signal — "workspace.json is missing or stale" — so the signal and the validation-error display are kept and only the promotion changes. The remediation now names the command this tool actually provides: before: Vreko generates it automatically from real codebase structure and activity, unlocking richer audit findings. vreko.dev after: Generate it with: agents-audit generate A committed artifact unlocks richer audit findings. That also serves META-182, which is about the producer never being named. `renderVrekoUpsell` remains exported and unchanged — it is part of the nine-export compatibility surface — and is simply no longer called by the CLI. Callers who want it can still invoke it, which is the "opt-in" the ruling allows. INTENTIONAL PARITY DIFFERENCES — 27/29, both deliberate and evidenced: 1. `agents-audit scan .` human-readable output differs by exactly this box. Exit code unchanged. `scan . --json` is byte-identical, so nothing consuming machine-readable output is affected. 2. Public exports gained `renderMissingArtifactNotice`. Purely additive — all nine historical exports remain, including `renderVrekoUpsell`. Everything else in the harness stays green: generate/--dry-run/--check/--force, invalid-artifact refusal, --force move-aside, manual-evidence preservation, atomic writes, generated-artifact equivalence and the producer stamp. This is a separate commit from the structural move deliberately: the restructure is behavior-preserving at 29/29, and this is the one place the ratified decision changes what a user sees. --- migration/parity-agents-audit-runtime.txt | 31 +++++++++--- .../src/cli.integration.test.ts | 4 ++ packages/agents-audit-compat/src/cli.ts | 4 +- packages/agents-audit-compat/src/index.ts | 2 +- .../agents-audit-compat/src/presenter.test.ts | 30 +++++++++++- packages/agents-audit-compat/src/presenter.ts | 47 +++++++++++++++++++ 6 files changed, 107 insertions(+), 11 deletions(-) diff --git a/migration/parity-agents-audit-runtime.txt b/migration/parity-agents-audit-runtime.txt index aabb4c6..9f78fb9 100644 --- a/migration/parity-agents-audit-runtime.txt +++ b/migration/parity-agents-audit-runtime.txt @@ -3,7 +3,24 @@ ============================================================== PASS agents-audit --help (exit 1, output identical) PASS agents-audit --version (exit 1, output identical) - PASS agents-audit scan . (exit 0, output identical) + FAIL agents-audit scan . + old exit=0 new exit=0 + --- output diff --- + 27,32c27,31 + < ╭─────────────────────────────────────────────────────────────────────────────╮ + < │ workspace.json not found. │ + < │ Vreko generates it automatically from real codebase structure and activity, │ + < │ unlocking richer audit findings. │ + < │ vreko.dev (​https://vreko.dev​) │ + < ╰─────────────────────────────────────────────────────────────────────────────╯ + \ No newline at end of file + --- + > ╭─────────────────────────────────────────────────────╮ + > │ workspace.json not found. │ + > │ Generate it with: agents-audit generate │ + > │ A committed artifact unlocks richer audit findings. │ + > ╰─────────────────────────────────────────────────────╯ + \ No newline at end of file PASS agents-audit scan . --json (exit 0, output identical) PASS agents-audit generate --dry-run (exit 0, output identical) PASS agents-audit generate --check (exit 1, output identical) @@ -28,8 +45,8 @@ --- assertions on perturbation side effects --- PASS [old] manual.fragileFiles survived regeneration verbatim PASS [new] manual.fragileFiles survived regeneration verbatim - PASS [old] --force moved invalid artifact aside (workspace.json.invalid.2026-07-26T04-12-47-304Z) - PASS [new] --force moved invalid artifact aside (workspace.json.invalid.2026-07-26T04-12-47-648Z) + PASS [old] --force moved invalid artifact aside (workspace.json.invalid.2026-07-26T04-16-58-284Z) + PASS [new] --force moved invalid artifact aside (workspace.json.invalid.2026-07-26T04-16-58-612Z) PASS [old] --dry-run wrote nothing PASS [old] --check wrote nothing PASS [new] --dry-run wrote nothing @@ -46,9 +63,11 @@ D. PUBLIC IMPORT SURFACE ============================================================== old exports: DEFAULT_AUDIT_CONFIG,detectCiProvider,generateWorkspaceJson,renderFindingsTable,renderScoreCard,renderVrekoUpsell,runAudit,saveReport,startInteractiveNavigation - new exports: DEFAULT_AUDIT_CONFIG,detectCiProvider,generateWorkspaceJson,renderFindingsTable,renderScoreCard,renderVrekoUpsell,runAudit,saveReport,startInteractiveNavigation - PASS public import surface identical + new exports: DEFAULT_AUDIT_CONFIG,detectCiProvider,generateWorkspaceJson,renderFindingsTable,renderMissingArtifactNotice,renderScoreCard,renderVrekoUpsell,runAudit,saveReport,startInteractiveNavigation + FAIL public import surface differs ============================================================== - RESULT: 29 passed, 0 failed (total 29) + RESULT: 27 passed, 2 failed (total 29) + FAILED: agents-audit scan . + FAILED: exports ============================================================== diff --git a/packages/agents-audit-compat/src/cli.integration.test.ts b/packages/agents-audit-compat/src/cli.integration.test.ts index 10927ca..2eb24fa 100644 --- a/packages/agents-audit-compat/src/cli.integration.test.ts +++ b/packages/agents-audit-compat/src/cli.integration.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ renderFindingsTable: vi.fn(), renderScoreCard: vi.fn(), renderVrekoUpsell: vi.fn(), + renderMissingArtifactNotice: vi.fn(), runGenerate: vi.fn(), ora: vi.fn(() => ({ start: () => ({ stop: vi.fn() }), @@ -22,6 +23,7 @@ vi.mock('./presenter.js', () => ({ renderFindingsTable: mocks.renderFindingsTable, renderScoreCard: mocks.renderScoreCard, renderVrekoUpsell: mocks.renderVrekoUpsell, + renderMissingArtifactNotice: mocks.renderMissingArtifactNotice, })); vi.mock('@workspacejson/cli', () => ({ runGenerate: mocks.runGenerate })); vi.mock('ora', () => ({ default: mocks.ora })); @@ -194,6 +196,8 @@ describe('CLI integration', () => { expect(exitCode).toBe(0); expect(mocks.renderScoreCard).not.toHaveBeenCalled(); expect(mocks.renderFindingsTable).not.toHaveBeenCalled(); + expect(mocks.renderMissingArtifactNotice).not.toHaveBeenCalled(); + // The vendor upsell is never called by the CLI any more (META-236). expect(mocks.renderVrekoUpsell).not.toHaveBeenCalled(); const parsed = JSON.parse(logs.find((line) => line.trim().startsWith('{')) ?? '{}') as Record; diff --git a/packages/agents-audit-compat/src/cli.ts b/packages/agents-audit-compat/src/cli.ts index 19e16f4..66c1939 100644 --- a/packages/agents-audit-compat/src/cli.ts +++ b/packages/agents-audit-compat/src/cli.ts @@ -9,7 +9,7 @@ import pc from 'picocolors'; import { runAudit } from './audit.js'; import { getExitCode, isActionable, loadConfig } from './cli-helpers.js'; import { runGenerate } from '@workspacejson/cli'; -import { renderFindingsTable, renderScoreCard, renderVrekoUpsell } from './presenter.js'; +import { renderFindingsTable, renderScoreCard, renderMissingArtifactNotice } from './presenter.js'; import { startInteractiveNavigation } from './navigator.js'; import { saveReport } from './reporter.js'; import type { AuditResult } from '@workspacejson/rules'; @@ -64,7 +64,7 @@ export async function runCli(argv: string[] = process.argv): Promise { renderFindingsTable(result.findings); if (!result.workspaceJsonFound || result.workspaceJsonStale) { - renderVrekoUpsell(result.workspaceJsonFound, result.workspaceJsonStatus, result.workspaceJsonErrors); + renderMissingArtifactNotice(result.workspaceJsonFound, result.workspaceJsonStatus, result.workspaceJsonErrors); } if (options.save || config.save) { diff --git a/packages/agents-audit-compat/src/index.ts b/packages/agents-audit-compat/src/index.ts index eeb882d..b9e0eb7 100644 --- a/packages/agents-audit-compat/src/index.ts +++ b/packages/agents-audit-compat/src/index.ts @@ -7,6 +7,6 @@ // must remain so. export { runAudit, DEFAULT_AUDIT_CONFIG } from './audit.js'; export { generateWorkspaceJson, detectCiProvider } from '@workspacejson/cli'; -export { renderFindingsTable, renderScoreCard, renderVrekoUpsell } from './presenter.js'; +export { renderFindingsTable, renderScoreCard, renderVrekoUpsell, renderMissingArtifactNotice } from './presenter.js'; export { startInteractiveNavigation } from './navigator.js'; export { saveReport } from './reporter.js'; diff --git a/packages/agents-audit-compat/src/presenter.test.ts b/packages/agents-audit-compat/src/presenter.test.ts index 3f95757..52cf0b5 100644 --- a/packages/agents-audit-compat/src/presenter.test.ts +++ b/packages/agents-audit-compat/src/presenter.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import { renderScoreCard, renderVrekoUpsell } from './presenter.js'; +import { describe, expect, it, vi } from 'vitest'; +import { renderScoreCard, renderVrekoUpsell, renderMissingArtifactNotice } from './presenter.js'; function normalize(output: string): string { return output.replace(/\u001b\[[0-9;]*m/g, '').replace(/[╭╮╰╯│─]/g, ' ').replace(/\s+/g, ' ').trim(); @@ -79,3 +79,29 @@ describe('presenter', () => { expect(normalized).toContain('must have required property version'); }); }); + +describe('renderMissingArtifactNotice', () => { + it('names the producer command instead of promoting a vendor', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + renderMissingArtifactNotice(false, 'missing', []); + + const output = ((logSpy as unknown as { mock: { calls: unknown[][] } }).mock.calls).flat().join(' '); + expect(output).toContain('agents-audit generate'); + expect(output).toContain('workspace.json'); + // META-236: the neutral producer must not advertise one implementation. + expect(output).not.toContain('vreko'); + expect(output).not.toContain('Vreko'); + logSpy.mockRestore(); + }); + + it('still surfaces validation errors for an invalid artifact', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + renderMissingArtifactNotice(true, 'invalid', ['/. must have required property version']); + + const output = ((logSpy as unknown as { mock: { calls: unknown[][] } }).mock.calls).flat().join(' '); + expect(output).toContain('must have required property version'); + logSpy.mockRestore(); + }); +}); diff --git a/packages/agents-audit-compat/src/presenter.ts b/packages/agents-audit-compat/src/presenter.ts index f803e01..1059ce4 100644 --- a/packages/agents-audit-compat/src/presenter.ts +++ b/packages/agents-audit-compat/src/presenter.ts @@ -62,6 +62,53 @@ export function renderFindingsTable(findings: Finding[]): void { console.log(''); } +/** + * Neutral replacement for `renderVrekoUpsell` in default CLI output (META-236). + * + * The old notice pointed users at a vendor site. A neutral, Apache-2.0 producer + * on a standards-donation track should not advertise one implementation in its + * default output — but the underlying signal is genuinely useful, and the fix + * for "workspace.json is missing" is a command this very tool provides. So the + * signal and the validation errors are kept and the promotion is replaced by + * the actual remediation command. + * + * `renderVrekoUpsell` remains exported for API compatibility; it is simply no + * longer called by the CLI. + */ +export function renderMissingArtifactNotice( + workspaceJsonExists: boolean, + workspaceJsonStatus: AuditResult['workspaceJsonStatus'], + workspaceJsonErrors: string[], +): void { + const message = workspaceJsonExists + ? dedent` + ${brand('workspace.json')} is stale or invalid. + Regenerate it with: ${pc.bold('agents-audit generate')} + ` + : dedent` + ${brand('workspace.json')} not found. + Generate it with: ${pc.bold('agents-audit generate')} + A committed artifact unlocks richer audit findings. + `; + + console.log(boxen(message, { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderStyle: 'round', + borderColor: 'green', + dimBorder: true, + })); + + if (workspaceJsonStatus === 'invalid' && workspaceJsonErrors.length > 0) { + console.log(pc.yellow(`\nworkspace.json validation issues:`)); + for (const error of workspaceJsonErrors) { + console.log(pc.yellow(`- ${error}`)); + } + } + + console.log(''); +} + +/** @deprecated Retained only for API compatibility; no longer used by the CLI (META-236). */ export function renderVrekoUpsell( workspaceJsonExists: boolean, workspaceJsonStatus: AuditResult['workspaceJsonStatus'], From 3f4917df8a6d35a50124066a1cc4e25006be74f6 Mon Sep 17 00:00:00 2001 From: Qwynn Marcelle Date: Sun, 26 Jul 2026 00:19:32 -0400 Subject: [PATCH 3/3] fix(ci): build before typecheck agents-audit consumes @workspacejson/cli's emitted declarations, so on a clean checkout tsc cannot resolve them until the neutral package has been built. This passed locally only because dist/ was already present. pnpm builds in topological order, so reordering is sufficient. Documented in CONTRIBUTING so a fresh clone does not hit the same wall. --- .github/workflows/ci.yml | 10 +++++++--- CONTRIBUTING.md | 15 +++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b66846..6e3a787 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,12 +31,16 @@ jobs: - name: Architecture guard red tests run: node scripts/check-architecture.test.mjs - - name: Typecheck all packages - run: pnpm -r typecheck - + # Build precedes typecheck. `agents-audit` consumes @workspacejson/cli's + # emitted declarations, so on a clean checkout those must exist before tsc + # can resolve them. pnpm builds in topological order, so the neutral + # package is built first. - name: Build all packages run: pnpm -r build + - name: Typecheck all packages + run: pnpm -r typecheck + - name: Run tests run: pnpm -r test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a0320b..e40ab38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,9 +7,10 @@ released packages — do not vendor or edit them here. ## Before You Start - Read `AGENTS.md` and `OWNERSHIP.md` at the repo root -- Know which of the two CLI packages you are changing: - - `packages/agents-audit/` — published `agents-audit`, contains the real generator - - `packages/cli/` — private `@workspacejson/cli`, the DataHub/dbt join shim +- Know which package you are changing: + - `packages/cli/` — `@workspacejson/cli`, the neutral producer (`src/producer/`) and its commands (`src/commands/`) + - `packages/agents-audit-compat/` — published `agents-audit`, a **frozen** compatibility bridge; do not add features to it + - `packages/datahub-adapter/` — private DataHub/dbt adapter, staged here pending extraction to `workspacejson/datahub-agent`; do not build on it - Keep changes within the owning package when possible - Avoid changing package entrypoints unless the public surface changes @@ -17,11 +18,13 @@ released packages — do not vendor or edit them here. ```bash pnpm install -pnpm typecheck +pnpm build # must precede typecheck on a clean checkout: agents-audit +pnpm typecheck # consumes @workspacejson/cli's emitted declarations pnpm test -pnpm build pnpm run check:architecture -node packages/agents-audit/dist/cli.js scan . +node scripts/check-architecture.test.mjs +node packages/agents-audit-compat/dist/cli.js scan . +node packages/cli/dist/cli.js generate --check ``` ## Change Expectations