From 3564d0de0523ebc13c079893717fb708fb22d60f Mon Sep 17 00:00:00 2001 From: Qwynn Marcelle Date: Tue, 4 Aug 2026 22:08:06 -0400 Subject: [PATCH 1/2] docs: reconcile published status and compatibility package metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit META-296 (ledger items 32, 33) plus the compatibility-package metadata residue. Documentation and package-metadata truth only — no publish authority, no package versions, no producer behavior, no standard dependencies. The contradiction Three surfaces told three stories. The root README said "nothing here is published yet" and listed @workspacejson/cli as "No — not yet on npm", while packages/cli/README.md gave install instructions and claimed npm provenance, and the manifest read 0.5.2. The package README was right; the root README was overtaken by a publish and never revisited. Corrected the root README, not the package one. Registry state is recorded as context, not as the fix's justification: @workspacejson/cli@0.5.2 and agents-audit@0.4.4 are the current npm versions. This change asserts nothing about which commit produced either tarball. Compatibility package metadata homepage https://www.workspacejson.dev/audit/ -> https://workspacejson.dev/audit/ author "workspace-json contributors" -> "workspace.json contributors" The `www.` host contradicted the bare-host canon META-272 ratified and that the neutral package already uses. The author string carried the retired hyphenated organization name rather than the product name the neutral package carries. Both hosts resolve — bare 307s to www — so this is naming coherence, not a broken link. Watched-red truth gate scripts/check-published-status.mjs derives package name, version and private state from packages/*/package.json and fails when prose contradicts them. It hard-codes no version, so it survives releases. It does not contact the registry: "is the published tarball what this repository claims" is a cross-repository distribution assertion owned by META-293, and a registry call in a repo-local gate would fail CI for reasons unrelated to the commit. Checks: packages-table-coverage, publish-status-contradiction, version-drift, version-claim-missing, private-published-claim, prose-contradiction. PUBLICATION STATUS IS NOT PUBLICATION AUTHORITY. The first draft of this gate flagged "`agents-audit` is not published from here" — a true and necessary statement about which repository owns that release, and exactly the distinction META-296 requires be preserved. The pattern now carries a negative lookahead for "from"/"by", and three legitimacy cases prove authority prose and frozen-bridge prose stay legal. Verification check:architecture 66 files, 3 manifests, no violations architecture red tests 20 passed, 0 failed check:published-status 2 manifests agree with 3 documentation surfaces published-status red tests 12 passed, 0 failed (8 red, 3 legitimacy, 1 baseline) build + typecheck PASS tests 108 passed (64 cli, 44 compat) release:verify-packs both tarballs release-safe --- .github/workflows/ci.yml | 6 + README.md | 29 +-- package.json | 2 + packages/agents-audit-compat/package.json | 4 +- scripts/check-published-status.mjs | 178 ++++++++++++++++++ scripts/check-published-status.test.mjs | 209 ++++++++++++++++++++++ 6 files changed, 414 insertions(+), 14 deletions(-) create mode 100644 scripts/check-published-status.mjs create mode 100644 scripts/check-published-status.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1798ad..3d3e94d 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: Published-status and version truth + run: pnpm run check:published-status + + - name: Published-status guard red tests + run: node scripts/check-published-status.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..a090418 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,18 @@ 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. Both packages are published; the versions in the table are the +> manifest versions in this repository, checked by `pnpm run check:published-status`. +> 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 | | -- | -- | -- | -- | -| [`packages/cli/`](./packages/cli/) | `@workspacejson/cli` | **No — not yet on npm** | the neutral producer and its `workspacejson` binary | +| [`packages/cli/`](./packages/cli/) | `@workspacejson/cli` | **Yes — `0.5.2`** | 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 | Those two packages are the whole repository. The private DataHub/dbt adapter @@ -28,24 +30,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..e7be34b 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:published-status": "node scripts/check-published-status.mjs", + "check:published-status:test": "node scripts/check-published-status.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-published-status.mjs b/scripts/check-published-status.mjs new file mode 100644 index 0000000..1e9f474 --- /dev/null +++ b/scripts/check-published-status.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node + +// Published-status and version truth gate (META-296). +// +// The root README once said `@workspacejson/cli` was "not yet on npm" while the +// package manifest read 0.5.2 and the package-level README gave install +// instructions and claimed npm provenance. Three surfaces, three different +// stories. Nothing mechanical could notice. +// +// WHAT THIS ASSERTS — and deliberately does not: +// +// The manifests in this repository are the arbiter for package NAME, VERSION +// and PRIVATE state. This gate derives those from `packages/*/package.json` +// and fails when prose contradicts them. It never hard-codes a version, so it +// keeps working across releases. +// +// It does NOT contact the registry, and it does not claim a package is on npm. +// "Is the published tarball what this repository says it is" is a distribution +// assertion across repositories and belongs to META-293, not here. Baking a +// registry call into a repo-local gate would also make CI fail for reasons +// that have nothing to do with the commit under test. + +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) => join(packagesDir, entry.name, "package.json")) + .filter((manifestPath) => existsSync(manifestPath)) + .map((manifestPath) => { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + return { + dir: `packages/${manifestPath.split("/").slice(-2, -1)[0]}`, + name: manifest.name, + version: manifest.version, + isPrivate: manifest.private === true, + manifestPath, + }; + }); + +const publishable = packages.filter((p) => !p.isPrivate); + +// --- 1: the root README packages table matches the manifests --------------- + +const readmePath = join(repoRoot, "README.md"); +const readme = readFileSync(readmePath, "utf8"); + +const NEGATION = /\bno\b|not yet|not published|unpublished|not on npm/i; +const SEMVER = /(\d+\.\d+\.\d+)/; + +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 = []; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]; + if (!line.trim().startsWith("|")) { + if (rows.length > 0) break; + continue; + } + const cells = line.split("|").slice(1, -1).map((c) => c.trim()); + if (cells.length < 3) continue; + if (/^-+$/.test(cells[0].replace(/\s/g, ""))) continue; // separator + rows.push({ directory: cells[0], pkg: cells[1], published: 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 published-status 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 state its status`); + continue; + } + + if (pkg.isPrivate) { + if (!NEGATION.test(row.published)) { + report("private-published-claim", `README.md:${row.line}`, + `${pkg.name} is \`private: true\` in ${pkg.dir}/package.json, but the table does not say it is unpublished`); + } + continue; + } + + if (NEGATION.test(row.published)) { + report("publish-status-contradiction", `README.md:${row.line}`, + `${pkg.name} is publishable (no \`private: true\` in ${pkg.dir}/package.json, publishConfig.access=` + + `${JSON.stringify(JSON.parse(readFileSync(pkg.manifestPath, "utf8")).publishConfig?.access)}) but the table says "${row.published}"`); + continue; + } + + const stated = row.published.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`); + } else if (stated !== pkg.version) { + report("version-drift", `README.md:${row.line}`, + `${pkg.name} row says ${stated}, manifest says ${pkg.version}`); + } + } +} + +// --- 2: no prose anywhere contradicts a publishable package ---------------- + +const docFiles = ["README.md", ...packages.map((p) => `${p.dir}/README.md`)] + .filter((f) => existsSync(join(repoRoot, f))); + +// Deliberately literal. A looser "package name near a negative word" heuristic +// flags legitimate prose such as "it gets no new features", which is true and +// should stay. +// +// PUBLICATION STATUS IS NOT PUBLICATION AUTHORITY. "`agents-audit` is not +// published from here" is a true and necessary statement about which repository +// owns the release, and META-296 requires that distinction be preserved rather +// than flattened. Only bare status claims are contradictions; the negative +// lookahead below is what keeps authority prose legal. +const NEGATIVE_PROSE = [ + { pattern: /nothing here is published yet/i, why: "asserts the repository publishes nothing" }, + { pattern: /not yet on npm/i, why: "asserts a package is absent from npm" }, + { pattern: /is not published(?!\s+(?:from|by)\b)/i, why: "asserts a package is not published at all" }, + { pattern: /is unpublished(?!\s+(?:from|by)\b)/i, why: "asserts a package is unpublished" }, +]; + +for (const file of docFiles) { + const text = readFileSync(join(repoRoot, file), "utf8"); + const lines = text.split("\n"); + + lines.forEach((line, index) => { + for (const { pattern, why } of NEGATIVE_PROSE) { + if (pattern.test(line)) { + report("prose-contradiction", `${file}:${index + 1}`, + `${why}, while ${publishable.map((p) => p.name).join(" and ")} are publishable in this repository: "${line.trim()}"`); + } + } + + // "Once X is published, ..." presumes X is not published yet. + const conditional = line.match(/once\s+`?(@?[\w@/.-]+?)`?\s+is published/i); + if (conditional) { + const named = publishable.find((p) => p.name === conditional[1]); + if (named) { + report("prose-contradiction", `${file}:${index + 1}`, + `presumes ${named.name} is not yet published, but it is publishable at ${named.version}: "${line.trim()}"`); + } + } + }); +} + +// --------------------------------------------------------------------------- + +if (violations.length > 0) { + console.error(`Published-status 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. Correct the prose, or correct the manifest."); + process.exit(1); +} + +const summary = packages + .map((p) => `${p.name}@${p.version}${p.isPrivate ? " (private)" : ""}`) + .join(", "); +console.log(`Published-status check passed: ${packages.length} manifest(s) — ${summary} — agree with ${docFiles.length} documentation surface(s).`); diff --git a/scripts/check-published-status.test.mjs b/scripts/check-published-status.test.mjs new file mode 100644 index 0000000..e6404ff --- /dev/null +++ b/scripts/check-published-status.test.mjs @@ -0,0 +1,209 @@ +#!/usr/bin/env node + +// Red tests for scripts/check-published-status.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. The final case asserts the unmodified repository passes, so a guard +// that fails everything cannot masquerade as working. +// +// The first case is the exact defect META-296 was filed for: the root README +// saying "not yet on npm" while the manifest carries a real version. + +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const guard = join(repoRoot, "scripts", "check-published-status.mjs"); + +const read = (p) => readFileSync(p, "utf8"); +const write = (p, s) => writeFileSync(p, s); + +const cases = [ + { + name: "publish-status-contradiction: table says a publishable package is not on npm", + expect: "publish-status-contradiction", + mutate: (root) => { + const p = join(root, "README.md"); + write(p, read(p).replace( + /\| `@workspacejson\/cli` \| \*\*Yes — `[\d.]+`\*\* \|/, + "| `@workspacejson/cli` | **No — not yet on npm** |", + )); + }, + }, + { + 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` \| \*\*Yes — `)[\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: "prose-contradiction: 'nothing here is published yet' returns to the README", + expect: "prose-contradiction", + mutate: (root) => { + const p = join(root, "README.md"); + write(p, read(p).replace(/> \*\*Status\.\*\*/, "> **Status: pre-release.** Nothing here is published yet.\n>\n> **Status.**")); + }, + }, + { + name: "prose-contradiction: package README claims the neutral producer is not published", + expect: "prose-contradiction", + mutate: (root) => { + const p = join(root, "packages/cli/README.md"); + write(p, `${read(p)}\n\nNote: this package is not published.\n`); + }, + }, + { + name: "prose-contradiction: 'Once X is published' presumes it is not", + expect: "prose-contradiction", + mutate: (root) => { + const p = join(root, "README.md"); + write(p, `${read(p)}\n\nOnce \`@workspacejson/cli\` is published, the neutral equivalent is available.\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.includes("`agents-audit`") || !l.trim().startsWith("|")).join("\n")); + }, + }, + { + name: "private-published-claim: a private package is documented as published", + expect: "private-published-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`); + }, + }, +]; + +function runGuard(root) { + return spawnSync(process.execPath, [join(root, "scripts", "check-published-status.mjs")], { + cwd: root, + encoding: "utf8", + }); +} + +function scratchCopy() { + const directory = mkdtempSync(join(tmpdir(), "wjson-published-status-")); + 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 }); + } +} + +// Cases that MUST stay legal. A gate that cannot tell publication *status* from +// publication *authority* would force the repository to delete true statements +// about who owns a release — which is the opposite of what META-296 asks for. +const legitimate = [ + { + name: "authority prose: 'is not published from here' is a true authority claim", + 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: "authority prose: 'is not published by this repository'", + mutate: (root) => { + const p = join(root, "README.md"); + write(p, `${read(p)}\n\n\`@workspacejson/spec\` is not published by this repository.\n`); + }, + }, + { + name: "frozen-bridge prose: 'gets no new features' is not a status 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`); + }, + }, +]; + +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(`\nPublished-status guard red tests: ${passed} passed, ${failed} failed.`); +if (failed > 0) process.exit(1); From 82c089c197c2bcee51ee8db642434946183ca0d6 Mon Sep 17 00:00:00 2001 From: Qwynn Marcelle Date: Tue, 4 Aug 2026 22:45:12 -0400 Subject: [PATCH 2/2] fix: the package-docs gate arbitrates manifests, not the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects a semantic defect in the gate added earlier on this branch. The script's header said the manifests arbitrate only name, version and private state, and that it does not contact the registry or claim a package is on npm. Its logic did the opposite. The defect It computed `publishable = packages.filter(p => !p.isPrivate)` and then treated documentation saying "not yet on npm" as a contradiction for any such package. Those are not the same fact: private !== true -> npm publication is PERMITTED by the manifest -> it does NOT establish that publication HAPPENED A legitimately unpublished package has a public, versioned manifest and is lying about nothing; the gate would have failed it. The window between a version bump and the publish that follows is the same situation, and this repository is in a version of it — origin/main predates the 0.5.2 publish, so no commit here corresponds to the published tarball. Removed publish-status-contradiction, and every prose rule that inferred a registry fact from a manifest: "nothing here is published yet", bare "is not published", and "once X is published". Removed rather than softened — a manifest cannot disprove any of them. Kept, and honest packages-table-coverage, unknown-package-row, version-drift, version-claim-missing, private-distribution-claim, package-readme-name. The original META-296 defect stays catchable for the right reason: the table carries a Version column that must match the manifest, so a cell reading "No — not yet on npm" now fails as a MISSING VERSION CLAIM rather than as an inferred statement about npm. Renamed check:published-status -> check:package-docs, and the script and its red tests with it. The old name invited exactly the confusion above. 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 real conflation. Stated in README prose and left to review, rather than faked with a check that would misfire. README Splits the evidence: a dated registry snapshot for npm state, and a separate statement that the table carries manifest versions kept in step by check:package-docs, with registry distribution verified under META-293. Also fixes a parser bug found by the new tests: the Packages table header row was being read as data, so the literal word "Package" registered as an undocumented package. Row collection now begins after the separator. Verification check:architecture 66 files, 3 manifests, no violations architecture red tests 20 passed, 0 failed check:package-docs 2 manifests agree with the documented names/versions package-docs red tests 12 passed, 0 failed (7 red, 4 legitimacy, 1 baseline) build + typecheck PASS tests 108 passed (64 cli, 44 compat) release:verify-packs both tarballs release-safe The four legitimacy cases are the point: "not yet on npm", "nothing here is published yet", "is not published from here", and frozen-bridge prose must all stay legal. A gate that forces deletion of true statements is worse than no gate. --- .github/workflows/ci.yml | 8 +- README.md | 21 ++- package.json | 4 +- scripts/check-package-docs.mjs | 171 +++++++++++++++++ ...s.test.mjs => check-package-docs.test.mjs} | 131 +++++++------ scripts/check-published-status.mjs | 178 ------------------ 6 files changed, 261 insertions(+), 252 deletions(-) create mode 100644 scripts/check-package-docs.mjs rename scripts/{check-published-status.test.mjs => check-package-docs.test.mjs} (63%) delete mode 100644 scripts/check-published-status.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d3e94d..08207ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,11 +31,11 @@ jobs: - name: Architecture guard red tests run: node scripts/check-architecture.test.mjs - - name: Published-status and version truth - run: pnpm run check:published-status + - name: Package documentation truth + run: pnpm run check:package-docs - - name: Published-status guard red tests - run: node scripts/check-published-status.test.mjs + - 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 diff --git a/README.md b/README.md index a090418..aeddb60 100644 --- a/README.md +++ b/README.md @@ -9,18 +9,27 @@ not own the specification — the normative schema, rules and contracts live in consumed here as released packages. > **Status.** The architecture below landed in META-247 and is the ratified -> target shape. Both packages are published; the versions in the table are the -> manifest versions in this repository, checked by `pnpm run check:published-status`. -> Package semver is independent of the specification profile — `@workspacejson/cli` +> 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` | **Yes — `0.5.2`** | 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`** diff --git a/package.json b/package.json index e7be34b..955f0bb 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,8 @@ "test": "pnpm -r test", "typecheck": "pnpm -r typecheck", "check:architecture": "node scripts/check-architecture.mjs", - "check:published-status": "node scripts/check-published-status.mjs", - "check:published-status:test": "node scripts/check-published-status.test.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/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-published-status.test.mjs b/scripts/check-package-docs.test.mjs similarity index 63% rename from scripts/check-published-status.test.mjs rename to scripts/check-package-docs.test.mjs index e6404ff..1d00cd5 100644 --- a/scripts/check-published-status.test.mjs +++ b/scripts/check-package-docs.test.mjs @@ -1,37 +1,43 @@ #!/usr/bin/env node -// Red tests for scripts/check-published-status.mjs (META-296). +// 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. The final case asserts the unmodified repository passes, so a guard +// check. A baseline case asserts the unmodified repository passes, so a guard // that fails everything cannot masquerade as working. // -// The first case is the exact defect META-296 was filed for: the root README -// saying "not yet on npm" while the manifest carries a real version. +// 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 { dirname, join, resolve } from "node:path"; +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 guard = join(repoRoot, "scripts", "check-published-status.mjs"); const read = (p) => readFileSync(p, "utf8"); const write = (p, s) => writeFileSync(p, s); const cases = [ { - name: "publish-status-contradiction: table says a publishable package is not on npm", - expect: "publish-status-contradiction", + // 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` \| \*\*Yes — `[\d.]+`\*\* \|/, - "| `@workspacejson/cli` | **No — not yet on npm** |", + /(\| `@workspacejson\/cli` \| )`[\d.]+`( \|)/, + "$1**No — not yet on npm**$2", )); }, }, @@ -40,10 +46,7 @@ const cases = [ expect: "version-drift", mutate: (root) => { const p = join(root, "README.md"); - write(p, read(p).replace( - /(\| `@workspacejson\/cli` \| \*\*Yes — `)[\d.]+(`\*\* \|)/, - "$19.9.9$2", - )); + write(p, read(p).replace(/(\| `@workspacejson\/cli` \| `)[\d.]+(` \|)/, "$19.9.9$2")); }, }, { @@ -57,58 +60,89 @@ const cases = [ }, }, { - name: "prose-contradiction: 'nothing here is published yet' returns to the README", - expect: "prose-contradiction", + 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(/> \*\*Status\.\*\*/, "> **Status: pre-release.** Nothing here is published yet.\n>\n> **Status.**")); + 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: "prose-contradiction: package README claims the neutral producer is not published", - expect: "prose-contradiction", + 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)}\n\nNote: this package is not published.\n`); + write(p, read(p).replace(/^# .*$/m, "# @workspacejson/something-else")); }, }, +]; + +// Cases that MUST stay legal. +const legitimate = [ { - name: "prose-contradiction: 'Once X is published' presumes it is not", - expect: "prose-contradiction", + // 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\nOnce \`@workspacejson/cli\` is published, the neutral equivalent is available.\n`); + write(p, `${read(p)}\n\nA future package in this repository is not yet on npm.\n`); }, }, { - name: "packages-table-coverage: a package is dropped from the table", - expect: "packages-table-coverage", + 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).split("\n").filter((l) => !l.includes("`agents-audit`") || !l.trim().startsWith("|")).join("\n")); + write(p, `${read(p)}\n\nAt the time of writing nothing here is published yet.\n`); }, }, { - name: "private-published-claim: a private package is documented as published", - expect: "private-published-claim", + name: "authority prose: 'is not published from here' distinguishes authority from status", 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`); + 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-published-status.mjs")], { + return spawnSync(process.execPath, [join(root, "scripts", "check-package-docs.mjs")], { cwd: root, encoding: "utf8", }); } function scratchCopy() { - const directory = mkdtempSync(join(tmpdir(), "wjson-published-status-")); + const directory = mkdtempSync(join(tmpdir(), "wjson-package-docs-")); const root = join(directory, "repo"); cpSync(repoRoot, root, { recursive: true, @@ -142,33 +176,6 @@ for (const testCase of cases) { } } -// Cases that MUST stay legal. A gate that cannot tell publication *status* from -// publication *authority* would force the repository to delete true statements -// about who owns a release — which is the opposite of what META-296 asks for. -const legitimate = [ - { - name: "authority prose: 'is not published from here' is a true authority claim", - 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: "authority prose: 'is not published by this repository'", - mutate: (root) => { - const p = join(root, "README.md"); - write(p, `${read(p)}\n\n\`@workspacejson/spec\` is not published by this repository.\n`); - }, - }, - { - name: "frozen-bridge prose: 'gets no new features' is not a status 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`); - }, - }, -]; - for (const testCase of legitimate) { const { directory, root } = scratchCopy(); try { @@ -205,5 +212,5 @@ for (const testCase of legitimate) { } } -console.log(`\nPublished-status guard red tests: ${passed} passed, ${failed} failed.`); +console.log(`\nPackage documentation guard red tests: ${passed} passed, ${failed} failed.`); if (failed > 0) process.exit(1); diff --git a/scripts/check-published-status.mjs b/scripts/check-published-status.mjs deleted file mode 100644 index 1e9f474..0000000 --- a/scripts/check-published-status.mjs +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env node - -// Published-status and version truth gate (META-296). -// -// The root README once said `@workspacejson/cli` was "not yet on npm" while the -// package manifest read 0.5.2 and the package-level README gave install -// instructions and claimed npm provenance. Three surfaces, three different -// stories. Nothing mechanical could notice. -// -// WHAT THIS ASSERTS — and deliberately does not: -// -// The manifests in this repository are the arbiter for package NAME, VERSION -// and PRIVATE state. This gate derives those from `packages/*/package.json` -// and fails when prose contradicts them. It never hard-codes a version, so it -// keeps working across releases. -// -// It does NOT contact the registry, and it does not claim a package is on npm. -// "Is the published tarball what this repository says it is" is a distribution -// assertion across repositories and belongs to META-293, not here. Baking a -// registry call into a repo-local gate would also make CI fail for reasons -// that have nothing to do with the commit under test. - -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) => join(packagesDir, entry.name, "package.json")) - .filter((manifestPath) => existsSync(manifestPath)) - .map((manifestPath) => { - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - return { - dir: `packages/${manifestPath.split("/").slice(-2, -1)[0]}`, - name: manifest.name, - version: manifest.version, - isPrivate: manifest.private === true, - manifestPath, - }; - }); - -const publishable = packages.filter((p) => !p.isPrivate); - -// --- 1: the root README packages table matches the manifests --------------- - -const readmePath = join(repoRoot, "README.md"); -const readme = readFileSync(readmePath, "utf8"); - -const NEGATION = /\bno\b|not yet|not published|unpublished|not on npm/i; -const SEMVER = /(\d+\.\d+\.\d+)/; - -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 = []; - for (let i = start + 1; i < lines.length; i++) { - const line = lines[i]; - if (!line.trim().startsWith("|")) { - if (rows.length > 0) break; - continue; - } - const cells = line.split("|").slice(1, -1).map((c) => c.trim()); - if (cells.length < 3) continue; - if (/^-+$/.test(cells[0].replace(/\s/g, ""))) continue; // separator - rows.push({ directory: cells[0], pkg: cells[1], published: 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 published-status 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 state its status`); - continue; - } - - if (pkg.isPrivate) { - if (!NEGATION.test(row.published)) { - report("private-published-claim", `README.md:${row.line}`, - `${pkg.name} is \`private: true\` in ${pkg.dir}/package.json, but the table does not say it is unpublished`); - } - continue; - } - - if (NEGATION.test(row.published)) { - report("publish-status-contradiction", `README.md:${row.line}`, - `${pkg.name} is publishable (no \`private: true\` in ${pkg.dir}/package.json, publishConfig.access=` + - `${JSON.stringify(JSON.parse(readFileSync(pkg.manifestPath, "utf8")).publishConfig?.access)}) but the table says "${row.published}"`); - continue; - } - - const stated = row.published.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`); - } else if (stated !== pkg.version) { - report("version-drift", `README.md:${row.line}`, - `${pkg.name} row says ${stated}, manifest says ${pkg.version}`); - } - } -} - -// --- 2: no prose anywhere contradicts a publishable package ---------------- - -const docFiles = ["README.md", ...packages.map((p) => `${p.dir}/README.md`)] - .filter((f) => existsSync(join(repoRoot, f))); - -// Deliberately literal. A looser "package name near a negative word" heuristic -// flags legitimate prose such as "it gets no new features", which is true and -// should stay. -// -// PUBLICATION STATUS IS NOT PUBLICATION AUTHORITY. "`agents-audit` is not -// published from here" is a true and necessary statement about which repository -// owns the release, and META-296 requires that distinction be preserved rather -// than flattened. Only bare status claims are contradictions; the negative -// lookahead below is what keeps authority prose legal. -const NEGATIVE_PROSE = [ - { pattern: /nothing here is published yet/i, why: "asserts the repository publishes nothing" }, - { pattern: /not yet on npm/i, why: "asserts a package is absent from npm" }, - { pattern: /is not published(?!\s+(?:from|by)\b)/i, why: "asserts a package is not published at all" }, - { pattern: /is unpublished(?!\s+(?:from|by)\b)/i, why: "asserts a package is unpublished" }, -]; - -for (const file of docFiles) { - const text = readFileSync(join(repoRoot, file), "utf8"); - const lines = text.split("\n"); - - lines.forEach((line, index) => { - for (const { pattern, why } of NEGATIVE_PROSE) { - if (pattern.test(line)) { - report("prose-contradiction", `${file}:${index + 1}`, - `${why}, while ${publishable.map((p) => p.name).join(" and ")} are publishable in this repository: "${line.trim()}"`); - } - } - - // "Once X is published, ..." presumes X is not published yet. - const conditional = line.match(/once\s+`?(@?[\w@/.-]+?)`?\s+is published/i); - if (conditional) { - const named = publishable.find((p) => p.name === conditional[1]); - if (named) { - report("prose-contradiction", `${file}:${index + 1}`, - `presumes ${named.name} is not yet published, but it is publishable at ${named.version}: "${line.trim()}"`); - } - } - }); -} - -// --------------------------------------------------------------------------- - -if (violations.length > 0) { - console.error(`Published-status 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. Correct the prose, or correct the manifest."); - process.exit(1); -} - -const summary = packages - .map((p) => `${p.name}@${p.version}${p.isPrivate ? " (private)" : ""}`) - .join(", "); -console.log(`Published-status check passed: ${packages.length} manifest(s) — ${summary} — agree with ${docFiles.length} documentation surface(s).`);