diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1798ad..08207ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,12 @@ jobs: - name: Architecture guard red tests run: node scripts/check-architecture.test.mjs + - name: Package documentation truth + run: pnpm run check:package-docs + + - name: Package documentation guard red tests + run: node scripts/check-package-docs.test.mjs + # 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 diff --git a/README.md b/README.md index 1a1556f..aeddb60 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,28 @@ 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. -> **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`. +> **Status.** The architecture below landed in META-247 and is the ratified +> target shape. +> +> **Registry snapshot, verified 2026-08-04:** both packages are published — +> `@workspacejson/cli@0.5.2` and `agents-audit@0.4.4`. +> +> The versions shown in the table below are the versions declared by this +> repository's package manifests. `pnpm run check:package-docs` keeps those +> manifest-backed claims synchronized; it does **not** verify the registry, and a +> manifest cannot establish what is on npm. Registry distribution is verified +> separately under META-293. +> +> Package semver is independent of the specification profile: `@workspacejson/cli` +> at `0.5.x` produces specification **v0.4** artifacts and is not evidence that +> schema v0.5 shipped. ## Packages -| Directory | Package | Published? | Role | +| Directory | Package | Version | 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 | +| [`packages/cli/`](./packages/cli/) | `@workspacejson/cli` | `0.5.2` | the neutral producer and its `workspacejson` binary | +| [`packages/agents-audit-compat/`](./packages/agents-audit-compat/) | `agents-audit` | `0.4.4` | frozen compatibility bridge; preserves the historical command and API | Those two packages are the whole repository. The private DataHub/dbt adapter that was staged here has been **extracted to `workspacejson/datahub-agent`** @@ -28,24 +39,27 @@ here. The boundary is machine-enforced and red-tested — see ## Generating the artifact -Today, the command that works is the compatibility one: +The neutral producer is the current route: ```bash -npx agents-audit generate -``` - -Once `@workspacejson/cli` is published, the neutral equivalent is: +npx @workspacejson/cli generate . -```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 ``` +The historical command still works and is kept working: + +```bash +npx agents-audit generate +``` + Both routes run **the same implementation** — `agents-audit` delegates to `@workspacejson/cli`, so the two binaries cannot drift apart during the -compatibility window. +compatibility window. New setups should use the neutral producer; `agents-audit` +exists for setups already pinned to it. `agents-audit` additionally keeps its audit commands (`scan`, `version`) and all nine of its historical public exports. diff --git a/package.json b/package.json index b5cde04..955f0bb 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "test": "pnpm -r test", "typecheck": "pnpm -r typecheck", "check:architecture": "node scripts/check-architecture.mjs", + "check:package-docs": "node scripts/check-package-docs.mjs", + "check:package-docs:test": "node scripts/check-package-docs.test.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" }, diff --git a/packages/agents-audit-compat/package.json b/packages/agents-audit-compat/package.json index f4a9163..5271b41 100644 --- a/packages/agents-audit-compat/package.json +++ b/packages/agents-audit-compat/package.json @@ -3,8 +3,8 @@ "version": "0.4.4", "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/", + "author": "workspace.json contributors", + "homepage": "https://workspacejson.dev/audit/", "repository": { "type": "git", "url": "git+https://github.com/workspacejson/cli.git" diff --git a/scripts/check-package-docs.mjs b/scripts/check-package-docs.mjs new file mode 100644 index 0000000..6d6a0f2 --- /dev/null +++ b/scripts/check-package-docs.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node + +// Package documentation truth gate (META-296). +// +// The root README once listed `@workspacejson/cli` as "not yet on npm" while the +// package manifest read 0.5.2 and the package-level README gave install +// instructions. Three surfaces, three stories, nothing mechanical to notice. +// +// WHAT ARBITRATES WHAT — the boundary this gate must not cross: +// +// The manifests in `packages/*/package.json` are the arbiter for package +// NAME, VERSION and PRIVATE state. This gate derives those and fails when the +// documentation contradicts them. It hard-codes no version, so it survives +// releases. +// +// The manifests are NOT an arbiter for whether a package is actually on npm. +// `private !== true` means publication is *permitted*, not that it *happened*. +// A legitimately unpublished package has a public, versioned manifest and is +// not lying about anything. During the window between a version bump and the +// publish that follows it, the manifest carries a version nothing has shipped. +// +// An earlier revision of this gate conflated the two: it rejected "not yet on +// npm" for any non-private package, which would have failed a correct +// repository and asserted a registry fact from a file that cannot establish +// one. That check is removed rather than softened. +// +// Registry distribution is verified separately, across repositories, under +// META-293. Documentation may state npm facts as a dated registry snapshot; +// this gate neither proves nor disproves them. +// +// The Version column is what keeps the original defect catchable, and for an +// honest reason: the table must carry each package's manifest version, so a cell +// reading "No — not yet on npm" fails as a missing version claim rather than as +// an inferred publication claim. +// +// NOT ASSERTED MECHANICALLY: that package semver is never presented as the +// specification-profile version. A regex cannot separate "produces specification +// v0.4 artifacts" — true, and `agents-audit` happens to be 0.4.4 — from a genuine +// conflation. It is stated in prose in the README instead, and left to review. + +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const violations = []; +const report = (check, file, detail) => violations.push({ check, file, detail }); + +// --- the arbiter: workspace manifests ------------------------------------- + +const packagesDir = join(repoRoot, "packages"); +const packages = readdirSync(packagesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ dirName: entry.name, manifestPath: join(packagesDir, entry.name, "package.json") })) + .filter(({ manifestPath }) => existsSync(manifestPath)) + .map(({ dirName, manifestPath }) => { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + return { + dir: `packages/${dirName}`, + name: manifest.name, + version: manifest.version, + isPrivate: manifest.private === true, + }; + }); + +// --- the root README packages table matches the manifests ------------------ + +const readme = readFileSync(join(repoRoot, "README.md"), "utf8"); + +const SEMVER = /(\d+\.\d+\.\d+)/; +const PRIVATE_MARKER = /\bprivate\b|not published|unpublished|internal only/i; + +function packagesTableRows(text) { + const lines = text.split("\n"); + const start = lines.findIndex((l) => /^##\s+Packages\s*$/.test(l)); + if (start === -1) return null; + const rows = []; + let inTable = false; + let seenSeparator = false; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]; + if (!line.trim().startsWith("|")) { + if (inTable) break; + continue; + } + inTable = true; + const cells = line.split("|").slice(1, -1).map((c) => c.trim()); + if (cells.length < 3) continue; + // The separator divides the header from the data. Everything before it is + // the header row — counting it as data makes the literal word "Package" look + // like an undocumented package. + if (cells.every((c) => /^:?-+:?$/.test(c.replace(/\s/g, "")))) { + seenSeparator = true; + continue; + } + if (!seenSeparator) continue; + rows.push({ directory: cells[0], pkg: cells[1], version: cells[2], line: i + 1 }); + } + return rows; +} + +const rows = packagesTableRows(readme); + +if (rows === null) { + report("packages-table-missing", "README.md", + "no `## Packages` section found; this gate reads that table as the repository's package-documentation claim"); +} else { + for (const pkg of packages) { + const row = rows.find((r) => r.pkg.includes(pkg.name)); + if (!row) { + report("packages-table-coverage", "README.md", + `workspace package ${pkg.name} has no row in the Packages table; every package must be documented`); + continue; + } + + if (pkg.isPrivate) { + if (!PRIVATE_MARKER.test(row.version) && !PRIVATE_MARKER.test(row.pkg)) { + report("private-distribution-claim", `README.md:${row.line}`, + `${pkg.name} is \`private: true\` in ${pkg.dir}/package.json, but its row does not say so — a private package must not read as publicly distributed`); + } + continue; + } + + const stated = row.version.match(SEMVER)?.[1]; + if (!stated) { + report("version-claim-missing", `README.md:${row.line}`, + `${pkg.name} row states no version. State the manifest version (${pkg.version}) so drift is detectable — this documents the manifest, not npm`); + } else if (stated !== pkg.version) { + report("version-drift", `README.md:${row.line}`, + `${pkg.name} row says ${stated}, ${pkg.dir}/package.json says ${pkg.version}`); + } + } + + for (const row of rows) { + if (!packages.some((p) => row.pkg.includes(p.name))) { + report("unknown-package-row", `README.md:${row.line}`, + `row documents "${row.pkg}", which is not a workspace package under packages/`); + } + } +} + +// --- package READMEs name themselves correctly ----------------------------- + +for (const pkg of packages) { + const file = `${pkg.dir}/README.md`; + if (!existsSync(join(repoRoot, file))) continue; + const text = readFileSync(join(repoRoot, file), "utf8"); + const heading = text.split("\n").find((l) => l.startsWith("# ")); + if (heading && !heading.includes(pkg.name)) { + report("package-readme-name", `${file}:1`, + `heading is "${heading.trim()}" but the manifest name is ${pkg.name}`); + } +} + +// --------------------------------------------------------------------------- + +if (violations.length > 0) { + console.error(`Package documentation check failed with ${violations.length} violation(s):\n`); + for (const { check, file, detail } of violations) { + console.error(` [${check}] ${file}\n ${detail}`); + } + console.error("\nThe package manifests are the arbiter for name, version and private state."); + console.error("Registry distribution is a separate assertion, owned by META-293."); + process.exit(1); +} + +const summary = packages + .map((p) => `${p.name}@${p.version}${p.isPrivate ? " (private)" : ""}`) + .join(", "); +console.log(`Package documentation check passed: ${packages.length} manifest(s) — ${summary} — agree with the documented names and versions.`); diff --git a/scripts/check-package-docs.test.mjs b/scripts/check-package-docs.test.mjs new file mode 100644 index 0000000..1d00cd5 --- /dev/null +++ b/scripts/check-package-docs.test.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node + +// Red tests for scripts/check-package-docs.mjs (META-296). +// +// A guard that has only ever been observed exiting 0 is not evidence. Each case +// below copies the repository into a scratch directory, introduces exactly one +// deliberate contradiction, and asserts the guard REJECTS it on the expected +// check. A baseline case asserts the unmodified repository passes, so a guard +// that fails everything cannot masquerade as working. +// +// The `legitimate` block matters as much as the red block. This gate's arbiter +// is the package manifests — name, version, private state — and NOT the npm +// registry. An earlier revision rejected "not yet on npm" for any non-private +// package, which conflated "publication is permitted" with "publication +// happened" and would have failed a correct repository that simply had not +// published yet. Those cases now assert that such prose stays legal. + +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve, dirname } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const read = (p) => readFileSync(p, "utf8"); +const write = (p, s) => writeFileSync(p, s); + +const cases = [ + { + // The original META-296 defect. It is caught as a MISSING VERSION CLAIM — + // the table must document the manifest version — not as an inferred + // statement about npm, which this gate has no standing to make. + name: "version-claim-missing: the original defect, a row with no version", + expect: "version-claim-missing", + mutate: (root) => { + const p = join(root, "README.md"); + write(p, read(p).replace( + /(\| `@workspacejson\/cli` \| )`[\d.]+`( \|)/, + "$1**No — not yet on npm**$2", + )); + }, + }, + { + name: "version-drift: table version disagrees with the manifest", + expect: "version-drift", + mutate: (root) => { + const p = join(root, "README.md"); + write(p, read(p).replace(/(\| `@workspacejson\/cli` \| `)[\d.]+(` \|)/, "$19.9.9$2")); + }, + }, + { + name: "version-drift: manifest bumped without updating the table", + expect: "version-drift", + mutate: (root) => { + const p = join(root, "packages/cli/package.json"); + const manifest = JSON.parse(read(p)); + manifest.version = "0.6.0"; + write(p, `${JSON.stringify(manifest, null, 2)}\n`); + }, + }, + { + name: "packages-table-coverage: a package is dropped from the table", + expect: "packages-table-coverage", + mutate: (root) => { + const p = join(root, "README.md"); + write(p, read(p).split("\n") + .filter((l) => !(l.trim().startsWith("|") && l.includes("`agents-audit`"))) + .join("\n")); + }, + }, + { + name: "unknown-package-row: the table documents a package that does not exist", + expect: "unknown-package-row", + mutate: (root) => { + const p = join(root, "README.md"); + write(p, read(p).replace( + /(\| `agents-audit` \| `[\d.]+` \| [^|]*\|)/, + "$1\n| [`packages/ghost/`](./packages/ghost/) | `@workspacejson/ghost` | `1.0.0` | does not exist |", + )); + }, + }, + { + name: "private-distribution-claim: a private package reads as publicly distributed", + expect: "private-distribution-claim", + mutate: (root) => { + const p = join(root, "packages/cli/package.json"); + const manifest = JSON.parse(read(p)); + manifest.private = true; + write(p, `${JSON.stringify(manifest, null, 2)}\n`); + }, + }, + { + name: "package-readme-name: a package README heading names the wrong package", + expect: "package-readme-name", + mutate: (root) => { + const p = join(root, "packages/cli/README.md"); + write(p, read(p).replace(/^# .*$/m, "# @workspacejson/something-else")); + }, + }, +]; + +// Cases that MUST stay legal. +const legitimate = [ + { + // The core boundary. A manifest cannot establish what is on npm, so a + // statement that a package is not published is not a contradiction of it. + name: "registry prose: 'not yet on npm' is not contradicted by a manifest", + mutate: (root) => { + const p = join(root, "README.md"); + write(p, `${read(p)}\n\nA future package in this repository is not yet on npm.\n`); + }, + }, + { + name: "registry prose: 'nothing here is published yet' is a registry claim, not a manifest claim", + mutate: (root) => { + const p = join(root, "README.md"); + write(p, `${read(p)}\n\nAt the time of writing nothing here is published yet.\n`); + }, + }, + { + name: "authority prose: 'is not published from here' distinguishes authority from status", + mutate: (root) => { + const p = join(root, "README.md"); + write(p, `${read(p)}\n\n\`agents-audit\` is not published from here; \`workspace-json/agents-audit\` remains its registry owner.\n`); + }, + }, + { + name: "frozen-bridge prose: 'gets no new features' is not a version claim", + mutate: (root) => { + const p = join(root, "packages/agents-audit-compat/README.md"); + write(p, `${read(p)}\n\nThis package is frozen and gets no new features.\n`); + }, + }, +]; + +function runGuard(root) { + return spawnSync(process.execPath, [join(root, "scripts", "check-package-docs.mjs")], { + cwd: root, + encoding: "utf8", + }); +} + +function scratchCopy() { + const directory = mkdtempSync(join(tmpdir(), "wjson-package-docs-")); + const root = join(directory, "repo"); + cpSync(repoRoot, root, { + recursive: true, + filter: (src) => !src.includes("/node_modules") && !src.includes("/.git/") && !src.endsWith("/.git"), + }); + return { directory, root }; +} + +let passed = 0; +let failed = 0; + +for (const testCase of cases) { + const { directory, root } = scratchCopy(); + try { + testCase.mutate(root); + const result = runGuard(root); + const output = `${result.stdout}${result.stderr}`; + if (result.status !== 0 && output.includes(`[${testCase.expect}]`)) { + console.log(`PASS rejected — ${testCase.name}`); + passed += 1; + } else if (result.status !== 0) { + console.error(`FAIL ${testCase.name}: rejected, but not as [${testCase.expect}]`); + console.error(output); + failed += 1; + } else { + console.error(`FAIL ${testCase.name}: guard ACCEPTED a deliberate contradiction`); + failed += 1; + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +for (const testCase of legitimate) { + const { directory, root } = scratchCopy(); + try { + testCase.mutate(root); + const result = runGuard(root); + if (result.status === 0) { + console.log(`PASS accepted — ${testCase.name}`); + passed += 1; + } else { + console.error(`FAIL ${testCase.name}: guard REJECTED legitimate prose`); + console.error(`${result.stdout}${result.stderr}`); + failed += 1; + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +// Baseline last: a guard that rejects everything is not a guard. +{ + const { directory, root } = scratchCopy(); + try { + const result = runGuard(root); + if (result.status === 0) { + console.log("PASS accepted — the unmodified repository"); + passed += 1; + } else { + console.error("FAIL the unmodified repository was REJECTED"); + console.error(`${result.stdout}${result.stderr}`); + failed += 1; + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +console.log(`\nPackage documentation guard red tests: ${passed} passed, ${failed} failed.`); +if (failed > 0) process.exit(1);