From 1a66ee155d6e4f199c6cdfa6de866f17ab68e685 Mon Sep 17 00:00:00 2001 From: Qwynn Marcelle Date: Mon, 17 Aug 2026 19:33:59 -0400 Subject: [PATCH] release: @workspacejson/spec + @workspacejson/rules 0.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Output of `pnpm changeset version` over the eight accumulated changesets. The fixed group moves 0.4.4 -> 0.5.0; the document profile does not move and stays at generated.specVersion 0.4. Also fixes the changelog parity assertion, which is META-332 and which this release is the first to actually hit. Two gates in this repository disagreed about the changelog heading format: packages/spec/src/index.test.ts required ## [0.5.0] (Keep a Changelog) scripts/verify-release-identity required ## 0.5.0 (Changesets) Changesets writes the bare form, so the test matched the first BRACKETED heading it could find — the historical ## [0.4.4] — and compared 0.4.4 against a manifest reading 0.5.0. The two assertions could not both be satisfied by any one file, so no changesets-generated release could ever have passed. The test now matches the release gate's form. Only the top heading is inspected; the bracketed entries below it are pre-Changesets history and are deliberately not matched, so a bracketed heading at the top would yield no match at all — the correct failure, since it would mean the released version was not versioned by Changesets. Test-only change: tests are not in `files` and do not ship, so it carries no changeset and does not move the version. --- .changeset/lucky-pugs-invent.md | 24 -- .changeset/olive-crabs-observe.md | 166 ----------- .changeset/olive-hosts-settle.md | 28 -- .changeset/olive-keys-report.md | 102 ------- .changeset/olive-moons-listen.md | 35 --- .changeset/plenty-cooks-repair.md | 41 --- .changeset/quiet-moons-admit.md | 57 ---- .changeset/tall-otters-classify.md | 54 ---- packages/rules/CHANGELOG.md | 157 +++++++++++ packages/rules/package.json | 2 +- packages/spec/CHANGELOG.md | 433 +++++++++++++++++++++++++++++ packages/spec/package.json | 2 +- packages/spec/src/index.test.ts | 14 +- 13 files changed, 605 insertions(+), 510 deletions(-) delete mode 100644 .changeset/lucky-pugs-invent.md delete mode 100644 .changeset/olive-crabs-observe.md delete mode 100644 .changeset/olive-hosts-settle.md delete mode 100644 .changeset/olive-keys-report.md delete mode 100644 .changeset/olive-moons-listen.md delete mode 100644 .changeset/plenty-cooks-repair.md delete mode 100644 .changeset/quiet-moons-admit.md delete mode 100644 .changeset/tall-otters-classify.md diff --git a/.changeset/lucky-pugs-invent.md b/.changeset/lucky-pugs-invent.md deleted file mode 100644 index 456da3a..0000000 --- a/.changeset/lucky-pugs-invent.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@workspacejson/rules": patch -"@workspacejson/spec": patch ---- - -Remove runtime dependencies that were declared but never imported. - -`@workspacejson/rules` declared `dedent`, `ignore`, `unified` and `zod` in -`dependencies`. None of them is imported anywhere in the package. `zod` and -`unified` appeared only as string literals in the framework-detection tables — -the parser looks for the *word* "zod" in a manifest, it never loads the library. - -Because these were `dependencies` rather than `devDependencies`, every consumer -installed all four to run code that does not exist. Removing them changes no -behavior: the full suite passes unchanged. - -`@workspacejson/spec` drops the unused `json-schema-to-typescript` -devDependency along with `scripts/generate-types.js`, which read the schema file -and discarded it. `src/types.ts` is hand-written and committed; nothing was ever -generated. The build script is now `tsc`. - -A new `unused-dependency` guard in `scripts/check-architecture.mjs` fails the -build if a declared runtime dependency is never imported, so this cannot -silently return. diff --git a/.changeset/olive-crabs-observe.md b/.changeset/olive-crabs-observe.md deleted file mode 100644 index 0f99a20..0000000 --- a/.changeset/olive-crabs-observe.md +++ /dev/null @@ -1,166 +0,0 @@ ---- -"@workspacejson/spec": minor ---- - -Admit raw co-change commit counts alongside the existing rate, and pin the basis -revision, per ADR-003 amendment A-009. This is the **reader-widening** step of a -staged transition; no producer changes. - -**At the document level this is a pure widening.** Every document that validated -before this release validates after it — there is no narrowing anywhere in the -artifact contract. `cochange-legacy-rate-v0.4.json` and -`cochange-legacy-head-basis-v0.4.json` ship as executable proof rather than the -claim being left as prose. - -**At the package API level it is a source-level break, and that is why this is a -minor.** `CoChangeEntry` was one interface with `rate: number`; it is now a union -whose members declare the other form's field as `?: never`. Reading `entry.rate` -off the union yields `number | undefined`, so a TypeScript consumer that assigns -it without narrowing **stops compiling**: - -```ts -const r: number = entry.rate; // was fine, now a type error -const r = entry.support !== undefined // narrow first - ? entry.support / entry.occurrences - : entry.rate; -``` - -The runtime shape of every existing artifact is unaffected; only the type of the -code reading it changes. Asserted in `src/type-invariants.ts`, which `tsc` -compiles — test files are excluded from the build, so a type claim written only -in a test would never be checked. - -A `generated.coChange` entry now takes **exactly one of two forms**: - -| Form | Carries | Status | -| -- | -- | -- | -| Legacy | `rate` + `occurrences` | Deprecated; still accepted | -| Observation | `support` + `occurrences` | What new producers emit | - -```diff - { files: [a, b], rate: 0.87, occurrences: 9, generated: false } // still valid -+ { files: [a, b], support: 8, occurrences: 24, generated: false } // now valid -``` - -Those two numbers are the worked invariant, not an illustration. Over the -analyzed history `a` changed in **20** qualifying commits and `b` in **12**, and -**8** commits changed both. `support` is that intersection; `occurrences` is the -symmetric union, `20 + 12 − 8 = 24`. The marginals are deliberately unequal, -because that is what makes the denominator observable: a producer using one -endpoint's marginal would emit 20 or 12 here, and an unordered pair gives it no -principled way to choose between them. 24 is neither. - -An entry carrying **both** is invalid — they are different contracts, the counts -need not agree, and a reader cannot know which was measured. An entry carrying -**neither** is invalid. This is a `oneOf` in the schema and a `?: never` union in -the types, so it fails at validation time and at compile time. - -The array is also **homogeneous**: every entry legacy, or every entry -observation. Per-entry exclusivity is not enough — each entry of a mixed array is -individually well-formed, so one artifact would carry two meanings of -`occurrences` with nothing saying so. An empty array satisfies both branches. - -Observation-form `occurrences` has a **minimum of 1**. A pair enters the -observation set because at least one of its files appeared in at least one -qualifying commit, so a pair whose union is empty was never observed and gets no -entry — absence, not a zero denominator. This makes `support / occurrences` total -on conforming artifacts: no reader can derive `0/0`, `NaN` or infinity, and no -consumer needs a guard the standard failed to specify. Legacy `occurrences` keeps -its original minimum of 0. - -- `support` — distinct qualifying commits in which **both** files changed. -- `occurrences` — **in the observation form**, distinct qualifying commits in - which **at least one** of the two changed. - -**`occurrences` means different things in the two forms.** In the observation -form it is the symmetric union denominator. In the legacy form it carries the -pre-amendment meaning, which was never normatively specified and must not be -assumed symmetric. Establish the form before reading it — `entry.support !== undefined` — -and never compare the value across forms. - -A rate is a reader's question, not a producer's observation. Storing it churned -the artifact on every commit and forced one analytical reading on every consumer. -Readers derive `support / occurrences`, or probability, lift, confidence or a -ranking, wherever `occurrences > 0`. Nothing derived is stored. - -**The denominator is the union, and that is load-bearing.** `files` is an -unordered pair with set semantics, so it has no subject file. A denominator -meaning "commits in which the subject changed" is not well-defined: given A -changing in 20 qualifying commits, B in 12, and both in 8, two conforming -producers could emit `occurrences: 20` or `occurrences: 12` for the same -observation and neither would be wrong. Counting the union makes both fields -symmetric — reversing the stored pair changes nothing — so independent producers -have a comparable surface. Both count **distinct commits**, never file events or -ordered relationships. - -`generated.basisRevision` names the revision the counts were taken over: a -full-length lowercase Git object name, 40 hex characters for SHA-1 or 64 for -SHA-256. It sits once at `generated` level, never per item. - -**Both the requirement and the pattern are scoped to the observation form.** The -key is declared globally with no constraints, because `generated` is -`additionalProperties: true` and a legacy artifact may already carry it with any -value. A global pattern would have made `basisRevision: "HEAD"` newly invalid — -a narrowing by the back door. `cochange-legacy-head-basis-v0.4.json` is the -regression guard; the same value is still rejected once an observation-form entry -appears. - -Four reader-visible states, defined normatively in A-009: - -| Shape | Means | -| -- | -- | -| `coChange` absent | Not analyzed | -| `coChange: []`, no pin | Legacy / unknown — **not** evidence of zero | -| `coChange: []`, pinned | Analyzed at that revision; no qualifying pairs | -| pin ≠ current revision | Stale observation | - -A producer emitting the observation form declares the pin whenever `coChange` -exists, **including when empty**. Schema validation cannot enforce that case — an -empty array carries no discriminator — so it is a producer obligation, and the -reader-side rule above is what keeps the states distinguishable regardless. - -A *qualifying commit* is one inside the analysis boundary the producer already -declares for every other observation in the section. This release defines **no** -new history-window, merge, rename or path-normalization policy. - -`support <= occurrences` is enforced by `validate()` and **not** by the schema: -JSON Schema draft 2020-12 cannot compare two instance values, so the invariant is -a producer obligation carried by the profile. A bare JSON Schema validator -accepts a document that violates it. Both directions are pinned by test and -`docs/conformance.md` states the disagreement explicitly. It applies to -observation-form entries only; a legacy entry has no `support`. - -`packages/spec/examples/invalid/` is new: eleven negative fixtures, each naming -the single defect it exhibits, checked in CI by `pnpm run check:examples`. The -gate previously ran positives only, which cannot show that anything is rejected. -Rejection by `validate()` is the substantive assertion; the gate also requires -rejection by `validateLegacy()`, which is structural coverage — it keys on the -absence of `generated.specVersion` and never inspects the defect, so it confirms -only that a rejected v0.4 document cannot re-enter through the legacy path. - -`version` is now derived from the packaged manifest instead of being a hardcoded -literal with a test asserting the literal. That pair only held while someone -remembered to hand-edit both during a release, and Changesets never rewrites a -constant in source — the first release to move the number would have shipped a -package reporting the old version. The test asserts parity with `package.json`. - -**Release sequence.** This is step 1 of three, on the ADR-004 §8 pattern: -**widen the reader → verify known consumer adoption → enable producer emission.** -Widening what a reader accepts is not permission to emit, and the steps must not -be collapsed. Removing `rate` is a separate fourth step at the next -document-profile change, and this release does not authorize it. - -**On the version number this produces.** A `minor` bump takes both packages from -`0.4.4` to **`0.5.0`** — `@workspacejson/rules` comes along unchanged because the -two are a fixed release group. That number is the **package** version and says -nothing about the document profile. **The document profile is unchanged: this -release still reads and writes `generated.specVersion: "0.4"`.** Package `0.5.0` -is not spec v0.5, no artifact's `specVersion` moves, no new profile identifier is -minted, and the deferred v0.5 profile work is untouched. The two numbers are -independent by policy — see `docs/versioning.md` — and this is exactly the -release where confusing them would be easiest. - -`minor` is the correct bump on its own terms: the artifact contract only widens, -but the exported TypeScript union is a source-level break for readers that access -`entry.rate` without narrowing. The package version moves for that reason, and -the document profile does not move at all. diff --git a/.changeset/olive-hosts-settle.md b/.changeset/olive-hosts-settle.md deleted file mode 100644 index 6524498..0000000 --- a/.changeset/olive-hosts-settle.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@workspacejson/spec": patch ---- - -Reconcile the schema `$id` host to the bare canonical domain, per ADR-005. - -The schema's `$id` was `https://www.workspacejson.dev/schema/v1.json` while every -package manifest and documentation reference uses `https://workspacejson.dev` -without the `www.` prefix. Both hosts serve the schema, so nothing was broken, -but the two strings disagreed — and `versioning.md` instructs consumers to -hash-check the materialized schema, which means the `$id` string is part of the -contract surface. - -The `$id` is now `https://workspacejson.dev/schema/v1.json`, matching the bare -canonical domain. The filename `v1.json` is unchanged. The `www.` host continues -to serve the schema; the change is about which string is canonical, not which -URL works. - -This change is folded into the same release as the ADR-004 root `version` -widening so consumers experience one schema-byte transition covering both -changes, rather than two consecutive pin invalidations. - -ADR-005 also settles two questions that were open alongside the host, so that -this is the only identity change: the file is **not** renamed — `v1.json` stays, -and the `v1` remains a legacy naming artifact rather than a version claim — and -no sibling schema document will be introduced, with future profiles continuing to -ride the `generated.specVersion` enum. Neither decision changes any bytes now; -recording them is what keeps a later rename from costing a second pin. diff --git a/.changeset/olive-keys-report.md b/.changeset/olive-keys-report.md deleted file mode 100644 index 8089cb2..0000000 --- a/.changeset/olive-keys-report.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -"@workspacejson/spec": minor ---- - -Add the standard-owned canonical path-identity surface, per ADR-006: stored keys -are data, not commands. - -Seven new exports, in two halves. - -**The grammar — `validateStoredKey(rawKey)`.** Decides whether one stored key is -canonical. - -```ts -validateStoredKey('src/a.ts'); // { valid: true, key: 'src/a.ts' } -validateStoredKey('src/../a.ts'); // { valid: false, reason: 'dotdot-segment' } -``` - -Pure, total, filesystem-free, deterministic, and applied to the original string -**before any path library sees it**. That ordering is the defect the record was -written about: the shipped consumers at the pinned revisions normalized first and -validated second, so `src/../a.ts` was already `a.ts` by the time anything asked -whether it was well-formed. - -**There is deliberately no repaired-key field.** A valid result carries the input -unchanged; a rejection carries a reason and nothing a caller could mistake for a -usable key. A malformed key matches nothing — including the value normalization -would have produced. Reason precedence is fixed and documented so that two -implementations classify the same key identically: cannot-be-a-string, then -cannot-be-POSIX, then merely non-canonical. - -Case and Unicode form are significant. `A.ts` and `a.ts` are two keys, and so are -the NFC and NFD spellings of `café.ts`. A genuine U+FFFD is a valid pathname -character; telling it from a substituted one needs the original bytes and belongs -to acquisition. - -**The document walk — `inspectStoredKeys(document)`.** Reports every malformed -key on every ratified path-bearing surface: `generated.fileIndex` keys, -`generated.coChange[].files`, `generated.fragility[].file`, and -`manual.fragileFiles[].path`. - -```ts -if (!validate(raw)) { - // Existing invalid-document handling. -} else { - for (const finding of inspectStoredKeys(raw)) { - console.warn(`${finding.pointer}: ${finding.rawKey} — ${finding.reason}`); - } -} -``` - -This is ADR-006 §9 obligation 1, *report it*. Obligation 2, *decline to match -it*, stays with the caller, because only the caller knows what a lookup is. - -**The input is a schema-validated document, not `unknown`.** That narrowing is -what makes an empty result mean something: `[]` says every inspected value in an -accepted document is well-formed, and an unvalidated value is outside the -declared input domain rather than silently "clean". `inspectStoredKeys` does not -call `validate()` internally and is not a second document validator — folding the -two together would destroy exactly that distinction. - -Findings are location-bearing records: one per occurrence, never deduplicated, -never normalized, never repaired. `rawKey` is the string the producer actually -wrote. Pointers are RFC 6901 with `~` escaped before `/`, so a pointer decodes -back to the exact stored key. Order is traversal order and is explicitly not part -of the contract. - -`manual.coChangePatterns` is **not** inspected. ADR-003 amendment A-005 has not -ratified its item shape — the schema constrains items to `{"type": "object"}` and -nothing more, while `types.ts` assumes `files: string[]`. Walking that field -would promote an authoring-time TypeScript assumption into a normative contract -ahead of the record that decides it. The surface is added once A-005 settles it. - -`canonicalizeHostQuery` is deliberately absent and must not be added to this -package: it needs a filesystem and a proven repository root, and ADR-006 §10 -assigns it to integrations and hosts. - -**Nothing narrows.** `validate()`, `validateV4()` and `validateLegacy()` are -unchanged, and `validate()` does not consult the stored-key grammar. Artifacts -carrying malformed keys on any path-bearing surface are still accepted, because -ADR-006 §9 requires a v0.4.x reader to report a malformed key and decline to -match it while continuing over the well-formed remainder. A dedicated suite fails -if a future change wires the two together — that is the intended alarm. Rejecting -such a document is a v0.5 document-profile change and is not authorized here. -No schema bytes changed. - -**Why `minor`.** These are additive public exports: nothing is removed, nothing -is renamed, no accepted type or value range narrows, and no existing signature -changes. Under `docs/versioning.md` removing a public export is breaking and -adding one is not, so this is a minor on its own terms. - -**On the version number this produces.** A `minor` takes both packages from -`0.4.4` to **`0.5.0`**, with `@workspacejson/rules` coming along unchanged because -the two are a fixed release group. This changeset does not move that number on its -own — the pending ADR-003 A-009 changeset already declares a `minor`, so `0.5.0` -is the next release with or without this one. - -That number is the **package** version and says nothing about the document -profile. **The document profile is unchanged: this release still reads and writes -`generated.specVersion: "0.4"`.** Package `0.5.0` is not spec v0.5, no artifact's -`specVersion` moves, no new profile identifier is minted, and the deferred v0.5 -profile work — narrowing validation and the hard-failure boundary — is untouched. -The two numbers are independent by policy; see `docs/versioning.md`. diff --git a/.changeset/olive-moons-listen.md b/.changeset/olive-moons-listen.md deleted file mode 100644 index 78eea46..0000000 --- a/.changeset/olive-moons-listen.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@workspacejson/rules": patch -"@workspacejson/spec": patch ---- - -Widen the validator to accept an optional root `version`, per ADR-004. - -`generated.specVersion` has always been the profile declaration, but at least one -external reader gates on a **root** `version` key instead. No producer has ever -emitted it, so that gate has never executed. - -The root object is `additionalProperties: false`, which means repairing the -mismatch is not the additive change it appears to be: adding a root key is -additive to the schema as a document and breaking for every already-deployed -validator. Acceptance therefore has to ship before emission, and this release is -the acceptance half. - -`validate()` and `validateV4()` now accept an optional root `version` of `"0.3"` -or `"0.4"`. When present it must equal `generated.specVersion` — a document whose -two declarations disagree is invalid, not resolved by precedence. No new profile -name is introduced and `generated.specVersion` is unchanged: still required, -still primary, still emitted. A reader that ignores the root key sees no -difference. - -`validateLegacy()` is corrected as a consequence. It previously identified the -pre-v0.3 shape as "has a root `version` string and fails `validate()`", which -stops being sufficient once v0.3/v0.4 documents may carry that key: a disagreeing -document would have been reported as legacy v0.1/v0.2 rather than rejected. It -now keys on the absence of `generated.specVersion`, so a disagreement is rejected -by both functions. - -**This release does not emit the field.** No producer writes a root `version`, -and ADR-004 §8 requires evidence that known validate-before-read consumers accept -it before any producer begins. Widening what a reader accepts is deliberately not -permission to start writing. diff --git a/.changeset/plenty-cooks-repair.md b/.changeset/plenty-cooks-repair.md deleted file mode 100644 index 2b22fff..0000000 --- a/.changeset/plenty-cooks-repair.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@workspacejson/rules": patch -"@workspacejson/spec": patch ---- - -Correct the published package metadata to name this repository, and record the -README and changelog corrections that ship inside the tarball. - -Both manifests still described the packages as they were published from the -frozen historical workspace. None of it was reachable: `repository.url` and -`bugs.url` pointed at a repository that no longer exists under that name, so -every "open an issue" and "view source" link on both npm pages led nowhere. - -`repository` now names this repository and carries a `directory` pointer, so npm -resolves each package to its own subtree rather than the monorepo root. `bugs` -follows it. `homepage` is the bare canonical host on both packages, per ADR-005 — -`@workspacejson/rules` additionally pointed at a `/audit/` subpath that predates -the neutral naming and no longer describes what the package is. - -`author` reads `workspacejson contributors` on both, matching the org that now -holds them. - -Two keywords were removed. `agents-audit` named the historical package rather -than these; `aaif` implied a standards-body status this project does not hold. - -`@workspacejson/rules` has a new `description`. The old one described a narrower -package — AGENTS.md hygiene auditing — than the one that actually ships, which -carries the parser, repository scanner, validator and rule engine. - -**Why a metadata-only change gets a changeset at all.** `README.md` and -`CHANGELOG.md` are both listed in `files`, so they are published bytes, not -repository furniture. Both package READMEs claimed to be published from the -historical workspace and loaded their brand assets from a frozen repository; -both changelogs recorded 0.4.4 as unreleased while the registry had already -served it. Those corrections reached consumers with no release note explaining -why the page changed. The manifest fields above are in the same position: they -are part of the published artifact even though no runtime behavior moves. - -Nothing in either package's runtime, types, schema or exports changes. `patch` -is correct on its own terms; the fixed group is taking a `minor` this release -for reasons recorded in the accompanying changesets, and this rides along. diff --git a/.changeset/quiet-moons-admit.md b/.changeset/quiet-moons-admit.md deleted file mode 100644 index a1f51c7..0000000 --- a/.changeset/quiet-moons-admit.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@workspacejson/rules": minor ---- - -Deprecate the hygiene score, and stop it certifying scans that observed nothing, -per ADR-003 amendment A-002. - -`computeHygieneScore([], 0)` returned `{ value: 100, grade: 'A' }`. No findings -meant no penalty; no penalty meant a full score; a full score meant an A. Nothing -in the function related the score to how much had been examined — `coverageRatio` -was computed and returned but never consulted by the scoring path. A scan that -looked at nothing certified a repository as flawless, and that value reached a -published artifact. - -**The function now returns `HygieneScore | null`,** and `null` when the scan -observed nothing: no findings, and no file-count denominator to say anything was -examined. `null` is not a bad grade. It is the statement that there is no score -to give, and a reader has to decide what to do about that instead of inheriting -an `A`. Where evidence exists — any finding, or a known denominator — the -arithmetic is unchanged. - -**`coverageRatio` is now `number | undefined`.** It was `0` whenever no total was -supplied, which is every current call site, so that zero was never a measurement -— it was the default parameter arriving unchanged. "Coverage was not measured" -and "coverage was zero" are different claims and no longer share a value. - -**Both are source-level breaks for TypeScript readers, which is why this is a -minor.** Code assigning the result to a bare `HygieneScore`, or `coverageRatio` -to a bare `number`, stops compiling. That is the intended alarm: it is exactly -the code that would otherwise read absence as a pass. `AuditResult.score` is -`HygieneScore | null` for the same reason — a caller handed no evidence needs -somewhere truthful to put that, and the previous non-nullable field left -fabricating a perfect score as the only way to satisfy it. - -**`computeHygieneScore`, `HygieneScore` and `AuditResult.score` are deprecated -and scheduled for removal at the next document-profile boundary.** A letter grade -is a judgement, and this standard is descriptive: it reports what a repository -*is*, not what a team must do about it. Scoring belongs to the consumer that -reads the descriptive fields. - -Migrating needs nothing that is not already public — `Finding.state`, -`.severity`, `.confidence` and `.temporalWeight` are the only inputs the function -ever had: - -```ts -const failures = findings.filter((f) => f.state === 'FAIL'); -const critical = failures.filter((f) => f.severity === 'critical'); -``` - -**Nothing is removed in this release and no schema bytes change.** Under ADR-003 -§5 a normative-optional field earns a deprecation notice and a documented -migration now, with removal at the next declared breaking boundary; the document -profile is unchanged at `generated.specVersion: "0.4"`, so this release is not -that boundary. `generated.hygiene` remains declared in the schema, because a -first-party producer still emits it and removing the declaration while that is -true would describe the artifact incorrectly. Emission ceases first, on the -producer's own schedule, and the field and exports go together afterwards. diff --git a/.changeset/tall-otters-classify.md b/.changeset/tall-otters-classify.md deleted file mode 100644 index 762caa2..0000000 --- a/.changeset/tall-otters-classify.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@workspacejson/spec": minor ---- - -Make the `generated.coChange[].generated` tooling-coupling flag optional in the -observation form, and define its absence, per ADR-003 amendment A-010. This is a -**reader widening** on a non-stable-floor path; no producer changes and no -emission is enabled by it. - -**The flag was a required boolean with no reproducible classifier.** `support` -and `occurrences` are observations — two producers counting the same commits get -the same numbers. `generated` is a *classification*: answering it requires a -judgement about what a file **is**, and this standard specifies no portable -deterministic classifier from public repository inputs. Requiring it did not -produce that judgement, it produced a value. The commit-graph producer, having no -classifier, emitted a constant `false` — which on its pinned fixture asserted -that `package-lock.json ↔ package.json` is a real source coupling that consumers -should **not** skip. - -**Absence is a third state, and readers must not collapse it into `false`.** - -| Value | Means | -| -- | -- | -| `true` | Classified as tooling-coupled — skip when surfacing real source couplings | -| `false` | Classified as **not** tooling-coupled | -| absent | **No classification performed.** The producer asserts nothing | - -So `if (!entry.generated)` is now a bug: it reads an unclassified pair as a -confirmed source coupling. Branch on `undefined` explicitly. A producer omits the -flag unless it implements a public, deterministic, perturbation-tested -classifier, and because two producers may classify the same pair differently and -both conform, the flag is **not** a producer-comparison surface. - -**The widening is asymmetric.** The requirement moved into the legacy `oneOf` -branch rather than disappearing: the legacy form is deprecated and frozen, every -artifact published in it already carries the flag, and widening it too would -loosen a shape no producer should still emit. - -**At the document level this is a pure widening.** Every document valid before -this release is valid after it. Nothing optional becomes required, no value range -narrows, the four stable read paths are untouched, and `generated.specVersion` -stays at `0.4`. Two fixtures ship as executable proof rather than prose: -`cochange-unclassified-v0.4.json` (observation form, nothing classified, carrying -an unflagged lockfile pair on purpose) and -`cochange-legacy-missing-generated.json` (the legacy form still requires it). - -**At the package API level it is a source-level break for TypeScript readers, -which is why this is a minor rather than a patch.** `generated` moves off -`CoChangeEntryCommon`: it remains `boolean` on `LegacyCoChangeEntry` and becomes -`boolean | undefined` on `ObservationCoChangeEntry`. Code assigning -`entry.generated` to a bare `boolean` without narrowing stops compiling — the -intended outcome, since that is exactly the code at risk of reading absence as -`false`. Asserted in `src/type-invariants.ts` rather than described. The runtime -shape of every existing artifact is unaffected. diff --git a/packages/rules/CHANGELOG.md b/packages/rules/CHANGELOG.md index 82aa8a5..1d97f8e 100644 --- a/packages/rules/CHANGELOG.md +++ b/packages/rules/CHANGELOG.md @@ -1,5 +1,162 @@ # Changelog +## 0.5.0 + +### Minor Changes + +- 4871663: Deprecate the hygiene score, and stop it certifying scans that observed nothing, + per ADR-003 amendment A-002. + + `computeHygieneScore([], 0)` returned `{ value: 100, grade: 'A' }`. No findings + meant no penalty; no penalty meant a full score; a full score meant an A. Nothing + in the function related the score to how much had been examined — `coverageRatio` + was computed and returned but never consulted by the scoring path. A scan that + looked at nothing certified a repository as flawless, and that value reached a + published artifact. + + **The function now returns `HygieneScore | null`,** and `null` when the scan + observed nothing: no findings, and no file-count denominator to say anything was + examined. `null` is not a bad grade. It is the statement that there is no score + to give, and a reader has to decide what to do about that instead of inheriting + an `A`. Where evidence exists — any finding, or a known denominator — the + arithmetic is unchanged. + + **`coverageRatio` is now `number | undefined`.** It was `0` whenever no total was + supplied, which is every current call site, so that zero was never a measurement + — it was the default parameter arriving unchanged. "Coverage was not measured" + and "coverage was zero" are different claims and no longer share a value. + + **Both are source-level breaks for TypeScript readers, which is why this is a + minor.** Code assigning the result to a bare `HygieneScore`, or `coverageRatio` + to a bare `number`, stops compiling. That is the intended alarm: it is exactly + the code that would otherwise read absence as a pass. `AuditResult.score` is + `HygieneScore | null` for the same reason — a caller handed no evidence needs + somewhere truthful to put that, and the previous non-nullable field left + fabricating a perfect score as the only way to satisfy it. + + **`computeHygieneScore`, `HygieneScore` and `AuditResult.score` are deprecated + and scheduled for removal at the next document-profile boundary.** A letter grade + is a judgement, and this standard is descriptive: it reports what a repository + _is_, not what a team must do about it. Scoring belongs to the consumer that + reads the descriptive fields. + + Migrating needs nothing that is not already public — `Finding.state`, + `.severity`, `.confidence` and `.temporalWeight` are the only inputs the function + ever had: + + ```ts + const failures = findings.filter((f) => f.state === "FAIL"); + const critical = failures.filter((f) => f.severity === "critical"); + ``` + + **Nothing is removed in this release and no schema bytes change.** Under ADR-003 + §5 a normative-optional field earns a deprecation notice and a documented + migration now, with removal at the next declared breaking boundary; the document + profile is unchanged at `generated.specVersion: "0.4"`, so this release is not + that boundary. `generated.hygiene` remains declared in the schema, because a + first-party producer still emits it and removing the declaration while that is + true would describe the artifact incorrectly. Emission ceases first, on the + producer's own schedule, and the field and exports go together afterwards. + +### Patch Changes + +- d0bb585: Remove runtime dependencies that were declared but never imported. + + `@workspacejson/rules` declared `dedent`, `ignore`, `unified` and `zod` in + `dependencies`. None of them is imported anywhere in the package. `zod` and + `unified` appeared only as string literals in the framework-detection tables — + the parser looks for the _word_ "zod" in a manifest, it never loads the library. + + Because these were `dependencies` rather than `devDependencies`, every consumer + installed all four to run code that does not exist. Removing them changes no + behavior: the full suite passes unchanged. + + `@workspacejson/spec` drops the unused `json-schema-to-typescript` + devDependency along with `scripts/generate-types.js`, which read the schema file + and discarded it. `src/types.ts` is hand-written and committed; nothing was ever + generated. The build script is now `tsc`. + + A new `unused-dependency` guard in `scripts/check-architecture.mjs` fails the + build if a declared runtime dependency is never imported, so this cannot + silently return. + +- 05ea429: Widen the validator to accept an optional root `version`, per ADR-004. + + `generated.specVersion` has always been the profile declaration, but at least one + external reader gates on a **root** `version` key instead. No producer has ever + emitted it, so that gate has never executed. + + The root object is `additionalProperties: false`, which means repairing the + mismatch is not the additive change it appears to be: adding a root key is + additive to the schema as a document and breaking for every already-deployed + validator. Acceptance therefore has to ship before emission, and this release is + the acceptance half. + + `validate()` and `validateV4()` now accept an optional root `version` of `"0.3"` + or `"0.4"`. When present it must equal `generated.specVersion` — a document whose + two declarations disagree is invalid, not resolved by precedence. No new profile + name is introduced and `generated.specVersion` is unchanged: still required, + still primary, still emitted. A reader that ignores the root key sees no + difference. + + `validateLegacy()` is corrected as a consequence. It previously identified the + pre-v0.3 shape as "has a root `version` string and fails `validate()`", which + stops being sufficient once v0.3/v0.4 documents may carry that key: a disagreeing + document would have been reported as legacy v0.1/v0.2 rather than rejected. It + now keys on the absence of `generated.specVersion`, so a disagreement is rejected + by both functions. + + **This release does not emit the field.** No producer writes a root `version`, + and ADR-004 §8 requires evidence that known validate-before-read consumers accept + it before any producer begins. Widening what a reader accepts is deliberately not + permission to start writing. + +- 8e5bf70: Correct the published package metadata to name this repository, and record the + README and changelog corrections that ship inside the tarball. + + Both manifests still described the packages as they were published from the + frozen historical workspace. None of it was reachable: `repository.url` and + `bugs.url` pointed at a repository that no longer exists under that name, so + every "open an issue" and "view source" link on both npm pages led nowhere. + + `repository` now names this repository and carries a `directory` pointer, so npm + resolves each package to its own subtree rather than the monorepo root. `bugs` + follows it. `homepage` is the bare canonical host on both packages, per ADR-005 — + `@workspacejson/rules` additionally pointed at a `/audit/` subpath that predates + the neutral naming and no longer describes what the package is. + + `author` reads `workspacejson contributors` on both, matching the org that now + holds them. + + Two keywords were removed. `agents-audit` named the historical package rather + than these; `aaif` implied a standards-body status this project does not hold. + + `@workspacejson/rules` has a new `description`. The old one described a narrower + package — AGENTS.md hygiene auditing — than the one that actually ships, which + carries the parser, repository scanner, validator and rule engine. + + **Why a metadata-only change gets a changeset at all.** `README.md` and + `CHANGELOG.md` are both listed in `files`, so they are published bytes, not + repository furniture. Both package READMEs claimed to be published from the + historical workspace and loaded their brand assets from a frozen repository; + both changelogs recorded 0.4.4 as unreleased while the registry had already + served it. Those corrections reached consumers with no release note explaining + why the page changed. The manifest fields above are in the same position: they + are part of the published artifact even though no runtime behavior moves. + + Nothing in either package's runtime, types, schema or exports changes. `patch` + is correct on its own terms; the fixed group is taking a `minor` this release + for reasons recorded in the accompanying changesets, and this rides along. + +- Updated dependencies [d0bb585] +- Updated dependencies [bd14f39] +- Updated dependencies [7739260] +- Updated dependencies [27475a2] +- Updated dependencies [05ea429] +- Updated dependencies [8e5bf70] +- Updated dependencies [8e08c8c] + - @workspacejson/spec@0.5.0 + ## [0.4.4] - 2026-07-23 ### Patch Changes diff --git a/packages/rules/package.json b/packages/rules/package.json index 4ad9f47..f4d8740 100644 --- a/packages/rules/package.json +++ b/packages/rules/package.json @@ -1,6 +1,6 @@ { "name": "@workspacejson/rules", - "version": "0.4.4", + "version": "0.5.0", "description": "Deterministic reference behavior for workspace.json: AGENTS.md parser, repository scanner, validator and rule engine", "license": "Apache-2.0", "author": "workspacejson contributors", diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index 5c825f6..2a63d09 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,438 @@ # Changelog +## 0.5.0 + +### Minor Changes + +- bd14f39: Admit raw co-change commit counts alongside the existing rate, and pin the basis + revision, per ADR-003 amendment A-009. This is the **reader-widening** step of a + staged transition; no producer changes. + + **At the document level this is a pure widening.** Every document that validated + before this release validates after it — there is no narrowing anywhere in the + artifact contract. `cochange-legacy-rate-v0.4.json` and + `cochange-legacy-head-basis-v0.4.json` ship as executable proof rather than the + claim being left as prose. + + **At the package API level it is a source-level break, and that is why this is a + minor.** `CoChangeEntry` was one interface with `rate: number`; it is now a union + whose members declare the other form's field as `?: never`. Reading `entry.rate` + off the union yields `number | undefined`, so a TypeScript consumer that assigns + it without narrowing **stops compiling**: + + ```ts + const r: number = entry.rate; // was fine, now a type error + const r = + entry.support !== undefined // narrow first + ? entry.support / entry.occurrences + : entry.rate; + ``` + + The runtime shape of every existing artifact is unaffected; only the type of the + code reading it changes. Asserted in `src/type-invariants.ts`, which `tsc` + compiles — test files are excluded from the build, so a type claim written only + in a test would never be checked. + + A `generated.coChange` entry now takes **exactly one of two forms**: + + | Form | Carries | Status | + | ----------- | ------------------------- | -------------------------- | + | Legacy | `rate` + `occurrences` | Deprecated; still accepted | + | Observation | `support` + `occurrences` | What new producers emit | + + ```diff + { files: [a, b], rate: 0.87, occurrences: 9, generated: false } // still valid + + { files: [a, b], support: 8, occurrences: 24, generated: false } // now valid + ``` + + Those two numbers are the worked invariant, not an illustration. Over the + analyzed history `a` changed in **20** qualifying commits and `b` in **12**, and + **8** commits changed both. `support` is that intersection; `occurrences` is the + symmetric union, `20 + 12 − 8 = 24`. The marginals are deliberately unequal, + because that is what makes the denominator observable: a producer using one + endpoint's marginal would emit 20 or 12 here, and an unordered pair gives it no + principled way to choose between them. 24 is neither. + + An entry carrying **both** is invalid — they are different contracts, the counts + need not agree, and a reader cannot know which was measured. An entry carrying + **neither** is invalid. This is a `oneOf` in the schema and a `?: never` union in + the types, so it fails at validation time and at compile time. + + The array is also **homogeneous**: every entry legacy, or every entry + observation. Per-entry exclusivity is not enough — each entry of a mixed array is + individually well-formed, so one artifact would carry two meanings of + `occurrences` with nothing saying so. An empty array satisfies both branches. + + Observation-form `occurrences` has a **minimum of 1**. A pair enters the + observation set because at least one of its files appeared in at least one + qualifying commit, so a pair whose union is empty was never observed and gets no + entry — absence, not a zero denominator. This makes `support / occurrences` total + on conforming artifacts: no reader can derive `0/0`, `NaN` or infinity, and no + consumer needs a guard the standard failed to specify. Legacy `occurrences` keeps + its original minimum of 0. + + - `support` — distinct qualifying commits in which **both** files changed. + - `occurrences` — **in the observation form**, distinct qualifying commits in + which **at least one** of the two changed. + + **`occurrences` means different things in the two forms.** In the observation + form it is the symmetric union denominator. In the legacy form it carries the + pre-amendment meaning, which was never normatively specified and must not be + assumed symmetric. Establish the form before reading it — `entry.support !== undefined` — + and never compare the value across forms. + + A rate is a reader's question, not a producer's observation. Storing it churned + the artifact on every commit and forced one analytical reading on every consumer. + Readers derive `support / occurrences`, or probability, lift, confidence or a + ranking, wherever `occurrences > 0`. Nothing derived is stored. + + **The denominator is the union, and that is load-bearing.** `files` is an + unordered pair with set semantics, so it has no subject file. A denominator + meaning "commits in which the subject changed" is not well-defined: given A + changing in 20 qualifying commits, B in 12, and both in 8, two conforming + producers could emit `occurrences: 20` or `occurrences: 12` for the same + observation and neither would be wrong. Counting the union makes both fields + symmetric — reversing the stored pair changes nothing — so independent producers + have a comparable surface. Both count **distinct commits**, never file events or + ordered relationships. + + `generated.basisRevision` names the revision the counts were taken over: a + full-length lowercase Git object name, 40 hex characters for SHA-1 or 64 for + SHA-256. It sits once at `generated` level, never per item. + + **Both the requirement and the pattern are scoped to the observation form.** The + key is declared globally with no constraints, because `generated` is + `additionalProperties: true` and a legacy artifact may already carry it with any + value. A global pattern would have made `basisRevision: "HEAD"` newly invalid — + a narrowing by the back door. `cochange-legacy-head-basis-v0.4.json` is the + regression guard; the same value is still rejected once an observation-form entry + appears. + + Four reader-visible states, defined normatively in A-009: + + | Shape | Means | + | ---------------------- | ---------------------------------------------- | + | `coChange` absent | Not analyzed | + | `coChange: []`, no pin | Legacy / unknown — **not** evidence of zero | + | `coChange: []`, pinned | Analyzed at that revision; no qualifying pairs | + | pin ≠ current revision | Stale observation | + + A producer emitting the observation form declares the pin whenever `coChange` + exists, **including when empty**. Schema validation cannot enforce that case — an + empty array carries no discriminator — so it is a producer obligation, and the + reader-side rule above is what keeps the states distinguishable regardless. + + A _qualifying commit_ is one inside the analysis boundary the producer already + declares for every other observation in the section. This release defines **no** + new history-window, merge, rename or path-normalization policy. + + `support <= occurrences` is enforced by `validate()` and **not** by the schema: + JSON Schema draft 2020-12 cannot compare two instance values, so the invariant is + a producer obligation carried by the profile. A bare JSON Schema validator + accepts a document that violates it. Both directions are pinned by test and + `docs/conformance.md` states the disagreement explicitly. It applies to + observation-form entries only; a legacy entry has no `support`. + + `packages/spec/examples/invalid/` is new: eleven negative fixtures, each naming + the single defect it exhibits, checked in CI by `pnpm run check:examples`. The + gate previously ran positives only, which cannot show that anything is rejected. + Rejection by `validate()` is the substantive assertion; the gate also requires + rejection by `validateLegacy()`, which is structural coverage — it keys on the + absence of `generated.specVersion` and never inspects the defect, so it confirms + only that a rejected v0.4 document cannot re-enter through the legacy path. + + `version` is now derived from the packaged manifest instead of being a hardcoded + literal with a test asserting the literal. That pair only held while someone + remembered to hand-edit both during a release, and Changesets never rewrites a + constant in source — the first release to move the number would have shipped a + package reporting the old version. The test asserts parity with `package.json`. + + **Release sequence.** This is step 1 of three, on the ADR-004 §8 pattern: + **widen the reader → verify known consumer adoption → enable producer emission.** + Widening what a reader accepts is not permission to emit, and the steps must not + be collapsed. Removing `rate` is a separate fourth step at the next + document-profile change, and this release does not authorize it. + + **On the version number this produces.** A `minor` bump takes both packages from + `0.4.4` to **`0.5.0`** — `@workspacejson/rules` comes along unchanged because the + two are a fixed release group. That number is the **package** version and says + nothing about the document profile. **The document profile is unchanged: this + release still reads and writes `generated.specVersion: "0.4"`.** Package `0.5.0` + is not spec v0.5, no artifact's `specVersion` moves, no new profile identifier is + minted, and the deferred v0.5 profile work is untouched. The two numbers are + independent by policy — see `docs/versioning.md` — and this is exactly the + release where confusing them would be easiest. + + `minor` is the correct bump on its own terms: the artifact contract only widens, + but the exported TypeScript union is a source-level break for readers that access + `entry.rate` without narrowing. The package version moves for that reason, and + the document profile does not move at all. + +- 27475a2: Add the standard-owned canonical path-identity surface, per ADR-006: stored keys + are data, not commands. + + Seven new exports, in two halves. + + **The grammar — `validateStoredKey(rawKey)`.** Decides whether one stored key is + canonical. + + ```ts + validateStoredKey("src/a.ts"); // { valid: true, key: 'src/a.ts' } + validateStoredKey("src/../a.ts"); // { valid: false, reason: 'dotdot-segment' } + ``` + + Pure, total, filesystem-free, deterministic, and applied to the original string + **before any path library sees it**. That ordering is the defect the record was + written about: the shipped consumers at the pinned revisions normalized first and + validated second, so `src/../a.ts` was already `a.ts` by the time anything asked + whether it was well-formed. + + **There is deliberately no repaired-key field.** A valid result carries the input + unchanged; a rejection carries a reason and nothing a caller could mistake for a + usable key. A malformed key matches nothing — including the value normalization + would have produced. Reason precedence is fixed and documented so that two + implementations classify the same key identically: cannot-be-a-string, then + cannot-be-POSIX, then merely non-canonical. + + Case and Unicode form are significant. `A.ts` and `a.ts` are two keys, and so are + the NFC and NFD spellings of `café.ts`. A genuine U+FFFD is a valid pathname + character; telling it from a substituted one needs the original bytes and belongs + to acquisition. + + **The document walk — `inspectStoredKeys(document)`.** Reports every malformed + key on every ratified path-bearing surface: `generated.fileIndex` keys, + `generated.coChange[].files`, `generated.fragility[].file`, and + `manual.fragileFiles[].path`. + + ```ts + if (!validate(raw)) { + // Existing invalid-document handling. + } else { + for (const finding of inspectStoredKeys(raw)) { + console.warn(`${finding.pointer}: ${finding.rawKey} — ${finding.reason}`); + } + } + ``` + + This is ADR-006 §9 obligation 1, _report it_. Obligation 2, _decline to match + it_, stays with the caller, because only the caller knows what a lookup is. + + **The input is a schema-validated document, not `unknown`.** That narrowing is + what makes an empty result mean something: `[]` says every inspected value in an + accepted document is well-formed, and an unvalidated value is outside the + declared input domain rather than silently "clean". `inspectStoredKeys` does not + call `validate()` internally and is not a second document validator — folding the + two together would destroy exactly that distinction. + + Findings are location-bearing records: one per occurrence, never deduplicated, + never normalized, never repaired. `rawKey` is the string the producer actually + wrote. Pointers are RFC 6901 with `~` escaped before `/`, so a pointer decodes + back to the exact stored key. Order is traversal order and is explicitly not part + of the contract. + + `manual.coChangePatterns` is **not** inspected. ADR-003 amendment A-005 has not + ratified its item shape — the schema constrains items to `{"type": "object"}` and + nothing more, while `types.ts` assumes `files: string[]`. Walking that field + would promote an authoring-time TypeScript assumption into a normative contract + ahead of the record that decides it. The surface is added once A-005 settles it. + + `canonicalizeHostQuery` is deliberately absent and must not be added to this + package: it needs a filesystem and a proven repository root, and ADR-006 §10 + assigns it to integrations and hosts. + + **Nothing narrows.** `validate()`, `validateV4()` and `validateLegacy()` are + unchanged, and `validate()` does not consult the stored-key grammar. Artifacts + carrying malformed keys on any path-bearing surface are still accepted, because + ADR-006 §9 requires a v0.4.x reader to report a malformed key and decline to + match it while continuing over the well-formed remainder. A dedicated suite fails + if a future change wires the two together — that is the intended alarm. Rejecting + such a document is a v0.5 document-profile change and is not authorized here. + No schema bytes changed. + + **Why `minor`.** These are additive public exports: nothing is removed, nothing + is renamed, no accepted type or value range narrows, and no existing signature + changes. Under `docs/versioning.md` removing a public export is breaking and + adding one is not, so this is a minor on its own terms. + + **On the version number this produces.** A `minor` takes both packages from + `0.4.4` to **`0.5.0`**, with `@workspacejson/rules` coming along unchanged because + the two are a fixed release group. This changeset does not move that number on its + own — the pending ADR-003 A-009 changeset already declares a `minor`, so `0.5.0` + is the next release with or without this one. + + That number is the **package** version and says nothing about the document + profile. **The document profile is unchanged: this release still reads and writes + `generated.specVersion: "0.4"`.** Package `0.5.0` is not spec v0.5, no artifact's + `specVersion` moves, no new profile identifier is minted, and the deferred v0.5 + profile work — narrowing validation and the hard-failure boundary — is untouched. + The two numbers are independent by policy; see `docs/versioning.md`. + +- 8e08c8c: Make the `generated.coChange[].generated` tooling-coupling flag optional in the + observation form, and define its absence, per ADR-003 amendment A-010. This is a + **reader widening** on a non-stable-floor path; no producer changes and no + emission is enabled by it. + + **The flag was a required boolean with no reproducible classifier.** `support` + and `occurrences` are observations — two producers counting the same commits get + the same numbers. `generated` is a _classification_: answering it requires a + judgement about what a file **is**, and this standard specifies no portable + deterministic classifier from public repository inputs. Requiring it did not + produce that judgement, it produced a value. The commit-graph producer, having no + classifier, emitted a constant `false` — which on its pinned fixture asserted + that `package-lock.json ↔ package.json` is a real source coupling that consumers + should **not** skip. + + **Absence is a third state, and readers must not collapse it into `false`.** + + | Value | Means | + | ------- | ------------------------------------------------------------------------- | + | `true` | Classified as tooling-coupled — skip when surfacing real source couplings | + | `false` | Classified as **not** tooling-coupled | + | absent | **No classification performed.** The producer asserts nothing | + + So `if (!entry.generated)` is now a bug: it reads an unclassified pair as a + confirmed source coupling. Branch on `undefined` explicitly. A producer omits the + flag unless it implements a public, deterministic, perturbation-tested + classifier, and because two producers may classify the same pair differently and + both conform, the flag is **not** a producer-comparison surface. + + **The widening is asymmetric.** The requirement moved into the legacy `oneOf` + branch rather than disappearing: the legacy form is deprecated and frozen, every + artifact published in it already carries the flag, and widening it too would + loosen a shape no producer should still emit. + + **At the document level this is a pure widening.** Every document valid before + this release is valid after it. Nothing optional becomes required, no value range + narrows, the four stable read paths are untouched, and `generated.specVersion` + stays at `0.4`. Two fixtures ship as executable proof rather than prose: + `cochange-unclassified-v0.4.json` (observation form, nothing classified, carrying + an unflagged lockfile pair on purpose) and + `cochange-legacy-missing-generated.json` (the legacy form still requires it). + + **At the package API level it is a source-level break for TypeScript readers, + which is why this is a minor rather than a patch.** `generated` moves off + `CoChangeEntryCommon`: it remains `boolean` on `LegacyCoChangeEntry` and becomes + `boolean | undefined` on `ObservationCoChangeEntry`. Code assigning + `entry.generated` to a bare `boolean` without narrowing stops compiling — the + intended outcome, since that is exactly the code at risk of reading absence as + `false`. Asserted in `src/type-invariants.ts` rather than described. The runtime + shape of every existing artifact is unaffected. + +### Patch Changes + +- d0bb585: Remove runtime dependencies that were declared but never imported. + + `@workspacejson/rules` declared `dedent`, `ignore`, `unified` and `zod` in + `dependencies`. None of them is imported anywhere in the package. `zod` and + `unified` appeared only as string literals in the framework-detection tables — + the parser looks for the _word_ "zod" in a manifest, it never loads the library. + + Because these were `dependencies` rather than `devDependencies`, every consumer + installed all four to run code that does not exist. Removing them changes no + behavior: the full suite passes unchanged. + + `@workspacejson/spec` drops the unused `json-schema-to-typescript` + devDependency along with `scripts/generate-types.js`, which read the schema file + and discarded it. `src/types.ts` is hand-written and committed; nothing was ever + generated. The build script is now `tsc`. + + A new `unused-dependency` guard in `scripts/check-architecture.mjs` fails the + build if a declared runtime dependency is never imported, so this cannot + silently return. + +- 7739260: Reconcile the schema `$id` host to the bare canonical domain, per ADR-005. + + The schema's `$id` was `https://www.workspacejson.dev/schema/v1.json` while every + package manifest and documentation reference uses `https://workspacejson.dev` + without the `www.` prefix. Both hosts serve the schema, so nothing was broken, + but the two strings disagreed — and `versioning.md` instructs consumers to + hash-check the materialized schema, which means the `$id` string is part of the + contract surface. + + The `$id` is now `https://workspacejson.dev/schema/v1.json`, matching the bare + canonical domain. The filename `v1.json` is unchanged. The `www.` host continues + to serve the schema; the change is about which string is canonical, not which + URL works. + + This change is folded into the same release as the ADR-004 root `version` + widening so consumers experience one schema-byte transition covering both + changes, rather than two consecutive pin invalidations. + + ADR-005 also settles two questions that were open alongside the host, so that + this is the only identity change: the file is **not** renamed — `v1.json` stays, + and the `v1` remains a legacy naming artifact rather than a version claim — and + no sibling schema document will be introduced, with future profiles continuing to + ride the `generated.specVersion` enum. Neither decision changes any bytes now; + recording them is what keeps a later rename from costing a second pin. + +- 05ea429: Widen the validator to accept an optional root `version`, per ADR-004. + + `generated.specVersion` has always been the profile declaration, but at least one + external reader gates on a **root** `version` key instead. No producer has ever + emitted it, so that gate has never executed. + + The root object is `additionalProperties: false`, which means repairing the + mismatch is not the additive change it appears to be: adding a root key is + additive to the schema as a document and breaking for every already-deployed + validator. Acceptance therefore has to ship before emission, and this release is + the acceptance half. + + `validate()` and `validateV4()` now accept an optional root `version` of `"0.3"` + or `"0.4"`. When present it must equal `generated.specVersion` — a document whose + two declarations disagree is invalid, not resolved by precedence. No new profile + name is introduced and `generated.specVersion` is unchanged: still required, + still primary, still emitted. A reader that ignores the root key sees no + difference. + + `validateLegacy()` is corrected as a consequence. It previously identified the + pre-v0.3 shape as "has a root `version` string and fails `validate()`", which + stops being sufficient once v0.3/v0.4 documents may carry that key: a disagreeing + document would have been reported as legacy v0.1/v0.2 rather than rejected. It + now keys on the absence of `generated.specVersion`, so a disagreement is rejected + by both functions. + + **This release does not emit the field.** No producer writes a root `version`, + and ADR-004 §8 requires evidence that known validate-before-read consumers accept + it before any producer begins. Widening what a reader accepts is deliberately not + permission to start writing. + +- 8e5bf70: Correct the published package metadata to name this repository, and record the + README and changelog corrections that ship inside the tarball. + + Both manifests still described the packages as they were published from the + frozen historical workspace. None of it was reachable: `repository.url` and + `bugs.url` pointed at a repository that no longer exists under that name, so + every "open an issue" and "view source" link on both npm pages led nowhere. + + `repository` now names this repository and carries a `directory` pointer, so npm + resolves each package to its own subtree rather than the monorepo root. `bugs` + follows it. `homepage` is the bare canonical host on both packages, per ADR-005 — + `@workspacejson/rules` additionally pointed at a `/audit/` subpath that predates + the neutral naming and no longer describes what the package is. + + `author` reads `workspacejson contributors` on both, matching the org that now + holds them. + + Two keywords were removed. `agents-audit` named the historical package rather + than these; `aaif` implied a standards-body status this project does not hold. + + `@workspacejson/rules` has a new `description`. The old one described a narrower + package — AGENTS.md hygiene auditing — than the one that actually ships, which + carries the parser, repository scanner, validator and rule engine. + + **Why a metadata-only change gets a changeset at all.** `README.md` and + `CHANGELOG.md` are both listed in `files`, so they are published bytes, not + repository furniture. Both package READMEs claimed to be published from the + historical workspace and loaded their brand assets from a frozen repository; + both changelogs recorded 0.4.4 as unreleased while the registry had already + served it. Those corrections reached consumers with no release note explaining + why the page changed. The manifest fields above are in the same position: they + are part of the published artifact even though no runtime behavior moves. + + Nothing in either package's runtime, types, schema or exports changes. `patch` + is correct on its own terms; the fixed group is taking a `minor` this release + for reasons recorded in the accompanying changesets, and this rides along. + ## [0.4.4] - 2026-07-23 ### Fixed diff --git a/packages/spec/package.json b/packages/spec/package.json index e5e2dab..cb62d01 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@workspacejson/spec", - "version": "0.4.4", + "version": "0.5.0", "description": "JSON Schema and TypeScript types for workspace.json", "license": "Apache-2.0", "author": "workspacejson contributors", diff --git a/packages/spec/src/index.test.ts b/packages/spec/src/index.test.ts index 6bbee37..82647ca 100644 --- a/packages/spec/src/index.test.ts +++ b/packages/spec/src/index.test.ts @@ -287,7 +287,19 @@ describe('schema identity invariants', () => { it('CHANGELOG top version header matches package.json version', () => { const changelog = readFileSync(CHANGELOG_PATH, 'utf8'); const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8')) as Record; - const match = changelog.match(/^## \[(\d+\.\d+\.\d+)\]/m); + // Changesets writes a bare `## ` heading, and step 6 of + // `scripts/verify-release-identity.mjs` requires exactly that form before a + // release may be tagged. This assertion previously required the + // hand-written Keep-a-Changelog form `## []`, which meant the two + // gates could not both be satisfied by the same file — whichever form the + // changelog carried, one of them failed. Matching the release gate is what + // makes them agree. + // + // Only the top heading is inspected. The bracketed entries below it are + // historical, pre-Changesets, and are deliberately not matched: a bracketed + // heading at the top would now yield no match at all, which is the correct + // failure — it says the released version was not versioned by Changesets. + const match = changelog.match(/^## (\d+\.\d+\.\d+)\s*$/m); expect(match).not.toBeNull(); expect(match![1]).toBe(pkg['version']); });