diff --git a/bun.lock b/bun.lock index aa02a1e3..272cebeb 100644 --- a/bun.lock +++ b/bun.lock @@ -10,9 +10,32 @@ "zod": "^4", }, }, + "packages/adapters/catalog-backstage": { + "name": "@adrkit/catalog-backstage", + "version": "0.0.0", + "dependencies": { + "@adrkit/core": "workspace:*", + "picomatch": "^4", + "yaml": "latest", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/picomatch": "^4", + }, + }, "packages/adapters/spec-kit": { "name": "@adrkit/spec-kit", - "version": "0.1.0", + "version": "0.1.2", + }, + "packages/catalog-envelope": { + "name": "@adrkit/catalog-envelope", + "version": "0.0.0", + "dependencies": { + "@adrkit/core": "workspace:*", + }, + "devDependencies": { + "@types/bun": "latest", + }, }, "packages/ci": { "name": "@adrkit/ci", @@ -94,6 +117,10 @@ "@actions/io": ["@actions/io@3.0.2", "", {}, "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw=="], + "@adrkit/catalog-backstage": ["@adrkit/catalog-backstage@workspace:packages/adapters/catalog-backstage"], + + "@adrkit/catalog-envelope": ["@adrkit/catalog-envelope@workspace:packages/catalog-envelope"], + "@adrkit/ci": ["@adrkit/ci@workspace:packages/ci"], "@adrkit/cli": ["@adrkit/cli@workspace:packages/cli"], diff --git a/packages/adapters/catalog-backstage/LICENSE b/packages/adapters/catalog-backstage/LICENSE new file mode 100644 index 00000000..b6621e66 --- /dev/null +++ b/packages/adapters/catalog-backstage/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/adapters/catalog-backstage/NOTICE b/packages/adapters/catalog-backstage/NOTICE new file mode 100644 index 00000000..7bf8911a --- /dev/null +++ b/packages/adapters/catalog-backstage/NOTICE @@ -0,0 +1,11 @@ +adrkit +Copyright 2026 Mark Beacom and adrkit contributors + +This product includes software developed as part of the adrkit project. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 + +EXCEPTION: The contents of the schema/ directory are additionally made available +under CC0 1.0 Universal. See schema/LICENSE. diff --git a/packages/adapters/catalog-backstage/README.md b/packages/adapters/catalog-backstage/README.md new file mode 100644 index 00000000..92749d00 --- /dev/null +++ b/packages/adapters/catalog-backstage/README.md @@ -0,0 +1,135 @@ +# @adrkit/catalog-backstage + +A standalone, offline generator that reads Backstage catalog descriptor files — +named explicitly by one local input manifest — and writes one versioned snapshot +envelope. + +It is invoked directly, by name. There is no dynamic runtime adapter or plugin +loader of any kind, and no composition host that discovers, resolves, or +dynamically imports a catalog adapter at runtime — not even one restricted to a +single statically-known package name +([ADR-0013](../../../docs/adr/0013-reconcile-adapter-isolation-and-catalog-binding-with-the-offline-snapshot-genera.md)). + +--- + +## Status: this package generates nothing yet + +Feature `010-catalog-backstage` is partially built. What exists in this package +today is its **placement** and its **dependency boundary** — the package +manifest, the entry point, and the tests that hold the boundary in place. That +is all. + +Not implemented here, and therefore not to be inferred from this package's +existence: the input-manifest reader, descriptor admissibility, canonical +identity, ownership derivation, the glob dialect, the atomic fail-closed +pipeline, and the snapshot envelope itself. Those are requirements on later +phases of the feature, recorded in +[`specs/010-catalog-backstage/`](../../../specs/010-catalog-backstage/). They are +not behaviour this package has. + +**No generator has run. No envelope exists.** + +### Where this sits on the evidence ladder + +Per [ADR-0014](../../../docs/adr/0014-stage-phase-landing-evidence-across-a-three-rung-validation-ladder.md), +and stated in that record's own vocabulary rather than a synonym for it: + +| Rung | State | This package | +|---|---|---| +| 1 | unit / contract / conformance evidence | partial — covers only what exists, which is placement and boundary | +| 2 | **reference-verified** | **not reached, and not claimed** | +| 3 | **externally validated** | **not reached, and not claimed** | + +[ADR-0020](../../../docs/adr/0020-rescope-sc-010-and-authorize-work-toward-the-backstage-catalog-adapter.md) +authorizes this work toward **rung 1 only**. It authorizes the work; it does not +authorize a release, and the decision to release at all is deferred to a later +record (clause 9). Nothing in this package is `reference-verified`, `externally +validated`, `adopted`, or in `sustained adoption`. + +Any verification performed here is performed by the maintainer. Maintainer-owned +verification is not external, third-party, or community adoption, and this +package will not describe it as any of those. Where a corpus of upstream-authored +descriptors is used as *input*, only that corpus **data** is third-party — never +the validation applied to it. + +--- + +## What a consumer may and may not conclude from this adapter's output + +This section is a requirement on this document +(`spec.md` FR-062, FR-063), not a disclaimer appended to it. + +**A consumer may conclude**, once the adapter produces output at all, exactly +this much: that a set of pure validator predicates returned a particular result +when invoked against particular descriptor content. That is the whole warrant. + +**A consumer may not conclude:** + +- **Anything about Backstage as a running system.** This package makes no claim + about how a Backstage instance resolves, interprets, or acts on a descriptor. + It evaluates descriptor *content* against a pinned model of the descriptor + format. A predicate's return value and a running system's behaviour are + different facts, and only the first is available here. +- **That a valid envelope is a correct envelope.** A populated, digest-verified + envelope proves **integrity**, not **correctness**: a semantically wrong + envelope can carry a perfectly valid self-digest (ADR-0020 clause 5). The + digest tells a reader the envelope is intact and unmodified. It says nothing + about whether the ownership it records is right. +- **That ownership was inferred.** Ownership comes from an explicit + `adrkit.io/owned-paths` annotation and from nowhere else. No descriptor-parent, + repository-root, or identity-only normalization heuristic is present in this + package as inferred, authoritative, default, or opt-in behaviour. Those were + measurement instruments in an earlier spike, labelled non-authoritative by + their own contract, and they do not carry into this adapter (`spec.md` FR-061). +- **That the `adrkit.io/owned-paths` annotation is an established convention.** + **Adoption of `adrkit.io/owned-paths` by anyone other than the maintainer is + neither established nor gated by this feature.** No descriptor in any upstream + corpus consulted by this work carries the annotation. Where an annotation + appears over real upstream descriptors, it is a maintainer-authored overlay + applied to descriptors that are otherwise unmodified — and the fact that the + overlay is maintainer-authored travels with any claim made from it. +- **That absence of ownership means anything about the entity.** An entity with + no annotation, an entity with an empty annotation, and an entity whose + annotation matched no path are three distinct states, and none of them is + evidence that the entity owns nothing. + +--- + +## Boundary + +- **Nothing outside `packages/adapters/**` may depend on this package.** + `packages/core`, `packages/cli`, and `schema/` import nothing from it and must + not otherwise learn it exists + ([ADR-0007](../../../docs/adr/0007-adapter-isolation-and-public-surface-build.md); + Constitution Principle III). Enforced by `bun run check:deps`, whose guard was + observed rejecting exactly that edge before it was relied on + ([ADR-0016](../../../docs/adr/0016-require-every-check-to-be-observed-failing-before-it-counts-as-coverage.md)). +- **This package does not depend on `@adrkit/catalog-envelope`, and that package + does not depend on this one.** The envelope file on disk is the entire + interface between them. Each declares the envelope's shape independently, which + is what makes the consumer's validation a check rather than a comparison of the + generator against itself. +- **The `@adrkit/core` dependency is for canonicalization primitives only** — + `canonicalStringify` and `compareCodeUnits`. It does **not** couple this + package's version to core's API. This adapter is versioned independently per + ADR-0007: its semver contract is with Backstage, not with `@adrkit/core`. +- **This package writes nothing to `schema/`** and adds nothing to + `schema/adr.schema.json`. The snapshot envelope is a separate artifact and + stays one. + +--- + +## Toolchain + +Bun for development; any published artifact targets Node +([ADR-0010](../../../docs/adr/0010-bun-toolchain.md)). + +```bash +bun test # from the repository root +bun run --filter='@adrkit/catalog-backstage' typecheck +bun run check:deps # the dependency boundary above +``` + +## License + +Apache-2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE). diff --git a/packages/adapters/catalog-backstage/package.json b/packages/adapters/catalog-backstage/package.json new file mode 100644 index 00000000..a9b3301a --- /dev/null +++ b/packages/adapters/catalog-backstage/package.json @@ -0,0 +1,45 @@ +{ + "name": "@adrkit/catalog-backstage", + "version": "0.0.0", + "description": "Standalone offline generator that reads Backstage catalog descriptors named by one explicit local manifest and writes one versioned snapshot envelope.", + "type": "module", + "license": "Apache-2.0", + "homepage": "https://adrkit.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/mbeacom/adrkit.git", + "directory": "packages/adapters/catalog-backstage" + }, + "bugs": { + "url": "https://github.com/mbeacom/adrkit/issues" + }, + "keywords": [ + "adr", + "architecture-decision-records", + "backstage", + "catalog", + "governance" + ], + "//versioning": "Independently versioned, per ADR-0007: an adapter's semver contract is with its upstream (Backstage), not with @adrkit/core. It does not move with the repository's release tag \u2014 see ReleaseVersioning in scripts/release-pack.ts. The @adrkit/core dependency declared below is for canonicalization primitives only (canonicalStringify, compareCodeUnits) and does NOT couple this package's version to core's API. package-boundary.md \u00a76 requires that be recorded here, because a reader seeing a core dependency would otherwise reasonably infer coupled versioning and be wrong.", + "//release": "Not released, and no release is scheduled or prepared. ADR-0020 clause 9 defers both the release vehicle and the decision to release at all to a later record. This package is deliberately absent from RELEASE_PACKAGES in scripts/release-pack.ts, ships no dist, declares no exports map, and carries version 0.0.0 to state that plainly. publishConfig below fixes the access level a future release would use; it does not authorize one.", + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "bun run typecheck", + "lint": "bun run typecheck", + "typecheck": "tsc --noEmit --customConditions bun --project ../../../tsconfig.json" + }, + "dependencies": { + "@adrkit/core": "workspace:*", + "picomatch": "^4", + "yaml": "latest" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/picomatch": "^4" + } +} diff --git a/packages/adapters/catalog-backstage/src/index.ts b/packages/adapters/catalog-backstage/src/index.ts new file mode 100644 index 00000000..135cbdda --- /dev/null +++ b/packages/adapters/catalog-backstage/src/index.ts @@ -0,0 +1,35 @@ +/** + * `@adrkit/catalog-backstage` — the adapter's only public entry point. + * + * **How this package is reached.** By an explicit, static `import` of this module, + * written by name in a caller's source. Per ADR-0013 and `spec.md` FR-002 the + * adapter is a standalone offline snapshot generator: there is no dynamic runtime + * adapter or plugin loader of any kind, and no composition host that discovers, + * resolves, or dynamically imports a catalog adapter at runtime — not even one + * restricted to a single statically-known package name. Nothing here registers + * itself with anything, and importing this module has no side effect. + * + * **What exists today.** Phase A of feature `010-catalog-backstage` creates this + * package's placement, its dependency boundary, and this entry point. It creates + * nothing else. The input-manifest reader, the descriptor admissibility and + * ownership validators, and the envelope generator are *not* implemented here; + * they are requirements on later phases, not behaviour this package has. + * + * **What this package has been shown to do: nothing.** No generator has run, no + * envelope exists, and no claim about Backstage as a running system is made or + * implied anywhere in this package. See `README.md`. + * + * @see {@link ../README.md} + * @see `docs/adr/0007-adapter-isolation-and-public-surface-build.md` + * @see `docs/adr/0013-reconcile-adapter-isolation-and-catalog-binding-with-the-offline-snapshot-genera.md` + * @see `docs/adr/0020-rescope-sc-010-and-authorize-work-toward-the-backstage-catalog-adapter.md` + */ + +/** + * This package's own name. + * + * Exported so that a test can assert a *specific observed value* obtained by + * statically importing this module, rather than asserting the absence of a + * loader and calling that coverage (ADR-0016 clause 3). + */ +export const PACKAGE_NAME = '@adrkit/catalog-backstage'; diff --git a/packages/adapters/catalog-backstage/test/envelope-shape-locality.test.ts b/packages/adapters/catalog-backstage/test/envelope-shape-locality.test.ts new file mode 100644 index 00000000..6c69484b --- /dev/null +++ b/packages/adapters/catalog-backstage/test/envelope-shape-locality.test.ts @@ -0,0 +1,297 @@ +/** + * T007 / FR-005 (locality half) — neither new package writes to or regenerates + * `schema/`, and the envelope shape each package needs is declared locally + * rather than in a shared schema module. + * + * This guard covers **both** packages of feature 010, from here, because the + * property it protects is a property of the pair rather than of either one. + * + * `package-boundary.md` §5 is the reason it matters. Both packages declare the + * envelope's shape independently, and that is the single deliberate duplication + * in the design: a shared type module would be an import edge, and if both sides + * derived their view of the envelope from one declaration, a generator that + * changed the shape would change it on both sides at once and the consumer's + * structural validation would be a tautology rather than a check. + * + * How the "declared locally" half is enforced, stated plainly because it is not + * obvious: **not** by counting shape declarations. At the time this guard was + * written neither package declares one, so a count would pass vacuously — and a + * check that has only ever passed vacuously is exactly what ADR-0016 says is not + * coverage. It is enforced instead by three rules that hold now and keep holding + * as the shapes land: no import edge between the two packages, no relative + * import escaping either package root, and no import of any module the package + * has not declared as a dependency. A shape can only arrive from a shared module + * by tripping one of those three. + */ + +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + ADAPTER_PACKAGE_NAME, + ADAPTER_ROOT, + CONSUMER_PACKAGE_NAME, + CONSUMER_ROOT, + escapingRelativeImports, + importSpecifiers, + packageScripts, + type Rule, + type ScannedFile, + scanned, + violations, + violationsInSource, +} from './source-scan.ts'; + +/** + * References to the published schema surface. A package that never names it + * cannot write it or regenerate it, which is a stronger and far more robust + * assertion than trying to enumerate the ways a file could be written. + */ +const SCHEMA_RULES: readonly Rule[] = [ + { + id: 'published-schema-file', + pattern: /schema\/adr\.schema\.json/, + why: 'the published schema is not this feature\u2019s to write; the envelope stays a separate artifact (FR-005)', + }, + { + id: 'schema-module', + pattern: /\badr\.schema\b/, + why: 'the ADR schema module belongs to @adrkit/core and is not part of the envelope surface', + }, + { + id: 'core-schema-subpath', + pattern: /@adrkit\/core\/schema/, + why: 'importing core\u2019s schema subpath would put the envelope on the published schema surface', + }, + { + id: 'schema-emit', + pattern: /\bschema:emit\b/, + why: 'regenerating the published schema from this feature is forbidden (FR-005)', + }, +]; + +const ADAPTER_MUST_NOT_NAME_CONSUMER: readonly Rule[] = [ + { + id: 'adapter-to-consumer', + pattern: new RegExp(CONSUMER_PACKAGE_NAME.replace('/', '\\/')), + why: 'FR-044: the adapter must not depend on the consumer; the envelope file on disk is the entire interface', + }, +]; + +const CONSUMER_MUST_NOT_NAME_ADAPTER: readonly Rule[] = [ + { + id: 'consumer-to-adapter', + pattern: new RegExp(ADAPTER_PACKAGE_NAME.replace('/', '\\/')), + why: 'FR-044: the consumer must not depend on the adapter; the envelope file on disk is the entire interface', + }, +]; + +/** Module specifiers that need no dependency declaration. */ +const BUILTIN = (specifier: string): boolean => + specifier.startsWith('node:') || specifier.startsWith('bun:') || specifier === 'bun'; + +/** `@scope/name/deep/path` → `@scope/name`; `name/deep` → `name`. */ +function packageNameOf(specifier: string): string { + const parts = specifier.split('/'); + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : (parts[0] ?? specifier); +} + +function declaredDependencies(packageRoot: string): Set { + const manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as Record< + string, + unknown + >; + const names = new Set(); + for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + const block = manifest[section]; + if (block && typeof block === 'object') for (const name of Object.keys(block)) names.add(name); + } + return names; +} + +function undeclaredImports(files: readonly ScannedFile[], packageRoot: string): string[] { + const declared = declaredDependencies(packageRoot); + const undeclared = new Set(); + for (const file of files) { + for (const specifier of importSpecifiers(file.code)) { + if (specifier.startsWith('.') || BUILTIN(specifier)) continue; + const name = packageNameOf(specifier); + if (!declared.has(name)) undeclared.add(`${file.path} -> ${name}`); + } + } + return [...undeclared].sort(); +} + +const adapterFiles = scanned(ADAPTER_ROOT); +const consumerFiles = scanned(CONSUMER_ROOT); + +describe('FR-005 — what this guard examined', () => { + // The anti-blindness half. Every assertion below concludes something from an + // absence, and an absence found in an empty file list is not a finding. + test('read the adapter source tree, including its entry point', () => { + expect(adapterFiles.map((file) => file.path)).toContain( + 'packages/adapters/catalog-backstage/src/index.ts', + ); + }); + + test('read the consumer source tree, including its entry point', () => { + expect(consumerFiles.map((file) => file.path)).toContain('packages/catalog-envelope/src/index.ts'); + }); +}); + +describe('FR-005 — neither package writes to or regenerates schema/', () => { + test('the adapter names no part of the published schema surface', () => { + expect(violations(adapterFiles, SCHEMA_RULES)).toEqual([]); + }); + + test('the consumer names no part of the published schema surface', () => { + expect(violations(consumerFiles, SCHEMA_RULES)).toEqual([]); + }); + + test('neither package declares a script that emits or writes the schema', () => { + const offending: string[] = []; + for (const [packageName, root] of [ + [ADAPTER_PACKAGE_NAME, ADAPTER_ROOT], + [CONSUMER_PACKAGE_NAME, CONSUMER_ROOT], + ] as const) { + for (const [name, command] of Object.entries(packageScripts(root))) { + if (/schema/.test(command)) offending.push(`${packageName}: ${name} -> ${command}`); + } + } + expect(offending).toEqual([]); + }); + + test('both packages declare the scripts this repository expects, so the check above read something', () => { + // Guards the guard: a package with no scripts at all would pass the script + // rule for the wrong reason. + expect(Object.keys(packageScripts(ADAPTER_ROOT)).sort()).toEqual(['build', 'lint', 'typecheck']); + expect(Object.keys(packageScripts(CONSUMER_ROOT)).sort()).toEqual(['build', 'lint', 'typecheck']); + }); +}); + +describe('FR-044 / package-boundary.md §5 — the shape is local, and there is no shared module', () => { + test('no adapter source names the consumer package', () => { + expect(violations(adapterFiles, ADAPTER_MUST_NOT_NAME_CONSUMER)).toEqual([]); + }); + + test('no consumer source names the adapter package', () => { + expect(violations(consumerFiles, CONSUMER_MUST_NOT_NAME_ADAPTER)).toEqual([]); + }); + + test('no relative import escapes either package root', () => { + // Closes the route that names neither package: `../../catalog-envelope/src/...` + // reaches across the boundary without ever writing the package name. + const escaping = [ + ...adapterFiles.flatMap((file) => escapingRelativeImports(file, ADAPTER_ROOT)), + ...consumerFiles.flatMap((file) => escapingRelativeImports(file, CONSUMER_ROOT)), + ]; + expect(escaping).toEqual([]); + }); + + test('neither package imports a module it has not declared as a dependency', () => { + // The manifest allowlist in scripts/check-deps.ts governs what may be + // *declared*. This governs what is actually *imported*, which is where a + // shared envelope-shape module would show up first. + expect(undeclaredImports(adapterFiles, ADAPTER_ROOT)).toEqual([]); + expect(undeclaredImports(consumerFiles, CONSUMER_ROOT)).toEqual([]); + }); +}); + +describe('FR-005 / FR-044 — the rules above, observed rejecting (permanent negative cases)', () => { + const SCHEMA_FIXTURES: readonly { readonly ruleId: string; readonly source: string }[] = [ + { ruleId: 'published-schema-file', source: 'const p = "schema/adr.schema.json";' }, + { ruleId: 'schema-module', source: 'import { AdrSchema } from "./adr.schema.ts";' }, + { ruleId: 'core-schema-subpath', source: 'import { x } from "@adrkit/core/schema";' }, + { ruleId: 'schema-emit', source: 'const cmd = "bun run schema:emit";' }, + ]; + + for (const { ruleId, source } of SCHEMA_FIXTURES) { + test(`${ruleId} fires on: ${source}`, () => { + expect( + violationsInSource('fixture.ts', source, SCHEMA_RULES).map((violation) => violation.ruleId), + ).toContain(ruleId); + }); + } + + test('every schema rule has been observed firing at least once', () => { + const exercised = new Set( + SCHEMA_FIXTURES.flatMap(({ source }) => + violationsInSource('fixture.ts', source, SCHEMA_RULES).map((violation) => violation.ruleId), + ), + ); + expect([...exercised].sort()).toEqual(SCHEMA_RULES.map((rule) => rule.id).sort()); + }); + + test('the adapter-to-consumer rule fires on an import of the consumer', () => { + const found = violationsInSource( + 'fixture.ts', + 'import { validate } from "@adrkit/catalog-envelope";', + ADAPTER_MUST_NOT_NAME_CONSUMER, + ); + expect(found.map((violation) => violation.ruleId)).toEqual(['adapter-to-consumer']); + }); + + test('the consumer-to-adapter rule fires on an import of the adapter', () => { + const found = violationsInSource( + 'fixture.ts', + 'import { generate } from "@adrkit/catalog-backstage";', + CONSUMER_MUST_NOT_NAME_ADAPTER, + ); + expect(found.map((violation) => violation.ruleId)).toEqual(['consumer-to-adapter']); + }); + + test('escapingRelativeImports fires on a path that leaves the package root', () => { + const escaping = escapingRelativeImports( + { + path: 'packages/adapters/catalog-backstage/src/pipeline.ts', + code: 'import type { Envelope } from "../../../catalog-envelope/src/index.ts";', + }, + ADAPTER_ROOT, + ); + expect(escaping).toEqual([ + { specifier: '../../../catalog-envelope/src/index.ts', resolved: 'packages/catalog-envelope/src/index.ts' }, + ]); + }); + + test('escapingRelativeImports leaves an in-package relative import alone', () => { + const escaping = escapingRelativeImports( + { + path: 'packages/adapters/catalog-backstage/src/pipeline.ts', + code: 'import { PACKAGE_NAME } from "./index.ts";', + }, + ADAPTER_ROOT, + ); + expect(escaping).toEqual([]); + }); + + test('undeclaredImports fires on a workspace package the manifest never declared', () => { + const found = undeclaredImports( + [ + { + path: 'packages/catalog-envelope/src/derive.ts', + code: 'import { evaluate } from "@adrkit/evaluator";\nimport { join } from "node:path";', + }, + ], + CONSUMER_ROOT, + ); + expect(found).toEqual(['packages/catalog-envelope/src/derive.ts -> @adrkit/evaluator']); + }); + + test('undeclaredImports accepts a declared dependency, a builtin, and a subpath of a declared dependency', () => { + const found = undeclaredImports( + [ + { + path: 'packages/adapters/catalog-backstage/src/x.ts', + code: [ + 'import { canonicalStringify } from "@adrkit/core";', + 'import { readFile } from "node:fs/promises";', + 'import { test } from "bun:test";', + 'import picomatch from "picomatch";', + ].join('\n'), + }, + ], + ADAPTER_ROOT, + ); + expect(found).toEqual([]); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/no-dynamic-loader.test.ts b/packages/adapters/catalog-backstage/test/no-dynamic-loader.test.ts new file mode 100644 index 00000000..bba23a05 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/no-dynamic-loader.test.ts @@ -0,0 +1,178 @@ +/** + * T006 / FR-002 — the adapter is reachable only by explicit static import, and + * registers no dynamic loader, plugin registry, or discovery hook. + * + * ADR-0013 and `spec.md` FR-002 forbid "no dynamic runtime adapter/plugin loader + * of any kind, and no separate composition host that discovers, resolves, or + * dynamically imports a catalog adapter at runtime — not even one restricted to + * a single statically-known package name." + * + * Two halves, because either alone is weak: + * + * - **Positive.** A static import of the entry point yields a specific observed + * value. This is what proves reachability, rather than inferring it from the + * absence of a loader (ADR-0016 clause 3). + * - **Negative.** Every rule below is driven against a fixture that must trip + * it. A rule nobody has watched reject anything is an untested function that + * happens to live in a test file (ADR-0016 clause 1). + */ + +import { describe, expect, test } from 'bun:test'; +import { + ADAPTER_ROOT, + EXCLUDED_FROM_SCAN, + type Rule, + scanned, + violations, + violationsInSource, +} from './source-scan.ts'; + +// The import under test. Static, by name, written here in source — which is the +// only way this package is reachable. +import * as adapter from '../src/index.ts'; + +/** + * Constructs that would let a module be resolved at runtime, or let this package + * announce itself to something that resolves modules at runtime. + */ +const LOADER_RULES: readonly Rule[] = [ + { + id: 'dynamic-import', + pattern: /\bimport\s*\(/, + why: 'a dynamic import() expression resolves a module path at runtime', + }, + { + id: 'require', + pattern: /\brequire\s*\(/, + why: 'require() resolves a module path at runtime', + }, + { + id: 'create-require', + pattern: /\bcreateRequire\b/, + why: 'createRequire() constructs a runtime module resolver', + }, + { + id: 'require-resolve', + pattern: /\brequire\s*\.\s*resolve\b/, + why: 'require.resolve() performs runtime module resolution', + }, + { + id: 'import-meta-resolve', + pattern: /\bimport\s*\.\s*meta\s*\.\s*resolve\b/, + why: 'import.meta.resolve() performs runtime module resolution', + }, + { + id: 'module-internal-load', + pattern: /\b_load\b/, + why: "Module._load is the CommonJS loader's internal resolution entry point", + }, + { + id: 'registry-or-discovery', + pattern: + /\b(?:registerAdapter|registerPlugin|adapterRegistry|pluginRegistry|loadAdapter|resolveAdapter|discoverAdapter|discoverAdapters|adapterFor)\b/, + why: 'an adapter/plugin registry or discovery hook, which FR-002 forbids even for a single statically-known name', + }, +]; + +/** Export names that would mean this package participates in discovery. */ +const FORBIDDEN_EXPORT_NAME = + /^(?:register|discover|load|resolve).*(?:Adapter|Plugin|Registry)$|Registry$/; + +describe('FR-002 — reachable only by explicit static import', () => { + test('the entry point yields a specific observed value when imported statically', () => { + // Asserting a value rather than an absence: this is positive evidence that + // the module resolved, which "no loader was found" would not be. + expect(adapter.PACKAGE_NAME).toBe('@adrkit/catalog-backstage'); + }); + + test('the public surface names no registration, discovery, or registry hook', () => { + const exported = Object.keys(adapter).sort(); + + // State what was examined. A future phase adding exports keeps this honest; + // an empty surface would be reported here rather than passing quietly. + expect(exported.length).toBeGreaterThan(0); + expect(exported.filter((name) => FORBIDDEN_EXPORT_NAME.test(name))).toEqual([]); + }); +}); + +describe('FR-002 — no dynamic loader anywhere in the adapter source', () => { + const files = scanned(ADAPTER_ROOT); + + test('examined the adapter source tree, and says which files', () => { + // The anti-blindness assertion. Without it, a scan of zero files reports the + // same clean result as a scan of a clean tree (ADR-0016). + expect(files.map((file) => file.path)).toContain( + 'packages/adapters/catalog-backstage/src/index.ts', + ); + expect(files.length).toBeGreaterThan(0); + }); + + test('the excluded-from-scan set is exactly the three self-referential guard files', () => { + // These files contain the rule literals themselves. The exclusion is pinned + // so it cannot grow into a way of hiding a violation. + expect([...EXCLUDED_FROM_SCAN]).toEqual([ + 'packages/adapters/catalog-backstage/test/envelope-shape-locality.test.ts', + 'packages/adapters/catalog-backstage/test/no-dynamic-loader.test.ts', + 'packages/adapters/catalog-backstage/test/source-scan.ts', + ]); + }); + + test('no scanned file uses a runtime module resolver or a discovery hook', () => { + expect(violations(files, LOADER_RULES)).toEqual([]); + }); +}); + +describe('FR-002 — the rules above, observed rejecting (permanent negative cases)', () => { + const REJECTED: readonly { readonly ruleId: string; readonly source: string }[] = [ + { ruleId: 'dynamic-import', source: 'const mod = await import(name);' }, + { ruleId: 'require', source: 'const mod = require("@adrkit/whatever");' }, + { ruleId: 'create-require', source: 'import { createRequire } from "node:module";' }, + { ruleId: 'require-resolve', source: 'const where = require.resolve("pkg");' }, + { ruleId: 'import-meta-resolve', source: 'const url = import.meta.resolve("pkg");' }, + { ruleId: 'module-internal-load', source: 'const m = Module._load("pkg", null, false);' }, + { ruleId: 'registry-or-discovery', source: 'export function registerAdapter(a: unknown) {}' }, + { ruleId: 'registry-or-discovery', source: 'export const adapterRegistry = new Map();' }, + { + ruleId: 'registry-or-discovery', + // FR-002's explicit edge: a loader restricted to one known name is still a loader. + source: 'export function loadAdapter() { return "@adrkit/catalog-backstage"; }', + }, + ]; + + for (const { ruleId, source } of REJECTED) { + test(`${ruleId} fires on: ${source}`, () => { + const found = violationsInSource('fixture.ts', source, LOADER_RULES); + expect(found.map((violation) => violation.ruleId)).toContain(ruleId); + }); + } + + test('every rule has been observed firing at least once', () => { + // Closes the gap where a rule is added but never exercised, which would make + // it look like coverage while rejecting nothing. + const exercised = new Set( + REJECTED.flatMap(({ source }) => + violationsInSource('fixture.ts', source, LOADER_RULES).map((violation) => violation.ruleId), + ), + ); + expect([...exercised].sort()).toEqual(LOADER_RULES.map((rule) => rule.id).sort()); + }); + + test('a forbidden export name is rejected, and an ordinary one is not', () => { + expect(['registerAdapter', 'adapterRegistry', 'discoverPlugin'].filter((name) => + FORBIDDEN_EXPORT_NAME.test(name), + )).toEqual(['registerAdapter', 'adapterRegistry', 'discoverPlugin']); + expect(['PACKAGE_NAME', 'generate', 'validateManifest'].filter((name) => + FORBIDDEN_EXPORT_NAME.test(name), + )).toEqual([]); + }); + + test('the same construct written inside a comment is not reported', () => { + // The positive control for comment stripping. Without it the rules would + // fail on this package's own documentation, and the cheapest repair would be + // to stop documenting what is forbidden. + const commented = ['// never write require("x") here', '/* nor await import(x) */', 'const a = 1;'].join( + '\n', + ); + expect(violationsInSource('fixture.ts', commented, LOADER_RULES)).toEqual([]); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/source-scan.ts b/packages/adapters/catalog-backstage/test/source-scan.ts new file mode 100644 index 00000000..9e242c70 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/source-scan.ts @@ -0,0 +1,245 @@ +/** + * Source-scanning helpers shared by this package's two Phase A boundary guards. + * + * Both guards have the shape ADR-0016 warns about: they conclude something from + * an *absence*. A scan that silently examined zero files, or that examined only + * comments, would report exactly the same green as a scan that looked properly. + * Three things are done about that, and they are the reason this file exists + * rather than two ad-hoc regexes: + * + * 1. Every scan returns the list of files it read, and the guards assert on that + * list by name. "Looked and found nothing" is then distinguishable from + * "could not look". + * 2. Every rule below is driven against a fixture that must trip it, so no rule + * is trusted until it has been observed firing. + * 3. The files excluded from scanning are a named, asserted constant rather than + * a filter buried in a call site, so the exclusion set cannot grow quietly. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; + +const HERE = import.meta.dir; + +/** `packages/adapters/catalog-backstage/` */ +export const ADAPTER_ROOT = dirname(HERE); +/** The repository root: up out of `catalog-backstage/`, `adapters/`, `packages/`. */ +export const REPO_ROOT = dirname(dirname(dirname(ADAPTER_ROOT))); +/** `packages/catalog-envelope/` */ +export const CONSUMER_ROOT = join(REPO_ROOT, 'packages', 'catalog-envelope'); + +export const ADAPTER_PACKAGE_NAME = '@adrkit/catalog-backstage'; +export const CONSUMER_PACKAGE_NAME = '@adrkit/catalog-envelope'; + +/** + * Files deliberately not scanned, because they contain the rule literals + * themselves and would match their own patterns. + * + * This is asserted to be exactly this set. An exclusion list that can grow + * without anyone noticing is the same defect the scans are guarding against. + */ +export const EXCLUDED_FROM_SCAN: readonly string[] = [ + 'packages/adapters/catalog-backstage/test/envelope-shape-locality.test.ts', + 'packages/adapters/catalog-backstage/test/no-dynamic-loader.test.ts', + 'packages/adapters/catalog-backstage/test/source-scan.ts', +]; + +export interface ScannedFile { + /** Repository-relative path, forward-slashed, stable across platforms. */ + readonly path: string; + /** File contents with comments removed; see {@link stripComments}. */ + readonly code: string; +} + +function displayPath(path: string): string { + return path.split(sep).join('/'); +} + +/** + * Remove `//` and block comments, preserving newlines and the contents of + * string and template literals. + * + * Why strip at all: these guards forbid *constructs*, not *words*. Both READMEs + * and several doc comments in this package describe the forbidden constructs in + * prose — a scanner that matched prose would fail on its own documentation, and + * the fix for that would be to stop documenting the rule. + * + * Known limitation, stated rather than hidden: a regular-expression literal + * containing an unescaped `//`, or a lone quote character inside one, can put + * this scanner into the wrong state for the rest of that construct. The failure + * direction is a false negative. It is accepted because the alternative is a + * parser, and because {@link scanned} additionally asserts stripping never + * empties a non-empty file. + */ +export function stripComments(source: string): string { + let out = ''; + let index = 0; + + while (index < source.length) { + const character = source[index] as string; + const lookahead = source[index + 1]; + + if (character === '/' && lookahead === '/') { + while (index < source.length && source[index] !== '\n') index += 1; + continue; + } + + if (character === '/' && lookahead === '*') { + index += 2; + while (index < source.length && !(source[index] === '*' && source[index + 1] === '/')) { + if (source[index] === '\n') out += '\n'; + index += 1; + } + index += 2; + continue; + } + + if (character === "'" || character === '"' || character === '`') { + out += character; + index += 1; + while (index < source.length) { + const inner = source[index] as string; + out += inner; + index += 1; + if (inner === '\\') { + if (index < source.length) { + out += source[index] as string; + index += 1; + } + continue; + } + if (inner === character) break; + } + continue; + } + + out += character; + index += 1; + } + + return out; +} + +/** + * Read every `.ts` file under the given package's `src/` and `test/` trees. + * + * Returns them sorted by path so the guards' reported file lists are + * deterministic. `node_modules/` is out of range by construction: only `src/` + * and `test/` are walked. + */ +export function scanned(packageRoot: string): ScannedFile[] { + const files: ScannedFile[] = []; + + for (const subtree of ['src', 'test'] as const) { + const base = join(packageRoot, subtree); + let entries: string[]; + try { + entries = readdirSync(base, { recursive: true }).map(String); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') continue; + throw error; + } + + for (const entry of entries) { + if (!entry.endsWith('.ts')) continue; + const absolute = join(base, entry); + const path = displayPath(relative(REPO_ROOT, absolute)); + if (EXCLUDED_FROM_SCAN.includes(path)) continue; + + const raw = readFileSync(absolute, 'utf8'); + const code = stripComments(raw); + if (raw.trim().length > 0 && code.trim().length === 0) { + throw new Error( + `${path}: comment stripping emptied a non-empty file, so this scan cannot see it. ` + + 'Refusing to report a result that would be indistinguishable from a clean file.', + ); + } + files.push({ path, code }); + } + } + + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + +export interface Rule { + readonly id: string; + readonly pattern: RegExp; + /** Why the construct is forbidden, carried into the violation for the reader. */ + readonly why: string; +} + +export interface Violation { + readonly path: string; + readonly ruleId: string; + readonly matched: string; + readonly why: string; +} + +/** Apply `rules` to already-stripped source, returning every match. */ +export function violations(files: readonly ScannedFile[], rules: readonly Rule[]): Violation[] { + const found: Violation[] = []; + for (const file of files) { + for (const rule of rules) { + const match = rule.pattern.exec(file.code); + if (match) { + found.push({ path: file.path, ruleId: rule.id, matched: match[0], why: rule.why }); + } + } + } + return found; +} + +/** Apply `rules` to one fixture string, as {@link violations} would see it. */ +export function violationsInSource(path: string, source: string, rules: readonly Rule[]): Violation[] { + return violations([{ path, code: stripComments(source) }], rules); +} + +/** + * Every module specifier imported or re-exported by `code`. + * + * Matched on comment-stripped source. Covers `from '…'` (import and re-export, + * including multi-line forms) and side-effecting `import '…'`. + */ +export function importSpecifiers(code: string): string[] { + const specifiers: string[] = []; + for (const pattern of [/\bfrom\s*['"]([^'"]+)['"]/g, /\bimport\s*['"]([^'"]+)['"]/g]) { + for (const match of code.matchAll(pattern)) { + const specifier = match[1]; + if (specifier !== undefined) specifiers.push(specifier); + } + } + return specifiers; +} + +/** + * Relative import specifiers that resolve outside `packageRoot`. + * + * A package-name rule alone does not close the boundary: `../../catalog-envelope/src/…` + * reaches the other package without ever naming it. + */ +export function escapingRelativeImports( + file: ScannedFile, + packageRoot: string, +): { readonly specifier: string; readonly resolved: string }[] { + const fileDirectory = dirname(join(REPO_ROOT, file.path)); + const escaping: { specifier: string; resolved: string }[] = []; + + for (const specifier of importSpecifiers(file.code)) { + if (!specifier.startsWith('.')) continue; + const target = resolve(fileDirectory, specifier); + const inside = relative(packageRoot, target); + if (inside.startsWith('..') || inside === '') { + escaping.push({ specifier, resolved: displayPath(relative(REPO_ROOT, target)) }); + } + } + + return escaping; +} + +/** The `scripts` block of a package manifest, as declared. */ +export function packageScripts(packageRoot: string): Record { + const manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as { + scripts?: Record; + }; + return manifest.scripts ?? {}; +} diff --git a/packages/adapters/catalog-backstage/tsconfig.json b/packages/adapters/catalog-backstage/tsconfig.json new file mode 100644 index 00000000..6402df86 --- /dev/null +++ b/packages/adapters/catalog-backstage/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/catalog-envelope/README.md b/packages/catalog-envelope/README.md new file mode 100644 index 00000000..0a4265ea --- /dev/null +++ b/packages/catalog-envelope/README.md @@ -0,0 +1,102 @@ +# @adrkit/catalog-envelope + +Reads a catalog snapshot envelope, validates its **integrity**, and — only after +every check passes — derives a `CatalogSnapshot`-shaped artifact from it. + +This package reads envelopes. It never generates them. + +--- + +## An integrity validator, not a correctness oracle + +This distinction is the reason this package exists as a separate thing, so it is +stated before anything else. + +Everything this package can establish is that an envelope is **intact, +well-formed, self-consistent, current, and about the repository that is asking.** +None of that establishes that the envelope's contents are **right**. + +A populated, digest-verified envelope proves integrity, not correctness: a +semantically wrong envelope can carry a perfectly valid self-digest +([ADR-0020](../../docs/adr/0020-rescope-sc-010-and-authorize-work-toward-the-backstage-catalog-adapter.md) +clause 5). A green result from this package means "nothing was corrupted, dropped, +or stale." It does not mean "the ownership recorded here is the ownership that +should have been recorded." Nothing in this package is able to determine the +latter, and no output of it may be read as having done so. + +Two consequences worth naming, because they are easy to invert: + +- **A rejection is a statement about the envelope, not about the repository.** An + envelope that fails validation tells you the artifact is unusable, not that the + catalog it describes is wrong. +- **Isolation is a property of the query, not a rejection.** A *valid* envelope + describing a different repository is accepted as valid; the repository boundary + shows up as the query returning no matches, not as an error. + +--- + +## Status: this package validates nothing yet + +Feature `010-catalog-backstage` is partially built. What exists here today is the +package's **placement** and its **dependency boundary**. The five ordered +validation steps, digest recomputation, staleness evaluation, repository-identity +handling, and snapshot derivation are requirements on a later phase, recorded in +[`specs/010-catalog-backstage/`](../../specs/010-catalog-backstage/). They are not +behaviour this package has. + +Per [ADR-0014](../../docs/adr/0014-stage-phase-landing-evidence-across-a-three-rung-validation-ladder.md), +stated in that record's own vocabulary: rung-1 evidence covers only what exists, +which is placement and boundary. This package is **not** `reference-verified` +(rung 2) and **not** `externally validated` (rung 3), and claims neither. Any +verification performed here is maintainer-owned, which is not external, +third-party, or community adoption and will not be described as such. + +No release is authorized or prepared; ADR-0020 clause 9 defers that decision to a +later record. + +--- + +## Boundary + +- **This package is deliberately not under `packages/adapters/`.** `spec.md` + FR-044 requires the envelope validator and `CatalogSnapshot` deriver to live in + a workspace package outside `packages/adapters/**`. The dependency check + classifies adapters by path prefix alone, so this package is a non-adapter as a + matter of its *location* rather than by an allowlisted exception. Moving it + would silently invert that classification. +- **There is no dependency edge to or from `@adrkit/catalog-backstage`, in either + direction, and there must never be one.** The envelope file on disk is the + entire interface. Both sides declare the envelope's shape independently — the + single deliberate duplication in this design. A shared type module would be an + import edge, and if both sides derived their view of the envelope from one + declaration, a generator that changed the shape would change it on both sides + at once and this package's validation would be a tautology. The cost is real + and accepted: the two declarations can diverge, and nothing but this package's + validation failing will say so. **That failure is the intended signal.** +- **This package writes nothing to `schema/`.** The envelope is not added to + `schema/adr.schema.json` and does not become a field on `CatalogSnapshot` or + `CatalogSnapshotEntity`. +- **It is not wired into `@adrkit/cli` or `@adrkit/core`**, and wiring it in is + explicitly out of scope for this feature. + +Both directions of the boundary above are enforced by `bun run check:deps`, and +each guard was observed rejecting the edge it forbids before it was relied on +([ADR-0016](../../docs/adr/0016-require-every-check-to-be-observed-failing-before-it-counts-as-coverage.md)). + +--- + +## Toolchain + +Bun for development; any published artifact targets Node +([ADR-0010](../../docs/adr/0010-bun-toolchain.md)). + +```bash +bun test # from the repository root +bun run --filter='@adrkit/catalog-envelope' typecheck +bun run check:deps +``` + +## License + +Apache-2.0. See the repository [LICENSE](../../LICENSE) and +[NOTICE](../../NOTICE). diff --git a/packages/catalog-envelope/package.json b/packages/catalog-envelope/package.json new file mode 100644 index 00000000..c6e9fd39 --- /dev/null +++ b/packages/catalog-envelope/package.json @@ -0,0 +1,40 @@ +{ + "name": "@adrkit/catalog-envelope", + "version": "0.0.0", + "description": "Validates a catalog snapshot envelope for integrity and derives a CatalogSnapshot-shaped artifact from it. Reads envelopes; never generates them.", + "type": "module", + "license": "Apache-2.0", + "homepage": "https://adrkit.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/mbeacom/adrkit.git", + "directory": "packages/catalog-envelope" + }, + "bugs": { + "url": "https://github.com/mbeacom/adrkit/issues" + }, + "keywords": [ + "adr", + "architecture-decision-records", + "catalog", + "governance" + ], + "//placement": "Deliberately NOT under packages/adapters/. spec.md FR-044 requires the envelope validator and CatalogSnapshot deriver to live in a workspace package outside packages/adapters/**. isAdapterPackage() in scripts/check-deps.ts classifies by path prefix alone, so this package is a non-adapter as a matter of its location rather than by an allowlisted exception \u2014 package-boundary.md \u00a73.1. Moving it under packages/adapters/ would silently invert that classification.", + "//boundary": "There is no dependency edge in either direction between this package and @adrkit/catalog-backstage, and there must never be one (FR-044; package-boundary.md \u00a73). The envelope file on disk is the entire interface. Both packages declare the envelope's shape independently, which is the single deliberate duplication in this design (\u00a75): a shared type module would be an import edge, and it is exactly that independence that makes this package's structural validation a check rather than a tautology.", + "//release": "Not released, and no release is scheduled or prepared. ADR-0020 clause 9 defers the release decision to a later record. Absent from RELEASE_PACKAGES in scripts/release-pack.ts, ships no dist, declares no exports map, version 0.0.0.", + "//wiring": "Wiring this package into @adrkit/cli or @adrkit/core is explicitly out of scope for feature 010 (package-boundary.md \u00a77) and would require amending allowedDependenciesFor('@adrkit/cli'), which must not be done under this feature.", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "bun run typecheck", + "lint": "bun run typecheck", + "typecheck": "tsc --noEmit --customConditions bun --project ../../tsconfig.json" + }, + "dependencies": { + "@adrkit/core": "workspace:*" + }, + "devDependencies": { + "@types/bun": "latest" + } +} diff --git a/packages/catalog-envelope/src/index.ts b/packages/catalog-envelope/src/index.ts new file mode 100644 index 00000000..33cd8cb0 --- /dev/null +++ b/packages/catalog-envelope/src/index.ts @@ -0,0 +1,42 @@ +/** + * `@adrkit/catalog-envelope` — the consumer's only public entry point. + * + * **What this package is.** An *integrity* validator. It reads a snapshot + * envelope, checks that the envelope is structurally well-formed, internally + * consistent, self-consistent with its declared digests, current, and about the + * repository asking — and only then derives a `CatalogSnapshot`-shaped artifact + * from it (`spec.md` FR-045 through FR-049). + * + * **What this package is not.** A correctness oracle. A populated, + * digest-verified envelope proves integrity, not correctness: a semantically + * wrong envelope can carry a perfectly valid self-digest (ADR-0020 clause 5). + * Everything this package can conclude is a statement about whether an envelope + * is intact and current — never a statement about whether its contents are + * *right*. + * + * **The boundary.** This package neither imports from nor is imported by + * `@adrkit/catalog-backstage` (`spec.md` FR-044). The envelope file on disk is + * the entire interface between them, and each declares the envelope's shape + * independently. That duplication is deliberate: were both sides to derive their + * view of the envelope from one shared declaration, a generator that changed the + * shape would change it on both sides at once, and this package's validation + * would be comparing the generator against itself. + * + * **What exists today.** Phase A of feature `010-catalog-backstage` creates this + * package's placement, its dependency boundary, and this entry point, and nothing + * else. The five ordered validation steps, digest recomputation, staleness + * evaluation, repository-identity handling, and snapshot derivation are + * requirements on a later phase, not behaviour this package has. + * + * @see {@link ../README.md} + * @see `specs/010-catalog-backstage/contracts/package-boundary.md` + */ + +/** + * This package's own name. + * + * Exported so that a test can assert a *specific observed value* obtained by + * statically importing this module, rather than asserting an absence and calling + * that coverage (ADR-0016 clause 3). + */ +export const PACKAGE_NAME = '@adrkit/catalog-envelope'; diff --git a/packages/catalog-envelope/tsconfig.json b/packages/catalog-envelope/tsconfig.json new file mode 100644 index 00000000..e8b02ca4 --- /dev/null +++ b/packages/catalog-envelope/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/scripts/check-deps.test.ts b/scripts/check-deps.test.ts index 8de5e7ee..417a94dd 100644 --- a/scripts/check-deps.test.ts +++ b/scripts/check-deps.test.ts @@ -234,6 +234,214 @@ describe('evaluator dependency boundary (Phase 4)', () => { }); }); +describe('catalog adapter and consumer dependency boundary (feature 010)', () => { + const ADAPTER_MANIFEST = 'packages/adapters/catalog-backstage/package.json'; + const CONSUMER_MANIFEST = 'packages/catalog-envelope/package.json'; + + function adapterManifest(extra: Record = {}): string { + return JSON.stringify( + { + name: '@adrkit/catalog-backstage', + version: '0.0.0', + dependencies: { '@adrkit/core': 'workspace:*', picomatch: '^4', yaml: 'latest', ...extra }, + devDependencies: { '@types/bun': 'latest', '@types/picomatch': '^4' }, + }, + null, + 2, + ); + } + + function consumerManifest(extra: Record = {}): string { + return JSON.stringify( + { + name: '@adrkit/catalog-envelope', + version: '0.0.0', + dependencies: { '@adrkit/core': 'workspace:*', ...extra }, + devDependencies: { '@types/bun': 'latest' }, + }, + null, + 2, + ); + } + + test('allows exactly the surfaces package-boundary.md §2 freezes for both packages', async () => { + const root = await resetTestDir(DIR_NAME); + await writeText(join(root, ADAPTER_MANIFEST), adapterManifest()); + await writeText(join(root, CONSUMER_MANIFEST), consumerManifest()); + await expect(checkDependencyRules(root)).resolves.toEqual({ ok: true, violations: [] }); + }); + + // T009 / SC-015 — a deliberately introduced edge from core, then the CLI, onto + // the adapter. Observed failing against the real workspace tree before being + // recorded here; these are the retained inputs (ADR-0016 clause 2). + test('rejects @adrkit/core depending on the catalog adapter', async () => { + const root = await resetTestDir(DIR_NAME); + await writeText(join(root, ADAPTER_MANIFEST), adapterManifest()); + await writeText( + join(root, 'packages/core/package.json'), + JSON.stringify( + { + name: '@adrkit/core', + version: '0.1.0', + dependencies: { + picomatch: '^4', + semver: '^7', + yaml: 'latest', + zod: '^4', + '@adrkit/catalog-backstage': 'workspace:*', + }, + }, + null, + 2, + ), + ); + + const result = await checkDependencyRules(root); + expect(result.ok).toBe(false); + expect(result.violations.map((violation) => violation.reason).sort()).toEqual([ + '@adrkit/core declares a dependency outside its allowed public surface', + 'non-adapter workspace depends on an adapter package', + ]); + }); + + test('rejects @adrkit/cli depending on the catalog adapter', async () => { + const root = await resetTestDir(DIR_NAME); + await writeText(join(root, ADAPTER_MANIFEST), adapterManifest()); + await writeText( + join(root, 'packages/cli/package.json'), + JSON.stringify( + { + name: '@adrkit/cli', + version: '0.1.0', + dependencies: { + '@adrkit/core': 'workspace:*', + '@adrkit/evaluator': 'workspace:*', + '@adrkit/catalog-backstage': 'workspace:*', + }, + }, + null, + 2, + ), + ); + + const result = await checkDependencyRules(root); + expect(result.ok).toBe(false); + expect(result.violations.map((violation) => violation.reason)).toContain( + 'non-adapter workspace depends on an adapter package', + ); + }); + + test('does NOT see a manifest-less directory such as schema/, and this records that limitation', async () => { + // FR-003 covers `schema/` as well as core and the CLI, but `schema/` carries + // no package.json, so `readWorkspacePackages()` never visits it and this + // check cannot express the rule for it. Observed directly: adding + // `import … from '@adrkit/catalog-backstage'` to `schema/adr.schema.ts` in + // the real tree leaves `bun run check:deps` at exit 0, printing + // `core-has-no-adapter-deps: ok`. + // + // The clause is held instead by `bunfig.toml`'s `linker = "isolated"` — root + // level files get no `node_modules/@adrkit/`, so the same edge fails + // `bun run typecheck` with `TS2307: Cannot find module + // '@adrkit/catalog-backstage'`. That is Constitution Principle III's stated + // reason the isolated linker is load-bearing, observed working. + // + // Recorded as a specific observed value rather than left as an assumption + // that this check covers schema/, which it does not (ADR-0016 clause 3). + const root = await resetTestDir(DIR_NAME); + await writeText(join(root, ADAPTER_MANIFEST), adapterManifest()); + await writeText( + join(root, 'schema/adr.schema.ts'), + "import { PACKAGE_NAME } from '@adrkit/catalog-backstage';\nexport const leaked = PACKAGE_NAME;\n", + ); + + await expect(checkDependencyRules(root)).resolves.toEqual({ ok: true, violations: [] }); + }); + + // T010 / FR-044 direction (i) — the consumer must not reach the adapter. + test('rejects @adrkit/catalog-envelope depending on the adapter', async () => { + const root = await resetTestDir(DIR_NAME); + await writeText(join(root, ADAPTER_MANIFEST), adapterManifest()); + await writeText( + join(root, CONSUMER_MANIFEST), + consumerManifest({ '@adrkit/catalog-backstage': 'workspace:*' }), + ); + + const result = await checkDependencyRules(root); + expect(result.ok).toBe(false); + expect(result.violations.map((violation) => violation.reason).sort()).toEqual([ + '@adrkit/catalog-envelope declares a dependency outside its allowed public surface', + 'non-adapter workspace depends on an adapter package', + ]); + }); + + // T011 / FR-044 direction (ii) — the adapter must not reach the consumer. Only + // the allowed-surface guard fires here: the adapter *is* an adapter package, so + // the non-adapter guard correctly does not apply, and the allowlist is the only + // thing standing between the two packages in this direction. + test('rejects the adapter depending on @adrkit/catalog-envelope', async () => { + const root = await resetTestDir(DIR_NAME); + await writeText( + join(root, ADAPTER_MANIFEST), + adapterManifest({ '@adrkit/catalog-envelope': 'workspace:*' }), + ); + await writeText(join(root, CONSUMER_MANIFEST), consumerManifest()); + + const result = await checkDependencyRules(root); + expect(result.ok).toBe(false); + expect(result.violations.map((violation) => violation.reason)).toEqual([ + '@adrkit/catalog-backstage declares a dependency outside its allowed public surface', + ]); + }); + + // T012 — the only proof the two allowlist entries actually exist. A package + // with no entry is silently unconstrained (`check-deps.ts` returns undefined + // and the allowed-surface guard is skipped), so a green check on such a package + // is evidence of nothing. Each entry is proven present by watching a disallowed + // dependency produce a violation. + test('proves the adapter allowlist entry exists, by rejecting a disallowed dependency', async () => { + const root = await resetTestDir(DIR_NAME); + await writeText(join(root, ADAPTER_MANIFEST), adapterManifest({ undici: '^6' })); + + const result = await checkDependencyRules(root); + expect(result.ok).toBe(false); + expect(result.violations.map((violation) => `${violation.dependency}: ${violation.reason}`)).toEqual([ + 'undici: @adrkit/catalog-backstage declares a dependency outside its allowed public surface', + ]); + }); + + test('proves the consumer allowlist entry exists, by rejecting a disallowed dependency', async () => { + const root = await resetTestDir(DIR_NAME); + await writeText(join(root, CONSUMER_MANIFEST), consumerManifest({ undici: '^6' })); + + const result = await checkDependencyRules(root); + expect(result.ok).toBe(false); + expect(result.violations.map((violation) => `${violation.dependency}: ${violation.reason}`)).toEqual([ + 'undici: @adrkit/catalog-envelope declares a dependency outside its allowed public surface', + ]); + }); + + test('the trap itself: a package with no allowlist entry passes with the same disallowed dependency', async () => { + // This is what makes the two tests above mean something. Observed directly in + // the real tree: removing the adapter's `allowedDependenciesFor()` entry while + // leaving `undici` declared returns `check:deps` to exit 0 and + // `core-has-no-adapter-deps: ok` — the identical output a clean tree produces. + // Absence of a rule and a satisfied rule are the same string here, which is + // exactly why package-boundary.md §4 calls the omission a green check that + // means nothing. + const root = await resetTestDir(DIR_NAME); + await writeText( + join(root, 'packages/adapters/unlisted/package.json'), + JSON.stringify( + { name: '@adrkit/adapter-unlisted', version: '0.1.0', dependencies: { undici: '^6' } }, + null, + 2, + ), + ); + + await expect(checkDependencyRules(root)).resolves.toEqual({ ok: true, violations: [] }); + }); +}); + describe('mcp dependency boundary (Phase 5)', () => { test('allows exactly core, the pinned SDK server, and zod, plus the dev-only SDK client and @types/bun', async () => { const root = await resetTestDir(DIR_NAME); diff --git a/scripts/check-deps.ts b/scripts/check-deps.ts index 6726a717..855c5c19 100644 --- a/scripts/check-deps.ts +++ b/scripts/check-deps.ts @@ -148,6 +148,47 @@ function allowedDependenciesFor(packageName: string): Record **In this direction, the allowlist entry is the only thing standing between the +> two packages.** + +The structural protection that catches every other case — "nothing outside +`packages/adapters/` may depend on an adapter" — offers nothing here, since the +depending package is itself inside `packages/adapters/`. If +`allowedDependenciesFor('@adrkit/catalog-backstage')` were ever removed, this +edge would pass silently. That is precisely the scenario constructed and observed +in [`../dep-allowlist-present/`](../dep-allowlist-present/) case C. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — edge removed, +`bun run check:deps` exits **0**, `core-has-no-adapter-deps: ok`. + +## Reproducing + +```bash +git apply specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/dep-adapter-to-consumer.patch +bun run check:deps # expect exit 1 +git checkout -- packages/adapters/catalog-backstage/package.json +bun run check:deps # expect exit 0 +``` diff --git a/specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/dep-adapter-to-consumer.patch b/specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/dep-adapter-to-consumer.patch new file mode 100644 index 00000000..242be6c9 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/dep-adapter-to-consumer.patch @@ -0,0 +1,12 @@ +diff --git a/packages/adapters/catalog-backstage/package.json b/packages/adapters/catalog-backstage/package.json +index a9b3301..5f10369 100644 +--- a/packages/adapters/catalog-backstage/package.json ++++ b/packages/adapters/catalog-backstage/package.json +@@ -34,6 +34,7 @@ + "typecheck": "tsc --noEmit --customConditions bun --project ../../../tsconfig.json" + }, + "dependencies": { ++ "@adrkit/catalog-envelope": "workspace:*", + "@adrkit/core": "workspace:*", + "picomatch": "^4", + "yaml": "latest" diff --git a/specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/observed.txt new file mode 100644 index 00000000..97e3f865 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/observed.txt @@ -0,0 +1,5 @@ +$ bun run check:deps +$ bun run scripts/check-deps.ts +packages/adapters/catalog-backstage/package.json: @adrkit/catalog-backstage dependencies.@adrkit/catalog-envelope - @adrkit/catalog-backstage declares a dependency outside its allowed public surface +error: script "check:deps" exited with code 1 +EXIT=1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/restored.observed.txt new file mode 100644 index 00000000..ccd3dcc7 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/restored.observed.txt @@ -0,0 +1,4 @@ +$ bun run check:deps +$ bun run scripts/check-deps.ts +core-has-no-adapter-deps: ok +EXIT=0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/README.md b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/README.md new file mode 100644 index 00000000..07a73f48 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/README.md @@ -0,0 +1,115 @@ +# Negative case: proving the allowlist entries exist at all + +**Task**: T012 · **Supports**: FR-003, FR-044 +**Contract**: `contracts/package-boundary.md` §4 +**Observed against**: `10149724938eb172972c7fb98a33956807ee761f`, worktree clean +**Tools**: Bun 1.3.14 +**Permanent automated cases**: `scripts/check-deps.test.ts` — *"proves the +adapter allowlist entry exists…"*, *"proves the consumer allowlist entry +exists…"*, and *"the trap itself: a package with no allowlist entry passes with +the same disallowed dependency"* + +## The trap this closes + +`allowedDependenciesFor()` returns `undefined` for any package it has no entry +for (`scripts/check-deps.ts:192`), and the allowed-surface guard is then +**skipped entirely**. A package with no entry is *silently unconstrained*: it +passes `check:deps` no matter what it declares. + +So a green `check:deps` proves nothing about a package's allowlist. Omitting an +entry does not produce a failure — it produces a green check that means nothing. +The only way to show an entry is present is to add a dependency it forbids and +watch a violation appear. + +Three cases: one per new package with the entries in place, and one with an entry +removed. **The third is what makes the first two mean anything.** + +--- + +## Case A — adapter declares `undici`, entry present + +Input: [`case-a-adapter-disallowed-dep.patch`](./case-a-adapter-disallowed-dep.patch) · +Output: [`case-a-adapter-disallowed-dep.observed.txt`](./case-a-adapter-disallowed-dep.observed.txt) + +Command: `bun run check:deps` · Exit **1** + +``` +packages/adapters/catalog-backstage/package.json: @adrkit/catalog-backstage dependencies.undici - @adrkit/catalog-backstage declares a dependency outside its allowed public surface +``` + +## Case B — consumer declares `undici`, entry present + +Input: [`case-b-consumer-disallowed-dep.patch`](./case-b-consumer-disallowed-dep.patch) · +Output: [`case-b-consumer-disallowed-dep.observed.txt`](./case-b-consumer-disallowed-dep.observed.txt) + +Command: `bun run check:deps` · Exit **1** + +``` +packages/catalog-envelope/package.json: @adrkit/catalog-envelope dependencies.undici - @adrkit/catalog-envelope declares a dependency outside its allowed public surface +``` + +Run independently of case A, as T012 requires: each package's entry is proven on +its own, not inferred from the other's. + +`undici` was chosen because it is a network client — the category Constitution +Principle III excludes most clearly, and a dependency neither package could ever +be granted. + +--- + +## Case C — the trap itself: same edge, entry removed + +Input: [`case-c-entry-removed-trap.patch`](./case-c-entry-removed-trap.patch) — +declares `undici` in the adapter **and** deletes +`allowedDependenciesFor('@adrkit/catalog-backstage')` · +Output: [`case-c-entry-removed-trap.observed.txt`](./case-c-entry-removed-trap.observed.txt) + +Command: `bun run check:deps` · Exit **0** + +``` +core-has-no-adapter-deps: ok +``` + +A network client sits in an adapter's `dependencies` and the check reports +success. + +**`case-c-entry-removed-trap.observed.txt` and `restored.observed.txt` are +byte-identical** — verified with `diff`, which reports no difference. The tree +with a forbidden dependency and no rule, and the tree that is actually clean, +produce the same bytes. There is no signal to read. + +This is what `package-boundary.md` §4 means by *"the one place where the absence +of a rule is indistinguishable from a satisfied rule"*, and it is +[ADR-0016](../../../../../docs/adr/0016-require-every-check-to-be-observed-failing-before-it-counts-as-coverage.md)'s +central defect shape: a check that reports success when it has failed to look. + +It also makes cases A and B meaningful. Without case C, an exit-1 in A and B +could not distinguish "the entry exists and rejected `undici`" from "some other +guard happened to reject it." Case C removes only the entry, holds everything +else fixed, and the violation disappears — so the entry is what produced it. + +--- + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — both packages' manifests and +`scripts/check-deps.ts` reverted; `bun run check:deps` exits **0**. + +Note the honest reading: **this file alone is not evidence of anything.** It is +identical to case C's output. It is meaningful only alongside A and B, which show +that the same command *does* distinguish an allowed surface from a disallowed one +while the entries are present. + +## Reproducing + +```bash +# case A +git apply .../dep-allowlist-present/case-a-adapter-disallowed-dep.patch +bun run check:deps # expect exit 1 +git checkout -- packages/adapters/catalog-backstage/package.json + +# case C — the trap +git apply .../dep-allowlist-present/case-c-entry-removed-trap.patch +bun run check:deps # expect exit 0 +git checkout -- packages/adapters/catalog-backstage/package.json scripts/check-deps.ts +``` diff --git a/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-a-adapter-disallowed-dep.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-a-adapter-disallowed-dep.observed.txt new file mode 100644 index 00000000..b3bd52a2 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-a-adapter-disallowed-dep.observed.txt @@ -0,0 +1,5 @@ +$ bun run check:deps +$ bun run scripts/check-deps.ts +packages/adapters/catalog-backstage/package.json: @adrkit/catalog-backstage dependencies.undici - @adrkit/catalog-backstage declares a dependency outside its allowed public surface +error: script "check:deps" exited with code 1 +EXIT=1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-a-adapter-disallowed-dep.patch b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-a-adapter-disallowed-dep.patch new file mode 100644 index 00000000..6fa75789 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-a-adapter-disallowed-dep.patch @@ -0,0 +1,12 @@ +diff --git a/packages/adapters/catalog-backstage/package.json b/packages/adapters/catalog-backstage/package.json +index a9b3301..8d9d986 100644 +--- a/packages/adapters/catalog-backstage/package.json ++++ b/packages/adapters/catalog-backstage/package.json +@@ -34,6 +34,7 @@ + "typecheck": "tsc --noEmit --customConditions bun --project ../../../tsconfig.json" + }, + "dependencies": { ++ "undici": "^6", + "@adrkit/core": "workspace:*", + "picomatch": "^4", + "yaml": "latest" diff --git a/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-b-consumer-disallowed-dep.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-b-consumer-disallowed-dep.observed.txt new file mode 100644 index 00000000..7b7171fe --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-b-consumer-disallowed-dep.observed.txt @@ -0,0 +1,5 @@ +$ bun run check:deps +$ bun run scripts/check-deps.ts +packages/catalog-envelope/package.json: @adrkit/catalog-envelope dependencies.undici - @adrkit/catalog-envelope declares a dependency outside its allowed public surface +error: script "check:deps" exited with code 1 +EXIT=1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-b-consumer-disallowed-dep.patch b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-b-consumer-disallowed-dep.patch new file mode 100644 index 00000000..93790324 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-b-consumer-disallowed-dep.patch @@ -0,0 +1,12 @@ +diff --git a/packages/catalog-envelope/package.json b/packages/catalog-envelope/package.json +index c6e9fd3..b2c1731 100644 +--- a/packages/catalog-envelope/package.json ++++ b/packages/catalog-envelope/package.json +@@ -32,6 +32,7 @@ + "typecheck": "tsc --noEmit --customConditions bun --project ../../tsconfig.json" + }, + "dependencies": { ++ "undici": "^6", + "@adrkit/core": "workspace:*" + }, + "devDependencies": { diff --git a/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-c-entry-removed-trap.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-c-entry-removed-trap.observed.txt new file mode 100644 index 00000000..ccd3dcc7 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-c-entry-removed-trap.observed.txt @@ -0,0 +1,4 @@ +$ bun run check:deps +$ bun run scripts/check-deps.ts +core-has-no-adapter-deps: ok +EXIT=0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-c-entry-removed-trap.patch b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-c-entry-removed-trap.patch new file mode 100644 index 00000000..6c385929 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-c-entry-removed-trap.patch @@ -0,0 +1,43 @@ +diff --git a/packages/adapters/catalog-backstage/package.json b/packages/adapters/catalog-backstage/package.json +index a9b3301..8d9d986 100644 +--- a/packages/adapters/catalog-backstage/package.json ++++ b/packages/adapters/catalog-backstage/package.json +@@ -34,6 +34,7 @@ + "typecheck": "tsc --noEmit --customConditions bun --project ../../../tsconfig.json" + }, + "dependencies": { ++ "undici": "^6", + "@adrkit/core": "workspace:*", + "picomatch": "^4", + "yaml": "latest" +diff --git a/scripts/check-deps.ts b/scripts/check-deps.ts +index 855c5c1..fcca55c 100644 +--- a/scripts/check-deps.ts ++++ b/scripts/check-deps.ts +@@ -148,26 +148,6 @@ function allowedDependenciesFor(packageName: string): Record` — `package.json` +- [X] T001 [P] Create the adapter package skeleton at `` — `package.json` (name `@adrkit/catalog-backstage`, `type: module`, `publishConfig.access: public`, and the `"//versioning"` note citing ADR-0007 and `ReleaseVersioning` in `scripts/release-pack.ts`, following the `packages/adapters/spec-kit/` precedent), @@ -158,7 +158,7 @@ ownership result, or produces an envelope. Phase A may run concurrently with Pha Depends: none Contract: `package-boundary.md` §2, §6 -- [ ] T002 [P] Create the consumer package skeleton at `` — `package.json` +- [X] T002 [P] Create the consumer package skeleton at `` — `package.json` (name `@adrkit/catalog-envelope` — working name, `type: module`), `tsconfig.json`, `src/index.ts`. This package is **not** under `packages/adapters/` and must not be. @@ -167,7 +167,7 @@ ownership result, or produces an envelope. Phase A may run concurrently with Pha Depends: none Contract: `package-boundary.md` §3 -- [ ] T003 Confirm both packages are picked up by the existing root `workspaces` +- [X] T003 Confirm both packages are picked up by the existing root `workspaces` globs `["packages/*", "packages/adapters/*"]` (root `package.json` lines 18–21) **without modifying them** — a needed change to those globs is a signal that placement is wrong. Declare the Node target in each package's `engines` field; @@ -178,20 +178,20 @@ ownership result, or produces an envelope. Phase A may run concurrently with Pha Discharges: FR-051 Depends: T001, T002 -- [ ] T004 [P] [US9] Write `/README.md` using ADR-0014 rung-1 language only, +- [X] T004 [P] [US9] Write `/README.md` using ADR-0014 rung-1 language only, with no rung-2 or rung-3 synonyms, and carrying the FR-063 adoption statement: what a downstream consumer may and may not conclude from this adapter's output. Barrier: BEFORE Discharges: FR-062, FR-063 (documentation half), SC-017 Depends: T001 -- [ ] T005 [P] Write `/README.md` under the same rung-1 honesty constraint, +- [X] T005 [P] Write `/README.md` under the same rung-1 honesty constraint, framing the package as an integrity validator and never as a correctness oracle. Barrier: BEFORE Discharges: none — supports SC-017 Depends: T002 -- [ ] T006 [P] [US9] Add a structural assertion test proving the adapter is reachable +- [X] T006 [P] [US9] Add a structural assertion test proving the adapter is reachable only by explicit static import and registers no dynamic loader, plugin registry, or discovery hook. Files: `/test/no-dynamic-loader.test.ts`. @@ -199,7 +199,7 @@ ownership result, or produces an envelope. Phase A may run concurrently with Pha Discharges: FR-002 Depends: T001 -- [ ] T007 [P] Add a locality guard test asserting that neither new package writes to +- [X] T007 [P] Add a locality guard test asserting that neither new package writes to or regenerates `schema/`, and that the envelope shape each package needs is declared locally rather than in a shared schema module. Files: `/test/envelope-shape-locality.test.ts`. @@ -208,7 +208,7 @@ ownership result, or produces an envelope. Phase A may run concurrently with Pha Depends: T001, T002 Contract: `package-boundary.md` §5 -- [ ] T008 Add explicit `allowedDependenciesFor()` entries for `@adrkit/catalog-backstage` +- [X] T008 Add explicit `allowedDependenciesFor()` entries for `@adrkit/catalog-backstage` (deps `@adrkit/core`, `picomatch`, `yaml`; devDeps `@types/bun`, `@types/picomatch`) and `@adrkit/catalog-envelope` (deps `@adrkit/core`; devDeps `@types/bun`). Do **not** amend the existing `@adrkit/cli` entry. @@ -218,7 +218,7 @@ ownership result, or produces an envelope. Phase A may run concurrently with Pha Depends: T001, T002 Contract: `package-boundary.md` §2, §4 -- [ ] T009 [US9] **Observed failing.** Introduce a dependency edge from `@adrkit/core` +- [X] T009 [US9] **Observed failing.** Introduce a dependency edge from `@adrkit/core` (then `@adrkit/cli`, then a `schema/`-owning package) onto the adapter; run `bun run check:deps`; observe the failure and record the exact emitted reason string; remove the edge; observe the pass. Retain the failing inputs as a @@ -228,7 +228,7 @@ ownership result, or produces an envelope. Phase A may run concurrently with Pha Discharges: SC-015 Depends: T008 -- [ ] T010 [US9] **Observed failing.** Add `@adrkit/catalog-backstage` to the consumer's +- [X] T010 [US9] **Observed failing.** Add `@adrkit/catalog-backstage` to the consumer's dependencies; run `bun run check:deps`; observe the guard at `scripts/check-deps.ts:175–182` emit `non-adapter workspace depends on an adapter package`; record the exact string; remove; observe the pass. @@ -238,7 +238,7 @@ ownership result, or produces an envelope. Phase A may run concurrently with Pha Depends: T008, T009 Contract: `package-boundary.md` §3 -- [ ] T011 [US9] **Observed failing.** Add `@adrkit/catalog-envelope` to the adapter's +- [X] T011 [US9] **Observed failing.** Add `@adrkit/catalog-envelope` to the adapter's dependencies; run `bun run check:deps`; observe the guard at `scripts/check-deps.ts:196–204` emit ` declares a dependency outside its allowed public surface`; record the exact string; remove; observe the pass. @@ -248,7 +248,7 @@ ownership result, or produces an envelope. Phase A may run concurrently with Pha Depends: T008, T010 Contract: `package-boundary.md` §3 -- [ ] T012 [US9] **Observed failing — closes the silent-unconstrained trap.** +- [X] T012 [US9] **Observed failing — closes the silent-unconstrained trap.** `allowedDependenciesFor()` returns `undefined` for any package with no entry (`scripts/check-deps.ts:151`), and the allowed-surface guard is then skipped entirely — so a package with no entry passes `check:deps` no matter what it